diff --git a/src/main/browser/anti-detection.test.ts b/src/main/browser/anti-detection.test.ts new file mode 100644 index 00000000000..c937e3342c6 --- /dev/null +++ b/src/main/browser/anti-detection.test.ts @@ -0,0 +1,100 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' + +import { ANTI_DETECTION_SCRIPT } from './anti-detection' + +type PermissionQueryResult = { + state: string + onchange: null +} + +type AntiDetectionContext = { + Notification: { + permission: string + requestPermission: (callback?: (permission: string) => void) => Promise + } + navigator: { + permissions: { + query: (descriptor: { name: string }) => Promise + } + } +} + +function createContext(args: { + nativeNotificationPermission: string + requestedNotificationPermission: string +}): AntiDetectionContext & Record { + class Permissions { + query(): Promise { + return Promise.resolve({ state: 'denied', onchange: null }) + } + } + + const Notification = { + permission: args.nativeNotificationPermission, + requestPermission(callback?: (permission: string) => void): Promise { + callback?.(args.requestedNotificationPermission) + return Promise.resolve(args.requestedNotificationPermission) + } + } + Object.defineProperty(Notification, 'permission', { + configurable: true, + get: () => args.nativeNotificationPermission + }) + + return { + Date, + Object, + Promise, + Set, + performance: { now: () => 0 }, + window: {}, + navigator: { + plugins: [], + languages: [], + permissions: new Permissions() + }, + Permissions, + Notification + } as AntiDetectionContext & Record +} + +describe('ANTI_DETECTION_SCRIPT', () => { + it('reports notification permission as granted after a site permission request succeeds', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + + expect(context.Notification.permission).toBe('default') + await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({ + state: 'prompt', + onchange: null + }) + + await expect(context.Notification.requestPermission()).resolves.toBe('granted') + + expect(context.Notification.permission).toBe('granted') + await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({ + state: 'granted', + onchange: null + }) + }) + + it('preserves notification permission when Electron already reports a grant', async () => { + const context = createContext({ + nativeNotificationPermission: 'granted', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + + expect(context.Notification.permission).toBe('granted') + await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({ + state: 'granted', + onchange: null + }) + }) +}) diff --git a/src/main/browser/anti-detection.ts b/src/main/browser/anti-detection.ts index f523fc05303..4c62850ddc6 100644 --- a/src/main/browser/anti-detection.ts +++ b/src/main/browser/anti-detection.ts @@ -59,12 +59,32 @@ export const ANTI_DETECTION_SCRIPT = `(function() { // but real Chrome returns 'prompt' for ungranted permissions. Returning // 'denied' is a strong bot signal. Override the query result for common // permissions that Turnstile and similar detectors probe. + var notificationPermission = 'default'; + var setNotificationPermission = function(permission) { + if (permission === 'granted' || permission === 'denied') { + notificationPermission = permission; + return permission; + } + notificationPermission = 'default'; + return 'default'; + }; + var notificationPermissionState = function() { + return notificationPermission === 'default' ? 'prompt' : notificationPermission; + }; + try { + if (Notification.permission === 'granted') { + notificationPermission = 'granted'; + } + } catch {} const promptPerms = new Set([ - 'notifications', 'geolocation', 'camera', 'microphone', + 'geolocation', 'camera', 'microphone', 'midi', 'idle-detection', 'storage-access' ]); const origQuery = Permissions.prototype.query; Permissions.prototype.query = function(desc) { + if (desc.name === 'notifications') { + return Promise.resolve({ state: notificationPermissionState(), onchange: null }); + } if (promptPerms.has(desc.name)) { return Promise.resolve({ state: 'prompt', onchange: null }); } @@ -75,8 +95,25 @@ export const ANTI_DETECTION_SCRIPT = `(function() { // or blocked. Turnstile cross-references this with the Permissions API. try { Object.defineProperty(Notification, 'permission', { - get: () => 'default' + get: () => notificationPermission }); + const origRequestPermission = Notification.requestPermission; + if (typeof origRequestPermission === 'function') { + Notification.requestPermission = function(callback) { + var wrappedCallback = typeof callback === 'function' + ? function(permission) { + callback(setNotificationPermission(permission)); + } + : undefined; + var result = origRequestPermission.call(Notification, wrappedCallback); + if (result && typeof result.then === 'function') { + return result.then(function(permission) { + return setNotificationPermission(permission); + }); + } + return result; + }; + } } catch {} // Why: Electron webviews may have an empty languages array. Real Chrome // always has at least one entry. An empty array is an automation signal. diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index 718620273e3..ce7871e7d98 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -9,7 +9,12 @@ vi.mock('electron', () => ({ webContents: { fromId: vi.fn() } })) -import { setupGuestContextMenu, setupGuestShortcutForwarding } from './browser-guest-ui' +import { + resolveGuestMouseWheelZoomDirection, + setupGuestContextMenu, + setupGuestMouseWheelZoomForwarding, + setupGuestShortcutForwarding +} from './browser-guest-ui' describe('setupGuestContextMenu', () => { const browserTabId = 'tab-1' @@ -253,6 +258,125 @@ describe('setupGuestContextMenu', () => { }) }) +describe('guest mouse wheel browser zoom', () => { + const browserTabId = 'tab-1' + let rendererSendMock: ReturnType + let guestOnMock: ReturnType + let guestOffMock: ReturnType + + function makeGuest() { + return { + on: guestOnMock, + off: guestOffMock + } as unknown as Electron.WebContents + } + + function makeRenderer() { + return { send: rendererSendMock } as unknown as Electron.WebContents + } + + function mouseWheel( + overrides: Partial = {} + ): Electron.MouseWheelInputEvent { + return { + type: 'mouseWheel', + x: 0, + y: 0, + deltaY: -120, + modifiers: ['ctrl'], + ...overrides + } + } + + function triggerBeforeMouse(mouse: Electron.MouseInputEvent): ReturnType { + const handler = guestOnMock.mock.calls.find((call) => call[0] === 'before-mouse-event')?.[1] as + | ((event: Electron.Event, mouse: Electron.MouseInputEvent) => void) + | undefined + expect(handler).toBeTypeOf('function') + const preventDefault = vi.fn() + handler!({ preventDefault } as unknown as Electron.Event, mouse) + return preventDefault + } + + beforeEach(() => { + rendererSendMock = vi.fn() + guestOnMock = vi.fn() + guestOffMock = vi.fn() + }) + + it('resolves ctrl wheel direction from guest mouse input', () => { + expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: -120 }), 'win32')).toBe('in') + expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: 120 }), 'linux')).toBe('out') + }) + + it('allows command wheel only on macOS', () => { + const commandWheel = mouseWheel({ modifiers: ['cmd'], deltaY: -120 }) + + expect(resolveGuestMouseWheelZoomDirection(commandWheel, 'darwin')).toBe('in') + expect(resolveGuestMouseWheelZoomDirection(commandWheel, 'win32')).toBeNull() + }) + + it('ignores non-zoom wheel input', () => { + expect( + resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: [], deltaY: -120 }), 'linux') + ).toBeNull() + expect( + resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: ['ctrl', 'alt'] }), 'linux') + ).toBeNull() + expect( + resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: ['ctrl', 'shift'] }), 'linux') + ).toBeNull() + expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: 0 }), 'linux')).toBeNull() + expect( + resolveGuestMouseWheelZoomDirection( + { type: 'mouseMove', x: 0, y: 0, modifiers: ['ctrl'] }, + 'linux' + ) + ).toBeNull() + }) + + it('forwards ctrl wheel to browser page zoom and consumes the guest wheel event', () => { + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const preventDefault = triggerBeforeMouse(mouseWheel({ deltaY: -120 })) + const outPreventDefault = triggerBeforeMouse(mouseWheel({ deltaY: 120 })) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(outPreventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in') + expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'out') + }) + + it('consumes guest ctrl wheel even when the renderer is unavailable', () => { + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => null + }) + + const preventDefault = triggerBeforeMouse(mouseWheel()) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).not.toHaveBeenCalled() + }) + + it('cleans up the mouse wheel listener on teardown', () => { + const cleanup = setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + cleanup() + + expect(guestOffMock).toHaveBeenCalledWith('before-mouse-event', expect.any(Function)) + }) +}) + describe('setupGuestShortcutForwarding', () => { const browserTabId = 'tab-1' let rendererSendMock: ReturnType diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 5470026b433..d8c7369a941 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -14,10 +14,42 @@ import { } from '../../shared/window-shortcut-policy' import { readGuestNavigationState } from './browser-guest-navigation-state' import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings' +import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null type ShouldForwardDictationShortcut = () => boolean +const CONTROL_MODIFIERS = new Set(['control', 'ctrl']) +const MAC_COMMAND_MODIFIERS = new Set(['meta', 'command', 'cmd']) +const WHEEL_ZOOM_BLOCKING_MODIFIERS = new Set(['alt', 'shift']) + +function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet): boolean { + return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false +} + +export function resolveGuestMouseWheelZoomDirection( + mouse: Electron.MouseInputEvent, + platform: NodeJS.Platform = process.platform +): BrowserPageZoomDirection | null { + if (mouse.type !== 'mouseWheel') { + return null + } + if (hasModifier(mouse, WHEEL_ZOOM_BLOCKING_MODIFIERS)) { + return null + } + const hasZoomModifier = + hasModifier(mouse, CONTROL_MODIFIERS) || + (platform === 'darwin' && hasModifier(mouse, MAC_COMMAND_MODIFIERS)) + if (!hasZoomModifier) { + return null + } + const deltaY = (mouse as Electron.MouseWheelInputEvent).deltaY + if (typeof deltaY !== 'number' || deltaY === 0) { + return null + } + return deltaY < 0 ? 'in' : 'out' +} + function isControlKeyRelease(input: Electron.Input): boolean { return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight') } @@ -424,6 +456,33 @@ export function setupGuestShortcutForwarding(args: { } } +export function setupGuestMouseWheelZoomForwarding(args: { + browserTabId: string + guest: Electron.WebContents + resolveRenderer: ResolveRenderer +}): () => void { + const { browserTabId, guest, resolveRenderer } = args + const handler = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + const direction = resolveGuestMouseWheelZoomDirection(mouse) + if (!direction) { + return + } + // Why: wheel input over a focused webview does not reach renderer DOM + // handlers, so consume it here and forward to the existing page-zoom path. + event.preventDefault() + resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction) + } + + guest.on('before-mouse-event', handler) + return () => { + try { + guest.off('before-mouse-event', handler) + } catch { + // Why: best-effort — guest may already be destroyed during teardown. + } + } +} + export function resolveRendererWebContents( rendererWebContentsIdByTabId: ReadonlyMap, browserTabId: string diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 222542b90ae..825402ee28c 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -32,6 +32,7 @@ import { resolveRendererWebContents, setupGrabShortcutForwarding, setupGuestContextMenu, + setupGuestMouseWheelZoomForwarding, setupGuestShortcutForwarding } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' @@ -184,6 +185,7 @@ export class BrowserManager { private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map void>() + private readonly mouseWheelZoomCleanupByTabId = new Map void>() private readonly annotationViewportBridgeOpsByTabId = new Map>() private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() @@ -751,6 +753,7 @@ export class BrowserManager { this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) this.setupShortcutForwarding(browserTabId, guest) + this.setupMouseWheelZoomForwarding(browserTabId, guest) this.flushPendingLoadFailure(browserTabId, webContentsId) this.flushPendingPermissionEvents(browserTabId, webContentsId) this.flushPendingPopupEvents(browserTabId, webContentsId) @@ -786,6 +789,11 @@ export class BrowserManager { fwdCleanup() this.shortcutForwardingCleanupByTabId.delete(browserTabId) } + const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (mouseWheelZoomCleanup) { + mouseWheelZoomCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } // Why: paused downloads wait for explicit product approval. If the owning // browser tab disappears first, cancel the request so the app does not // retain orphaned download items or write files after context is gone. @@ -834,6 +842,7 @@ export class BrowserManager { this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() this.pendingDownloadIdsByGuestId.clear() + this.mouseWheelZoomCleanupByTabId.clear() this.annotationViewportBridgeOpsByTabId.clear() } @@ -1417,6 +1426,24 @@ export class BrowserManager { ) } + private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + + this.mouseWheelZoomCleanupByTabId.set( + browserTabId, + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId) + }) + ) + } + private forwardOrQueueGuestLoadFailure( guestWebContentsId: number, loadError: { code: number; description: string; validatedUrl: string } diff --git a/src/main/browser/browser-session-permission-policy.ts b/src/main/browser/browser-session-permission-policy.ts new file mode 100644 index 00000000000..a66e27980e3 --- /dev/null +++ b/src/main/browser/browser-session-permission-policy.ts @@ -0,0 +1,17 @@ +const AUTO_GRANTED_BROWSER_PERMISSIONS = new Set([ + 'fullscreen', + // Agent-browser clipboard commands execute via CDP in this session; denying + // them breaks trusted runtime commands even when invoked with a user gesture. + 'clipboard-read', + 'clipboard-sanitized-write', + // User-opened browser pages need these profile-scoped grants to complete + // normal site flows like web push setup and durable app storage. + 'notifications', + // Chromium can request this at runtime even though Electron's TS union does + // not list it; chatgpt.com uses it to keep browser storage from eviction. + 'persistent-storage' +]) + +export function isAutoGrantedBrowserSessionPermission(permission: string): boolean { + return AUTO_GRANTED_BROWSER_PERMISSIONS.has(permission) +} diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 2b511e53adb..d6de343e6f4 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -257,23 +257,35 @@ describe('BrowserSessionRegistry persistence', () => { requestHandler(guestWc, 'clipboard-read', permissionCallback) requestHandler(guestWc, 'clipboard-sanitized-write', permissionCallback) requestHandler(guestWc, 'notifications', permissionCallback) + requestHandler(guestWc, 'persistent-storage', permissionCallback) + requestHandler(guestWc, 'geolocation', permissionCallback) requestHandler(guestWc, 'media', permissionCallback, { mediaTypes: ['video'] }) await vi.waitFor(() => - expect(permissionCallback.mock.calls).toEqual([[true], [true], [true], [false], [true]]) + expect(permissionCallback.mock.calls).toEqual([ + [true], + [true], + [true], + [true], + [true], + [false], + [true] + ]) ) expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ guestWebContentsId: 401, - permission: 'notifications', + permission: 'geolocation', rawUrl: 'https://example.com/account' }) expect( browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) - ).toEqual(['notifications']) + ).toEqual(['geolocation']) expect(checkHandler(null, 'fullscreen', '')).toBe(true) expect(checkHandler(null, 'clipboard-read', '')).toBe(true) expect(checkHandler(null, 'clipboard-sanitized-write', '')).toBe(true) - expect(checkHandler(null, 'notifications', '')).toBe(false) + expect(checkHandler(null, 'notifications', '')).toBe(true) + expect(checkHandler(null, 'persistent-storage', '')).toBe(true) + expect(checkHandler(null, 'geolocation', '')).toBe(false) expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) expect(defaultSession.setDisplayMediaRequestHandler).toHaveBeenCalled() const displayMediaHandler = defaultSession.setDisplayMediaRequestHandler.mock.calls[0][0] diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 81123befc84..914a48578ab 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -206,7 +206,9 @@ describe('BrowserSessionRegistry', () => { await vi.waitFor(() => expect(cb).toHaveBeenCalledWith(true)) expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) - expect(checkHandler(null, 'notifications', '', {})).toBe(false) + expect(checkHandler(null, 'notifications', '', {})).toBe(true) + expect(checkHandler(null, 'persistent-storage', '', {})).toBe(true) + expect(checkHandler(null, 'geolocation', '', {})).toBe(false) }) it('wires WebAuthn device selection for isolated partitions', () => { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 13afae49b35..af5170d1cd9 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -20,6 +20,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../sh import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' import { allowsBrowserWebAuthnPermission, clearBrowserWebAuthnAccessHandlers, @@ -493,10 +494,6 @@ class BrowserSessionRegistry { sess.setUserAgent(cleanUA) setupClientHintsOverride(sess, cleanUA) } - // Why: agent-browser clipboard commands execute via CDP in this session. - // Until there is a separate trusted bridge, denying clipboard-read breaks - // those runtime commands even when invoked with a user gesture. - const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write']) sess.setPermissionRequestHandler((webContents, permission, callback, details) => { // Why: `media` (camera/mic) must defer to macOS TCC instead of being // denied outright. Denying at the session layer would make pages inside @@ -530,7 +527,7 @@ class BrowserSessionRegistry { ) return } - const allowed = autoGranted.has(permission) + const allowed = isAutoGrantedBrowserSessionPermission(permission) if (!allowed) { browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, @@ -547,7 +544,7 @@ class BrowserSessionRegistry { if (allowsBrowserWebAuthnPermission(permission, details)) { return true } - return autoGranted.has(permission) + return isAutoGrantedBrowserSessionPermission(permission) }) installBrowserWebAuthnAccessHandlers(sess) sess.setDisplayMediaRequestHandler((_request, callback) => {