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

This commit is contained in:
Jeremy Rose 2023-12-05 17:36:23 -08:00 committed by GitHub
parent ee8d97d7fe
commit 85bc005cd6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 302 additions and 200 deletions

View file

@ -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",

View file

@ -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

View file

@ -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();

View file

@ -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_;

View file

@ -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

View file

@ -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_

View file

@ -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));
}

View file

@ -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")');
});
});
});