Browse Source

refactor: move JS dialog handling to JS (#40598)

Jeremy Rose 1 year ago
parent
commit
85bc005cd6

+ 0 - 2
filenames.gni

@@ -372,8 +372,6 @@ filenames = {
     "shell/browser/electron_download_manager_delegate.h",
     "shell/browser/electron_gpu_client.cc",
     "shell/browser/electron_gpu_client.h",
-    "shell/browser/electron_javascript_dialog_manager.cc",
-    "shell/browser/electron_javascript_dialog_manager.h",
     "shell/browser/electron_navigation_throttle.cc",
     "shell/browser/electron_navigation_throttle.h",
     "shell/browser/electron_permission_manager.cc",

+ 52 - 2
lib/browser/api/web-contents.ts

@@ -1,5 +1,5 @@
-import { app, ipcMain, session, webFrameMain } from 'electron/main';
-import type { BrowserWindowConstructorOptions, LoadURLOptions } from 'electron/main';
+import { app, ipcMain, session, webFrameMain, dialog } from 'electron/main';
+import type { BrowserWindowConstructorOptions, LoadURLOptions, MessageBoxOptions, WebFrameMain } from 'electron/main';
 
 import * as url from 'url';
 import * as path from 'path';
@@ -729,6 +729,56 @@ WebContents.prototype._init = function () {
     }
   });
 
+  const originCounts = new Map<string, number>();
+  const openDialogs = new Set<AbortController>();
+  this.on('-run-dialog' as any, async (info: {frame: WebFrameMain, dialogType: 'prompt' | 'confirm' | 'alert', messageText: string, defaultPromptText: string}, callback: (success: boolean, user_input: string) => void) => {
+    const originUrl = new URL(info.frame.url);
+    const origin = originUrl.protocol === 'file:' ? originUrl.href : originUrl.origin;
+    if ((originCounts.get(origin) ?? 0) < 0) return callback(false, '');
+
+    const prefs = this.getLastWebPreferences();
+    if (!prefs || prefs.disableDialogs) return callback(false, '');
+
+    // We don't support prompt() for some reason :)
+    if (info.dialogType === 'prompt') return callback(false, '');
+
+    originCounts.set(origin, (originCounts.get(origin) ?? 0) + 1);
+
+    // TODO: translate?
+    const checkbox = originCounts.get(origin)! > 1 && prefs.safeDialogs ? prefs.safeDialogsMessage || 'Prevent this app from creating additional dialogs' : '';
+    const parent = this.getOwnerBrowserWindow();
+    const abortController = new AbortController();
+    const options: MessageBoxOptions = {
+      message: info.messageText,
+      checkboxLabel: checkbox,
+      signal: abortController.signal,
+      ...(info.dialogType === 'confirm') ? {
+        buttons: ['OK', 'Cancel'],
+        defaultId: 0,
+        cancelId: 1
+      } : {
+        buttons: ['OK'],
+        defaultId: -1, // No default button
+        cancelId: 0
+      }
+    };
+    openDialogs.add(abortController);
+    const promise = parent && !prefs.offscreen ? dialog.showMessageBox(parent, options) : dialog.showMessageBox(options);
+    try {
+      const result = await promise;
+      if (abortController.signal.aborted) return;
+      if (result.checkboxChecked) originCounts.set(origin, -1);
+      return callback(result.response === 0, '');
+    } finally {
+      openDialogs.delete(abortController);
+    }
+  });
+
+  this.on('-cancel-dialogs' as any, () => {
+    for (const controller of openDialogs) { controller.abort(); }
+    openDialogs.clear();
+  });
+
   app.emit('web-contents-created', { sender: this, preventDefault () {}, get defaultPrevented () { return false; } }, this);
 
   // Properties

+ 55 - 5
shell/browser/api/electron_api_web_contents.cc

@@ -88,7 +88,6 @@
 #include "shell/browser/electron_browser_client.h"
 #include "shell/browser/electron_browser_context.h"
 #include "shell/browser/electron_browser_main_parts.h"
