From ade67ab1f2a21e36637d59b0f6578e1fbfab6da8 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 13:32:13 -0700 Subject: [PATCH] fix(browser): expire abandoned extension menu snapshots (#35167) * fix(browser): expire abandoned extension menu snapshots * Keep menu snapshot expiry cases in a focused host suite --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- ...rowser-extension-context-menu-host.test.ts | 120 ++++++++++++++++++ .../browser-extension-context-menu-host.ts | 33 ++++- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 src/main/browser/extensions/browser-extension-context-menu-host.test.ts diff --git a/src/main/browser/extensions/browser-extension-context-menu-host.test.ts b/src/main/browser/extensions/browser-extension-context-menu-host.test.ts new file mode 100644 index 00000000000..a2302ecde40 --- /dev/null +++ b/src/main/browser/extensions/browser-extension-context-menu-host.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { StoredContextMenuItem } from './browser-extension-profile-state-store' + +const mocks = vi.hoisted(() => ({ + items: Array(), + emit: vi.fn() +})) +const VAULT = 'a'.repeat(32) + +vi.mock('./api/web-navigation', () => ({ chromeFrameId: () => 0 })) +vi.mock('./api/permissions', () => ({ noteBrowserExtensionUserGesture: vi.fn() })) +vi.mock('./browser-extension-api-host', () => ({ + emitBrowserExtensionEvent: mocks.emit, + getBrowserExtensionProfileIdForSession: () => 'default' +})) +vi.mock('./browser-extension-active-tab', () => ({ grantBrowserExtensionActiveTab: vi.fn() })) +vi.mock('./browser-extension-document-ids', () => ({ getBrowserExtensionDocumentId: () => null })) +vi.mock('./browser-extension-install-store', () => ({ + getBrowserExtensionInstallStore: () => ({ get: () => null }) +})) +vi.mock('./browser-extension-namespace-registry', () => ({ extensionHasPermission: () => true })) +vi.mock('./browser-extension-profile-state-store', () => ({ + getBrowserExtensionProfileStateStore: () => ({ read: () => ({ contextMenus: mocks.items }) }) +})) +vi.mock('./browser-extension-session-loader', () => ({ + getBrowserExtensionSessionForProfile: () => ({ + extensions: { getExtension: (id: string) => ({ id, name: 'Vault' }) } + }) +})) +vi.mock('./browser-extension-tab-model', () => ({ + resolveBrowserExtensionTab: () => null, + describeBrowserExtensionTab: vi.fn() +})) + +import { createContextMenuItem } from './browser-extension-context-menu-model' +import * as menuHost from './browser-extension-context-menu-host' + +beforeEach(() => mocks.emit.mockClear()) + +describe('context-menu snapshot expiry', () => { + it('expires abandoned menu tokens without another menu being opened', async () => { + mocks.items = createContextMenuItem([], { id: 'expired', title: 'Expired', contexts: ['all'] }) + vi.useFakeTimers() + try { + const tokens = Array.from( + { length: 100 }, + () => menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? '' + ) + const pendingTimers = vi.getTimerCount() + await vi.advanceTimersByTimeAsync(600_001) + const results = tokens.map((token) => + menuHost.runBrowserExtensionContextMenuClick({ + token, + extensionId: VAULT, + key: '"expired"' + }) + ) + + expect(results).toEqual(Array(100).fill(false)) + expect(mocks.emit.mock.calls).toEqual([]) + expect(pendingTimers).toBe(1) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('keeps a later menu usable when an older menu expires and clears its last timer', async () => { + mocks.items = createContextMenuItem([], { id: 'fresh', title: 'Fresh', contexts: ['all'] }) + vi.useFakeTimers() + try { + const oldToken = + menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? '' + await vi.advanceTimersByTimeAsync(300_001) + const freshToken = + menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? '' + await vi.advanceTimersByTimeAsync(300_000) + const oldResult = menuHost.runBrowserExtensionContextMenuClick({ + token: oldToken, + extensionId: VAULT, + key: '"fresh"' + }) + const freshResult = menuHost.runBrowserExtensionContextMenuClick({ + token: freshToken, + extensionId: VAULT, + key: '"fresh"' + }) + + expect(oldResult).toBe(false) + expect(freshResult).toBe(true) + expect( + menuHost.runBrowserExtensionContextMenuClick({ + token: freshToken, + extensionId: VAULT, + key: '"fresh"' + }) + ).toBe(false) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('refuses an expired token when the wall clock advances before its timer runs', async () => { + mocks.items = createContextMenuItem([], { id: 'jump', title: 'Jump', contexts: ['all'] }) + vi.useFakeTimers() + try { + const token = + menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? '' + vi.setSystemTime(Date.now() + 600_001) + + expect( + menuHost.runBrowserExtensionContextMenuClick({ token, extensionId: VAULT, key: '"jump"' }) + ).toBe(false) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/browser/extensions/browser-extension-context-menu-host.ts b/src/main/browser/extensions/browser-extension-context-menu-host.ts index f6ef1867d38..4c983973f2c 100644 --- a/src/main/browser/extensions/browser-extension-context-menu-host.ts +++ b/src/main/browser/extensions/browser-extension-context-menu-host.ts @@ -50,6 +50,7 @@ type MenuSnapshot = { const SNAPSHOT_TTL_MS = 10 * 60_000 const snapshots = new Map() +let expiryTimer: ReturnType | null = null function pruneSnapshots(now: number): void { for (const [token, snapshot] of snapshots) { @@ -59,6 +60,31 @@ function pruneSnapshots(now: number): void { } } +function scheduleSnapshotExpiry(): void { + const oldest = snapshots.values().next().value + if (expiryTimer || !oldest) { + return + } + const delay = Math.min( + SNAPSHOT_TTL_MS + 1, + Math.max(1, SNAPSHOT_TTL_MS + 1 - (Date.now() - oldest.createdAt)) + ) + expiryTimer = setTimeout(() => { + expiryTimer = null + pruneSnapshots(Date.now()) + scheduleSnapshotExpiry() + }, delay) + expiryTimer.unref() +} + +function forgetSnapshot(token: string): void { + snapshots.delete(token) + if (snapshots.size === 0 && expiryTimer) { + clearTimeout(expiryTimer) + expiryTimer = null + } +} + function topDocumentId(tabId: number | null): string | null { const contents = tabId === null ? null : resolveBrowserExtensionTab(tabId)?.contents return contents ? getBrowserExtensionDocumentId(contents.mainFrame.frameTreeNodeId) : null @@ -126,6 +152,7 @@ export function buildBrowserExtensionPageContextMenu( mediaType: params.mediaType, createdAt: now }) + scheduleSnapshotExpiry() return { token, groups } } @@ -191,7 +218,10 @@ export function runBrowserExtensionContextMenuClick( return false } // Why one-shot: a menu is clicked once; a replayed token must not fire again. - snapshots.delete(click.token) + forgetSnapshot(click.token) + if (Date.now() - snapshot.createdAt > SNAPSHOT_TTL_MS) { + return false + } return runClick(snapshot, click.extensionId, click.key) } @@ -237,5 +267,6 @@ export function buildBrowserExtensionActionContextMenu( editable: false, createdAt: now }) + scheduleSnapshotExpiry() return { token, groups } }