From 0bf0dbd07b3421718680b76f6341ef039f3bb649 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Wed, 15 Apr 2026 14:41:39 +0100 Subject: [PATCH] fix: persist visual zoom level limits across navigations setVisualZoomLevelLimits previously only called through to the renderer via IPC, and ElectronBrowserClient::OverrideWebPreferences unconditionally reset default_minimum/maximum_page_scale_factor to 1.f on every navigation, wiping out any limits that had been set. Additionally, for webview guests, Chromium routes touchpad pinch-to-zoom through the root (embedder) compositor rather than the guest's compositor, so setting page scale limits on the guest alone had no effect. This stores the limits in WebContentsPreferences so they survive navigation resets, and propagates them to the embedder for webview guests. --- lib/browser/api/web-contents.ts | 17 +- .../browser/api/electron_api_web_contents.cc | 22 +++ shell/browser/api/electron_api_web_contents.h | 1 + shell/browser/web_contents_preferences.cc | 15 ++ shell/browser/web_contents_preferences.h | 3 + typings/internal-electron.d.ts | 171 ++++++++++++++---- 6 files changed, 188 insertions(+), 41 deletions(-) diff --git a/lib/browser/api/web-contents.ts b/lib/browser/api/web-contents.ts index 51f266e906..51271a149b 100644 --- a/lib/browser/api/web-contents.ts +++ b/lib/browser/api/web-contents.ts @@ -152,11 +152,10 @@ WebContents.prototype.sendToFrame = function (frameId, channel, ...args) { }; // Following methods are mapped to webFrame. -const webFrameMethods = ['insertCSS', 'insertText', 'removeInsertedCSS', 'setVisualZoomLevelLimits'] as ( +const webFrameMethods = ['insertCSS', 'insertText', 'removeInsertedCSS'] as ( | 'insertCSS' | 'insertText' | 'removeInsertedCSS' - | 'setVisualZoomLevelLimits' )[]; for (const method of webFrameMethods) { @@ -165,6 +164,20 @@ for (const method of webFrameMethods) { }; } +// setVisualZoomLevelLimits persists the limits in WebContentsPreferences so +// they survive cross-navigation preference resets, then forwards to the +// renderer for immediate effect on the current page. +WebContents.prototype.setVisualZoomLevelLimits = function (minimumLevel: number, maximumLevel: number): Promise { + this._setVisualZoomLevelLimits(minimumLevel, maximumLevel); + return ipcMainUtils.invokeInWebContents( + this, + IPC_MESSAGES.RENDERER_WEB_FRAME_METHOD, + 'setVisualZoomLevelLimits', + minimumLevel, + maximumLevel + ); +}; + const waitTillCanExecuteJavaScript = async (webContents: Electron.WebContents) => { if (webContents.getURL() && !webContents.isLoadingMainFrame()) return; diff --git a/shell/browser/api/electron_api_web_contents.cc b/shell/browser/api/electron_api_web_contents.cc index 97197d7024..6b670cef73 100644 --- a/shell/browser/api/electron_api_web_contents.cc +++ b/shell/browser/api/electron_api_web_contents.cc @@ -4024,6 +4024,26 @@ void WebContents::SetImageAnimationPolicy(const std::string& new_policy) { web_contents()->OnWebPreferencesChanged(); } +void WebContents::SetVisualZoomLevelLimits(double min_level, double max_level) { + auto* web_preferences = WebContentsPreferences::From(web_contents()); + if (web_preferences) { + web_preferences->SetVisualZoomLevelLimits(static_cast(min_level), + static_cast(max_level)); + } + + // Touchpad pinch-to-zoom for child frames (webview guests) is handled by the + // root compositor, so propagate the limits to the embedder as well. + if (embedder_) { + auto* embedder_prefs = + WebContentsPreferences::From(embedder_->web_contents()); + if (embedder_prefs) { + embedder_prefs->SetVisualZoomLevelLimits(static_cast(min_level), + static_cast(max_level)); + embedder_->web_contents()->OnWebPreferencesChanged(); + } + } +} + void WebContents::SetBackgroundColor(std::optional maybe_color) { SkColor color = maybe_color.value_or((is_guest() && guest_transparent_) || type_ == Type::kBrowserView @@ -4727,6 +4747,8 @@ void WebContents::FillObjectTemplate(v8::Isolate* isolate, .SetMethod("takeHeapSnapshot", &WebContents::TakeHeapSnapshot) .SetMethod("setImageAnimationPolicy", &WebContents::SetImageAnimationPolicy) + .SetMethod("_setVisualZoomLevelLimits", + &WebContents::SetVisualZoomLevelLimits) .SetMethod("_getProcessMemoryInfo", &WebContents::GetProcessMemoryInfo) .SetProperty("id", &WebContents::ID) .SetProperty("session", &WebContents::Session) diff --git a/shell/browser/api/electron_api_web_contents.h b/shell/browser/api/electron_api_web_contents.h index 7325bc9883..8874e670e9 100644 --- a/shell/browser/api/electron_api_web_contents.h +++ b/shell/browser/api/electron_api_web_contents.h @@ -431,6 +431,7 @@ class WebContents final : public ExclusiveAccessContext, void SetTemporaryZoomLevel(double level); void SetImageAnimationPolicy(const std::string& new_policy); + void SetVisualZoomLevelLimits(double min_level, double max_level); // content::RenderWidgetHost::InputEventObserver: void OnInputEvent(const content::RenderWidgetHost& rfh, diff --git a/shell/browser/web_contents_preferences.cc b/shell/browser/web_contents_preferences.cc index da2fe80b8a..348e1fedc8 100644 --- a/shell/browser/web_contents_preferences.cc +++ b/shell/browser/web_contents_preferences.cc @@ -149,6 +149,8 @@ void WebContentsPreferences::Clear() { v8_cache_options_ = blink::mojom::V8CacheOptions::kDefault; deprecated_paste_enabled_ = false; focus_on_navigation_ = true; + default_minimum_page_scale_factor_ = std::nullopt; + default_maximum_page_scale_factor_ = std::nullopt; #if BUILDFLAG(IS_MAC) scroll_bounce_ = false; @@ -278,6 +280,12 @@ bool WebContentsPreferences::SetImageAnimationPolicy(std::string policy) { return false; } +void WebContentsPreferences::SetVisualZoomLevelLimits(float min_level, + float max_level) { + default_minimum_page_scale_factor_ = min_level; + default_maximum_page_scale_factor_ = max_level; +} + bool WebContentsPreferences::IsSandboxed() const { if (sandbox_) return *sandbox_; @@ -471,6 +479,13 @@ void WebContentsPreferences::OverrideWebkitPrefs( prefs->v8_cache_options = v8_cache_options_; prefs->dom_paste_enabled = deprecated_paste_enabled_; + + if (default_minimum_page_scale_factor_) + prefs->default_minimum_page_scale_factor = + *default_minimum_page_scale_factor_; + if (default_maximum_page_scale_factor_) + prefs->default_maximum_page_scale_factor = + *default_maximum_page_scale_factor_; } WEB_CONTENTS_USER_DATA_KEY_IMPL(WebContentsPreferences); diff --git a/shell/browser/web_contents_preferences.h b/shell/browser/web_contents_preferences.h index 2ae8bac18b..65918fba43 100644 --- a/shell/browser/web_contents_preferences.h +++ b/shell/browser/web_contents_preferences.h @@ -69,6 +69,7 @@ class WebContentsPreferences } bool ShouldIgnoreMenuShortcuts() const { return ignore_menu_shortcuts_; } bool SetImageAnimationPolicy(std::string policy); + void SetVisualZoomLevelLimits(float min_level, float max_level); bool ShouldDisableHtmlFullscreenWindowResize() const { return disable_html_fullscreen_window_resize_; } @@ -135,6 +136,8 @@ class WebContentsPreferences blink::mojom::V8CacheOptions v8_cache_options_; bool deprecated_paste_enabled_ = false; bool focus_on_navigation_; + std::optional default_minimum_page_scale_factor_; + std::optional default_maximum_page_scale_factor_; #if BUILDFLAG(IS_MAC) bool scroll_bounce_; diff --git a/typings/internal-electron.d.ts b/typings/internal-electron.d.ts index da5846af77..e7c15b34ff 100644 --- a/typings/internal-electron.d.ts +++ b/typings/internal-electron.d.ts @@ -18,7 +18,10 @@ declare namespace Electron { setDesktopName(name: string): void; setAppPath(path: string | null): void; _clientCertRequestPasswordHandler: ((params: ClientCertRequestParams) => Promise) | null; - on(event: '-client-certificate-request-password', listener: (event: Event, callback: (password: string) => void) => Promise): this; + on( + event: '-client-certificate-request-password', + listener: (event: Event, callback: (password: string) => void) => Promise + ): this; } interface AutoUpdater { @@ -34,7 +37,10 @@ declare namespace Electron { _setEscapeTouchBarItem: (item: TouchBarItemType | {}) => void; _refreshTouchBarItem: (itemID: string) => void; on(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this; - removeListener(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this; + removeListener( + event: '-touch-bar-interaction', + listener: (event: Event, itemID: string, details: any) => void + ): this; } interface BrowserWindow extends BaseWindow { @@ -45,12 +51,15 @@ declare namespace Electron { frameName: string; _browserViews: BrowserView[]; on(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this; - removeListener(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this; + removeListener( + event: '-touch-bar-interaction', + listener: (event: Event, itemID: string, details: any) => void + ): this; } interface BrowserView { - ownerWindow: BrowserWindow | null - webContentsView: WebContentsView + ownerWindow: BrowserWindow | null; + webContentsView: WebContentsView; } interface BrowserWindowConstructorOptions { @@ -63,7 +72,7 @@ declare namespace Electron { overrideGlobalValueFromIsolatedWorld(keys: string[], value: any): void; overrideGlobalValueWithDynamicPropsFromIsolatedWorld(keys: string[], value: any): void; overrideGlobalPropertyFromIsolatedWorld(keys: string[], getter: Function, setter?: Function): void; - } + }; } interface ServiceWorkers { @@ -73,7 +82,7 @@ declare namespace Electron { interface ServiceWorkerMain { _send(internal: boolean, channel: string, args: any): void; - _startExternalRequest(hasTimeout: boolean): { id: string, ok: boolean }; + _startExternalRequest(hasTimeout: boolean): { id: string; ok: boolean }; _finishExternalRequest(uuid: string): void; _countExternalRequests(): number; } @@ -95,8 +104,18 @@ declare namespace Electron { _getPreloadScript(): Electron.PreloadScript | null; browserWindowOptions: BrowserWindowConstructorOptions; _windowOpenHandler: ((details: Electron.HandlerDetails) => any) | null; - _callWindowOpenHandler(event: any, details: Electron.HandlerDetails): {browserWindowConstructorOptions: Electron.BrowserWindowConstructorOptions | null, outlivesOpener: boolean, createWindow?: Electron.CreateWindowFunction}; - _setNextChildWebPreferences(prefs: Partial & Pick): void; + _callWindowOpenHandler( + event: any, + details: Electron.HandlerDetails + ): { + browserWindowConstructorOptions: Electron.BrowserWindowConstructorOptions | null; + outlivesOpener: boolean; + createWindow?: Electron.CreateWindowFunction; + }; + _setNextChildWebPreferences( + prefs: Partial & + Pick + ): void; _send(internal: boolean, channel: string, args: any): boolean; _sendInternal(channel: string, ...args: any[]): void; _printToPDF(options: any): Promise; @@ -114,8 +133,8 @@ declare namespace Electron { _goToIndex(index: number): void; _removeNavigationEntryAtIndex(index: number): boolean; _getHistory(): Electron.NavigationEntry[]; - _restoreHistory(index: number, entries: Electron.NavigationEntry[]): void - _clearHistory():void + _restoreHistory(index: number, entries: Electron.NavigationEntry[]): void; + _clearHistory(): void; destroy(): void; // attachToIframe(embedderWebContents: Electron.WebContents, embedderFrameToken: string): void; @@ -123,6 +142,7 @@ declare namespace Electron { setEmbedder(embedder: Electron.WebContents): void; viewInstanceId: number; _setOwnerWindow(w: BaseWindow | null): void; + _setVisualZoomLevelLimits(minLevel: number, maxLevel: number): void; } interface WebFrameMain { @@ -166,7 +186,15 @@ declare namespace Electron { commandsMap: Record; groupsMap: Record; getItemCount(): number; - popupAt(window: BaseWindow, frame: WebFrameMain | undefined, x: number, y: number, positioning: number, sourceType: Required['sourceType'], callback: () => void): void; + popupAt( + window: BaseWindow, + frame: WebFrameMain | undefined, + x: number, + y: number, + positioning: number, + sourceType: Required['sourceType'], + callback: () => void + ): void; closePopupAt(id: number): void; setSublabel(index: number, label: string): void; setToolTip(index: number, tooltip: string): void; @@ -234,17 +262,76 @@ declare namespace Electron { } interface WebContents { - on(event: '-new-window', listener: (event: Electron.Event, url: string, frameName: string, disposition: Electron.HandlerDetails['disposition'], - rawFeatures: string, referrer: Electron.Referrer, postData: LoadURLOptions['postData']) => void): this; - on(event: '-add-new-contents', listener: (event: Event, webContents: Electron.WebContents, disposition: string, - _userGesture: boolean, _left: number, _top: number, _width: number, _height: number, url: string, frameName: string, - referrer: Electron.Referrer, rawFeatures: string, postData: LoadURLOptions['postData']) => void): this; - on(event: '-will-add-new-contents', listener: (event: Electron.Event, url: string, frameName: string, rawFeatures: string, disposition: Electron.HandlerDetails['disposition'], referrer: Electron.Referrer, postData: LoadURLOptions['postData']) => void): this; - on(event: '-ipc-message', listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void): this; - on(event: '-ipc-message-sync', listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void): this; - on(event: '-ipc-invoke', listener: (event: Electron.IpcMainInvokeEvent, internal: boolean, channel: string, args: any[]) => void): this; - on(event: '-ipc-ports', listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, message: any, ports: any[]) => void): this; - on(event: '-run-dialog', listener: (info: {frame: WebFrameMain, dialogType: 'prompt' | 'confirm' | 'alert', messageText: string, defaultPromptText: string}, callback: (success: boolean, user_input: string) => void) => void): this; + on( + event: '-new-window', + listener: ( + event: Electron.Event, + url: string, + frameName: string, + disposition: Electron.HandlerDetails['disposition'], + rawFeatures: string, + referrer: Electron.Referrer, + postData: LoadURLOptions['postData'] + ) => void + ): this; + on( + event: '-add-new-contents', + listener: ( + event: Event, + webContents: Electron.WebContents, + disposition: string, + _userGesture: boolean, + _left: number, + _top: number, + _width: number, + _height: number, + url: string, + frameName: string, + referrer: Electron.Referrer, + rawFeatures: string, + postData: LoadURLOptions['postData'] + ) => void + ): this; + on( + event: '-will-add-new-contents', + listener: ( + event: Electron.Event, + url: string, + frameName: string, + rawFeatures: string, + disposition: Electron.HandlerDetails['disposition'], + referrer: Electron.Referrer, + postData: LoadURLOptions['postData'] + ) => void + ): this; + on( + event: '-ipc-message', + listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void + ): this; + on( + event: '-ipc-message-sync', + listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void + ): this; + on( + event: '-ipc-invoke', + listener: (event: Electron.IpcMainInvokeEvent, internal: boolean, channel: string, args: any[]) => void + ): this; + on( + event: '-ipc-ports', + listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, message: any, ports: any[]) => void + ): this; + on( + event: '-run-dialog', + listener: ( + info: { + frame: WebFrameMain; + dialogType: 'prompt' | 'confirm' | 'alert'; + messageText: string; + defaultPromptText: string; + }, + callback: (success: boolean, user_input: string) => void + ) => void + ): this; on(event: '-cancel-dialogs', listener: () => void): this; on(event: 'ready-to-show', listener: () => void): this; on(event: '-before-unload-fired', listener: (event: Electron.Event, proceed: boolean) => void): this; @@ -263,7 +350,12 @@ declare namespace Electron { declare namespace ElectronInternal { interface DesktopCapturer { - startHandling(captureWindow: boolean, captureScreen: boolean, thumbnailSize: Electron.Size, fetchWindowIcons: boolean): void; + startHandling( + captureWindow: boolean, + captureScreen: boolean, + thumbnailSize: Electron.Size, + fetchWindowIcons: boolean + ): void; _onerror?: (error: string) => void; _onfinished?: (sources: Electron.DesktopCapturerSource[], fetchWindowIcons: boolean) => void; } @@ -283,7 +375,8 @@ declare namespace ElectronInternal { appIcon: Electron.NativeImage | null; } - interface IpcRendererInternal extends NodeJS.EventEmitter, Pick { + interface IpcRendererInternal + extends NodeJS.EventEmitter, Pick { invoke(channel: string, ...args: any[]): Promise; } @@ -305,21 +398,21 @@ declare namespace ElectronInternal { } type MediaSize = { - name: string, - custom_display_name: string, - height_microns: number, - width_microns: number, - imageable_area_left_microns?: number, - imageable_area_bottom_microns?: number, - imageable_area_right_microns?: number, - imageable_area_top_microns?: number, - is_default?: 'true', - } + name: string; + custom_display_name: string; + height_microns: number; + width_microns: number; + imageable_area_left_microns?: number; + imageable_area_bottom_microns?: number; + imageable_area_right_microns?: number; + imageable_area_top_microns?: number; + is_default?: 'true'; + }; type PageSize = { - width: number, - height: number, - } + width: number; + height: number; + }; type ModuleLoader = () => any; @@ -329,7 +422,7 @@ declare namespace ElectronInternal { } interface UtilityProcessWrapper extends NodeJS.EventEmitter { - readonly pid: (number) | (undefined); + readonly pid: number | undefined; kill(): boolean; postMessage(message: any, transfer?: any[]): void; }