-#include "shell/browser/electron_javascript_dialog_manager.h"
 #include "shell/browser/electron_navigation_throttle.h"
 #include "shell/browser/file_select_helper.h"
 #include "shell/browser/native_window.h"
@@ -263,6 +262,21 @@ struct Converter<WindowOpenDisposition> {
   }
 };
 
+template <>
+struct Converter<content::JavaScriptDialogType> {
+  static v8::Local<v8::Value> ToV8(v8::Isolate* isolate,
+                                   content::JavaScriptDialogType val) {
+    switch (val) {
+      case content::JAVASCRIPT_DIALOG_TYPE_ALERT:
+        return gin::ConvertToV8(isolate, "alert");
+      case content::JAVASCRIPT_DIALOG_TYPE_CONFIRM:
+        return gin::ConvertToV8(isolate, "confirm");
+      case content::JAVASCRIPT_DIALOG_TYPE_PROMPT:
+        return gin::ConvertToV8(isolate, "prompt");
+    }
+  }
+};
+
 template <>
 struct Converter<content::SavePageType> {
   static bool FromV8(v8::Isolate* isolate,
@@ -1587,10 +1601,7 @@ void WebContents::RequestMediaAccessPermission(
 
 content::JavaScriptDialogManager* WebContents::GetJavaScriptDialogManager(
     content::WebContents* source) {
-  if (!dialog_manager_)
-    dialog_manager_ = std::make_unique<ElectronJavaScriptDialogManager>();
-
-  return dialog_manager_.get();
+  return this;
 }
 
 void WebContents::OnAudioStateChanged(bool audible) {
@@ -3747,6 +3758,45 @@ void WebContents::OnInputEvent(const blink::WebInputEvent& event) {
   Emit("input-event", event);
 }
 
+void WebContents::RunJavaScriptDialog(content::WebContents* web_contents,
+                                      content::RenderFrameHost* rfh,
+                                      content::JavaScriptDialogType dialog_type,
+                                      const std::u16string& message_text,
+                                      const std::u16string& default_prompt_text,
+                                      DialogClosedCallback callback,
+                                      bool* did_suppress_message) {
+  CHECK_EQ(web_contents, this->web_contents());
+
+  auto* isolate = JavascriptEnvironment::GetIsolate();
+  v8::HandleScope scope(isolate);
+  auto info = gin::DataObjectBuilder(isolate)
+                  .Set("frame", rfh)
+                  .Set("dialogType", dialog_type)
+                  .Set("messageText", message_text)
+                  .Set("defaultPromptText", default_prompt_text)
+                  .Build();
+
+  EmitWithoutEvent("-run-dialog", info, std::move(callback));
+}
+
+void WebContents::RunBeforeUnloadDialog(content::WebContents* web_contents,
+                                        content::RenderFrameHost* rfh,
+                                        bool is_reload,
+                                        DialogClosedCallback callback) {
+  // TODO: asyncify?
+  bool default_prevented = Emit("will-prevent-unload");
+  std::move(callback).Run(default_prevented, std::u16string());
+}
+
+void WebContents::CancelDialogs(content::WebContents* web_contents,
+                                bool reset_state) {
+  auto* isolate = JavascriptEnvironment::GetIsolate();
+  v8::HandleScope scope(isolate);
+  EmitWithoutEvent(
+      "-cancel-dialogs",
+      gin::DataObjectBuilder(isolate).Set("resetState", reset_state).Build());
+}
+
 v8::Local<v8::Promise> WebContents::GetProcessMemoryInfo(v8::Isolate* isolate) {
   gin_helper::Promise<gin_helper::Dictionary> promise(isolate);
   v8::Local<v8::Promise> handle = promise.GetHandle();

+ 17 - 2
shell/browser/api/electron_api_web_contents.h

@@ -23,6 +23,7 @@
 #include "chrome/browser/ui/exclusive_access/exclusive_access_manager.h"
 #include "content/common/frame.mojom.h"
 #include "content/public/browser/devtools_agent_host.h"
+#include "content/public/browser/javascript_dialog_manager.h"
 #include "content/public/browser/keyboard_event_processing_result.h"
 #include "content/public/browser/render_widget_host.h"
 #include "content/public/browser/web_contents.h"
@@ -85,7 +86,6 @@ class SkRegion;
 namespace electron {
 
 class ElectronBrowserContext;
-class ElectronJavaScriptDialogManager;
 class InspectableWebContents;
 class WebContentsZoomController;
 class WebViewGuestDelegate;
@@ -107,6 +107,7 @@ class WebContents : public ExclusiveAccessContext,
                     public content::WebContentsObserver,
                     public content::WebContentsDelegate,
                     public content::RenderWidgetHost::InputEventObserver,
+                    public content::JavaScriptDialogManager,
                     public InspectableWebContentsDelegate,
                     public InspectableWebContentsViewDelegate,
                     public BackgroundThrottlingSource {
@@ -453,6 +454,21 @@ class WebContents : public ExclusiveAccessContext,
   // content::RenderWidgetHost::InputEventObserver:
   void OnInputEvent(const blink::WebInputEvent& event) override;
 
+  // content::JavaScriptDialogManager:
+  void RunJavaScriptDialog(content::WebContents* web_contents,
+                           content::RenderFrameHost* rfh,
+                           content::JavaScriptDialogType dialog_type,
+                           const std::u16string& message_text,
+                           const std::u16string& default_prompt_text,
+                           DialogClosedCallback callback,
+                           bool* did_suppress_message) override;
+  void RunBeforeUnloadDialog(content::WebContents* web_contents,
+                             content::RenderFrameHost* rfh,
+                             bool is_reload,
+                             DialogClosedCallback callback) override;
+  void CancelDialogs(content::WebContents* web_contents,
+                     bool reset_state) override;
+
   SkRegion* draggable_region() {
     return force_non_draggable_ ? nullptr : draggable_region_.get();
   }
@@ -762,7 +778,6 @@ class WebContents : public ExclusiveAccessContext,
   v8::Global<v8::Value> devtools_web_contents_;
   v8::Global<v8::Value> debugger_;
 
-  std::unique_ptr<ElectronJavaScriptDialogManager> dialog_manager_;
   std::unique_ptr<WebViewGuestDelegate> guest_delegate_;
   std::unique_ptr<FrameSubscriber> frame_subscriber_;
 

+ 0 - 137
shell/browser/electron_javascript_dialog_manager.cc

@@ -1,137 +0,0 @@
-// Copyright (c) 2013 GitHub, Inc.
-// Use of this source code is governed by the MIT license that can be
-// found in the LICENSE file.
-
-#include "shell/browser/electron_javascript_dialog_manager.h"
-
-#include <string>
-#include <utility>
-#include <vector>
-
-#include "base/functional/bind.h"
-#include "base/strings/utf_string_conversions.h"
-#include "shell/browser/api/electron_api_web_contents.h"
-#include "shell/browser/native_window.h"
-#include "shell/browser/ui/message_box.h"
-#include "shell/browser/web_contents_preferences.h"
-#include "shell/common/options_switches.h"
-#include "ui/gfx/image/image_skia.h"
-
-using content::JavaScriptDialogType;
-
-namespace electron {
-
-namespace {
-
-constexpr int kUserWantsNoMoreDialogs = -1;
-
-}  // namespace
-
-ElectronJavaScriptDialogManager::ElectronJavaScriptDialogManager() = default;
-ElectronJavaScriptDialogManager::~ElectronJavaScriptDialogManager() = default;
-
-void ElectronJavaScriptDialogManager::RunJavaScriptDialog(
-    content::WebContents* web_contents,
-    content::RenderFrameHost* rfh,
-    JavaScriptDialogType dialog_type,
-    const std::u16string& message_text,
-    const std::u16string& default_prompt_text,
-    DialogClosedCallback callback,
-    bool* did_suppress_message) {
-  auto origin_url = rfh->GetLastCommittedURL();
-
-  std::string origin;
-  // For file:// URLs we do the alert filtering by the
-  // file path currently loaded
-  if (origin_url.SchemeIsFile()) {
-    origin = origin_url.path();
-  } else {
-    origin = origin_url.DeprecatedGetOriginAsURL().spec();
-  }
-
-  if (origin_counts_[origin] == kUserWantsNoMoreDialogs) {
-    return std::move(callback).Run(false, std::u16string());
-  }
-
-  if (dialog_type != JavaScriptDialogType::JAVASCRIPT_DIALOG_TYPE_ALERT &&
-      dialog_type != JavaScriptDialogType::JAVASCRIPT_DIALOG_TYPE_CONFIRM) {
-    std::move(callback).Run(false, std::u16string());
-    return;
-  }
-
-  auto* web_preferences = WebContentsPreferences::From(web_contents);
-
-  if (web_preferences && web_preferences->ShouldDisableDialogs()) {
-    return std::move(callback).Run(false, std::u16string());
-  }
-
-  // No default button
-  int default_id = -1;
-  int cancel_id = 0;
-
-  std::vector<std::string> buttons = {"OK"};
-  if (dialog_type == JavaScriptDialogType::JAVASCRIPT_DIALOG_TYPE_CONFIRM) {
-    buttons.emplace_back("Cancel");
-    // First button is default, second button is cancel
-    default_id = 0;
-    cancel_id = 1;
-  }
-
-  origin_counts_[origin]++;
-
-  std::string checkbox;
-  if (origin_counts_[origin] > 1 && web_preferences &&
-      web_preferences->ShouldUseSafeDialogs() &&
-      !web_preferences->GetSafeDialogsMessage(&checkbox)) {
-    checkbox = "Prevent this app from creating additional dialogs";
-  }
-
-  // Don't set parent for offscreen window.
-  NativeWindow* window = nullptr;
-  if (web_preferences && !web_preferences->IsOffscreen()) {
-    auto* relay = NativeWindowRelay::FromWebContents(web_contents);
-    if (relay)
-      window = relay->GetNativeWindow();
-  }
-
-  electron::MessageBoxSettings settings;
-  settings.parent_window = window;
-  settings.checkbox_label = checkbox;
-  settings.buttons = buttons;
-  settings.default_id = default_id;
-  settings.cancel_id = cancel_id;
-  settings.message = base::UTF16ToUTF8(message_text);
-
-  electron::ShowMessageBox(
-      settings,
-      base::BindOnce(&ElectronJavaScriptDialogManager::OnMessageBoxCallback,
-                     base::Unretained(this), std::move(callback), origin));
-}
-
-void ElectronJavaScriptDialogManager::RunBeforeUnloadDialog(
-    content::WebContents* web_contents,
-    content::RenderFrameHost* rfh,
-    bool is_reload,
-    DialogClosedCallback callback) {
-  auto* api_web_contents = api::WebContents::From(web_contents);
-  if (api_web_contents) {
-    bool default_prevented = api_web_contents->Emit("will-prevent-unload");
-    std::move(callback).Run(default_prevented, std::u16string());
-  }
-}
-
-void ElectronJavaScriptDialogManager::CancelDialogs(
-    content::WebContents* web_contents,
-    bool reset_state) {}
-
-void ElectronJavaScriptDialogManager::OnMessageBoxCallback(
-    DialogClosedCallback callback,
-    const std::string& origin,
-    int code,
-    bool checkbox_checked) {
-  if (checkbox_checked)
-    origin_counts_[origin] = kUserWantsNoMoreDialogs;
-  std::move(callback).Run(code == 0, std::u16string());
-}
-
-}  // namespace electron

+ 0 - 51
shell/browser/electron_javascript_dialog_manager.h

@@ -1,51 +0,0 @@
-// Copyright (c) 2013 GitHub, Inc.
-// Use of this source code is governed by the MIT license that can be
-// found in the LICENSE file.
-
-#ifndef ELECTRON_SHELL_BROWSER_ELECTRON_JAVASCRIPT_DIALOG_MANAGER_H_
-#define ELECTRON_SHELL_BROWSER_ELECTRON_JAVASCRIPT_DIALOG_MANAGER_H_
-
-#include <map>
-#include <string>
-
-#include "content/public/browser/javascript_dialog_manager.h"
-
-namespace content {
-class WebContents;
-}
-
-namespace electron {
-
-class ElectronJavaScriptDialogManager
-    : public content::JavaScriptDialogManager {
- public:
-  ElectronJavaScriptDialogManager();
-  ~ElectronJavaScriptDialogManager() override;
-
-  // content::JavaScriptDialogManager implementations.
-  void RunJavaScriptDialog(content::WebContents* web_contents,
-                           content::RenderFrameHost* rfh,
-                           content::JavaScriptDialogType dialog_type,
-                           const std::u16string& message_text,
-                           const std::u16string& default_prompt_text,
-                           DialogClosedCallback callback,
-                           bool* did_suppress_message) override;
-  void RunBeforeUnloadDialog(content::WebContents* web_contents,
-                             content::RenderFrameHost* rfh,
-                             bool is_reload,
-                             DialogClosedCallback callback) override;
-  void CancelDialogs(content::WebContents* web_contents,
-                     bool reset_state) override;
-
- private:
-  void OnMessageBoxCallback(DialogClosedCallback callback,
-                            const std::string& origin,
-                            int code,
-                            bool checkbox_checked);
-
-  std::map<std::string, int> origin_counts_;
-};
-
-}  // namespace electron
-
-#endif  // ELECTRON_SHELL_BROWSER_ELECTRON_JAVASCRIPT_DIALOG_MANAGER_H_

+ 3 - 0
shell/browser/web_contents_preferences.cc

@@ -391,6 +391,9 @@ void WebContentsPreferences::SaveLastPreferences() {
            allow_running_insecure_content_);
   dict.Set(options::kExperimentalFeatures, experimental_features_);
   dict.Set(options::kEnableBlinkFeatures, enable_blink_features_.value_or(""));
+  dict.Set("disableDialogs", disable_dialogs_);
+  dict.Set("safeDialogs", safe_dialogs_);
+  dict.Set("safeDialogsMessage", safe_dialogs_message_.value_or(""));
 
   last_web_preferences_ = base::Value(std::move(dict));
 }

+ 175 - 1
spec/chromium-spec.ts

@@ -1,5 +1,5 @@
 import { expect } from 'chai';
-import { BrowserWindow, WebContents, webFrameMain, session, ipcMain, app, protocol, webContents } from 'electron/main';
+import { BrowserWindow, WebContents, webFrameMain, session, ipcMain, app, protocol, webContents, dialog, MessageBoxOptions } from 'electron/main';
 import { closeAllWindows } from './lib/window-helpers';
 import * as https from 'node:https';
 import * as http from 'node:http';
@@ -2396,6 +2396,26 @@ describe('chromium features', () => {
           window.alert({ toString: null });
         }).to.throw('Cannot convert object to primitive value');
       });
+
+      it('shows a message box', async () => {
+        const w = new BrowserWindow({ show: false });
+        w.loadURL('about:blank');
+        const p = once(w.webContents, '-run-dialog');
+        w.webContents.executeJavaScript('alert("hello")', true);
+        const [info] = await p;
+        expect(info.frame).to.equal(w.webContents.mainFrame);
+        expect(info.messageText).to.equal('hello');
+        expect(info.dialogType).to.equal('alert');
+      });
+
+      it('does not crash if a webContents is destroyed while an alert is showing', async () => {
+        const w = new BrowserWindow({ show: false });
+        w.loadURL('about:blank');
+        const p = once(w.webContents, '-run-dialog');
+        w.webContents.executeJavaScript('alert("hello")', true);
+        await p;
+        w.webContents.close();
+      });
     });
 
     describe('window.confirm(message, title)', () => {
@@ -2404,6 +2424,160 @@ describe('chromium features', () => {
           (window.confirm as any)({ toString: null }, 'title');
         }).to.throw('Cannot convert object to primitive value');
       });
+
+      it('shows a message box', async () => {
+        const w = new BrowserWindow({ show: false });
+        w.loadURL('about:blank');
+        const p = once(w.webContents, '-run-dialog');
+        const resultPromise = w.webContents.executeJavaScript('confirm("hello")', true);
+        const [info, cb] = await p;
+        expect(info.frame).to.equal(w.webContents.mainFrame);
+        expect(info.messageText).to.equal('hello');
+        expect(info.dialogType).to.equal('confirm');
+        cb(true, '');
+        const result = await resultPromise;
+        expect(result).to.be.true();
+      });
+    });
+
+    describe('safeDialogs web preference', () => {
+      const originalShowMessageBox = dialog.showMessageBox;
+      afterEach(() => {
+        dialog.showMessageBox = originalShowMessageBox;
+        if (protocol.isProtocolHandled('https')) protocol.unhandle('https');
+        if (protocol.isProtocolHandled('file')) protocol.unhandle('file');
+      });
+      it('does not show the checkbox if not enabled', async () => {
+        const w = new BrowserWindow({ show: false, webPreferences: { safeDialogs: false } });
+        w.loadURL('about:blank');
+        // 1. The first alert() doesn't show the safeDialogs message.
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: false });
+        await w.webContents.executeJavaScript('alert("hi")');
+
+        let recordedOpts: MessageBoxOptions | undefined;
+        dialog.showMessageBox = (bw, opts?: MessageBoxOptions) => {
+          recordedOpts = opts;
+          return Promise.resolve({ response: 0, checkboxChecked: false });
+        };
+        await w.webContents.executeJavaScript('alert("hi")');
+        expect(recordedOpts?.checkboxLabel).to.equal('');
+      });
+
+      it('is respected', async () => {
+        const w = new BrowserWindow({ show: false, webPreferences: { safeDialogs: true } });
+        w.loadURL('about:blank');
+        // 1. The first alert() doesn't show the safeDialogs message.
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: false });
+        await w.webContents.executeJavaScript('alert("hi")');
+
+        // 2. The second alert() shows the message with a checkbox. Respond that we checked it.
+        let recordedOpts: MessageBoxOptions | undefined;
+        dialog.showMessageBox = (bw, opts?: MessageBoxOptions) => {
+          recordedOpts = opts;
+          return Promise.resolve({ response: 0, checkboxChecked: true });
+        };
+        await w.webContents.executeJavaScript('alert("hi")');
+        expect(recordedOpts?.checkboxLabel).to.be.a('string').with.length.above(0);
+
+        // 3. The third alert() shouldn't show a dialog.
+        dialog.showMessageBox = () => Promise.reject(new Error('unexpected showMessageBox'));
+        await w.webContents.executeJavaScript('alert("hi")');
+      });
+
+      it('shows the safeDialogMessage', async () => {
+        const w = new BrowserWindow({ show: false, webPreferences: { safeDialogs: true, safeDialogsMessage: 'foo bar' } });
+        w.loadURL('about:blank');
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: false });
+        await w.webContents.executeJavaScript('alert("hi")');
+        let recordedOpts: MessageBoxOptions | undefined;
+        dialog.showMessageBox = (bw, opts?: MessageBoxOptions) => {
+          recordedOpts = opts;
+          return Promise.resolve({ response: 0, checkboxChecked: true });
+        };
+        await w.webContents.executeJavaScript('alert("hi")');
+        expect(recordedOpts?.checkboxLabel).to.equal('foo bar');
+      });
+
+      it('has persistent state across navigations', async () => {
+        const w = new BrowserWindow({ show: false, webPreferences: { safeDialogs: true } });
+        w.loadURL('about:blank');
+        // 1. The first alert() doesn't show the safeDialogs message.
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: false });
+        await w.webContents.executeJavaScript('alert("hi")');
+
+        // 2. The second alert() shows the message with a checkbox. Respond that we checked it.
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: true });
+        await w.webContents.executeJavaScript('alert("hi")');
+
+        // 3. The third alert() shouldn't show a dialog.
+        dialog.showMessageBox = () => Promise.reject(new Error('unexpected showMessageBox'));
+        await w.webContents.executeJavaScript('alert("hi")');
+
+        // 4. After navigating to the same origin, message boxes should still be hidden.
+        w.loadURL('about:blank');
+        await w.webContents.executeJavaScript('alert("hi")');
+      });
+
+      it('is separated by origin', async () => {
+        protocol.handle('https', () => new Response(''));
+        const w = new BrowserWindow({ show: false, webPreferences: { safeDialogs: true } });
+        w.loadURL('https://example1');
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: false });
+        await w.webContents.executeJavaScript('alert("hi")');
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: true });
+        await w.webContents.executeJavaScript('alert("hi")');
+        dialog.showMessageBox = () => Promise.reject(new Error('unexpected showMessageBox'));
+        await w.webContents.executeJavaScript('alert("hi")');
+
+        // A different origin is allowed to show message boxes after navigation.
+        w.loadURL('https://example2');
+        let dialogWasShown = false;
+        dialog.showMessageBox = () => {
+          dialogWasShown = true;
+          return Promise.resolve({ response: 0, checkboxChecked: false });
+        };
+        await w.webContents.executeJavaScript('alert("hi")');
+        expect(dialogWasShown).to.be.true();
+
+        // Navigating back to the first origin means alerts are blocked again.
+        w.loadURL('https://example1');
+        dialog.showMessageBox = () => Promise.reject(new Error('unexpected showMessageBox'));
+        await w.webContents.executeJavaScript('alert("hi")');
+      });
+
+      it('treats different file: paths as different origins', async () => {
+        protocol.handle('file', () => new Response(''));
+        const w = new BrowserWindow({ show: false, webPreferences: { safeDialogs: true } });
+        w.loadURL('file:///path/1');
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: false });
+        await w.webContents.executeJavaScript('alert("hi")');
+        dialog.showMessageBox = () => Promise.resolve({ response: 0, checkboxChecked: true });
+        await w.webContents.executeJavaScript('alert("hi")');
+        dialog.showMessageBox = () => Promise.reject(new Error('unexpected showMessageBox'));
+        await w.webContents.executeJavaScript('alert("hi")');
+
+        w.loadURL('file:///path/2');
+        let dialogWasShown = false;
+        dialog.showMessageBox = () => {
+          dialogWasShown = true;
+          return Promise.resolve({ response: 0, checkboxChecked: false });
+        };
+        await w.webContents.executeJavaScript('alert("hi")');
+        expect(dialogWasShown).to.be.true();
+      });
+    });
+    describe('disableDialogs web preference', () => {
+      const originalShowMessageBox = dialog.showMessageBox;
+      afterEach(() => {
+        dialog.showMessageBox = originalShowMessageBox;
+        if (protocol.isProtocolHandled('https')) protocol.unhandle('https');
+      });
+      it('is respected', async () => {
+        const w = new BrowserWindow({ show: false, webPreferences: { disableDialogs: true } });
+        w.loadURL('about:blank');
+        dialog.showMessageBox = () => Promise.reject(new Error('unexpected message box'));
+        await w.webContents.executeJavaScript('alert("hi")');
+      });
     });
   });