diff --git a/src/main/browser/browser-manager-guest-cleanup.ts b/src/main/browser/browser-manager-guest-cleanup.ts index 67ded30cecc..d067170d9f6 100644 --- a/src/main/browser/browser-manager-guest-cleanup.ts +++ b/src/main/browser/browser-manager-guest-cleanup.ts @@ -14,6 +14,7 @@ export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavi } this.tabIdByWebContentsId.delete(guestWebContentsId) this.certificateTrustController?.onGuestRetired(guestWebContentsId) + this.siteDisplayController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { policyCleanup() diff --git a/src/main/browser/browser-manager-guest-navigation-policy.ts b/src/main/browser/browser-manager-guest-navigation-policy.ts index ee5bedca467..89c0af15f43 100644 --- a/src/main/browser/browser-manager-guest-navigation-policy.ts +++ b/src/main/browser/browser-manager-guest-navigation-policy.ts @@ -136,6 +136,7 @@ export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManager // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. this.clearedLoadErrorsByGuestId.delete(guest.id) this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) + this.siteDisplayController?.onMainFrameNavigationCommitted(guest.id) // Why: an offscreen page has no renderer to publish its row, so this commit is the only // moment paired clients can learn the new url — every failure path above already announces. this.notifyBrowserGuestStateChanged(guest.id) diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts index 99e9a381c56..16b1143197d 100644 --- a/src/main/browser/browser-manager-registration.ts +++ b/src/main/browser/browser-manager-registration.ts @@ -72,6 +72,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli this.worktreeIdByTabId.set(browserTabId, worktreeId) } this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) + this.siteDisplayController?.onGuestRegistered(guest, sessionProfileId ?? null) this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts index 32a002a9e6f..bd628236727 100644 --- a/src/main/browser/browser-manager-state.ts +++ b/src/main/browser/browser-manager-state.ts @@ -1,5 +1,6 @@ import { BrowserGrabSessionController } from './browser-grab-session-controller' import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import type { BrowserSiteDisplayController } from './browser-site-display-controller' import { createPageInitiatedTabBudget, type PageInitiatedTabBudget @@ -153,6 +154,7 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt protected readonly clearedLoadErrorsByGuestId = new Map() protected browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null protected certificateTrustController: BrowserCertificateTrustController | null = null + protected siteDisplayController: BrowserSiteDisplayController | null = null protected shouldForwardDictationShortcut: (() => boolean) | null = null protected readonly pendingLoadFailuresByGuestId = new Map< number, @@ -176,6 +178,10 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt this.certificateTrustController = controller } + setSiteDisplayController(controller: BrowserSiteDisplayController): void { + this.siteDisplayController = controller + } + installCertificateRequestGuard(session: Electron.Session): void { this.certificateTrustController?.installSessionRequestGuard(session) } diff --git a/src/main/browser/browser-profile-site-store.ts b/src/main/browser/browser-profile-site-store.ts new file mode 100644 index 00000000000..295b4c79c70 --- /dev/null +++ b/src/main/browser/browser-profile-site-store.ts @@ -0,0 +1,123 @@ +import { app } from 'electron' +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' + +/** A JSON file of browser profile id → site → entry, kept in the Orca profile directory. */ +export type BrowserProfileSiteStore = { + configure: (profileDirectory: string) => void + get: (profileId: string, site: string) => Entry | undefined + /** Null forgets the site. */ + set: (profileId: string, site: string, entry: Entry | null) => void + list: (profileId: string) => [site: string, entry: Entry][] + forget: (profileId: string) => void +} + +type StoredSites = Record> + +export function createBrowserProfileSiteStore({ + fileName, + description, + parseEntry +}: { + fileName: string + /** Names the file in the save-failure log. */ + description: string + /** Null drops an unreadable or empty entry. */ + parseEntry: (value: unknown) => Entry | null +}): BrowserProfileSiteStore { + let filePathOverride: string | null = null + let cached: { path: string; sites: StoredSites } | null = null + + // Why lazy: app.getPath('userData') throws before ready. + const storePath = (): string => filePathOverride ?? join(app.getPath('userData'), fileName) + + const parse = (raw: string): StoredSites => { + const parsed: unknown = JSON.parse(raw) + const stored: StoredSites = {} + if (!parsed || typeof parsed !== 'object' || !('profiles' in parsed)) { + return stored + } + const { profiles } = parsed + if (!profiles || typeof profiles !== 'object') { + return stored + } + for (const [profileId, sites] of Object.entries(profiles)) { + if (!sites || typeof sites !== 'object') { + continue + } + const parsedSites: Record = {} + for (const [site, value] of Object.entries(sites)) { + const entry = parseEntry(value) + if (entry !== null) { + parsedSites[site] = entry + } + } + stored[profileId] = parsedSites + } + return stored + } + + const load = (): StoredSites => { + let path: string + try { + path = storePath() + } catch { + return {} + } + if (cached?.path === path) { + return cached.sites + } + let sites: StoredSites = {} + try { + sites = parse(readFileSync(path, 'utf-8')) + } catch { + // Missing or unreadable: every site starts over. + } + cached = { path, sites } + return sites + } + + const persist = (sites: StoredSites): void => { + try { + const path = storePath() + cached = { path, sites } + mkdirSync(dirname(path), { recursive: true }) + writeFileDurableSync( + durableWriteTempPath(path), + path, + `${JSON.stringify({ version: 1, profiles: sites }, null, 2)}\n` + ) + } catch (error) { + // Why: the change still holds for this run; failing the user's action would punish them. + console.error(`[browser] Could not save ${description}:`, error) + } + } + + return { + configure: (profileDirectory) => { + filePathOverride = join(profileDirectory, fileName) + }, + get: (profileId, site) => load()[profileId]?.[site], + set: (profileId, site, entry) => { + const stored = load() + const sites = { ...stored[profileId] } + if (entry === null) { + delete sites[site] + } else { + sites[site] = entry + } + persist({ ...stored, [profileId]: sites }) + }, + list: (profileId) => + Object.entries(load()[profileId] ?? {}).sort(([a], [b]) => a.localeCompare(b)), + forget: (profileId) => { + const stored = load() + if (!(profileId in stored)) { + return + } + const { [profileId]: _forgotten, ...rest } = stored + persist(rest) + } + } +} diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 41ade0ffd50..0082292c915 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -42,6 +42,7 @@ import { cancelBrowserWebAuthnAccountRequestsForSession } from './browser-webaut import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path' import { markBrowserIdentityMigrationNoticePending } from './browser-identity-mode-store' import { forgetBrowserSitePermissions } from './browser-site-permission-store' +import { forgetBrowserSiteDisplay } from './browser-site-display-store' export type BrowserSessionRegistryProfileOptions = { orcaProfileId: string @@ -274,6 +275,7 @@ class BrowserSessionRegistry { this.profiles.delete(profileId) this.persistProfiles() forgetBrowserSitePermissions(profileId) + forgetBrowserSiteDisplay(profileId) const meta = this.loadPersistedMeta() const pendingCookieImports = { ...meta.pendingCookieImports } delete pendingCookieImports[profile.partition] diff --git a/src/main/browser/browser-session-startup.ts b/src/main/browser/browser-session-startup.ts index 3959cd88f40..9a3c4b386a7 100644 --- a/src/main/browser/browser-session-startup.ts +++ b/src/main/browser/browser-session-startup.ts @@ -3,6 +3,7 @@ import type { BrowserSessionRegistryProfileOptions } from './browser-session-reg import { collectOrphanedBrowserRoutePartitionStorage } from './browser-route-partition-storage-runtime' import { configureRouteSessionsForOrcaProfile } from './browser-route-session-runtime' import { configureBrowserSitePermissionStore } from './browser-site-permission-store' +import { configureBrowserSiteDisplayStore } from './browser-site-display-store' import { configurePairedRuntimeBrowserClientHostsForOrcaProfile } from './paired-runtime-browser-client-host-runtime' let initialized = false @@ -19,6 +20,7 @@ export function initializeBrowserSessionsForApp( if (activeProfile) { browserSessionRegistry.configureForOrcaProfile(activeProfile) configureBrowserSitePermissionStore(activeProfile.profileDirectory) + configureBrowserSiteDisplayStore(activeProfile.profileDirectory) configureRouteSessionsForOrcaProfile({ orcaProfileId: activeProfile.orcaProfileId, profileDirectory: activeProfile.profileDirectory diff --git a/src/main/browser/browser-site-display-controller.test.ts b/src/main/browser/browser-site-display-controller.test.ts new file mode 100644 index 00000000000..2b01034fe6c --- /dev/null +++ b/src/main/browser/browser-site-display-controller.test.ts @@ -0,0 +1,186 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import { + browserPageZoomLevelToPercent, + normalizeBrowserPageZoomLevel +} from '../../shared/browser-page-zoom' +import { BrowserSiteDisplayController } from './browser-site-display-controller' +import { configureBrowserSiteDisplayStore } from './browser-site-display-store' + +type FakeGuest = Electron.WebContents & + EventEmitter & { url: string; level: number; mediaSent: unknown[] } + +let nextGuestId = 1 + +function fakeGuest(url: string): FakeGuest { + const debuggerEvents = new EventEmitter() + let attached = false + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only reads id, getURL, zoom, isDestroyed, dom-ready and the debugger. + const guest = new EventEmitter() as FakeGuest + Object.assign(guest, { + id: nextGuestId++, + url, + level: 0, + mediaSent: [], + isDestroyed: () => false, + getURL: () => guest.url, + getZoomLevel: () => guest.level, + setZoomLevel: (level: number) => { + guest.level = level + }, + debugger: Object.assign(debuggerEvents, { + isAttached: () => attached, + attach: () => { + attached = true + }, + detach: () => { + attached = false + debuggerEvents.emit('detach') + }, + sendCommand: (method: string, params: { features: { value: string }[] }) => { + if (method === 'Emulation.setEmulatedMedia') { + guest.mediaSent.push(params.features[0].value) + } + return Promise.resolve({}) + } + }) + }) + return guest +} + +/** What the page shows after the navigation commits and its document loads. */ +function commit(controller: BrowserSiteDisplayController, guest: FakeGuest, url: string): void { + guest.url = url + controller.onMainFrameNavigationCommitted(guest.id) + guest.emit('dom-ready') +} + +function uiStore(initial: Partial = {}): { + getUI: () => PersistedUIState + onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void + update: (update: Partial) => void +} { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller reads only uiZoomLevel and browserDefaultZoomLevel. + let ui = { uiZoomLevel: 0, browserDefaultZoomLevel: 0, ...initial } as PersistedUIState + const listeners: ((ui: PersistedUIState) => void)[] = [] + return { + getUI: () => ui, + onUIChanged: (listener) => { + listeners.push(listener) + return () => {} + }, + update: (update) => { + ui = { ...ui, ...update } + listeners.forEach((listener) => listener(ui)) + } + } +} + +const percent = (guest: FakeGuest): number => browserPageZoomLevelToPercent(guest.level) + +beforeEach(() => { + configureBrowserSiteDisplayStore(mkdtempSync(join(tmpdir(), 'orca-site-display-'))) +}) + +describe('BrowserSiteDisplayController zoom', () => { + it('keeps zoom per site and browser profile, and resets it to Default Zoom', () => { + const controller = new BrowserSiteDisplayController() + const ui = uiStore({ browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(0.5) }) + controller.followZoomSettings(ui) + const github = fakeGuest('https://github.com/a') + const githubOtherPort = fakeGuest('https://github.com:8443/b') + const githubWork = fakeGuest('https://github.com/c') + const docs = fakeGuest('https://docs.example.com/') + controller.onGuestRegistered(github, null) + controller.onGuestRegistered(githubOtherPort, 'default') + controller.onGuestRegistered(githubWork, 'work') + controller.onGuestRegistered(docs, null) + + controller.zoomPage(github.id, 'in') + + expect([github, githubOtherPort, githubWork, docs].map(percent)).toEqual([125, 125, 110, 110]) + commit(controller, docs, 'https://github.com/d') + expect(percent(docs)).toBe(125) + + expect(controller.zoomPage(github.id, 'reset')).toMatchObject({ siteHasZoom: false }) + expect([github, githubOtherPort, docs].map(percent)).toEqual([110, 110, 110]) + }) + + it("restores the site's zoom after Orca's UI zoom is copied onto the page", () => { + const controller = new BrowserSiteDisplayController() + const ui = uiStore() + controller.followZoomSettings(ui) + const guest = fakeGuest('https://github.com/') + controller.onGuestRegistered(guest, null) + controller.zoomPage(guest.id, 'in') + // Electron copies the embedder's zoom (here 120%) onto every webview, on change and after commits. + const copyUIZoom = (): void => { + guest.level = 1 + } + + copyUIZoom() + ui.update({ uiZoomLevel: 1 }) + expect(percent(guest)).toBe(110) + + guest.url = 'https://github.com/next' + controller.onMainFrameNavigationCommitted(guest.id) + copyUIZoom() + guest.emit('dom-ready') + expect(percent(guest)).toBe(110) + + copyUIZoom() + controller.zoomPage(guest.id, 'in') + expect(percent(guest)).toBe(125) + }) +}) + +describe('BrowserSiteDisplayController color scheme', () => { + it('sets the site scheme as each page commits and clears it once on leaving', () => { + const controller = new BrowserSiteDisplayController() + const guest = fakeGuest('https://github.com/') + controller.onGuestRegistered(guest, null) + controller.setColorScheme(guest.id, 'dark') + + // Why it matters: each send replaces every emulated feature, including an agent's own. + controller.zoomPage(guest.id, 'in') + commit(controller, guest, 'https://docs.example.com/') + commit(controller, guest, 'https://news.example.com/') + commit(controller, guest, 'https://github.com/x') + + expect(guest.mediaSent).toEqual(['dark', '', 'dark']) + }) + + it('gives a restored tab its scheme when its first load started before it registered', () => { + const controller = new BrowserSiteDisplayController() + const first = fakeGuest('https://github.com/') + controller.onGuestRegistered(first, null) + controller.setColorScheme(first.id, 'light') + + const restored = fakeGuest('') + controller.onGuestRegistered(restored, null) + restored.url = 'https://github.com/restored' + controller.onMainFrameNavigationCommitted(restored.id) + + expect(restored.mediaSent).toEqual(['light']) + }) + + it('attaches again after DevTools detaches the debugger', () => { + const controller = new BrowserSiteDisplayController() + const guest = fakeGuest('https://github.com/') + controller.onGuestRegistered(guest, null) + controller.setColorScheme(guest.id, 'dark') + + guest.debugger.detach() + expect(guest.debugger.isAttached()).toBe(false) + commit(controller, guest, 'https://github.com/next') + + expect(guest.debugger.isAttached()).toBe(true) + expect(guest.mediaSent.at(-1)).toBe('dark') + }) +}) + +vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })) diff --git a/src/main/browser/browser-site-display-controller.ts b/src/main/browser/browser-site-display-controller.ts new file mode 100644 index 00000000000..735075bfc92 --- /dev/null +++ b/src/main/browser/browser-site-display-controller.ts @@ -0,0 +1,248 @@ +import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' +import { getBrowserSiteDisplay, updateBrowserSiteDisplay } from './browser-site-display-store' +import { + browserSiteDisplayKey, + type BrowserPageSiteDisplay, + type BrowserSiteColorScheme +} from '../../shared/browser-site-display' +import { + DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, + nextBrowserPageZoomLevel, + normalizeBrowserPageZoomLevel, + type BrowserPageZoomDirection +} from '../../shared/browser-page-zoom' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import { errorMessage } from '../../shared/error-message' + +type TrackedGuest = { + guest: Electron.WebContents + profileId: string + stopWatchingDomReady: () => void + colorScheme: { + lease: ElectronDebuggerLease + stopWatchingDetach: () => void + /** Orca's scheme is in force on the page, so leaving the site must clear it. */ + standing: boolean + } | null +} + +const ZOOM_EPSILON = 1e-6 + +/** + * Shows each page the way its browser profile remembers the site: its own zoom (or Default Zoom) + * and its own color scheme (or Orca's theme). Only webview guests take part; streamed pages have + * no menu to change either. + */ +export class BrowserSiteDisplayController { + private readonly guests = new Map() + private defaultZoomLevel = DEFAULT_BROWSER_PAGE_ZOOM_LEVEL + + /** Electron pushes Orca's UI zoom onto every webview, so a UI zoom change moves pages too. */ + followZoomSettings(ui: { + getUI: () => PersistedUIState + onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void + }): void { + let { uiZoomLevel, browserDefaultZoomLevel } = ui.getUI() + this.defaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel) + ui.onUIChanged((next) => { + if ( + next.uiZoomLevel === uiZoomLevel && + next.browserDefaultZoomLevel === browserDefaultZoomLevel + ) { + return + } + ;({ uiZoomLevel, browserDefaultZoomLevel } = next) + this.defaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel) + this.reapplyZoom() + }) + } + + onGuestRegistered(guest: Electron.WebContents, profileId: string | null): void { + const previous = this.guests.get(guest.id) + previous?.stopWatchingDomReady() + // Why dom-ready: once Orca's UI zoom has changed, Electron copies it onto the guest at some + // point after each commit; dom-ready always comes after that copy. + const onDomReady = (): void => this.applyZoom(guest.id) + guest.on('dom-ready', onDomReady) + this.guests.set(guest.id, { + guest, + profileId: profileId ?? 'default', + stopWatchingDomReady: () => guest.off('dom-ready', onDomReady), + colorScheme: previous?.colorScheme ?? null + }) + this.applyColorScheme(guest.id) + this.applyZoom(guest.id) + } + + onGuestRetired(guestId: number): void { + this.releaseColorScheme(guestId) + const tracked = this.guests.get(guestId) + if (tracked && !tracked.guest.isDestroyed()) { + tracked.stopWatchingDomReady() + } + this.guests.delete(guestId) + } + + /** + * Why at commit: the new document's scripts read prefers-color-scheme only after this, while the + * page being left keeps its own scheme until it is replaced. Zoom is keyed by the committed host. + */ + onMainFrameNavigationCommitted(guestId: number): void { + this.applyColorScheme(guestId) + this.applyZoom(guestId) + } + + private reapplyZoom(): void { + for (const guestId of this.guests.keys()) { + this.applyZoom(guestId) + } + } + + getPageDisplay(guestId: number): BrowserPageSiteDisplay | null { + const tracked = this.guests.get(guestId) + if (!tracked) { + return null + } + const site = browserSiteDisplayKey(tracked.guest.getURL()) + const saved = site ? getBrowserSiteDisplay(tracked.profileId, site) : {} + return { + site, + zoomLevel: saved.zoomLevel ?? this.defaultZoomLevel, + siteHasZoom: saved.zoomLevel !== undefined, + colorScheme: saved.colorScheme ?? null + } + } + + zoomPage(guestId: number, direction: BrowserPageZoomDirection): BrowserPageSiteDisplay | null { + const display = this.getPageDisplay(guestId) + const profileId = this.guests.get(guestId)?.profileId + if (!display?.site || !profileId) { + return display + } + // Why from the saved level: Electron pushes Orca's UI zoom onto every webview, so the live + // level can be stale and stepping from it would save the wrong zoom. + const next = nextBrowserPageZoomLevel(display.zoomLevel, direction, this.defaultZoomLevel) + const zoomLevel = next === this.defaultZoomLevel ? undefined : next + if (zoomLevel !== (display.siteHasZoom ? display.zoomLevel : undefined)) { + updateBrowserSiteDisplay(profileId, display.site, { zoomLevel }) + this.forEachSiteGuest(profileId, display.site, (siteGuestId) => this.applyZoom(siteGuestId)) + } + return this.getPageDisplay(guestId) + } + + setColorScheme( + guestId: number, + colorScheme: BrowserSiteColorScheme | null + ): BrowserPageSiteDisplay | null { + const display = this.getPageDisplay(guestId) + const profileId = this.guests.get(guestId)?.profileId + if (!display?.site || !profileId || display.colorScheme === colorScheme) { + return display + } + updateBrowserSiteDisplay(profileId, display.site, { colorScheme: colorScheme ?? undefined }) + this.forEachSiteGuest(profileId, display.site, (siteGuestId) => + this.applyColorScheme(siteGuestId) + ) + return this.getPageDisplay(guestId) + } + + private forEachSiteGuest( + profileId: string, + site: string, + apply: (guestId: number) => void + ): void { + for (const [guestId, tracked] of this.guests) { + if ( + tracked.profileId === profileId && + browserSiteDisplayKey(tracked.guest.getURL()) === site + ) { + apply(guestId) + } + } + } + + private applyZoom(guestId: number): void { + const tracked = this.guests.get(guestId) + if (!tracked || tracked.guest.isDestroyed()) { + return + } + const level = this.getPageDisplay(guestId)?.zoomLevel ?? this.defaultZoomLevel + // Why compare first: Chromium keeps zoom per host, so a no-op write would still reset a + // same-host tab that a different profile or the UI zoom push moved. + if (Math.abs(tracked.guest.getZoomLevel() - level) > ZOOM_EPSILON) { + tracked.guest.setZoomLevel(level) + } + } + + private applyColorScheme(guestId: number): void { + const tracked = this.guests.get(guestId) + if (!tracked || tracked.guest.isDestroyed()) { + return + } + const colorScheme = this.getPageDisplay(guestId)?.colorScheme ?? undefined + if (!colorScheme && !tracked.colorScheme?.standing) { + return + } + const state = tracked.colorScheme ?? this.acquireColorScheme(tracked) + if (!state) { + return + } + state.standing = colorScheme !== undefined + // Why resend on every navigation: setEmulatedMedia replaces every emulated feature, so an + // agent's `set media` call silently drops the scheme and only a fresh write restores it. + tracked.guest.debugger + .sendCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-color-scheme', value: colorScheme ?? '' }] + }) + .catch((error: unknown) => { + console.warn('[browser-site-display] failed to set the page color scheme', { + guestWebContentsId: guestId, + error: errorMessage(error) + }) + }) + } + + // Why a lease held for the guest's life: an agent's lease that attached first would otherwise + // detach on release and take the scheme with it. + private acquireColorScheme(tracked: TrackedGuest): TrackedGuest['colorScheme'] { + const { guest } = tracked + let lease: ElectronDebuggerLease + try { + lease = acquireElectronDebugger(guest) + } catch (error) { + // Why: attach fails while DevTools holds the page; the next navigation tries again. + console.warn('[browser-site-display] could not attach to set the page color scheme', { + guestWebContentsId: guest.id, + error: errorMessage(error) + }) + return null + } + // Why: opening DevTools detaches us and clears the scheme; drop the lease so the next + // navigation attaches again. + const onDetach = (): void => this.releaseColorScheme(guest.id) + guest.debugger.on('detach', onDetach) + tracked.colorScheme = { + lease, + stopWatchingDetach: () => guest.debugger.off('detach', onDetach), + standing: false + } + return tracked.colorScheme + } + + private releaseColorScheme(guestId: number): void { + const tracked = this.guests.get(guestId) + const state = tracked?.colorScheme + if (!tracked || !state) { + return + } + tracked.colorScheme = null + try { + state.stopWatchingDetach() + state.lease.release() + } catch { + // Why: best-effort — the guest may already be destroyed. + } + } +} + +export const browserSiteDisplayController = new BrowserSiteDisplayController() diff --git a/src/main/browser/browser-site-display-store.ts b/src/main/browser/browser-site-display-store.ts new file mode 100644 index 00000000000..c300a0f9d1e --- /dev/null +++ b/src/main/browser/browser-site-display-store.ts @@ -0,0 +1,48 @@ +import { + isBrowserSiteColorScheme, + type BrowserSiteDisplay +} from '../../shared/browser-site-display' +import { normalizeBrowserPageZoomLevel } from '../../shared/browser-page-zoom' +import { createBrowserProfileSiteStore } from './browser-profile-site-store' + +function parseSiteDisplay(value: unknown): BrowserSiteDisplay | null { + if (!value || typeof value !== 'object') { + return null + } + const display: BrowserSiteDisplay = {} + if ('zoomLevel' in value && typeof value.zoomLevel === 'number') { + display.zoomLevel = normalizeBrowserPageZoomLevel(value.zoomLevel) + } + if ('colorScheme' in value && isBrowserSiteColorScheme(value.colorScheme)) { + display.colorScheme = value.colorScheme + } + return Object.keys(display).length > 0 ? display : null +} + +/** Browser profile id → site → its own zoom and color scheme. */ +const store = createBrowserProfileSiteStore({ + fileName: 'browser-site-display.json', + description: 'site zoom and appearance', + parseEntry: parseSiteDisplay +}) + +export function configureBrowserSiteDisplayStore(profileDirectory: string): void { + store.configure(profileDirectory) +} + +export function getBrowserSiteDisplay(profileId: string, site: string): BrowserSiteDisplay { + return store.get(profileId, site) ?? {} +} + +/** Undefined fields are forgotten; a site left with nothing is dropped. */ +export function updateBrowserSiteDisplay( + profileId: string, + site: string, + update: BrowserSiteDisplay +): void { + store.set(profileId, site, parseSiteDisplay({ ...store.get(profileId, site), ...update })) +} + +export function forgetBrowserSiteDisplay(profileId: string): void { + store.forget(profileId) +} diff --git a/src/main/browser/browser-site-permission-store.ts b/src/main/browser/browser-site-permission-store.ts index 119728358c8..f488dd57dcc 100644 --- a/src/main/browser/browser-site-permission-store.ts +++ b/src/main/browser/browser-site-permission-store.ts @@ -1,7 +1,3 @@ -import { app } from 'electron' -import { mkdirSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' import { isBrowserSitePermission, isBrowserSitePermissionDecision, @@ -9,27 +5,13 @@ import { type BrowserSitePermissionDecision, type BrowserSitePermissionEntry } from '../../shared/browser-site-permission' +import { createBrowserProfileSiteStore } from './browser-profile-site-store' export const BROWSER_SITE_PERMISSIONS_FILE_NAME = 'browser-site-permissions.json' type SiteDecisions = BrowserSitePermissionEntry['decisions'] -/** Browser profile id → origin → remembered answers. */ -type StoredDecisions = Record> -let filePathOverride: string | null = null -let cached: { path: string; decisions: StoredDecisions } | null = null - -// Why lazy: app.getPath('userData') throws before ready. -function storePath(): string { - return filePathOverride ?? join(app.getPath('userData'), BROWSER_SITE_PERMISSIONS_FILE_NAME) -} - -/** Answers belong to the Orca profile whose browser profiles they name. */ -export function configureBrowserSitePermissionStore(profileDirectory: string): void { - filePathOverride = join(profileDirectory, BROWSER_SITE_PERMISSIONS_FILE_NAME) -} - -function parseSiteDecisions(value: unknown): SiteDecisions { +function parseSiteDecisions(value: unknown): SiteDecisions | null { const decisions: SiteDecisions = {} if (value && typeof value === 'object') { for (const [permission, decision] of Object.entries(value)) { @@ -38,69 +20,19 @@ function parseSiteDecisions(value: unknown): SiteDecisions { } } } - return decisions + return Object.keys(decisions).length > 0 ? decisions : null } -function parseStoredDecisions(raw: string): StoredDecisions { - const parsed: unknown = JSON.parse(raw) - const stored: StoredDecisions = {} - if (!parsed || typeof parsed !== 'object' || !('profiles' in parsed)) { - return stored - } - const { profiles } = parsed - if (!profiles || typeof profiles !== 'object') { - return stored - } - for (const [profileId, sites] of Object.entries(profiles)) { - if (!sites || typeof sites !== 'object') { - continue - } - const parsedSites: Record = {} - for (const [origin, decisions] of Object.entries(sites)) { - const parsedDecisions = parseSiteDecisions(decisions) - if (Object.keys(parsedDecisions).length > 0) { - parsedSites[origin] = parsedDecisions - } - } - stored[profileId] = parsedSites - } - return stored -} +/** Browser profile id → origin → remembered answers. */ +const store = createBrowserProfileSiteStore({ + fileName: BROWSER_SITE_PERMISSIONS_FILE_NAME, + description: 'site permissions', + parseEntry: parseSiteDecisions +}) -function load(): StoredDecisions { - let path: string - try { - path = storePath() - } catch { - return {} - } - if (cached?.path === path) { - return cached.decisions - } - let decisions: StoredDecisions = {} - try { - decisions = parseStoredDecisions(readFileSync(path, 'utf-8')) - } catch { - // Missing or unreadable: every site is asked again. - } - cached = { path, decisions } - return decisions -} - -function persist(decisions: StoredDecisions): void { - try { - const path = storePath() - cached = { path, decisions } - mkdirSync(dirname(path), { recursive: true }) - writeFileDurableSync( - durableWriteTempPath(path), - path, - `${JSON.stringify({ version: 1, profiles: decisions }, null, 2)}\n` - ) - } catch (error) { - // Why: the answer still holds for this run; failing the page's request would punish the user. - console.error('[browser] Could not save site permissions:', error) - } +/** Answers belong to the Orca profile whose browser profiles they name. */ +export function configureBrowserSitePermissionStore(profileDirectory: string): void { + store.configure(profileDirectory) } export function getBrowserSitePermissionDecision( @@ -108,7 +40,7 @@ export function getBrowserSitePermissionDecision( origin: string, permission: BrowserSitePermission ): BrowserSitePermissionDecision | undefined { - return load()[profileId]?.[origin]?.[permission] + return store.get(profileId, origin)?.[permission] } /** Null forgets the answer, so the site is asked again. */ @@ -118,9 +50,7 @@ export function setBrowserSitePermissionDecisions( permissions: readonly BrowserSitePermission[], decision: BrowserSitePermissionDecision | null ): void { - const stored = load() - const sites = { ...stored[profileId] } - const decisions = { ...sites[origin] } + const decisions = { ...store.get(profileId, origin) } for (const permission of permissions) { if (decision) { decisions[permission] = decision @@ -128,25 +58,13 @@ export function setBrowserSitePermissionDecisions( delete decisions[permission] } } - if (Object.keys(decisions).length > 0) { - sites[origin] = decisions - } else { - delete sites[origin] - } - persist({ ...stored, [profileId]: sites }) + store.set(profileId, origin, Object.keys(decisions).length > 0 ? decisions : null) } export function listBrowserSitePermissions(profileId: string): BrowserSitePermissionEntry[] { - return Object.entries(load()[profileId] ?? {}) - .map(([origin, decisions]) => ({ origin, decisions })) - .sort((a, b) => a.origin.localeCompare(b.origin)) + return store.list(profileId).map(([origin, decisions]) => ({ origin, decisions })) } export function forgetBrowserSitePermissions(profileId: string): void { - const stored = load() - if (!(profileId in stored)) { - return - } - const { [profileId]: _forgotten, ...rest } = stored - persist(rest) + store.forget(profileId) } diff --git a/src/main/ipc/browser-guest-view-ipc.ts b/src/main/ipc/browser-guest-view-ipc.ts index 0193eeff8a0..f8c20fa25c1 100644 --- a/src/main/ipc/browser-guest-view-ipc.ts +++ b/src/main/ipc/browser-guest-view-ipc.ts @@ -1,5 +1,7 @@ -import { ipcMain } from 'electron' +import { ipcMain, type IpcMainInvokeEvent } from 'electron' import { browserManager } from '../browser/browser-manager' +import { browserSiteDisplayController } from '../browser/browser-site-display-controller' +import { isBrowserPageZoomDirection } from '../../shared/browser-page-zoom' import { publishBrowserClientPageMetadata } from '../browser/browser-client-page-metadata-transport' import { BrowserClientPageMetadataParams, @@ -8,6 +10,10 @@ import { import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { isTrustedBrowserRenderer } from './browser-renderer-trust' import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import { + isBrowserSiteColorScheme, + type BrowserPageSiteDisplay +} from '../../shared/browser-site-display' import { isValidBrowserAnnotationViewportBridgeMarkers, isValidBrowserAnnotationViewportBridgeToken, @@ -22,6 +28,50 @@ export function registerBrowserGuestViewHandlers(): void { ipcMain.removeHandler('browser:acceptDownload') ipcMain.removeHandler('browser:cancelDownload') ipcMain.removeHandler('browser:publishClientPageMetadata') + ipcMain.removeHandler('browser:getPageSiteDisplay') + ipcMain.removeHandler('browser:zoomPage') + ipcMain.removeHandler('browser:setPageSiteColorScheme') + + // Why the page registry only: these act on a browser page, never on a document preview. + const pageGuestId = (event: IpcMainInvokeEvent, browserPageId: unknown): number | null => + isTrustedBrowserRenderer(event.sender) && typeof browserPageId === 'string' + ? browserManager.getGuestWebContentsId(browserPageId) + : null + + ipcMain.handle( + 'browser:getPageSiteDisplay', + (event, args: { browserPageId: string }): BrowserPageSiteDisplay | null => { + const guestId = pageGuestId(event, args?.browserPageId) + return guestId === null ? null : browserSiteDisplayController.getPageDisplay(guestId) + } + ) + + ipcMain.handle( + 'browser:zoomPage', + (event, args: { browserPageId: string; direction: unknown }): BrowserPageSiteDisplay | null => { + const guestId = pageGuestId(event, args?.browserPageId) + const { direction } = args + if (guestId === null || !isBrowserPageZoomDirection(direction)) { + return null + } + return browserSiteDisplayController.zoomPage(guestId, direction) + } + ) + + ipcMain.handle( + 'browser:setPageSiteColorScheme', + ( + event, + args: { browserPageId: string; colorScheme: unknown } + ): BrowserPageSiteDisplay | null => { + const guestId = pageGuestId(event, args?.browserPageId) + const { colorScheme } = args + if (guestId === null || (colorScheme !== null && !isBrowserSiteColorScheme(colorScheme))) { + return null + } + return browserSiteDisplayController.setColorScheme(guestId, colorScheme) + } + ) ipcMain.handle('browser:openDevTools', (event, args: { browserPageId: string }) => { if (!isTrustedBrowserRenderer(event.sender)) { diff --git a/src/main/ipc/browser-preview-tool-authorization.test.ts b/src/main/ipc/browser-preview-tool-authorization.test.ts index 31413e7b73e..640763b395e 100644 --- a/src/main/ipc/browser-preview-tool-authorization.test.ts +++ b/src/main/ipc/browser-preview-tool-authorization.test.ts @@ -130,6 +130,9 @@ const BROWSER_PAGE_CHANNELS = [ 'browser:activeTabChanged', 'browser:openDevTools', 'browser:setViewportOverride', + 'browser:getPageSiteDisplay', + 'browser:zoomPage', + 'browser:setPageSiteColorScheme', 'browser:publishClientPageMetadata', 'browser:cancelDownload', 'browser:session:listProfiles', diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts index 436d5f42497..4be93a71ffa 100644 --- a/src/main/persistence-ui-state.test.ts +++ b/src/main/persistence-ui-state.test.ts @@ -772,9 +772,10 @@ describe('Store', () => { it('normalizes default browser zoom UI writes', async () => { const store = await createStore() + // 1.26 is about 130%, which snaps to Chrome's 125% preset. store.updateUI({ browserDefaultZoomLevel: 1.26 }) - expect(store.getUI().browserDefaultZoomLevel).toBe(1.5) + expect(1.2 ** (store.getUI().browserDefaultZoomLevel ?? 0)).toBeCloseTo(1.25) }) it('encrypts the Kagi session link on disk and decrypts it on load', async () => { diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 8f3de3f0523..1e4f48f3e1a 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -8,7 +8,8 @@ import { setNodeDistMirrorSettingReader } from '../node-runtime/node-dist-mirror import { handleBrowserGuestLogin } from '../browser/browser-http-auth-prompt' import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog' import { preservePreviousHangDetection } from '../hang-watchdog/previous-hang-detection' -import { browserCertificateTrustController } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' +import { browserSiteDisplayController } from '../browser/browser-site-display-controller' import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' import { getCanonicalUserDataPath } from '../persistence' import { getAppEnvironment } from '../../shared/app-environment' @@ -291,6 +292,8 @@ export async function initializeReadyFoundation(): Promise { return currentStore.getSshTargets().map((target) => target.id) } }) + browserSiteDisplayController.followZoomSettings(store) + browserManager.setSiteDisplayController(browserSiteDisplayController) try { // Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write. await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 907b2457bb8..1ece1b6e523 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -76,6 +76,10 @@ vi.mock('../persistence', () => ({ return {} } onSettingsChanged() {} + getUI() { + return {} + } + onUIChanged() {} getSshTargets() { return [] } @@ -87,6 +91,8 @@ vi.mock('../persistence/profile-state/profile-state-startup-authority', () => ({ store: { getSettings: () => ({}), onSettingsChanged: () => {}, + getUI: () => ({}), + onUIChanged: () => {}, getSshTargets: () => [] } }) @@ -119,7 +125,8 @@ vi.mock('../browser/browser-manager', () => ({ installCertificateRequestGuard: vi.fn(), removeCertificateRequestGuard: vi.fn(), notifyPermissionDenied: vi.fn(), - handleGuestWillDownload: vi.fn() + handleGuestWillDownload: vi.fn(), + setSiteDisplayController: vi.fn() } })) vi.mock('../orca-profiles/profile-index-store', () => ({ diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index 70664a8f30d..10b3d5f254f 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -1,4 +1,9 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { + BrowserPageSiteDisplay, + BrowserSiteColorScheme +} from '../../shared/browser-site-display' +import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' import type { BrowserIdentityModeSetResult, BrowserIdentityModeStatus, @@ -96,6 +101,17 @@ export type BrowserApi = { browserPageId: string override: BrowserViewportOverride | null }) => Promise + getPageSiteDisplay: (args: { browserPageId: string }) => Promise + /** Zooms the page's site in its browser profile, or resets it to Default Zoom. */ + zoomPage: (args: { + browserPageId: string + direction: BrowserPageZoomDirection + }) => Promise + /** Null makes the site match Orca's theme again. */ + setPageSiteColorScheme: (args: { + browserPageId: string + colorScheme: BrowserSiteColorScheme | null + }) => Promise reportViewportScrollState?: (args: { browserPageId: string state: BrowserViewportScrollState diff --git a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts index 5d5de630344..33624327de3 100644 --- a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -1,5 +1,10 @@ import { ipcRenderer } from 'electron' import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import type { + BrowserPageSiteDisplay, + BrowserSiteColorScheme +} from '../../shared/browser-site-display' +import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' import type { BrowserWebAuthnAccountRequest, BrowserWebAuthnAccountResponse @@ -74,6 +79,17 @@ export const browserGuestRegistrationAndDownloadsApi = { browserPageId: string override: BrowserViewportOverride | null }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), + getPageSiteDisplay: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:getPageSiteDisplay', args), + zoomPage: (args: { + browserPageId: string + direction: BrowserPageZoomDirection + }): Promise => ipcRenderer.invoke('browser:zoomPage', args), + setPageSiteColorScheme: (args: { + browserPageId: string + colorScheme: BrowserSiteColorScheme | null + }): Promise => + ipcRenderer.invoke('browser:setPageSiteColorScheme', args), reportViewportScrollState: (args: { browserPageId: string state: { diff --git a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx index f7bba3bfe9a..a109ef90fc4 100644 --- a/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx +++ b/src/renderer/src/components/browser-cookie-import-google-disclosure.test.tsx @@ -54,6 +54,7 @@ describe('cookie-import Google disclosure footer', () => { 'browser toolbar overflow', () => ( { onImportFromFile={vi.fn()} viewportPresetId={null} onApplyViewportPreset={vi.fn()} + siteDisplay={null} + onZoomPage={vi.fn()} + onSetSiteColorScheme={vi.fn()} overflow={{ triggerRef: createRef(), tools: [], diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx index 4e0098f6c1f..a5210f37faa 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.chrome-parity.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserCertificateFailure, @@ -14,7 +14,11 @@ import type { BrowserHistoryNavigateCommand, BrowserPageCommandTarget } from '../../../../shared/browser-page-command-target' -import type { BrowserPageZoomCommand } from '../../../../shared/browser-page-zoom' +import type { BrowserPageSiteDisplay } from '../../../../shared/browser-site-display' +import { + normalizeBrowserPageZoomLevel, + type BrowserPageZoomCommand +} from '../../../../shared/browser-page-zoom' const toastMocks = vi.hoisted(() => ({ loading: vi.fn(), @@ -54,6 +58,7 @@ let reloadRequests = paneChannel() let hardReloadRequests = paneChannel() let zoomRequests = paneChannel() let openDevTools = vi.fn(async () => true) +let zoomPage = vi.fn(async (): Promise => null) let proceedCertificate = vi.fn(async () => ({ ok: true as const })) let openUrl = vi.fn(async () => {}) let writeClipboardText = vi.fn(async () => {}) @@ -69,6 +74,12 @@ beforeEach(() => { hardReloadRequests = paneChannel() zoomRequests = paneChannel() openDevTools = vi.fn(async () => true) + zoomPage = vi.fn(async () => ({ + site: 'example.com', + zoomLevel: normalizeBrowserPageZoomLevel(1), + siteHasZoom: true, + colorScheme: null + })) proceedCertificate = vi.fn(async () => ({ ok: true as const })) openUrl = vi.fn(async () => {}) writeClipboardText = vi.fn(async () => {}) @@ -78,7 +89,8 @@ beforeEach(() => { onContextMenuDismissed: contextMenuDismissed.subscribe, onPermissionDenied: permissionDenied.subscribe, openDevTools, - proceedCertificate + proceedCertificate, + zoomPage }, ui: { onFindInBrowserPage: (_source: unknown, callback: () => void) => @@ -169,21 +181,18 @@ describe('ClientHostedBrowserPagePane chrome parity', () => { expect(webview.goForward).toHaveBeenCalledTimes(1) }) - it('zooms the retained guest and shows the level while the chord lands', () => { - const { webview } = renderPane() + it('zooms its own page and shows the level main settled on', async () => { + renderPane() // The HUD is aria-hidden until a zoom lands, so it has to be queried past that gate. const indicator = screen.getByRole('status', { hidden: true }) expect(indicator.getAttribute('aria-hidden')).toBe('true') act(() => zoomRequests.emit({ browserPageId: 'page-b', direction: 'in' })) - expect(webview.setZoomLevel).not.toHaveBeenCalled() - act(() => zoomRequests.emit({ browserPageId: 'page-a', direction: 'in' })) - expect(webview.setZoomLevel).toHaveBeenCalledTimes(1) - expect(webview.setZoomLevel.mock.calls[0]![0]).toBeGreaterThan(0) - expect(indicator.getAttribute('aria-hidden')).toBe('false') - expect(indicator.textContent).not.toBe('100%') + expect(zoomPage.mock.calls).toEqual([[{ browserPageId: 'page-a', direction: 'in' }]]) + await waitFor(() => expect(indicator.getAttribute('aria-hidden')).toBe('false')) + expect(indicator.textContent).toBe('125%') }) it('offers Proceed Anyway on a certificate failure and sends it to the local session', () => { diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index 144dc82117f..74290aa68b9 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -131,7 +131,7 @@ export function ClientHostedBrowserPagePane({ addressBarInputRef, startAddressBarFocusGrab }) - const zoom = useBrowserPageZoomFeedback(browserTab.id) + const zoom = useBrowserPageZoomFeedback() const reload = useBrowserPageReloadActions({ browserTab, webviewRef, @@ -152,8 +152,6 @@ export function ClientHostedBrowserPagePane({ ...shortcutOwner, isActiveRef, webviewRef, - paneZoomLevelRef: zoom.paneZoomLevelRef, - setBrowserDefaultZoomLevel: zoom.setBrowserDefaultZoomLevel, showBrowserZoomFeedback: zoom.showBrowserZoomFeedback, reloadWebviewOrRecoverGuest: reload.reloadWebviewOrRecoverGuest }) @@ -320,8 +318,7 @@ export function ClientHostedBrowserPagePane({ // browserTab.url here named the previous page and offered it an HTTPS retry it never needed. const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? toDisplayUrl(browserTab.url) const browserZoomIndicatorState = getBrowserPageZoomIndicatorState({ - feedbackVisible: zoom.browserZoomFeedbackVisible, - isDefaultZoom: zoom.browserZoomPercent === zoom.browserDefaultZoomPercent + feedbackVisible: zoom.browserZoomFeedbackVisible }) const markup = useClientHostedBrowserMarkup({ @@ -339,7 +336,10 @@ export function ClientHostedBrowserPagePane({ const loading = !restoredPageUnrecovered && (placement === null || browserTab.loading) return ( -
+
{/* IPC-driven context menu in a Portal so position:fixed escapes ancestor transform/backdrop-filter containing blocks. */} (null) const mountedRef = useMountedRef() + const showSiteDisplay = (request: Promise): void => { + void request.then((display) => { + if (mountedRef.current) { + setSiteDisplay(display) + } + }) + } + const zoomPage = (direction: BrowserPageZoomDirection): void => + showSiteDisplay(window.api.browser.zoomPage({ browserPageId, direction })) + const setSiteColorScheme = (colorScheme: BrowserSiteColorScheme | null): void => + showSiteDisplay(window.api.browser.setPageSiteColorScheme({ browserPageId, colorScheme })) + + useEffect(() => { + if (!menuOpen) { + return + } + // Why: Cmd/Ctrl +/-/0 pressed inside the open menu zoom the page through the pane, so read + // the result back; main answers invokes in order, so this read sees that zoom. + return addBrowserPageZoomEventListener((command) => { + if (command.browserPageId === browserPageId) { + void window.api.browser.getPageSiteDisplay({ browserPageId }).then((display) => { + if (mountedRef.current) { + setSiteDisplay(display) + } + }) + } + }) + }, [browserPageId, menuOpen, mountedRef]) + useLayoutEffect(() => { // Why: step 3 falls back to the Import Cookies row inside this menu, so open // it only when the tour reaches that step and the hint button is hidden. @@ -84,6 +120,10 @@ export function BrowserToolbarMenu({ if (shouldForceMenuOpen && !open) { return } + if (open) { + // Why on open: the site changes with every navigation and keyboard zoom, so a cached value goes stale. + showSiteDisplay(window.api.browser.getPageSiteDisplay({ browserPageId })) + } setMenuOpen(open) } @@ -232,6 +272,7 @@ export function BrowserToolbarMenu({ return ( <> void handleImportFromFile()} viewportPresetId={viewportPresetId} onApplyViewportPreset={applyViewportPreset} + siteDisplay={siteDisplay} + onZoomPage={zoomPage} + onSetSiteColorScheme={setSiteColorScheme} onOpenSourceFile={onOpenSourceFile} overflow={overflow} /> diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx index d564b12d389..0e68be237b8 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx @@ -150,7 +150,7 @@ export function BrowserPagePane({ const grabElementShortcut = useShortcutLabel('browser.grabElement') const slotViewport = useBrowserPageSlotViewport(workspaceId) - const zoom = useBrowserPageZoomFeedback(browserTab.id) + const zoom = useBrowserPageZoomFeedback() const { resourceNotice, setResourceNotice } = useBrowserPageResourceNotices(browserTab.id) const guestFocus = useWebviewGuestFocus(webviewRef) const { @@ -226,8 +226,6 @@ export function BrowserPagePane({ setFindOpen, focusAddressBarNow, focusWebviewNow, - paneZoomLevelRef: zoom.paneZoomLevelRef, - setBrowserZoomPercent: zoom.setBrowserZoomPercent, pendingAnnotationPayload: grabAnnotations.pendingAnnotationPayload, inputLocked, faviconUrl: browserTab.faviconUrl, @@ -277,8 +275,6 @@ export function BrowserPagePane({ isActiveRef, markupIsActive: markup.isActive, webviewRef, - paneZoomLevelRef: zoom.paneZoomLevelRef, - setBrowserDefaultZoomLevel: zoom.setBrowserDefaultZoomLevel, showBrowserZoomFeedback: zoom.showBrowserZoomFeedback, reloadWebviewOrRecoverGuest: reload.reloadWebviewOrRecoverGuest, startGrabIntent: grabAnnotations.startGrabIntent, @@ -298,8 +294,7 @@ export function BrowserPagePane({ const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl) const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab const browserZoomIndicatorState = getBrowserPageZoomIndicatorState({ - feedbackVisible: zoom.browserZoomFeedbackVisible, - isDefaultZoom: zoom.browserZoomPercent === zoom.browserDefaultZoomPercent + feedbackVisible: zoom.browserZoomFeedbackVisible }) useEffect(() => { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx index ab2b8d25230..70326b253dd 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-menu-dropdown.tsx @@ -21,6 +21,11 @@ import type { BrowserSessionProfile, BrowserViewportPresetId } from '../../../../../shared/browser-workspace-types' +import type { + BrowserPageSiteDisplay, + BrowserSiteColorScheme +} from '../../../../../shared/browser-site-display' +import type { BrowserPageZoomDirection } from '../../../../../shared/browser-page-zoom' type DetectedBrowserEntry = { family: string @@ -34,8 +39,13 @@ import { BrowserChromeFoldedMenuItems, type BrowserChromeOverflowMenuProps } from './browser-chrome-folded-tools' +import { + BrowserToolbarAppearanceSub, + BrowserToolbarZoomRow +} from './browser-toolbar-page-display-items' type BrowserToolbarMenuDropdownProps = { + browserPageId: string menuOpen: boolean onMenuOpenChange: (open: boolean) => void allProfiles: BrowserSessionProfile[] @@ -49,11 +59,15 @@ type BrowserToolbarMenuDropdownProps = { onImportFromFile: () => void viewportPresetId: BrowserViewportPresetId | null onApplyViewportPreset: (nextId: BrowserViewportPresetId | null) => void + siteDisplay: BrowserPageSiteDisplay | null + onZoomPage: (direction: BrowserPageZoomDirection) => void + onSetSiteColorScheme: (colorScheme: BrowserSiteColorScheme | null) => void onOpenSourceFile?: (() => void) | null overflow: BrowserChromeOverflowMenuProps } export function BrowserToolbarMenuDropdown({ + browserPageId, menuOpen, onMenuOpenChange, allProfiles, @@ -67,6 +81,9 @@ export function BrowserToolbarMenuDropdown({ onImportFromFile, viewportPresetId, onApplyViewportPreset, + siteDisplay, + onZoomPage, + onSetSiteColorScheme, onOpenSourceFile = null, overflow }: BrowserToolbarMenuDropdownProps): React.JSX.Element { @@ -89,6 +106,7 @@ export function BrowserToolbarMenuDropdown({ - + {detectedBrowsers.map((browser) => browser.profiles.length > 1 ? ( @@ -173,7 +191,7 @@ export function BrowserToolbarMenuDropdown({ )} - + {browser.profiles.map((profile) => ( + + @@ -221,7 +245,7 @@ export function BrowserToolbarMenuDropdown({ )} - + {/* Why: Viewport is a "pick one of N" control, so use a radio group for proper a11y semantics (role="menuitemradio", aria-checked). The "Default" option represents a null preset (no override), diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-page-display-items.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-page-display-items.tsx new file mode 100644 index 00000000000..28bb0da8000 --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-toolbar-page-display-items.tsx @@ -0,0 +1,164 @@ +import { Minus, Plus, RotateCcw, SunMoon, ZoomIn } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import { + browserPageZoomLevelToPercent, + nextBrowserPageZoomLevel, + type BrowserPageZoomDirection +} from '../../../../../shared/browser-page-zoom' +import { + isBrowserSiteColorScheme, + type BrowserPageSiteDisplay, + type BrowserSiteColorScheme +} from '../../../../../shared/browser-site-display' + +const ZOOM_KEY_DIRECTIONS: Record = { + '-': 'out', + '=': 'in', + '+': 'in', + '0': 'reset' +} + +export function BrowserToolbarZoomRow({ + display, + onZoom +}: { + display: BrowserPageSiteDisplay | null + onZoom: (direction: BrowserPageZoomDirection) => void +}): React.JSX.Element { + const zoomLevel = display?.zoomLevel ?? 0 + const percent = browserPageZoomLevelToPercent(zoomLevel) + return ( + event.preventDefault()} + onKeyDown={(event) => { + const direction = ZOOM_KEY_DIRECTIONS[event.key] + if (direction) { + event.preventDefault() + onZoom(direction) + } + }} + > + + {translate('auto.components.browser.pane.BrowserToolbarMenu.zoom', 'Zoom')} + + + + + + + + ) +} + +const MATCH_ORCA_VALUE = 'orca' + +export function BrowserToolbarAppearanceSub({ + browserPageId, + display, + onSetColorScheme +}: { + browserPageId: string + display: BrowserPageSiteDisplay | null + onSetColorScheme: (colorScheme: BrowserSiteColorScheme | null) => void +}): React.JSX.Element { + return ( + + + + {translate('auto.components.browser.pane.BrowserToolbarMenu.appearance', 'Appearance')} + + + + + onSetColorScheme(isBrowserSiteColorScheme(value) ? value : null) + } + > + + {translate( + 'auto.components.browser.pane.BrowserToolbarMenu.appearanceMatchOrca', + 'Match Orca' + )} + + + + {translate( + 'auto.components.browser.pane.BrowserToolbarMenu.appearanceLight', + 'Light' + )} + + + {translate('auto.components.browser.pane.BrowserToolbarMenu.appearanceDark', 'Dark')} + + + + + + ) +} diff --git a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts index 351cfa94af3..a5907d2a88c 100644 --- a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts @@ -39,11 +39,9 @@ export type AttachBrowserPageWebviewArgs = { activeLoadFailureRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject keepAddressBarFocusRef: MutableRefObject - paneZoomLevelRef: MutableRefObject viewportPresetIdRef: MutableRefObject onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> setGuestRecoveryGeneration: Dispatch> - setBrowserZoomPercent: Dispatch> focusAddressBarNow: () => boolean syncNavigationState: (webview: Electron.WebviewTag) => void syncBrowserAnnotationViewportBridge: () => void diff --git a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts index d3659e58f73..2728e491452 100644 --- a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts +++ b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts @@ -42,11 +42,9 @@ export function bindBrowserPageWebviewListeners({ activeLoadFailureRef, recoveryNavigationValidationRef, keepAddressBarFocusRef, - paneZoomLevelRef, viewportPresetIdRef, onUpdatePageStateRef, setGuestRecoveryGeneration, - setBrowserZoomPercent, focusAddressBarNow, syncNavigationState, syncBrowserAnnotationViewportBridge, @@ -80,11 +78,9 @@ export function bindBrowserPageWebviewListeners({ activeLoadFailureRef, recoveryNavigationValidationRef, keepAddressBarFocusRef, - paneZoomLevelRef, viewportPresetIdRef, onUpdatePageStateRef, setGuestRecoveryGeneration, - setBrowserZoomPercent, focusAddressBarNow, syncNavigationState, syncBrowserAnnotationViewportBridge diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts index b62e20a07f2..0d0619f2da4 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-evicted-guest-recovery.test.ts @@ -39,11 +39,9 @@ function createPage(id: string) { activeLoadFailureRef: ref(null), recoveryNavigationValidationRef: ref(null), keepAddressBarFocusRef: ref(false), - paneZoomLevelRef: ref(0), viewportPresetIdRef: ref(null), onUpdatePageStateRef: ref(vi.fn()), setGuestRecoveryGeneration: setGeneration, - setBrowserZoomPercent: vi.fn(), focusAddressBarNow: () => false, syncNavigationState: vi.fn(), syncBrowserAnnotationViewportBridge: vi.fn() diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts index 22ef6137de8..18554701162 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts @@ -87,11 +87,9 @@ function createSession( activeLoadFailureRef: loadFailure, recoveryNavigationValidationRef: ref(null), keepAddressBarFocusRef: ref(false), - paneZoomLevelRef: ref(0), viewportPresetIdRef: ref(null), onUpdatePageStateRef: ref(update), setGuestRecoveryGeneration: vi.fn(), - setBrowserZoomPercent: vi.fn(), focusAddressBarNow: () => false, syncNavigationState: vi.fn(), syncBrowserAnnotationViewportBridge: sync diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts index dd5e243ba25..9b6af2da267 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts @@ -14,7 +14,6 @@ import { createBrowserPageGuestRecovery, type BrowserPageGuestRecovery } from './browser-page-guest-recovery' -import { browserPageZoomLevelToPercent, setBrowserPageZoomLevel } from './browser-page-zoom' import { registeredWebContentsIds, replacePersistentWebview, @@ -40,11 +39,9 @@ export type BrowserPageWebviewGuestSessionArgs = { activeLoadFailureRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject keepAddressBarFocusRef: MutableRefObject - paneZoomLevelRef: MutableRefObject viewportPresetIdRef: MutableRefObject onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> setGuestRecoveryGeneration: Dispatch> - setBrowserZoomPercent: Dispatch> focusAddressBarNow: () => boolean syncNavigationState: (webview: Electron.WebviewTag) => void syncBrowserAnnotationViewportBridge: () => void @@ -71,11 +68,9 @@ export function createBrowserPageWebviewGuestSession({ activeLoadFailureRef, recoveryNavigationValidationRef, keepAddressBarFocusRef, - paneZoomLevelRef, viewportPresetIdRef, onUpdatePageStateRef, setGuestRecoveryGeneration, - setBrowserZoomPercent, focusAddressBarNow, syncNavigationState, syncBrowserAnnotationViewportBridge @@ -259,13 +254,6 @@ export function createBrowserPageWebviewGuestSession({ if (!queuedAnnotationViewportBridgeSync) { syncBrowserAnnotationViewportBridge() } - // Why: Chromium restores per-origin zoom on reload/navigation, so reassert THIS pane's level after - // every guest load. Uses the pane-local level, not the shared setting, so reloading one tab never - // adopts a zoom the user applied to a different tab. - const appliedLevel = setBrowserPageZoomLevel(webview, paneZoomLevelRef.current) - if (appliedLevel !== null) { - setBrowserZoomPercent(browserPageZoomLevelToPercent(appliedLevel)) - } // Why: CDP viewport overrides are scoped to the debugger session and don't survive cross-origin nav, so reapply (idempotently) on dom-ready. const presetId = viewportPresetIdRef.current const preset = getBrowserViewportPreset(presetId) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts deleted file mode 100644 index 16e942b8166..00000000000 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - applyBrowserPageZoom, - browserPageZoomLevelToPercent, - forgetExplicitBrowserPageZoomLevel, - getBrowserPageZoomIndicatorState, - getExplicitBrowserPageZoomLevel, - nextBrowserPageZoomLevel, - normalizeBrowserPageZoomLevel, - rememberExplicitBrowserPageZoomLevel, - setBrowserPageZoomLevel -} from './browser-page-zoom' - -describe('browserPageZoomLevelToPercent', () => { - it('maps Electron zoom levels to Chromium-style percentages', () => { - expect(browserPageZoomLevelToPercent(0)).toBe(100) - expect(browserPageZoomLevelToPercent(0.5)).toBe(110) - expect(browserPageZoomLevelToPercent(-0.5)).toBe(91) - expect(browserPageZoomLevelToPercent(5)).toBe(249) - }) -}) - -describe('normalizeBrowserPageZoomLevel', () => { - it('rounds to supported steps and clamps to Electron zoom bounds', () => { - expect(normalizeBrowserPageZoomLevel(1.24)).toBe(1) - expect(normalizeBrowserPageZoomLevel(1.26)).toBe(1.5) - expect(normalizeBrowserPageZoomLevel(10)).toBe(5) - expect(normalizeBrowserPageZoomLevel(-10)).toBe(-3) - expect(normalizeBrowserPageZoomLevel(Number.NaN)).toBe(0) - }) -}) - -describe('nextBrowserPageZoomLevel', () => { - it('steps, clamps, and resets browser page zoom levels', () => { - expect(nextBrowserPageZoomLevel(0, 'in')).toBe(0.5) - expect(nextBrowserPageZoomLevel(0, 'out')).toBe(-0.5) - expect(nextBrowserPageZoomLevel(3, 'reset')).toBe(0) - expect(nextBrowserPageZoomLevel(5, 'in')).toBe(5) - expect(nextBrowserPageZoomLevel(-3, 'out')).toBe(-3) - }) - - it('resets to the configured default zoom level', () => { - expect(nextBrowserPageZoomLevel(3, 'reset', 1)).toBe(1) - expect(nextBrowserPageZoomLevel(3, 'reset', 1.26)).toBe(1.5) - }) -}) - -describe('applyBrowserPageZoom', () => { - it('applies the next zoom level to a live webview', () => { - const webview = { - getZoomLevel: vi.fn(() => 1), - setZoomLevel: vi.fn() - } - - expect(applyBrowserPageZoom(webview, 'in')).toBe(1.5) - expect(webview.setZoomLevel).toHaveBeenCalledWith(1.5) - }) - - it('resets the webview to the configured default zoom level', () => { - const webview = { - getZoomLevel: vi.fn(() => 2), - setZoomLevel: vi.fn() - } - - expect(applyBrowserPageZoom(webview, 'reset', 1)).toBe(1) - expect(webview.setZoomLevel).toHaveBeenCalledWith(1) - }) - - it('returns null for missing or destroyed webviews', () => { - expect(applyBrowserPageZoom(null, 'in')).toBeNull() - expect( - applyBrowserPageZoom( - { - isDestroyed: () => true, - getZoomLevel: vi.fn(() => 0), - setZoomLevel: vi.fn() - }, - 'out' - ) - ).toBeNull() - }) - - it('returns null when webview zoom methods throw', () => { - const getZoomFailure = { - getZoomLevel: vi.fn(() => { - throw new Error('detached') - }), - setZoomLevel: vi.fn() - } - const setZoomFailure = { - getZoomLevel: vi.fn(() => 0), - setZoomLevel: vi.fn(() => { - throw new Error('destroyed') - }) - } - - expect(applyBrowserPageZoom(getZoomFailure, 'in')).toBeNull() - expect(applyBrowserPageZoom(setZoomFailure, 'out')).toBeNull() - }) -}) - -describe('setBrowserPageZoomLevel', () => { - it('normalizes and applies an explicit zoom level', () => { - const webview = { - getZoomLevel: vi.fn(() => 0), - setZoomLevel: vi.fn() - } - - expect(setBrowserPageZoomLevel(webview, 1.26)).toBe(1.5) - expect(webview.setZoomLevel).toHaveBeenCalledWith(1.5) - }) - - it('restores the configured level when Chromium carries zoom across reloads', () => { - let live = 0.5 - const webview = { - getZoomLevel: vi.fn(() => live), - setZoomLevel: vi.fn((level: number) => { - live = level - }) - } - - expect(setBrowserPageZoomLevel(webview, 0)).toBe(0) - expect(webview.setZoomLevel).toHaveBeenNthCalledWith(1, 0) - // Chromium hands the stale level back again on the next load. - live = 0.5 - expect(setBrowserPageZoomLevel(webview, 0)).toBe(0) - expect(webview.setZoomLevel).toHaveBeenNthCalledWith(2, 0) - }) - - // Why: HostZoomMap is keyed by host per partition, so even a no-op write - // overwrites the host-wide zoom a sibling tab on the same hostname set. - it('does not write when the webview already holds the level', () => { - const webview = { - getZoomLevel: vi.fn(() => 0), - setZoomLevel: vi.fn() - } - - expect(setBrowserPageZoomLevel(webview, 0)).toBe(0) - expect(webview.setZoomLevel).not.toHaveBeenCalled() - }) - - it('treats an unnormalized held level as already applied', () => { - const webview = { - getZoomLevel: vi.fn(() => 1.26), - setZoomLevel: vi.fn() - } - - expect(setBrowserPageZoomLevel(webview, 1.5)).toBe(1.5) - expect(webview.setZoomLevel).not.toHaveBeenCalled() - }) -}) - -// A guest webview outlives its React pane (worktree switch, Settings visit), so -// the level the USER applied has to outlive the pane too — otherwise the pane -// re-seeds from the shared Settings default and hijacks an already-zoomed tab. -describe('explicit pane zoom levels', () => { - it('reports null until the user zooms the tab', () => { - forgetExplicitBrowserPageZoomLevel('page-1') - expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull() - }) - - it('survives a pane remount so a later Settings default cannot hijack the tab', () => { - forgetExplicitBrowserPageZoomLevel('page-1') - rememberExplicitBrowserPageZoomLevel('page-1', 1.5) - - // Remount: the pane seeds from the explicit level, not the shared default. - const settingsDefault = 0 - expect(getExplicitBrowserPageZoomLevel('page-1') ?? settingsDefault).toBe(1.5) - }) - - it('is dropped when the guest is destroyed so a reused id cannot inherit it', () => { - rememberExplicitBrowserPageZoomLevel('page-1', 1.5) - forgetExplicitBrowserPageZoomLevel('page-1') - expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull() - }) - - it('keeps tabs independent', () => { - forgetExplicitBrowserPageZoomLevel('page-2') - rememberExplicitBrowserPageZoomLevel('page-1', 1.5) - expect(getExplicitBrowserPageZoomLevel('page-2')).toBeNull() - }) -}) - -/** - * Models BrowserPagePane's zoom wiring: each pane keeps its own level, dom-ready reasserts - * that level, and zooming also writes the shared `browserDefaultZoomLevel` setting. - */ -describe('browser pane zoom across reloads', () => { - function createPane(sharedSetting: { level: number }) { - // Chromium remembers zoom per origin and replays it on reload. - const originZoom = new Map() - let url = 'https://a.example' - let live = 0 - const webview = { - getZoomLevel: () => live, - setZoomLevel: (level: number) => { - live = level - originZoom.set(url, level) - } - } - let paneLevel = sharedSetting.level - webview.setZoomLevel(paneLevel) - - return { - get level() { - return live - }, - zoom(direction: 'in' | 'out' | 'reset') { - const next = applyBrowserPageZoom(webview, direction) - if (next !== null) { - paneLevel = next - sharedSetting.level = next - } - }, - load(nextUrl = url) { - url = nextUrl - live = originZoom.get(url) ?? 0 - setBrowserPageZoomLevel(webview, paneLevel) - } - } - } - - it('reasserts the pane level after normal and hard reloads', () => { - const setting = { level: 0 } - const pane = createPane(setting) - - // Chromium hands back a stale 150% for this origin on reload. - pane.load() - expect(pane.level).toBe(0) - pane.load() - expect(pane.level).toBe(0) - }) - - it('keeps an explicit non-default configured zoom across a reload', () => { - const setting = { level: 1.5 } - const pane = createPane(setting) - expect(pane.level).toBe(1.5) - - pane.load() - expect(pane.level).toBe(1.5) - }) - - it('resets to 100% on reset even after zooming moved the shared setting', () => { - const setting = { level: 0 } - const pane = createPane(setting) - - pane.zoom('in') - pane.zoom('in') - expect(pane.level).toBe(1) - expect(setting.level).toBe(1) - - // Regression: resetting toward the shared setting would be a fixed point and never move. - pane.zoom('reset') - expect(pane.level).toBe(0) - }) - - it('does not adopt another tab zoom when reloading an untouched tab', () => { - const setting = { level: 0 } - const tabA = createPane(setting) - const tabB = createPane(setting) - - tabB.zoom('in') - tabB.zoom('in') - expect(tabB.level).toBe(1) - expect(setting.level).toBe(1) - expect(tabA.level).toBe(0) - - // Regression: reasserting the shared setting would silently zoom tab A to tab B's level. - tabA.load() - expect(tabA.level).toBe(0) - }) - - it('keeps a zoomed tab at its own level across reload and cross-origin navigation', () => { - const setting = { level: 0 } - const pane = createPane(setting) - - pane.zoom('in') - expect(pane.level).toBe(0.5) - - pane.load() - expect(pane.level).toBe(0.5) - pane.load('https://b.example') - expect(pane.level).toBe(0.5) - }) -}) - -/** - * Chromium's HostZoomMap is keyed by HOST per partition, so two tabs on one - * hostname share live zoom and a reassert by either moves both. Suppressing the - * reassert to protect the sibling is exactly what #10800 fixed (an - * externally-changed level must snap back on reload), so the two requirements - * are the same write seen from opposite sides. What IS in our control is not - * emitting a redundant host-wide write. - */ -describe('browser panes sharing one hostname', () => { - it('skips the host-wide write when the pane already holds its level', () => { - let hostLevel = 0 - const webviewFor = (): { getZoomLevel: () => number; setZoomLevel: (n: number) => void } => ({ - getZoomLevel: () => hostLevel, - setZoomLevel: (level: number) => { - hostLevel = level - } - }) - const tabA = webviewFor() - const tabB = webviewFor() - - expect(applyBrowserPageZoom(tabA, 'in')).toBe(0.5) - // Tab B reasserts the level the host already carries: no write at all. - setBrowserPageZoomLevel(tabB, 0.5) - - expect(hostLevel).toBe(0.5) - }) -}) - -describe('getBrowserPageZoomIndicatorState', () => { - it('shows browser zoom percent only while feedback is active', () => { - expect( - getBrowserPageZoomIndicatorState({ feedbackVisible: true, isDefaultZoom: false }) - ).toEqual({ - ariaHidden: false, - opacityClassName: 'opacity-100' - }) - expect( - getBrowserPageZoomIndicatorState({ feedbackVisible: false, isDefaultZoom: false }) - ).toEqual({ - ariaHidden: true, - opacityClassName: 'opacity-0' - }) - }) -}) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.ts index 983c46166d0..90f2c1eff64 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-zoom.ts @@ -1,20 +1,6 @@ -import { - DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - nextBrowserPageZoomLevel, - normalizeBrowserPageZoomLevel, - type BrowserPageZoomCommand, - type BrowserPageZoomDirection -} from '../../../../../shared/browser-page-zoom' +import type { BrowserPageZoomCommand } from '../../../../../shared/browser-page-zoom' -export { - BROWSER_PAGE_ZOOM_LEVELS, - DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - browserPageZoomLevelToPercent, - nextBrowserPageZoomLevel, - normalizeBrowserPageZoomLevel, - type BrowserPageZoomCommand, - type BrowserPageZoomDirection -} from '../../../../../shared/browser-page-zoom' +export type { BrowserPageZoomCommand } from '../../../../../shared/browser-page-zoom' export const ORCA_BROWSER_PAGE_ZOOM_EVENT = 'orca:browser-page-zoom' @@ -23,79 +9,11 @@ export type BrowserPageZoomIndicatorState = { opacityClassName: 'opacity-100' | 'opacity-0' } -export type BrowserPageZoomIndicatorInput = { - feedbackVisible: boolean - isDefaultZoom: boolean -} - -type BrowserPageZoomWebview = { - getZoomLevel: () => number - setZoomLevel: (level: number) => void - isDestroyed?: () => boolean -} - -export function applyBrowserPageZoom( - webview: BrowserPageZoomWebview | null | undefined, - direction: BrowserPageZoomDirection, - resetLevel: number = DEFAULT_BROWSER_PAGE_ZOOM_LEVEL -): number | null { - try { - if (!webview || webview.isDestroyed?.()) { - return null - } - const next = nextBrowserPageZoomLevel(webview.getZoomLevel(), direction, resetLevel) - webview.setZoomLevel(next) - return next - } catch { - return null - } -} - -export function setBrowserPageZoomLevel( - webview: BrowserPageZoomWebview | null | undefined, - level: number -): number | null { - try { - if (!webview || webview.isDestroyed?.()) { - return null - } - const next = normalizeBrowserPageZoomLevel(level) - // Why compare first: Chromium's HostZoomMap is keyed by host per partition, - // so a no-op write still overwrites the host-wide zoom a sibling tab on the - // same hostname set. Only write when this pane actually needs to move. - if (normalizeBrowserPageZoomLevel(webview.getZoomLevel()) === next) { - return next - } - webview.setZoomLevel(next) - return next - } catch { - return null - } -} - -// Why module-level: the guest webview outlives its React pane (a worktree -// switch or a Settings visit unmounts the chrome and parks the viewport), so a -// pane-local ref re-seeds from the shared Settings default on every remount and -// retroactively hijacks a tab the user already zoomed. -const explicitPaneZoomLevels = new Map() - -/** The level this tab holds because the USER zoomed it, or null while it is - * still sitting on the configured new-tab seed. */ -export function getExplicitBrowserPageZoomLevel(browserPageId: string): number | null { - return explicitPaneZoomLevels.get(browserPageId) ?? null -} - -export function rememberExplicitBrowserPageZoomLevel(browserPageId: string, level: number): void { - explicitPaneZoomLevels.set(browserPageId, level) -} - -export function forgetExplicitBrowserPageZoomLevel(browserPageId: string): void { - explicitPaneZoomLevels.delete(browserPageId) -} - export function getBrowserPageZoomIndicatorState({ feedbackVisible -}: BrowserPageZoomIndicatorInput): BrowserPageZoomIndicatorState { +}: { + feedbackVisible: boolean +}): BrowserPageZoomIndicatorState { // Why: browser zoom percent is transient feedback; non-default page zoom // should not leave a permanent badge over the webview. return { @@ -104,6 +22,11 @@ export function getBrowserPageZoomIndicatorState({ } } +/** Zoom chords that land in Orca's own window while a browser pane holds focus. */ +export function dispatchBrowserPageZoom(command: BrowserPageZoomCommand): void { + window.dispatchEvent(new CustomEvent(ORCA_BROWSER_PAGE_ZOOM_EVENT, { detail: command })) +} + export function addBrowserPageZoomEventListener( callback: (command: BrowserPageZoomCommand) => void ): () => void { diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx index 322e5e38e40..161eb752e8f 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.split.test.tsx @@ -17,8 +17,6 @@ const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' type FakeWebview = { goBack: ReturnType goForward: ReturnType - getZoomLevel: () => number - setZoomLevel: ReturnType } type PaneSpies = { @@ -31,15 +29,14 @@ let historyNavigate = paneChannel() let reloadRequests = paneChannel() let hardReloadRequests = paneChannel() let zoomRequests = paneChannel() +let zoomPage = vi.fn(() => Promise.resolve(null)) let grabModeToggleListeners: ((browserPageId: string, intent: GrabIntent) => void)[] = [] function createSpies(): PaneSpies { return { webview: { goBack: vi.fn(), - goForward: vi.fn(), - getZoomLevel: () => 0, - setZoomLevel: vi.fn() + goForward: vi.fn() }, reload: vi.fn(), startGrabIntent: vi.fn() @@ -57,10 +54,9 @@ function PaneHarness({ spies: PaneSpies markupIsActive?: boolean }): React.JSX.Element { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only calls the history and zoom members the fake provides. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only calls the history members the fake provides. const webviewRef = useRef(spies.webview as unknown as Electron.WebviewTag) const isActiveRef = useRef(true) - const paneZoomLevelRef = useRef(0) useBrowserPageKeyboardShortcuts({ browserTabId: `page-${id}`, workspaceId: `workspace-${id}`, @@ -69,8 +65,6 @@ function PaneHarness({ isActiveRef, markupIsActive, webviewRef, - paneZoomLevelRef, - setBrowserDefaultZoomLevel: vi.fn(), showBrowserZoomFeedback: vi.fn(), reloadWebviewOrRecoverGuest: spies.reload, startGrabIntent: spies.startGrabIntent, @@ -143,6 +137,7 @@ beforeEach(() => { reloadRequests = paneChannel() hardReloadRequests = paneChannel() zoomRequests = paneChannel() + zoomPage = vi.fn(() => Promise.resolve(null)) grabModeToggleListeners = [] const inert = (): (() => void) => () => {} Object.defineProperty(window, 'api', { @@ -153,7 +148,8 @@ beforeEach(() => { grabModeToggleListeners.push(callback) return () => {} }, - onGrabActionShortcut: inert + onGrabActionShortcut: inert, + zoomPage }, ui: { onBrowserHistoryNavigate: historyNavigate.subscribe, @@ -183,11 +179,10 @@ describe('useBrowserPageKeyboardShortcuts in a split of two active browser panes expect(b.webview.goBack).toHaveBeenCalledTimes(1) expect(b.webview.goForward).toHaveBeenCalledTimes(1) expect(b.reload.mock.calls).toEqual([[false], [true]]) - expect(b.webview.setZoomLevel).toHaveBeenCalledTimes(1) + expect(zoomPage.mock.calls).toEqual([[{ browserPageId: 'page-b', direction: 'in' }]]) expect(a.webview.goBack).not.toHaveBeenCalled() expect(a.webview.goForward).not.toHaveBeenCalled() expect(a.reload).not.toHaveBeenCalled() - expect(a.webview.setZoomLevel).not.toHaveBeenCalled() }) it('answers a chrome chord only in the focused split', () => { diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts index cf882307986..647c63d6065 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts @@ -16,8 +16,6 @@ export function useBrowserPageKeyboardShortcuts({ isActiveRef, markupIsActive, webviewRef, - paneZoomLevelRef, - setBrowserDefaultZoomLevel, showBrowserZoomFeedback, reloadWebviewOrRecoverGuest, startGrabIntent, @@ -31,8 +29,6 @@ export function useBrowserPageKeyboardShortcuts({ isActiveRef: MutableRefObject markupIsActive: boolean webviewRef: MutableRefObject - paneZoomLevelRef: MutableRefObject - setBrowserDefaultZoomLevel: (level: number) => void showBrowserZoomFeedback: (level: number) => void reloadWebviewOrRecoverGuest: (ignoreCache: boolean) => void startGrabIntent: (intent: GrabIntent) => void @@ -48,8 +44,6 @@ export function useBrowserPageKeyboardShortcuts({ chromeShortcutScope, isActiveRef, webviewRef, - paneZoomLevelRef, - setBrowserDefaultZoomLevel, showBrowserZoomFeedback, reloadWebviewOrRecoverGuest }) diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts index 1a35e8e88f9..5dd956ecf14 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts @@ -59,8 +59,6 @@ export function useBrowserPageWebviewLifecycle({ setFindOpen, focusAddressBarNow, focusWebviewNow, - paneZoomLevelRef, - setBrowserZoomPercent, pendingAnnotationPayload, inputLocked, faviconUrl, @@ -99,8 +97,6 @@ export function useBrowserPageWebviewLifecycle({ setFindOpen: Dispatch> focusAddressBarNow: () => boolean focusWebviewNow: () => boolean - paneZoomLevelRef: MutableRefObject - setBrowserZoomPercent: Dispatch> pendingAnnotationPayload: BrowserGrabPayload | null inputLocked: boolean faviconUrl: string | null @@ -254,11 +250,9 @@ export function useBrowserPageWebviewLifecycle({ activeLoadFailureRef, recoveryNavigationValidationRef, keepAddressBarFocusRef, - paneZoomLevelRef, viewportPresetIdRef, onUpdatePageStateRef, setGuestRecoveryGeneration, - setBrowserZoomPercent, focusAddressBarNow, syncNavigationState, syncBrowserAnnotationViewportBridge, diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts index c622f107eb5..f3629784a06 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts @@ -5,13 +5,7 @@ import { keybindingMatchesAction } from '../../../../../shared/keybindings' import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target' import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' import { isEditableKeyboardTarget } from './browser-keyboard' -import { - addBrowserPageZoomEventListener, - applyBrowserPageZoom, - rememberExplicitBrowserPageZoomLevel, - type BrowserPageZoomCommand, - type BrowserPageZoomDirection -} from './browser-page-zoom' +import { addBrowserPageZoomEventListener, type BrowserPageZoomCommand } from './browser-page-zoom' import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** @@ -26,8 +20,6 @@ export function useBrowserPageWebviewShortcuts({ chromeShortcutScope, isActiveRef, webviewRef, - paneZoomLevelRef, - setBrowserDefaultZoomLevel, showBrowserZoomFeedback, reloadWebviewOrRecoverGuest }: { @@ -37,8 +29,6 @@ export function useBrowserPageWebviewShortcuts({ chromeShortcutScope: BrowserChromeShortcutScope isActiveRef: MutableRefObject webviewRef: MutableRefObject - paneZoomLevelRef: MutableRefObject - setBrowserDefaultZoomLevel: (level: number) => void showBrowserZoomFeedback: (level: number) => void reloadWebviewOrRecoverGuest: (ignoreCache: boolean) => void }): void { @@ -156,23 +146,16 @@ export function useBrowserPageWebviewShortcuts({ if (!isActive) { return } - const applyActivePageZoom = (direction: BrowserPageZoomDirection): void => { - if (!isActiveRef.current) { + // Why main: zoom belongs to the page's site in its browser profile, which main remembers. + const handleZoom = ({ browserPageId, direction }: BrowserPageZoomCommand): void => { + if (browserPageId !== browserTabId || !isActiveRef.current) { return } - // Why: reset targets 100% like Chromium; the configured default is a new-tab seed, not a reset target. - const nextLevel = applyBrowserPageZoom(webviewRef.current, direction) - if (nextLevel !== null) { - paneZoomLevelRef.current = nextLevel - rememberExplicitBrowserPageZoomLevel(browserTabId, nextLevel) - setBrowserDefaultZoomLevel(nextLevel) - showBrowserZoomFeedback(nextLevel) - } - } - const handleZoom = ({ browserPageId, direction }: BrowserPageZoomCommand): void => { - if (browserPageId === browserTabId) { - applyActivePageZoom(direction) - } + void window.api.browser.zoomPage({ browserPageId, direction }).then((display) => { + if (display?.site) { + showBrowserZoomFeedback(display.zoomLevel) + } + }) } const removeGuestListener = window.api.ui.onZoomBrowserPage(handleZoom) const removeLocalListener = addBrowserPageZoomEventListener(handleZoom) @@ -180,13 +163,5 @@ export function useBrowserPageWebviewShortcuts({ removeGuestListener() removeLocalListener() } - }, [ - browserTabId, - isActive, - isActiveRef, - paneZoomLevelRef, - setBrowserDefaultZoomLevel, - showBrowserZoomFeedback, - webviewRef - ]) + }, [browserTabId, isActive, isActiveRef, showBrowserZoomFeedback]) } diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts index 09474d21418..835ef7efd58 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts @@ -1,37 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useAppStore } from '@/store' -import { - browserPageZoomLevelToPercent, - DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - getExplicitBrowserPageZoomLevel, - normalizeBrowserPageZoomLevel -} from './browser-page-zoom' +import { browserPageZoomLevelToPercent } from '../../../../../shared/browser-page-zoom' import { BROWSER_PAGE_ZOOM_FEEDBACK_MS } from '../describe-page/browser-annotation-geometry' -export function useBrowserPageZoomFeedback(browserTabId: string): { - paneZoomLevelRef: React.MutableRefObject +export function useBrowserPageZoomFeedback(): { browserZoomPercent: number - setBrowserZoomPercent: React.Dispatch> browserZoomFeedbackVisible: boolean showBrowserZoomFeedback: (level: number) => void - browserDefaultZoomPercent: number - setBrowserDefaultZoomLevel: (level: number) => void } { - const browserDefaultZoomLevel = useAppStore( - (state) => state.browserDefaultZoomLevel ?? DEFAULT_BROWSER_PAGE_ZOOM_LEVEL - ) - const setBrowserDefaultZoomLevel = useAppStore((state) => state.setBrowserDefaultZoomLevel) - const normalizedBrowserDefaultZoomLevel = normalizeBrowserPageZoomLevel(browserDefaultZoomLevel) - const browserDefaultZoomPercent = browserPageZoomLevelToPercent(normalizedBrowserDefaultZoomLevel) - // Why: the level THIS pane should hold. Seeded from the configured default ("applied to newly - // opened browser tabs") and moved only by zooming this pane, so a reload can't broadcast another - // tab's zoom through the shared setting. Why the module-level lookup: the guest webview outlives - // this component (worktree switch, Settings visit), so re-seeding on remount would let a later - // Settings change retroactively hijack a tab the user already zoomed. - const paneZoomLevelRef = useRef(undefined!) - paneZoomLevelRef.current ??= - getExplicitBrowserPageZoomLevel(browserTabId) ?? normalizedBrowserDefaultZoomLevel - const [browserZoomPercent, setBrowserZoomPercent] = useState(browserDefaultZoomPercent) + const [browserZoomPercent, setBrowserZoomPercent] = useState(100) const [browserZoomFeedbackVisible, setBrowserZoomFeedbackVisible] = useState(false) const browserZoomFeedbackTimerRef = useRef>(undefined) useEffect(() => { @@ -48,13 +24,5 @@ export function useBrowserPageZoomFeedback(browserTabId: string): { }, BROWSER_PAGE_ZOOM_FEEDBACK_MS) }, []) - return { - paneZoomLevelRef, - browserZoomPercent, - setBrowserZoomPercent, - browserZoomFeedbackVisible, - showBrowserZoomFeedback, - browserDefaultZoomPercent, - setBrowserDefaultZoomLevel - } + return { browserZoomPercent, browserZoomFeedbackVisible, showBrowserZoomFeedback } } diff --git a/src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts b/src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts index 6aeb2c60f81..23d00848b80 100644 --- a/src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/webview-registry.test.ts @@ -240,21 +240,16 @@ describe('webview registry drag listeners', () => { expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) }) - it('preserves the viewport and zoom only while replacing a guest', async () => { + it('preserves the viewport only while replacing a guest', async () => { const { destroyPersistentWebview, registerPersistentWebview, replacePersistentWebview } = await import('./webview-registry') - const { getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel } = - await import('./browser-page-zoom') registerPersistentWebview('page-1', createWebview()) - rememberExplicitBrowserPageZoomLevel('page-1', 1.5) await replacePersistentWebview('page-1', { preserveViewport: true }) - expect(getExplicitBrowserPageZoomLevel('page-1')).toBe(1.5) expect(viewportMocks.removeBrowserPageViewport).not.toHaveBeenCalled() registerPersistentWebview('page-1', createWebview()) await destroyPersistentWebview('page-1') - expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull() expect(viewportMocks.clearBrowserPageViewportPresetSize).toHaveBeenCalledWith('page-1') expect(viewportMocks.removeBrowserPageViewport).toHaveBeenCalledWith('page-1') }) @@ -303,34 +298,6 @@ describe('webview registry drag listeners', () => { expect(webview.style.pointerEvents).toBe('auto') }) - it('preserves explicit zoom across a parent-drift replacement', async () => { - const { registerPersistentWebview, replacePersistentWebview } = - await import('./webview-registry') - const { getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel } = - await import('./browser-page-zoom') - - registerPersistentWebview('page-1', createWebview()) - rememberExplicitBrowserPageZoomLevel('page-1', 1.5) - - await replacePersistentWebview('page-1', { preserveViewport: true }) - - expect(getExplicitBrowserPageZoomLevel('page-1')).toBe(1.5) - }) - - it('forgets explicit zoom on a real tab close', async () => { - const { destroyPersistentWebview, registerPersistentWebview } = - await import('./webview-registry') - const { getExplicitBrowserPageZoomLevel, rememberExplicitBrowserPageZoomLevel } = - await import('./browser-page-zoom') - - registerPersistentWebview('page-1', createWebview()) - rememberExplicitBrowserPageZoomLevel('page-1', 1.5) - - destroyPersistentWebview('page-1') - - expect(getExplicitBrowserPageZoomLevel('page-1')).toBeNull() - }) - it('moves focus back to the renderer before detaching the focused webview', async () => { const { moveFocusToRendererBeforeWebviewDetach } = await import('./webview-registry') const webview = createWebview() diff --git a/src/renderer/src/components/browser-pane/host-guest/webview-registry.ts b/src/renderer/src/components/browser-pane/host-guest/webview-registry.ts index 0ecd4c475aa..ef035542f28 100644 --- a/src/renderer/src/components/browser-pane/host-guest/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/host-guest/webview-registry.ts @@ -3,7 +3,6 @@ import { clearBrowserPageViewportPresetSize, removeBrowserPageViewport } from './browser-page-viewport' -import { forgetExplicitBrowserPageZoomLevel } from './browser-page-zoom' import { acquireWebviewsDragPassthrough, isWebviewDragPassthroughActive, @@ -252,13 +251,9 @@ export function moveFocusToRendererBeforeWebviewDetach(webview: Electron.Webview function removePersistentWebview( browserTabId: string, - { preserveViewport, preserveZoom }: { preserveViewport: boolean; preserveZoom: boolean } + { preserveViewport }: { preserveViewport: boolean } ): Promise { const webview = webviewRegistry.get(browserTabId) - if (!preserveZoom) { - // The guest is gone, so its user-applied zoom must not be inherited by a later tab that reuses the id. - forgetExplicitBrowserPageZoomLevel(browserTabId) - } if (!webview) { // Why: the viewport can outlive a missing webview entry; tear it down on // explicit close paths so overlay slots do not leak parked shells. @@ -286,15 +281,12 @@ function removePersistentWebview( } export function destroyPersistentWebview(browserTabId: string): Promise { - return removePersistentWebview(browserTabId, { - preserveViewport: false, - preserveZoom: false - }) + return removePersistentWebview(browserTabId, { preserveViewport: false }) } export function replacePersistentWebview( browserTabId: string, { preserveViewport = false }: { preserveViewport?: boolean } = {} ): Promise { - return removePersistentWebview(browserTabId, { preserveViewport, preserveZoom: true }) + return removePersistentWebview(browserTabId, { preserveViewport }) } diff --git a/src/renderer/src/components/settings/BrowserDefaultZoomSetting.tsx b/src/renderer/src/components/settings/BrowserDefaultZoomSetting.tsx index 6c7ef5c5b2a..418ab9f6a50 100644 --- a/src/renderer/src/components/settings/BrowserDefaultZoomSetting.tsx +++ b/src/renderer/src/components/settings/BrowserDefaultZoomSetting.tsx @@ -26,10 +26,10 @@ export function BrowserDefaultZoomSetting({ 'Default Zoom' )} description={translate( - 'auto.components.settings.BrowserDefaultZoomSetting.2622126877', - 'Zoom level applied to newly opened browser tabs.' + 'auto.components.settings.BrowserDefaultZoomSetting.siteDefault', + "Zoom for sites you haven't zoomed." )} - keywords={['browser', 'zoom', 'scale', 'default', 'page zoom', 'new tab', 'percentage']} + keywords={['browser', 'zoom', 'scale', 'default', 'page zoom', 'site', 'percentage']} className="flex items-center justify-between gap-4 py-2" >
@@ -41,8 +41,8 @@ export function BrowserDefaultZoomSetting({

{translate( - 'auto.components.settings.BrowserDefaultZoomSetting.bbeec087d3', - 'Applied to newly opened browser tabs.' + 'auto.components.settings.BrowserDefaultZoomSetting.siteDefault', + "Zoom for sites you haven't zoomed." )}

diff --git a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts index 763c553fcfd..28404732d5f 100644 --- a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts @@ -5,7 +5,8 @@ import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' import { stepUIZoomLevel } from '../../../../shared/ui-zoom-level' import { useAppStore } from '../../store' -import { resolveZoomTarget } from '../resolve-zoom-target' +import { focusedBrowserPageId, resolveZoomTarget } from '../resolve-zoom-target' +import { dispatchBrowserPageZoom } from '@/components/browser-pane/host-guest/browser-page-zoom' import { requestPdfZoom } from '@/components/editor/pdf-zoom-request' export function registerZoomIpcBridge(unsubs: (() => void)[]): void { @@ -29,6 +30,13 @@ export function registerZoomIpcBridge(unsubs: (() => void)[]): void { if (target === 'terminal') { return } + if (target === 'browser') { + const browserPageId = focusedBrowserPageId(document.activeElement) + if (browserPageId) { + dispatchBrowserPageZoom({ browserPageId, direction }) + } + return + } if (target === 'editor') { // Why: a PDF owns its page zoom; editor font zoom would change nothing visible. if (requestPdfZoom(direction)) { diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts index 7c4c491a588..f093bb35085 100644 --- a/src/renderer/src/hooks/resolve-zoom-target.ts +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -13,12 +13,38 @@ function closestFocusedElement(element: unknown, selector: string): unknown { return null } +const BROWSER_PAGE_ID_ATTRIBUTES = [ + 'data-browser-page-pane-id', + 'data-browser-page-viewport-id', + 'data-browser-page-menu-id' +] + +/** + * The browser page that holds focus: its address bar or toolbar, its open "..." menu, or the + * page itself. The page's and the menu both live outside the pane. + */ +export function focusedBrowserPageId(activeElement: unknown): string | null { + for (const attribute of BROWSER_PAGE_ID_ATTRIBUTES) { + const owner = closestFocusedElement(activeElement, `[${attribute}]`) + if ( + typeof owner === 'object' && + owner !== null && + 'getAttribute' in owner && + typeof owner.getAttribute === 'function' + ) { + const browserPageId: unknown = owner.getAttribute(attribute) + return typeof browserPageId === 'string' ? browserPageId : null + } + } + return null +} + /** Zoom belongs to the focused surface within the current workspace. */ export function resolveZoomTarget(args: { activeView: TopLevelView activeTabType: WorkspaceVisibleTabType activeElement: unknown -}): 'terminal' | 'editor' | 'simulator' | 'chat' | 'ui' { +}): 'terminal' | 'editor' | 'simulator' | 'chat' | 'browser' | 'ui' { const { activeView, activeTabType, activeElement } = args const terminalInputFocused = typeof activeElement === 'object' && @@ -37,14 +63,18 @@ export function resolveZoomTarget(args: { ) ) + // Why first: as in Chrome, zooming from the address bar or toolbar zooms the page, and a + // floating browser can hold focus over Tasks or Settings. + if (focusedBrowserPageId(activeElement)) { + return 'browser' + } if (activeView !== 'terminal') { return 'ui' } if (activeTabType === 'simulator') { return 'simulator' } - // Why: keyboard/menu zoom in an active browser tab belongs to Orca chrome. - // Browser page zoom has a dedicated route for wheel and page-specific IPC. + // Why: with focus outside the pane, the chord belongs to Orca's own zoom. if (activeTabType === 'browser') { return 'ui' } diff --git a/src/renderer/src/hooks/zoom-routing.test.ts b/src/renderer/src/hooks/zoom-routing.test.ts index 4ee5624e26b..e458101e4ec 100644 --- a/src/renderer/src/hooks/zoom-routing.test.ts +++ b/src/renderer/src/hooks/zoom-routing.test.ts @@ -7,17 +7,33 @@ function makeTarget(args: { hasXtermClass?: boolean editorClosest?: boolean chatClosest?: boolean + browserPageId?: string + /** The page's sits in a viewport shell outside its pane. */ + viewportPageId?: string }): { classList: { contains: (token: string) => boolean } closest: (selector: string) => object | null } { - const { hasXtermClass = false, editorClosest = false, chatClosest = false } = args + const { + hasXtermClass = false, + editorClosest = false, + chatClosest = false, + browserPageId, + viewportPageId + } = args return { classList: { contains: (token: string) => hasXtermClass && token === 'xterm-helper-textarea' }, - closest: (selector) => - (selector === '[data-native-chat-root]' ? chatClosest : editorClosest) ? {} : null + closest: (selector) => { + if (selector === '[data-browser-page-pane-id]') { + return browserPageId ? { getAttribute: () => browserPageId } : null + } + if (selector === '[data-browser-page-viewport-id]') { + return viewportPageId ? { getAttribute: () => viewportPageId } : null + } + return (selector === '[data-native-chat-root]' ? chatClosest : editorClosest) ? {} : null + } } } @@ -85,6 +101,16 @@ describe('resolveZoomTarget', () => { ).toBe('ui') }) + it('routes to the page whose floating browser holds focus over another view', () => { + expect( + resolveZoomTarget({ + activeView: 'tasks', + activeTabType: 'terminal', + activeElement: makeTarget({ browserPageId: 'page-1' }) + }) + ).toBe('browser') + }) + it('routes to ui zoom for active browser tabs before stale DOM focus', () => { expect( resolveZoomTarget({ @@ -136,6 +162,10 @@ describe('registerZoomIpcBridge', () => { }) const requestPdfZoom = vi.fn(() => pdfTakesZoom) + const dispatchBrowserPageZoom = vi.fn() + vi.doMock('@/components/browser-pane/host-guest/browser-page-zoom', () => ({ + dispatchBrowserPageZoom + })) vi.doMock('@/components/editor/pdf-zoom-request', () => ({ requestPdfZoom })) vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom })) vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged })) @@ -180,6 +210,7 @@ describe('registerZoomIpcBridge', () => { } return { fire, + dispatchBrowserPageZoom, requestPdfZoom, applyUIZoom, dispatchZoomLevelChanged, @@ -229,7 +260,37 @@ describe('registerZoomIpcBridge', () => { expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled() }) - it('applies app zoom for an active browser tab', async () => { + it('zooms the page, not the app, while focus is in its browser pane', async () => { + const zoom = await mountZoomBridge({ + activeTabType: 'browser', + activeElement: makeTarget({ browserPageId: 'page-1' }) + }) + + zoom.fire('in') + zoom.fire('reset') + + expect(zoom.dispatchBrowserPageZoom.mock.calls).toEqual([ + [{ browserPageId: 'page-1', direction: 'in' }], + [{ browserPageId: 'page-1', direction: 'reset' }] + ]) + expect(zoom.applyUIZoom).not.toHaveBeenCalled() + }) + + it('zooms the page from the View menu while the page itself holds focus', async () => { + const zoom = await mountZoomBridge({ + activeTabType: 'browser', + activeElement: makeTarget({ viewportPageId: 'page-1' }) + }) + + zoom.fire('out') + + expect(zoom.dispatchBrowserPageZoom.mock.calls).toEqual([ + [{ browserPageId: 'page-1', direction: 'out' }] + ]) + expect(zoom.applyUIZoom).not.toHaveBeenCalled() + }) + + it('applies app zoom for an active browser tab when focus is outside its pane', async () => { const zoom = await mountZoomBridge({ activeTabType: 'browser' }) zoom.fire('in') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 90fc87002df..30b4434f98a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7401,9 +7401,8 @@ "ee110e1830": "No default base ref" }, "BrowserDefaultZoomSetting": { - "2622126877": "Zoom level applied to newly opened browser tabs.", - "bbeec087d3": "Applied to newly opened browser tabs.", - "265597101f": "Default Zoom" + "265597101f": "Default Zoom", + "siteDefault": "Zoom for sites you haven't zoomed." }, "BrowserHomePageSetting": { "d4ddcd0056": "Save", @@ -17079,7 +17078,16 @@ "bf648471c5": "Creating…", "53bbe3dab4": "Imported {{value0}} cookies from file.", "c5f0e4d3b2a1": "Imported {{value0}} cookies from {{value1}} ({{value2}}).", - "d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}." + "d6a1f5e4c3b2": "Imported {{value0}} cookies from {{value1}}.", + "zoom": "Zoom", + "zoomOut": "Zoom out", + "zoomIn": "Zoom in", + "resetZoom": "Reset zoom", + "appearance": "Appearance", + "appearanceMatchOrca": "Match Orca", + "appearanceLight": "Light", + "appearanceDark": "Dark", + "zoomPercent": "Zoom {{value0}}%" }, "GrabConfirmationSheet": { "314a0aaa5b": "Attach to AI", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 61c7e82e41e..b40c89911c3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -7103,8 +7103,6 @@ "ee110e1830": "Sin ref base predeterminada" }, "BrowserDefaultZoomSetting": { - "2622126877": "Nivel de zoom aplicado a nuevas pestañas del navegador.", - "bbeec087d3": "Se aplica a nuevas pestañas del navegador.", "265597101f": "Zoom predeterminado" }, "BrowserHomePageSetting": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e33e4ffcffb..f2ad7a7cdc2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -7216,8 +7216,6 @@ "ee110e1830": "Aucun ref de base par défaut" }, "BrowserDefaultZoomSetting": { - "2622126877": "Niveau de zoom appliqué aux nouveaux onglets du navigateur.", - "bbeec087d3": "Appliqué aux nouveaux onglets du navigateur.", "265597101f": "Zoom par défaut" }, "BrowserHomePageSetting": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4fd96ca5b7c..b8a52a77e4e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -7093,8 +7093,6 @@ "ee110e1830": "デフォルトの基本参照はありません" }, "BrowserDefaultZoomSetting": { - "2622126877": "新しく開いたブラウザタブに適用されるズームレベル。", - "bbeec087d3": "新しく開いたブラウザタブに適用されます。", "265597101f": "デフォルトのズーム" }, "BrowserHomePageSetting": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1055b2223f5..2fb09657776 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -7093,8 +7093,6 @@ "ee110e1830": "기본 기준 참조 없음" }, "BrowserDefaultZoomSetting": { - "2622126877": "새로 열린 브라우저 탭에 적용되는 확대/축소 수준입니다.", - "bbeec087d3": "새로 열린 브라우저 탭에 적용됩니다.", "265597101f": "기본 줌" }, "BrowserHomePageSetting": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 61ca79cd36a..7b329053098 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -7093,8 +7093,6 @@ "ee110e1830": "没有默认的基本参考" }, "BrowserDefaultZoomSetting": { - "2622126877": "缩放级别应用于新打开的浏览器选项卡。", - "bbeec087d3": "应用于新打开的浏览器选项卡。", "265597101f": "默认缩放" }, "BrowserHomePageSetting": { diff --git a/src/renderer/src/store/slices/browser-webview-cleanup.test.ts b/src/renderer/src/store/slices/browser-webview-cleanup.test.ts index e0b4999664c..22f448b12f8 100644 --- a/src/renderer/src/store/slices/browser-webview-cleanup.test.ts +++ b/src/renderer/src/store/slices/browser-webview-cleanup.test.ts @@ -11,11 +11,6 @@ import { destroyWorktreeBrowserGuests } from './browser-webview-cleanup' import { destroyPersistentWebview } from '../../components/browser-pane/host-guest/webview-registry' -import { - forgetExplicitBrowserPageZoomLevel, - getExplicitBrowserPageZoomLevel, - rememberExplicitBrowserPageZoomLevel -} from '../../components/browser-pane/host-guest/browser-page-zoom' function workspace(id: string): BrowserWorkspace { return { @@ -131,29 +126,4 @@ describe('destroyWorktreeBrowserGuests', () => { expect(destroyPersistentWebview).not.toHaveBeenCalled() }) - - it('re-remembers explicit zoom past the destroy-path forget (eviction is not a close)', () => { - // Mirror the real registry contract: a plain destroy forgets explicit zoom. - vi.mocked(destroyPersistentWebview).mockImplementation((browserTabId: string) => { - forgetExplicitBrowserPageZoomLevel(browserTabId) - return Promise.resolve() - }) - rememberExplicitBrowserPageZoomLevel('page-1', 1.5) - rememberExplicitBrowserPageZoomLevel('legacy-workspace', 0.5) - - destroyWorktreeBrowserGuests( - { 'wt-1': [workspace('workspace-1'), workspace('legacy-workspace')] }, - { 'workspace-1': [page('page-1', 'workspace-1'), page('page-2', 'workspace-1')] }, - 'wt-1' - ) - - expect(getExplicitBrowserPageZoomLevel('page-1')).toBe(1.5) - expect(getExplicitBrowserPageZoomLevel('legacy-workspace')).toBe(0.5) - // A page the user never zoomed stays unremembered. - expect(getExplicitBrowserPageZoomLevel('page-2')).toBeNull() - - forgetExplicitBrowserPageZoomLevel('page-1') - forgetExplicitBrowserPageZoomLevel('legacy-workspace') - vi.mocked(destroyPersistentWebview).mockReset() - }) }) diff --git a/src/renderer/src/store/slices/browser-webview-cleanup.ts b/src/renderer/src/store/slices/browser-webview-cleanup.ts index 426ac77f706..a4a619fc05b 100644 --- a/src/renderer/src/store/slices/browser-webview-cleanup.ts +++ b/src/renderer/src/store/slices/browser-webview-cleanup.ts @@ -3,10 +3,6 @@ import { destroyPersistentWebview, moveFocusToRendererBeforeFocusedWebviewHidden } from '../../components/browser-pane/host-guest/webview-registry' -import { - getExplicitBrowserPageZoomLevel, - rememberExplicitBrowserPageZoomLevel -} from '../../components/browser-pane/host-guest/browser-page-zoom' import { releaseBrowserPageMount } from '../../components/browser-pane/host-guest/browser-page-mount-admission' export { moveFocusToRendererBeforeFocusedWebviewHidden } @@ -57,10 +53,6 @@ export function collectBrowserWebviewIds( // Why: guest-budget eviction destroys every guest a hidden worktree retains // while its tabs/pages stay in the store, so a revisit rebuilds from state. -// Eviction is not a user close — the tab stays in the UI, so the user's zoom -// is re-remembered past the destroy-path forget: the revisit reasserts it -// instead of writing the default through Chromium's partition-wide HostZoomMap -// (which would also reset same-host sibling tabs). export function destroyWorktreeBrowserGuests( browserTabsByWorktree: Record, browserPagesByWorkspace: Record, @@ -72,11 +64,7 @@ export function destroyWorktreeBrowserGuests( // workspace tab id (same fallback as collectBrowserWebviewIds). const guestIds = pages.length === 0 ? [tab.id] : pages.map((page) => page.id) for (const guestId of guestIds) { - const explicitZoomLevel = getExplicitBrowserPageZoomLevel(guestId) destroyRemovedBrowserWebview(guestId) - if (explicitZoomLevel !== null) { - rememberExplicitBrowserPageZoomLevel(guestId, explicitZoomLevel) - } } } } diff --git a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts index 03a317d3e41..cded3dfc0e9 100644 --- a/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts +++ b/src/renderer/src/store/slices/ui-hydration-workspace-preferences.test.ts @@ -375,7 +375,8 @@ describe('createUISlice hydratePersistedUI', () => { }) ) - expect(store.getState().browserDefaultZoomLevel).toBe(1.5) + // 1.26 is about 130%, which snaps to Chrome's 125% preset. + expect(1.2 ** store.getState().browserDefaultZoomLevel).toBeCloseTo(1.25) }) it('persists normalized default browser zoom changes', () => { @@ -385,8 +386,10 @@ describe('createUISlice hydratePersistedUI', () => { store.getState().setBrowserDefaultZoomLevel(10) - expect(store.getState().browserDefaultZoomLevel).toBe(5) - expect(setUI).toHaveBeenCalledWith({ browserDefaultZoomLevel: 5 }) + // Clamped to Chrome's 500% maximum. + const { browserDefaultZoomLevel } = store.getState() + expect(1.2 ** browserDefaultZoomLevel).toBeCloseTo(5) + expect(setUI).toHaveBeenCalledWith({ browserDefaultZoomLevel }) }) it('drops an invalid Kagi session link during hydration', () => { diff --git a/src/shared/browser-page-zoom.test.ts b/src/shared/browser-page-zoom.test.ts new file mode 100644 index 00000000000..3dbfd8b6875 --- /dev/null +++ b/src/shared/browser-page-zoom.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + browserPageZoomLevelToPercent, + nextBrowserPageZoomLevel, + normalizeBrowserPageZoomLevel +} from './browser-page-zoom' + +const percentsFrom = (level: number, direction: 'in' | 'out', steps: number): number[] => { + const percents: number[] = [] + for (let step = 0; step < steps; step++) { + level = nextBrowserPageZoomLevel(level, direction) + percents.push(browserPageZoomLevelToPercent(level)) + } + return percents +} + +describe('browser page zoom presets', () => { + it("steps through Chrome's presets and stops at 25% and 500%", () => { + expect(percentsFrom(0, 'in', 10)).toEqual([110, 125, 150, 175, 200, 250, 300, 400, 500, 500]) + expect(percentsFrom(0, 'out', 8)).toEqual([90, 80, 75, 67, 50, 33, 25, 25]) + }) + + it('snaps levels saved before the presets to the nearest one', () => { + // 0.5-level steps used to give 110%, 131%, 144% and 173%. + expect( + [0.5, 1.5, 2, 3].map(normalizeBrowserPageZoomLevel).map(browserPageZoomLevelToPercent) + ).toEqual([110, 125, 150, 175]) + expect(normalizeBrowserPageZoomLevel(Number.NaN)).toBe(0) + }) + + it('resets to the level it is given', () => { + expect(nextBrowserPageZoomLevel(3, 'reset', normalizeBrowserPageZoomLevel(1.5))).toBe( + normalizeBrowserPageZoomLevel(1.5) + ) + }) +}) diff --git a/src/shared/browser-page-zoom.ts b/src/shared/browser-page-zoom.ts index 591d7e960d2..01549102597 100644 --- a/src/shared/browser-page-zoom.ts +++ b/src/shared/browser-page-zoom.ts @@ -1,34 +1,49 @@ export type BrowserPageZoomDirection = 'in' | 'out' | 'reset' +export function isBrowserPageZoomDirection(value: unknown): value is BrowserPageZoomDirection { + return value === 'in' || value === 'out' || value === 'reset' +} + export type BrowserPageZoomCommand = { browserPageId: string direction: BrowserPageZoomDirection } -export const BROWSER_PAGE_ZOOM_STEP = 0.5 -export const BROWSER_PAGE_ZOOM_MIN = -3 -export const BROWSER_PAGE_ZOOM_MAX = 5 export const DEFAULT_BROWSER_PAGE_ZOOM_LEVEL = 0 -export const BROWSER_PAGE_ZOOM_LEVELS: readonly number[] = Array.from( - { - length: Math.round((BROWSER_PAGE_ZOOM_MAX - BROWSER_PAGE_ZOOM_MIN) / BROWSER_PAGE_ZOOM_STEP) + 1 - }, - (_, index) => BROWSER_PAGE_ZOOM_MIN + index * BROWSER_PAGE_ZOOM_STEP +// Why: Chrome's page zoom presets; Electron levels are log base 1.2 of the zoom factor. +const BROWSER_PAGE_ZOOM_PERCENTS = [ + 25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500 +] as const + +export const BROWSER_PAGE_ZOOM_LEVELS: readonly number[] = BROWSER_PAGE_ZOOM_PERCENTS.map( + (percent) => Math.log(percent / 100) / Math.log(1.2) ) -export function normalizeBrowserPageZoomLevel(value: unknown): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return DEFAULT_BROWSER_PAGE_ZOOM_LEVEL +/** The nearest preset; anything that is not a finite level counts as 100%. */ +function nearestZoomLevelIndex(level: unknown): number { + if (typeof level !== 'number' || !Number.isFinite(level)) { + return BROWSER_PAGE_ZOOM_PERCENTS.indexOf(100) } - const roundedToStep = Math.round(value / BROWSER_PAGE_ZOOM_STEP) * BROWSER_PAGE_ZOOM_STEP - return Math.max(BROWSER_PAGE_ZOOM_MIN, Math.min(BROWSER_PAGE_ZOOM_MAX, roundedToStep)) + let nearest = 0 + for (let index = 1; index < BROWSER_PAGE_ZOOM_LEVELS.length; index++) { + if ( + Math.abs(BROWSER_PAGE_ZOOM_LEVELS[index] - level) < + Math.abs(BROWSER_PAGE_ZOOM_LEVELS[nearest] - level) + ) { + nearest = index + } + } + return nearest +} + +/** Snaps any level, including ones saved before the presets, to the nearest preset. */ +export function normalizeBrowserPageZoomLevel(value: unknown): number { + return BROWSER_PAGE_ZOOM_LEVELS[nearestZoomLevelIndex(value)] } export function browserPageZoomLevelToPercent(level: number): number { - // Why: Electron zoom levels are exponential; show the same percentage users - // expect from Chromium browser zoom controls. - return Math.round(100 * 1.2 ** normalizeBrowserPageZoomLevel(level)) + return BROWSER_PAGE_ZOOM_PERCENTS[nearestZoomLevelIndex(level)] } export function nextBrowserPageZoomLevel( @@ -36,12 +51,10 @@ export function nextBrowserPageZoomLevel( direction: BrowserPageZoomDirection, resetLevel: number = DEFAULT_BROWSER_PAGE_ZOOM_LEVEL ): number { - const rawNext = - direction === 'in' - ? current + BROWSER_PAGE_ZOOM_STEP - : direction === 'out' - ? current - BROWSER_PAGE_ZOOM_STEP - : normalizeBrowserPageZoomLevel(resetLevel) - - return normalizeBrowserPageZoomLevel(rawNext) + if (direction === 'reset') { + return normalizeBrowserPageZoomLevel(resetLevel) + } + const index = nearestZoomLevelIndex(current) + const next = direction === 'in' ? index + 1 : index - 1 + return BROWSER_PAGE_ZOOM_LEVELS[Math.max(0, Math.min(BROWSER_PAGE_ZOOM_LEVELS.length - 1, next))] } diff --git a/src/shared/browser-site-display.ts b/src/shared/browser-site-display.ts new file mode 100644 index 00000000000..5cdb3d3a9e0 --- /dev/null +++ b/src/shared/browser-site-display.ts @@ -0,0 +1,44 @@ +import { BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN } from './browser-site-permission' + +/** A site's own color scheme; a site with none matches Orca's theme. */ +export type BrowserSiteColorScheme = 'light' | 'dark' + +/** What a browser profile remembers about how one site is shown. */ +export type BrowserSiteDisplay = { + zoomLevel?: number + colorScheme?: BrowserSiteColorScheme +} + +/** How the page in a tab is shown right now, for the browser menu. */ +export type BrowserPageSiteDisplay = { + /** Null for pages no site owns; neither setting applies to them. */ + site: string | null + zoomLevel: number + /** False while the site uses Default Zoom. */ + siteHasZoom: boolean + colorScheme: BrowserSiteColorScheme | null +} + +export function isBrowserSiteColorScheme(value: unknown): value is BrowserSiteColorScheme { + return value === 'light' || value === 'dark' +} + +/** + * The hostname, port ignored: Chromium shares page zoom across ports, so `localhost:3000` and + * `localhost:5173` must be one site or they would keep resetting each other. Null for pages no site + * owns (about:blank, data:, chrome errors). + */ +export function browserSiteDisplayKey(url: string): string | null { + try { + const parsed = new URL(url) + if (parsed.protocol === 'file:') { + return BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN + } + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.hostname || null + } + } catch { + // Not a URL; no site. + } + return null +}