From 5d35d81923d520d79c275a98ac385a078fc4dc5a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:04:19 +0800 Subject: [PATCH 1/8] feat(desktop,ui): multi-tab Browser section mirroring the terminal tab model (CODE-266) --- .../src/__tests__/shell-state.test.ts | 132 +++++++++++++++--- .../shell/browser/browser-webview-pane.tsx | 25 +++- .../src/renderer/src/shell/desktop-shell.tsx | 26 +++- .../src/shell/layout/right-panel-region.tsx | 9 ++ .../src/renderer/src/shell/store/model.ts | 92 ++++++++++-- .../src/renderer/src/shell/store/store.ts | 78 +++++++++-- packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/zh-cn.ts | 1 + packages/ui/src/shell/panels/index.ts | 1 + .../src/shell/panels/section-browser-tabs.tsx | 58 ++++++++ .../ui/src/shell/panels/section-panel.tsx | 22 ++- packages/ui/src/shell/panels/vocabulary.tsx | 5 + 12 files changed, 396 insertions(+), 54 deletions(-) create mode 100644 packages/ui/src/shell/panels/section-browser-tabs.tsx diff --git a/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts b/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts index 400a521df..2b6fe74eb 100644 --- a/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts +++ b/apps/desktop/src/renderer/src/__tests__/shell-state.test.ts @@ -6,9 +6,11 @@ import { createDefaultDesktopShellState, createDefaultRightPanelState, createPanelState, + createRightBrowserTab, createRightFileTab, createRightTerminalTab, DEFAULT_LAYOUT, + openBrowserUrlState, openFileTabState, parsePersistedDesktopShellState, RIGHT_PANEL_MAX_SIZE, @@ -16,7 +18,9 @@ import { SIDEBAR_MAX_SIZE, SIDEBAR_MIN_SIZE, serializeDesktopShellState, + updateBrowserTabState, } from '@renderer/shell/store/model'; +import { createFixedArray } from 'foxts/create-fixed-array'; import { describe, expect, it } from 'vitest'; describe('desktop shell state persistence', () => { @@ -54,9 +58,31 @@ describe('desktop shell state persistence', () => { expect(panelTypes(state.bottomPanel)).toEqual(['terminal']); }); - it('clamps latest layout values', () => { + it('falls back to defaults for a stale v2 payload instead of migrating it', () => { const state = parsePersistedDesktopShellState({ version: 2, + sidebarOpen: false, + layout: DEFAULT_LAYOUT, + expansionStack: [], + rightPanel: { + open: true, + activeSection: 'browser', + terminalTabCount: 1, + activeTerminalTabIndex: 0, + fileTabPaths: [], + activeFileTabIndex: 0, + browserUrl: 'https://example.com', + }, + bottomPanel: { open: true, tabs: ['terminal'], activeTabIndex: 0 }, + }); + + expect(state.sidebarOpen).toBe(true); + expect(state.rightPanel).toEqual(createDefaultRightPanelState()); + }); + + it('clamps latest layout values', () => { + const state = parsePersistedDesktopShellState({ + version: 3, sidebarOpen: true, layout: { sidebarW: 10, rightW: 10000, bottomH: 1 }, expansionStack: [], @@ -78,7 +104,7 @@ describe('desktop shell state persistence', () => { it('rejects invalid bottom tabs and falls back when none remain', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: { sidebarW: SIDEBAR_MAX_SIZE, @@ -101,7 +127,7 @@ describe('desktop shell state persistence', () => { it('restores the right panel section and terminal tab count, clamping the active index', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -122,7 +148,7 @@ describe('desktop shell state persistence', () => { it('rejects an invalid right panel section and falls back to diff', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -140,7 +166,7 @@ describe('desktop shell state persistence', () => { it('caps a corrupted terminal tab count', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: [], @@ -158,7 +184,7 @@ describe('desktop shell state persistence', () => { it('filters expansion stack to open panels', () => { const state = parsePersistedDesktopShellState({ - version: 2, + version: 3, sidebarOpen: true, layout: DEFAULT_LAYOUT, expansionStack: ['right', 'bottom', 'bottom', 'invalid'], @@ -176,12 +202,16 @@ describe('desktop shell state persistence', () => { it('round trips the latest serialized shape', () => { const fileTab = createRightFileTab('/w/PLAN.md'); + const browserTab = createRightBrowserTab('http://web--app-1a2b3c.localhost:19523'); const rightPanel: RightPanelState = { open: true, activeSection: 'browser', terminal: { tabs: [createRightTerminalTab(), createRightTerminalTab()], activeTabId: null }, files: { tabs: [fileTab, createRightFileTab('/w/report.pdf')], activeTabId: fileTab.id }, - browser: { url: 'http://web--app-1a2b3c.localhost:19523' }, + browser: { + tabs: [createRightBrowserTab('https://example.com'), browserTab], + activeTabId: browserTab.id, + }, }; const source: DesktopShellState = { sidebarOpen: false, @@ -206,25 +236,95 @@ describe('desktop shell state persistence', () => { '/w/report.pdf', ]); expect(parsed.rightPanel.files.activeTabId).toBe(parsed.rightPanel.files.tabs[0].id); - expect(parsed.rightPanel.browser.url).toBe('http://web--app-1a2b3c.localhost:19523'); + expect(parsed.rightPanel.browser.tabs.map((tab) => tab.url)).toEqual([ + 'https://example.com', + 'http://web--app-1a2b3c.localhost:19523', + ]); + expect(parsed.rightPanel.browser.activeTabId).toBe(parsed.rightPanel.browser.tabs[1].id); expect(panelTypes(parsed.bottomPanel)).toEqual(['files']); }); - it('drops renderer-scoped blob URLs from persisted browser state', () => { + it('keeps blob-URL tabs but drops their renderer-scoped URLs', () => { const source = createDefaultDesktopShellState(); - source.rightPanel.browser.url = 'blob:http://localhost:5173/expired-preview'; + source.rightPanel.browser = { + tabs: [ + createRightBrowserTab('blob:http://localhost:5173/expired-preview'), + createRightBrowserTab('https://example.com'), + ], + activeTabId: null, + }; const serialized = serializeDesktopShellState(source); - expect(serialized.rightPanel.browserUrl).toBeNull(); + expect(serialized.rightPanel.browserTabUrls).toEqual([null, 'https://example.com']); - const parsed = parsePersistedDesktopShellState({ - ...serialized, + const parsed = parsePersistedDesktopShellState(serialized); + expect(parsed.rightPanel.browser.tabs.map((tab) => tab.url)).toEqual([ + null, + 'https://example.com', + ]); + }); + + it('caps restored browser tabs and clamps the active index', () => { + const state = parsePersistedDesktopShellState({ + version: 3, + sidebarOpen: true, + layout: DEFAULT_LAYOUT, + expansionStack: [], rightPanel: { - ...serialized.rightPanel, - browserUrl: 'blob:http://localhost:5173/expired-preview', + open: true, + activeSection: 'browser', + terminalTabCount: 0, + activeTerminalTabIndex: 0, + fileTabPaths: [], + activeFileTabIndex: 0, + browserTabUrls: createFixedArray(30).map((i) => `https://example.com/${i}`), + activeBrowserTabIndex: 99, }, + bottomPanel: { open: false, tabs: ['terminal'], activeTabIndex: 0 }, }); - expect(parsed.rightPanel.browser.url).toBeNull(); + + expect(state.rightPanel.browser.tabs).toHaveLength(20); + expect(state.rightPanel.browser.activeTabId).toBe(state.rightPanel.browser.tabs[19].id); + }); +}); + +describe('openBrowserUrlState', () => { + it('navigates the active tab and resets its stale title', () => { + const tab = { ...createRightBrowserTab('https://old.example'), title: 'Old page' }; + const browser = { tabs: [tab], activeTabId: tab.id }; + + const next = openBrowserUrlState(browser, 'https://new.example'); + + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]).toMatchObject({ id: tab.id, url: 'https://new.example', title: null }); + }); + + it('seeds a first tab when none is active', () => { + const next = openBrowserUrlState({ tabs: [], activeTabId: null }, 'https://example.com'); + + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0].url).toBe('https://example.com'); + expect(next.activeTabId).toBe(next.tabs[0].id); + }); +}); + +describe('updateBrowserTabState', () => { + it('patches only the targeted tab', () => { + const a = createRightBrowserTab('https://a.example'); + const b = createRightBrowserTab('https://b.example'); + const browser = { tabs: [a, b], activeTabId: a.id }; + + const next = updateBrowserTabState(browser, b.id, { title: 'B' }); + + expect(next.tabs[0].title).toBeNull(); + expect(next.tabs[1].title).toBe('B'); + }); + + it('is a no-op for an unknown tab id', () => { + const a = createRightBrowserTab(null); + const browser = { tabs: [a], activeTabId: a.id }; + + expect(updateBrowserTabState(browser, 'missing', { title: 'X' })).toBe(browser); }); }); diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index 18e376808..8937f76c7 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -23,13 +23,19 @@ const IDLE_NAV: WebviewNavState = { }; /** - * The Browser section's Electron ``, mounted once inside the shell's resident + * One browser tab's Electron ``, mounted once inside the shell's resident * panel-content stack (moving a webview in the DOM reloads it) and shown/hidden via visibility. */ -export function BrowserWebviewPane(): React.ReactNode { +export function BrowserWebviewPane({ + tabId, + url, +}: { + tabId: string; + url: string | null; +}): React.ReactNode { const t = useTranslations('workbench.preview.browser'); - const url = useDesktopShellStore((state) => state.rightPanel.browser.url); - const setBrowserUrl = useDesktopShellStore((state) => state.setBrowserUrl); + const setBrowserTabUrl = useDesktopShellStore((state) => state.setBrowserTabUrl); + const setBrowserTabTitle = useDesktopShellStore((state) => state.setBrowserTabTitle); const [webview, setWebview] = useState(null); const [nav, setNav] = useState(IDLE_NAV); // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; @@ -54,10 +60,13 @@ export function BrowserWebviewPane(): React.ReactNode { })); }; const onNavigate = (event: Electron.DidNavigateEvent): void => { - if (!signal.aborted) setBrowserUrl(event.url); + if (!signal.aborted) setBrowserTabUrl(tabId, event.url); setNav((prev) => ({ ...prev, failure: null })); sync(); }; + const onTitleUpdated = (event: Electron.PageTitleUpdatedEvent): void => { + if (!signal.aborted) setBrowserTabTitle(tabId, event.title); + }; const onFail = (event: Electron.DidFailLoadEvent): void => { // -3 = ERR_ABORTED: fired for cancelled loads (e.g. quick re-navigation), not real failures. if (event.errorCode === -3 || !event.isMainFrame || signal.aborted) return; @@ -70,16 +79,18 @@ export function BrowserWebviewPane(): React.ReactNode { webview.addEventListener('did-stop-loading', sync); webview.addEventListener('did-navigate', onNavigate); webview.addEventListener('did-navigate-in-page', onNavigate); + webview.addEventListener('page-title-updated', onTitleUpdated); webview.addEventListener('did-fail-load', onFail); return () => { webview.removeEventListener('did-start-loading', sync); webview.removeEventListener('did-stop-loading', sync); webview.removeEventListener('did-navigate', onNavigate); webview.removeEventListener('did-navigate-in-page', onNavigate); + webview.removeEventListener('page-title-updated', onTitleUpdated); webview.removeEventListener('did-fail-load', onFail); }; }, - [webview, setBrowserUrl, t], + [webview, tabId, setBrowserTabUrl, setBrowserTabTitle, t], ); return ( @@ -89,7 +100,7 @@ export function BrowserWebviewPane(): React.ReactNode { canGoBack={nav.canGoBack} canGoForward={nav.canGoForward} failure={nav.failure} - onNavigate={(next) => setBrowserUrl(next)} + onNavigate={(next) => setBrowserTabUrl(tabId, next)} onBack={() => webview?.goBack()} onForward={() => webview?.goForward()} onReload={() => webview?.reload()} diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 331de7c22..a2235e362 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -138,6 +138,9 @@ export function DesktopShell({ closeRightFileTab: state.closeRightFileTab, setActiveRightFileTab: state.setActiveRightFileTab, openBrowserUrl: state.openBrowserUrl, + addRightBrowserTab: state.addRightBrowserTab, + closeRightBrowserTab: state.closeRightBrowserTab, + setActiveRightBrowserTab: state.setActiveRightBrowserTab, openRightTerminalAttachTab: state.openRightTerminalAttachTab, resetSidebarSize: state.resetSidebarSize, resetRightPanelSize: state.resetRightPanelSize, @@ -257,6 +260,9 @@ export function DesktopShell({ closeRightFileTab, setActiveRightFileTab, openBrowserUrl, + addRightBrowserTab, + closeRightBrowserTab, + setActiveRightBrowserTab, openRightTerminalAttachTab, resetSidebarSize, resetRightPanelSize, @@ -405,6 +411,9 @@ export function DesktopShell({ onSelectFileTab={setActiveRightFileTab} onCloseFileTab={closeRightFileTab} onOpenFileTab={openRightFileTab} + onSelectBrowserTab={setActiveRightBrowserTab} + onCloseBrowserTab={closeRightBrowserTab} + onAddBrowserTab={addRightBrowserTab} onToggleMax={() => toggleMaxPanel('right')} /> ); @@ -455,13 +464,16 @@ export function DesktopShell({ /> ), })); - // The browser webview lives here permanently: unmounting or DOM-moving a webview - // reloads it, so section switches only toggle its visibility. - items.push({ - id: 'browser-resident', - active: rightPanel.activeSection === 'browser', - node: , - }); + // Browser webviews live here permanently: unmounting or DOM-moving a webview + // reloads it, so section and tab switches only toggle visibility. + const activeIsBrowser = rightPanel.activeSection === 'browser'; + for (const tab of rightPanel.browser.tabs) { + items.push({ + id: tab.id, + active: activeIsBrowser && tab.id === rightPanel.browser.activeTabId, + node: , + }); + } return createPortal(, host); } diff --git a/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx b/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx index 6e2798c76..d58639d89 100644 --- a/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx +++ b/apps/desktop/src/renderer/src/shell/layout/right-panel-region.tsx @@ -28,6 +28,9 @@ export function DesktopRightPanelRegion({ onSelectFileTab, onCloseFileTab, onOpenFileTab, + onSelectBrowserTab, + onCloseBrowserTab, + onAddBrowserTab, onToggleMax, }: { panel: RightPanelState; @@ -45,6 +48,9 @@ export function DesktopRightPanelRegion({ onSelectFileTab: (id: string) => void; onCloseFileTab: (id: string) => void; onOpenFileTab: (path: string) => void; + onSelectBrowserTab: (id: string) => void; + onCloseBrowserTab: (id: string) => void; + onAddBrowserTab: () => void; onToggleMax: () => void; }): React.ReactNode { return ( @@ -76,6 +82,9 @@ export function DesktopRightPanelRegion({ onSelectTerminalTab={onSelectTerminalTab} onCloseTerminalTab={onCloseTerminalTab} onAddTerminalTab={onAddTerminalTab} + onSelectBrowserTab={onSelectBrowserTab} + onCloseBrowserTab={onCloseBrowserTab} + onAddBrowserTab={onAddBrowserTab} onToggleMax={onToggleMax} /> ); diff --git a/apps/desktop/src/renderer/src/shell/store/model.ts b/apps/desktop/src/renderer/src/shell/store/model.ts index feaf3b26b..953838fa9 100644 --- a/apps/desktop/src/renderer/src/shell/store/model.ts +++ b/apps/desktop/src/renderer/src/shell/store/model.ts @@ -36,13 +36,21 @@ export interface RightPanelFilesState { activeTabId: string | null; } -/** The right panel's single-instance in-app browser (Electron webview). */ -export interface RightPanelBrowserState { +/** One open page in the right panel's browser section (its own Electron webview). */ +export interface BrowserSectionTab extends PanelSectionTab { url: string | null; + /** Last title reported by the page; null falls back to an index-derived label. */ + title: string | null; +} + +/** The right panel's in-app browser: webview sub-tabs, mirroring the terminal section. */ +export interface RightPanelBrowserState { + tabs: BrowserSectionTab[]; + activeTabId: string | null; } /** The right panel: fixed Diff/Terminal/Browser/Files sections, with per-instance - * sub-tabs for Terminal (PTYs) and Files (viewers). */ + * sub-tabs for Terminal (PTYs), Browser (webviews), and Files (viewers). */ export interface RightPanelState { open: boolean; activeSection: PanelSection; @@ -66,7 +74,7 @@ export interface DesktopShellState { } export interface PersistedDesktopShellState { - version: 2; + version: 3; sidebarOpen: boolean; layout: LayoutState; expansionStack: PanelSide[]; @@ -81,7 +89,9 @@ export interface PersistedRightPanelState { activeTerminalTabIndex: number; fileTabPaths: string[]; activeFileTabIndex: number; - browserUrl: string | null; + /** Null entries are empty tabs, kept so activeBrowserTabIndex stays aligned. */ + browserTabUrls: Array; + activeBrowserTabIndex: number; } export interface PersistedPanelState { @@ -90,7 +100,7 @@ export interface PersistedPanelState { activeTabIndex: number; } -export const DESKTOP_SHELL_STORAGE_KEY = 'linkcode.desktop.shell-state:v2'; +export const DESKTOP_SHELL_STORAGE_KEY = 'linkcode.desktop.shell-state:v3'; export const SIDEBAR_MIN_SIZE = 240; export const SIDEBAR_MAX_SIZE = 520; @@ -120,6 +130,9 @@ const MAX_PERSISTED_RIGHT_TERMINAL_TABS = 20; /** Defensive cap on the file tab count restored from persisted state. */ const MAX_PERSISTED_RIGHT_FILE_TABS = 20; +/** Defensive cap on the browser tab count restored from persisted state. */ +const MAX_PERSISTED_RIGHT_BROWSER_TABS = 20; + let tabSequence = 0; const PanelSideSchema = z.enum(['right', 'bottom']); @@ -162,7 +175,7 @@ export function createDefaultRightPanelState(): RightPanelState { activeSection: 'diff', terminal: { tabs: [], activeTabId: null }, files: { tabs: [], activeTabId: null }, - browser: { url: null }, + browser: { tabs: [], activeTabId: null }, }; } @@ -191,6 +204,11 @@ export function createRightFileTab(path: string): FileSectionTab { return { id: `right-file-${tabSequence}`, path }; } +export function createRightBrowserTab(url: string | null = null): BrowserSectionTab { + tabSequence += 1; + return { id: `right-browser-${tabSequence}`, url, title: null }; +} + /** Removes a section sub-tab, falling back the active tab to a neighbor if it was the one closed. */ export function closeSectionTabState( section: { tabs: Tab[]; activeTabId: string | null }, @@ -217,6 +235,36 @@ export function openFileTabState(files: RightPanelFilesState, path: string): Rig return { tabs: [...files.tabs, tab], activeTabId: tab.id }; } +/** Navigates the active browser tab (title resets until the page reports one), or seeds a first tab. */ +export function openBrowserUrlState( + browser: RightPanelBrowserState, + url: string, +): RightPanelBrowserState { + const active = browser.tabs.find((tab) => tab.id === browser.activeTabId); + if (active) return updateBrowserTabState(browser, active.id, { url, title: null }); + const tab = createRightBrowserTab(url); + return { tabs: [...browser.tabs, tab], activeTabId: tab.id }; +} + +/** The browser section always activates with at least one (possibly empty) tab. */ +export function seedBrowserSection(panel: RightPanelState): RightPanelState { + if (panel.activeSection !== 'browser' || panel.browser.tabs.length > 0) return panel; + const tab = createRightBrowserTab(); + return { ...panel, browser: { tabs: [tab], activeTabId: tab.id } }; +} + +export function updateBrowserTabState( + browser: RightPanelBrowserState, + tabId: string, + patch: Partial>, +): RightPanelBrowserState { + if (!browser.tabs.some((tab) => tab.id === tabId)) return browser; + return { + ...browser, + tabs: browser.tabs.map((tab) => (tab.id === tabId ? { ...tab, ...patch } : tab)), + }; +} + export function pushExpandedPanel(stack: PanelSide[], side: PanelSide): PanelSide[] { return [...removeExpandedPanel(stack, side), side]; } @@ -278,7 +326,7 @@ export function parsePersistedDesktopShellState(value: unknown): DesktopShellSta export function serializeDesktopShellState(state: DesktopShellState): PersistedDesktopShellState { return { - version: 2, + version: 3, sidebarOpen: state.sidebarOpen, layout: normalizeLayout(state.layout), expansionStack: normalizeExpansionStack( @@ -302,7 +350,7 @@ function createPersistedShellStateSchema(): z.ZodType { return z .object({ - version: z.literal(2), + version: z.literal(3), sidebarOpen: z.boolean().catch(fallback.sidebarOpen), layout: PersistedLayoutSchema, expansionStack: PersistedExpansionStackSchema, @@ -370,7 +418,8 @@ function createPersistedRightPanelSchema(): z.ZodType { // Absent in pre-files persisted payloads; the catches make the section start empty. fileTabPaths: z.array(z.string().min(1)).catch([]), activeFileTabIndex: FiniteNumberSchema.int().catch(0), - browserUrl: z.string().min(1).nullable().catch(null), + browserTabUrls: z.array(z.string().min(1).nullable()).catch([]), + activeBrowserTabIndex: FiniteNumberSchema.int().catch(0), }) .catch({ open: fallback.open, @@ -379,7 +428,8 @@ function createPersistedRightPanelSchema(): z.ZodType { activeTerminalTabIndex: 0, fileTabPaths: [], activeFileTabIndex: 0, - browserUrl: null, + browserTabUrls: [], + activeBrowserTabIndex: 0, }) .transform( ({ @@ -389,7 +439,8 @@ function createPersistedRightPanelSchema(): z.ZodType { activeTerminalTabIndex, fileTabPaths, activeFileTabIndex, - browserUrl, + browserTabUrls, + activeBrowserTabIndex, }) => { const tabCount = clamp(terminalTabCount, 0, MAX_PERSISTED_RIGHT_TERMINAL_TABS); const tabs = createFixedArray(tabCount).map(() => createRightTerminalTab()); @@ -399,6 +450,11 @@ function createPersistedRightPanelSchema(): z.ZodType { .map((path) => createRightFileTab(path)); const activeFileIndex = fileTabs.length > 0 ? clamp(activeFileTabIndex, 0, fileTabs.length - 1) : 0; + const browserTabs = browserTabUrls + .slice(0, MAX_PERSISTED_RIGHT_BROWSER_TABS) + .map((url) => createRightBrowserTab(durableBrowserUrl(url))); + const activeBrowserIndex = + browserTabs.length > 0 ? clamp(activeBrowserTabIndex, 0, browserTabs.length - 1) : 0; return { open, @@ -411,7 +467,10 @@ function createPersistedRightPanelSchema(): z.ZodType { tabs: fileTabs, activeTabId: fileTabs.length > 0 ? fileTabs[activeFileIndex].id : null, }, - browser: { url: durableBrowserUrl(browserUrl) }, + browser: { + tabs: browserTabs, + activeTabId: browserTabs.length > 0 ? browserTabs[activeBrowserIndex].id : null, + }, }; }, ); @@ -433,7 +492,12 @@ function serializeRightPanel(panel: RightPanelState): PersistedRightPanelState { 0, Math.max(0, panel.files.tabs.length - 1), ), - browserUrl: durableBrowserUrl(panel.browser.url), + browserTabUrls: panel.browser.tabs.map((tab) => durableBrowserUrl(tab.url)), + activeBrowserTabIndex: clamp( + panel.browser.tabs.findIndex((tab) => tab.id === panel.browser.activeTabId), + 0, + Math.max(0, panel.browser.tabs.length - 1), + ), }; } diff --git a/apps/desktop/src/renderer/src/shell/store/store.ts b/apps/desktop/src/renderer/src/shell/store/store.ts index e9d61fbc0..4999a78dc 100644 --- a/apps/desktop/src/renderer/src/shell/store/store.ts +++ b/apps/desktop/src/renderer/src/shell/store/store.ts @@ -12,17 +12,21 @@ import type { import { closeSectionTabState, createDefaultDesktopShellState, + createRightBrowserTab, createRightTerminalTab, createTab, DEFAULT_LAYOUT, DESKTOP_SHELL_STORAGE_KEY, getExpandedPanel, normalizeLayout, + openBrowserUrlState, openFileTabState, PersistedDesktopShellStateSchema, pushExpandedPanel, removeExpandedPanel, + seedBrowserSection, serializeDesktopShellState, + updateBrowserTabState, } from './model'; /** The bottom panel's window type when it needs to seed a first tab. */ @@ -48,10 +52,15 @@ interface DesktopShellActions { openRightFileTab: (path: string) => void; closeRightFileTab: (id: string) => void; setActiveRightFileTab: (id: string) => void; - /** Navigate the in-app browser and bring the browser section forward. */ + /** Navigate the active browser tab (or open a first one) and bring the browser section forward. */ openBrowserUrl: (url: string) => void; - /** Track a navigation that happened inside the webview (keeps the address bar honest). */ - setBrowserUrl: (url: string | null) => void; + /** Open a new browser tab (empty unless a URL is given) and make it active. */ + addRightBrowserTab: (url?: string) => void; + closeRightBrowserTab: (id: string) => void; + setActiveRightBrowserTab: (id: string) => void; + /** Track a navigation that happened inside a tab's webview (keeps the address bar honest). */ + setBrowserTabUrl: (tabId: string, url: string | null) => void; + setBrowserTabTitle: (tabId: string, title: string | null) => void; /** Attach a viewer tab for a terminal that already exists on the daemon (script logs). */ openRightTerminalAttachTab: (terminalId: string) => void; resetSidebarSize: () => void; @@ -201,14 +210,18 @@ export const useDesktopShellStore = create()( setActiveSection(section) { updateShellState((current) => ({ ...current, - rightPanel: { ...current.rightPanel, activeSection: section }, + rightPanel: seedBrowserSection({ ...current.rightPanel, activeSection: section }), })); }, openRightPanelSection(section) { updateShellState((current) => ({ ...current, - rightPanel: { ...current.rightPanel, open: true, activeSection: section }, + rightPanel: seedBrowserSection({ + ...current.rightPanel, + open: true, + activeSection: section, + }), })); }, @@ -285,15 +298,62 @@ export const useDesktopShellStore = create()( ...current.rightPanel, open: true, activeSection: 'browser', - browser: { url }, + browser: openBrowserUrlState(current.rightPanel.browser, url), + }, + })); + }, + + addRightBrowserTab(url) { + const tab = createRightBrowserTab(url ?? null); + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: { + tabs: [...current.rightPanel.browser.tabs, tab], + activeTabId: tab.id, + }, + }, + })); + }, + + closeRightBrowserTab(id) { + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: closeSectionTabState(current.rightPanel.browser, id), }, })); }, - setBrowserUrl(url) { + setActiveRightBrowserTab(id) { updateShellState((current) => ({ ...current, - rightPanel: { ...current.rightPanel, browser: { url } }, + rightPanel: { + ...current.rightPanel, + browser: { ...current.rightPanel.browser, activeTabId: id }, + }, + })); + }, + + setBrowserTabUrl(tabId, url) { + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: updateBrowserTabState(current.rightPanel.browser, tabId, { url }), + }, + })); + }, + + setBrowserTabTitle(tabId, title) { + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + browser: updateBrowserTabState(current.rightPanel.browser, tabId, { title }), + }, })); }, @@ -331,7 +391,7 @@ export const useDesktopShellStore = create()( }, { name: DESKTOP_SHELL_STORAGE_KEY, - version: 2, + version: 3, schema: PersistedDesktopShellStateSchema, partialize: serializeDesktopShellState, }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 38c1c59d5..3547279d8 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -322,6 +322,7 @@ export const en = { }, openWindow: 'Open window', newTerminalTab: 'New terminal', + newBrowserTab: 'New tab', closeTab: 'Close {label}', fullscreen: 'Fullscreen', restore: 'Restore', diff --git a/packages/i18n/src/locales/zh-cn.ts b/packages/i18n/src/locales/zh-cn.ts index e13a5aaf3..7f8378120 100644 --- a/packages/i18n/src/locales/zh-cn.ts +++ b/packages/i18n/src/locales/zh-cn.ts @@ -313,6 +313,7 @@ export const zhCN = { }, openWindow: '打开窗口', newTerminalTab: '新建终端', + newBrowserTab: '新建标签页', closeTab: '关闭 {label}', fullscreen: '全屏', restore: '还原', diff --git a/packages/ui/src/shell/panels/index.ts b/packages/ui/src/shell/panels/index.ts index 62f6af0e9..ae679a46e 100644 --- a/packages/ui/src/shell/panels/index.ts +++ b/packages/ui/src/shell/panels/index.ts @@ -13,6 +13,7 @@ export { PanelRegion } from './panel-region'; export type { SectionPanelRegionProps, SectionPanelState } from './section-panel'; export { SectionPanelRegion } from './section-panel'; export type { + BrowserPanelSectionTab, PanelControl, PanelSection, PanelSectionTab, diff --git a/packages/ui/src/shell/panels/section-browser-tabs.tsx b/packages/ui/src/shell/panels/section-browser-tabs.tsx new file mode 100644 index 000000000..0b71f40cb --- /dev/null +++ b/packages/ui/src/shell/panels/section-browser-tabs.tsx @@ -0,0 +1,58 @@ +import { PlusIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { cn } from '../../lib/cn'; +import { ShellIconButton } from '../shell-control'; +import { SectionTabButton } from './section-tab-button'; +import type { BrowserPanelSectionTab } from './vocabulary'; +import { PANEL_WINDOW_ICONS } from './vocabulary'; + +/** The browser section's own sub-tab strip, one tab per webview instance. */ +export function SectionBrowserTabStrip({ + tabs, + activeTabId, + onSelectTab, + onCloseTab, + onAddTab, + className, +}: { + tabs: BrowserPanelSectionTab[]; + activeTabId: string | null; + onSelectTab: (id: string) => void; + onCloseTab: (id: string) => void; + onAddTab: () => void; + className?: string; +}): React.ReactNode { + const t = useTranslations('workbench.panel'); + const browserLabel = useTranslations('workbench.panel.window')('browser'); + + return ( +
+
+ {tabs.map((tab, index) => { + const fallback = `${browserLabel} ${index + 1}`; + const label = tab.title !== null && tab.title.length > 0 ? tab.title : fallback; + return ( + onSelectTab(tab.id)} + onClose={() => onCloseTab(tab.id)} + /> + ); + })} + + + +
+
+ ); +} diff --git a/packages/ui/src/shell/panels/section-panel.tsx b/packages/ui/src/shell/panels/section-panel.tsx index 0238c2046..937c5d61d 100644 --- a/packages/ui/src/shell/panels/section-panel.tsx +++ b/packages/ui/src/shell/panels/section-panel.tsx @@ -3,8 +3,9 @@ import { cn } from '../../lib/cn'; import type { ChromeSurface, PanelChromePortalProps } from './chrome-portal'; import { getPanelChromePlacement, PanelContextualChromePortal } from './chrome-portal'; import { PanelContextualControls } from './panel-controls'; +import { SectionBrowserTabStrip } from './section-browser-tabs'; import { SectionTerminalTabStrip } from './section-terminal-tabs'; -import type { PanelSection, PanelSectionTab } from './vocabulary'; +import type { BrowserPanelSectionTab, PanelSection, PanelSectionTab } from './vocabulary'; import { PANEL_SECTIONS, PANEL_TAB_ACTIVE_CLASSNAME, @@ -19,6 +20,10 @@ export interface SectionPanelState { tabs: PanelSectionTab[]; activeTabId: string | null; }; + browser: { + tabs: BrowserPanelSectionTab[]; + activeTabId: string | null; + }; } export interface SectionPanelRegionProps { @@ -41,6 +46,9 @@ export interface SectionPanelRegionProps { onSelectTerminalTab: (id: string) => void; onCloseTerminalTab: (id: string) => void; onAddTerminalTab: () => void; + onSelectBrowserTab: (id: string) => void; + onCloseBrowserTab: (id: string) => void; + onAddBrowserTab: () => void; onToggleMax: () => void; } @@ -62,6 +70,9 @@ export function SectionPanelRegion({ onSelectTerminalTab, onCloseTerminalTab, onAddTerminalTab, + onSelectBrowserTab, + onCloseBrowserTab, + onAddBrowserTab, onToggleMax, }: SectionPanelRegionProps): React.ReactNode { const chromePlacement = getPanelChromePlacement('right', chromeSurface); @@ -78,6 +89,15 @@ export function SectionPanelRegion({ onAddTab={onAddTerminalTab} /> )} + {panel.activeSection === 'browser' && ( + + )}
{/* Sections without inline content (e.g. Browser, whose webview lives in the shell's diff --git a/packages/ui/src/shell/panels/vocabulary.tsx b/packages/ui/src/shell/panels/vocabulary.tsx index f5176871b..cf582529a 100644 --- a/packages/ui/src/shell/panels/vocabulary.tsx +++ b/packages/ui/src/shell/panels/vocabulary.tsx @@ -21,6 +21,11 @@ export interface PanelSectionTab { id: string; } +/** One browser sub-tab; a null title falls back to an index-derived label. */ +export interface BrowserPanelSectionTab extends PanelSectionTab { + title: string | null; +} + export interface PanelControl { id: string; label: string; From 4d2c2f376f8ba164e7c94d4833846b2ebe72c8db Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:09:43 +0800 Subject: [PATCH 2/8] feat(desktop,ipc): route Browser-pane guest popups into a new in-app tab (CODE-266) --- apps/desktop/src/main/window.ts | 23 ++++++++++++------- .../src/renderer/src/shell/desktop-shell.tsx | 6 +++++ .../src/renderer/src/shell/store/store.ts | 18 +++++++++++++++ packages/ipc/src/bridge.ts | 5 ++++ packages/ipc/src/electron-renderer.ts | 10 ++++++++ packages/ipc/src/events.ts | 2 ++ 6 files changed, 56 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 5cf9985aa..45175c256 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -1,7 +1,11 @@ import { release } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { DAEMON_RUNTIME_CHANGED_CHANNEL, UPDATER_STATUS_CHANNEL } from '@linkcode/ipc'; +import { + BROWSER_OPEN_TAB_CHANNEL, + DAEMON_RUNTIME_CHANGED_CHANNEL, + UPDATER_STATUS_CHANNEL, +} from '@linkcode/ipc'; import { bindElectronSystemIpc } from '@linkcode/ipc/electron-main'; import { BrowserWindow, ipcMain, nativeTheme, shell } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; @@ -69,13 +73,6 @@ function createWindow(): BrowserWindow { }, }); - // window.open / target=_blank means "the system browser" everywhere in the app - // (chat links, preview open-external); nothing may spawn a child Electron window. - win.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://') || url.startsWith('https://')) void shell.openExternal(url); - return { action: 'deny' }; - }); - const updateBackgroundColor = (): void => { win.setBackgroundColor(desktopBackgroundColor()); }; @@ -105,6 +102,16 @@ function createWindow(): BrowserWindow { if (isHttpUrl(url)) void shell.openExternal(url); }); + // Browser-pane guests run with `allowpopups`, and popups would otherwise spawn unmanaged + // BrowserWindows: keep untrusted content window-less by rerouting http(s) popups into a new + // in-app browser tab (renderer push) and denying everything else. + win.webContents.on('did-attach-webview', (_event, guest) => { + guest.setWindowOpenHandler(({ url }) => { + if (isHttpUrl(url) && !win.isDestroyed()) win.webContents.send(BROWSER_OPEN_TAB_CHANNEL, url); + return { action: 'deny' }; + }); + }); + void loadRenderer(win); return win; } diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index a2235e362..43c120a15 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -237,6 +237,12 @@ export function DesktopShell({ [systemBridge], ); + const openBrowserTab = useDesktopShellStore((state) => state.openBrowserTab); + useAbortableEffect( + () => systemBridge.browser.onOpenTab(openBrowserTab), + [systemBridge, openBrowserTab], + ); + const active = activeSession; const titledSession = active?.title === undefined ? null : active; const hideMainTitle = draft !== null || (active === null ? false : titledSession === null); diff --git a/apps/desktop/src/renderer/src/shell/store/store.ts b/apps/desktop/src/renderer/src/shell/store/store.ts index 4999a78dc..7c9dc5a14 100644 --- a/apps/desktop/src/renderer/src/shell/store/store.ts +++ b/apps/desktop/src/renderer/src/shell/store/store.ts @@ -54,6 +54,8 @@ interface DesktopShellActions { setActiveRightFileTab: (id: string) => void; /** Navigate the active browser tab (or open a first one) and bring the browser section forward. */ openBrowserUrl: (url: string) => void; + /** Open `url` in a NEW active browser tab and bring the section forward (guest popups). */ + openBrowserTab: (url: string) => void; /** Open a new browser tab (empty unless a URL is given) and make it active. */ addRightBrowserTab: (url?: string) => void; closeRightBrowserTab: (id: string) => void; @@ -303,6 +305,22 @@ export const useDesktopShellStore = create()( })); }, + openBrowserTab(url) { + const tab = createRightBrowserTab(url); + updateShellState((current) => ({ + ...current, + rightPanel: { + ...current.rightPanel, + open: true, + activeSection: 'browser', + browser: { + tabs: [...current.rightPanel.browser.tabs, tab], + activeTabId: tab.id, + }, + }, + })); + }, + addRightBrowserTab(url) { const tab = createRightBrowserTab(url ?? null); updateShellState((current) => ({ diff --git a/packages/ipc/src/bridge.ts b/packages/ipc/src/bridge.ts index d6f8f1bcc..684d66ae3 100644 --- a/packages/ipc/src/bridge.ts +++ b/packages/ipc/src/bridge.ts @@ -60,4 +60,9 @@ export interface SystemBridge { /** Subscribe to notification clicks; main focuses the window, then pushes the `clickToken`. */ onClick(cb: (clickToken: string) => void): () => void; }; + browser: { + /** Subscribe to Browser-pane guest popups (window.open / target=_blank) redirected by main; + * the renderer opens the URL in a new in-app browser tab. */ + onOpenTab(cb: (url: string) => void): () => void; + }; } diff --git a/packages/ipc/src/electron-renderer.ts b/packages/ipc/src/electron-renderer.ts index 90d08a315..14054a10e 100644 --- a/packages/ipc/src/electron-renderer.ts +++ b/packages/ipc/src/electron-renderer.ts @@ -5,6 +5,7 @@ import type { IpcRenderer } from 'electron'; import type { SystemBridge } from './bridge'; import type { DesktopSettings, UpdaterStatus } from './context'; import { + BROWSER_OPEN_TAB_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, DAEMON_URL_SNAPSHOT_CHANNEL, NOTIFICATION_CLICKED_CHANNEL, @@ -97,6 +98,15 @@ export function createElectronSystemBridge( return () => ipcRenderer.removeListener(NOTIFICATION_CLICKED_CHANNEL, handler); }, }, + browser: { + onOpenTab(cb) { + const handler: IpcRendererListener = (_event, value: unknown) => { + if (typeof value === 'string') cb(value); + }; + ipcRenderer.on(BROWSER_OPEN_TAB_CHANNEL, handler); + return () => ipcRenderer.removeListener(BROWSER_OPEN_TAB_CHANNEL, handler); + }, + }, }; } diff --git a/packages/ipc/src/events.ts b/packages/ipc/src/events.ts index c688e9b8c..f9771e621 100644 --- a/packages/ipc/src/events.ts +++ b/packages/ipc/src/events.ts @@ -19,6 +19,8 @@ export const UPDATER_STATUS_CHANNEL = 'linkcode.system.app.updaterStatus'; export const DAEMON_RUNTIME_CHANGED_CHANNEL = 'linkcode.system.daemon.runtimeChanged'; /** Main → renderer push: an OS notification was clicked; payload is its `clickToken`. */ export const NOTIFICATION_CLICKED_CHANNEL = 'linkcode.system.notifications.clicked'; +/** Main → renderer push: a Browser-pane guest page opened a popup; payload is its http(s) URL. */ +export const BROWSER_OPEN_TAB_CHANNEL = 'linkcode.system.browser.openTab'; export const systemIpcEvents = { windowMinimize: defineInvokeEventa('linkcode.system.window.minimize'), From a0660821645e83dd68b370bb6f4a5dadc45fef28 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:21:36 +0800 Subject: [PATCH 3/8] feat(desktop,ui,ipc): browser find-in-page, zoom, devtools menu, download toast (CODE-266) --- apps/desktop/src/main/window.ts | 20 ++- .../shell/browser/browser-webview-pane.tsx | 144 +++++++++++++++--- .../src/renderer/src/shell/desktop-shell.tsx | 15 ++ packages/i18n/src/locales/en.ts | 13 ++ packages/i18n/src/locales/zh-cn.ts | 13 ++ packages/ipc/src/bridge.ts | 3 + packages/ipc/src/context.ts | 6 + packages/ipc/src/electron-renderer.ts | 18 ++- packages/ipc/src/events.ts | 2 + .../ui/src/shell/browser/browser-pane.tsx | 111 +++++++++++++- packages/ui/src/shell/browser/index.ts | 2 +- 11 files changed, 322 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/window.ts b/apps/desktop/src/main/window.ts index 45175c256..60e8319bf 100644 --- a/apps/desktop/src/main/window.ts +++ b/apps/desktop/src/main/window.ts @@ -1,13 +1,15 @@ import { release } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import type { BrowserDownloadDone } from '@linkcode/ipc'; import { + BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, UPDATER_STATUS_CHANNEL, } from '@linkcode/ipc'; import { bindElectronSystemIpc } from '@linkcode/ipc/electron-main'; -import { BrowserWindow, ipcMain, nativeTheme, shell } from 'electron'; +import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; // electron-vite resolves `?asset` to a runtime file path. Used as the Win/Linux window icon in dev // (packaged builds get the real icon from the bundle). macOS uses a separate Dock image set at bootstrap. @@ -19,6 +21,9 @@ import { watchDaemonRuntime } from './daemon-discovery'; import { systemContextFor } from './system-context'; import { onUpdaterStatus } from './updater'; +/** Must match the renderer's Browser-pane ``. */ +const BROWSER_PARTITION = 'persist:linkcode-browser'; + export function createDesktopWindow(): BrowserWindow { const win = createWindow(); const ctx = systemContextFor(win); @@ -39,6 +44,19 @@ export function createDesktopWindow(): BrowserWindow { }); win.once('closed', unwatchRuntime); + // Browser-pane downloads keep Electron's default save flow; the renderer only gets a + // terminal-state push for its toast. + const browserSession = session.fromPartition(BROWSER_PARTITION); + const onWillDownload = (_event: Electron.Event, item: Electron.DownloadItem): void => { + item.once('done', (_doneEvent, state) => { + if (win.isDestroyed()) return; + const result: BrowserDownloadDone = { filename: item.getFilename(), state }; + win.webContents.send(BROWSER_DOWNLOAD_DONE_CHANNEL, result); + }); + }; + browserSession.on('will-download', onWillDownload); + win.once('closed', () => browserSession.removeListener('will-download', onWillDownload)); + return win; } diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index 8937f76c7..aaab97e7e 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -1,7 +1,9 @@ +import { isKeyboardShortcutLocalTarget, useKeyboardShortcut } from '@linkcode/ui'; +import type { BrowserFindState } from '@linkcode/ui/shell/browser'; import { BrowserPane } from '@linkcode/ui/shell/browser'; import type { WebviewTag } from 'electron'; import { useEffect as useAbortableEffect } from 'foxact/use-abortable-effect'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; import { useDesktopShellStore } from '../store/store'; @@ -22,6 +24,14 @@ const IDLE_NAV: WebviewNavState = { failure: null, }; +function whenNotLocal(event: KeyboardEvent): boolean { + return !isKeyboardShortcutLocalTarget(event.target); +} + +/** Chromium's supported zoom-level range (each level is a 1.2× factor step). */ +const MIN_ZOOM_LEVEL = -8; +const MAX_ZOOM_LEVEL = 9; + /** * One browser tab's Electron ``, mounted once inside the shell's resident * panel-content stack (moving a webview in the DOM reloads it) and shown/hidden via visibility. @@ -36,8 +46,10 @@ export function BrowserWebviewPane({ const t = useTranslations('workbench.preview.browser'); const setBrowserTabUrl = useDesktopShellStore((state) => state.setBrowserTabUrl); const setBrowserTabTitle = useDesktopShellStore((state) => state.setBrowserTabTitle); + const rootRef = useRef(null); const [webview, setWebview] = useState(null); const [nav, setNav] = useState(IDLE_NAV); + const [find, setFind] = useState(null); // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. const captureWebview = (element: HTMLWebViewElement | null): void => { @@ -75,12 +87,24 @@ export function BrowserWebviewPane({ failure: t('loadFailed', { error: event.errorDescription }), })); }; + const onFoundInPage = (event: Electron.FoundInPageEvent): void => { + if (signal.aborted) return; + setFind((prev) => + prev === null + ? prev + : { + ...prev, + matches: { active: event.result.activeMatchOrdinal, total: event.result.matches }, + }, + ); + }; webview.addEventListener('did-start-loading', sync); webview.addEventListener('did-stop-loading', sync); webview.addEventListener('did-navigate', onNavigate); webview.addEventListener('did-navigate-in-page', onNavigate); webview.addEventListener('page-title-updated', onTitleUpdated); webview.addEventListener('did-fail-load', onFail); + webview.addEventListener('found-in-page', onFoundInPage); return () => { webview.removeEventListener('did-start-loading', sync); webview.removeEventListener('did-stop-loading', sync); @@ -88,31 +112,109 @@ export function BrowserWebviewPane({ webview.removeEventListener('did-navigate-in-page', onNavigate); webview.removeEventListener('page-title-updated', onTitleUpdated); webview.removeEventListener('did-fail-load', onFail); + webview.removeEventListener('found-in-page', onFoundInPage); }; }, [webview, tabId, setBrowserTabUrl, setBrowserTabTitle, t], ); + const openFind = (): void => { + setFind((prev) => prev ?? { query: '', matches: null }); + }; + const closeFind = (): void => { + webview?.stopFindInPage('clearSelection'); + setFind(null); + }; + const changeFindQuery = (query: string): void => { + setFind({ query, matches: null }); + if (query.length > 0) webview?.findInPage(query); + else webview?.stopFindInPage('clearSelection'); + }; + const stepFind = (forward: boolean): void => { + if (find !== null && find.query.length > 0) { + webview?.findInPage(find.query, { forward, findNext: true }); + } + }; + const zoom = (action: 'in' | 'out' | 'reset'): void => { + if (webview === null) return; + const level = webview.getZoomLevel(); + if (action === 'in') webview.setZoomLevel(Math.min(level + 1, MAX_ZOOM_LEVEL)); + else if (action === 'out') webview.setZoomLevel(Math.max(level - 1, MIN_ZOOM_LEVEL)); + else webview.setZoomLevel(0); + }; + + // Owner-scoped chords: the registry only fires these while this tab's pane is the + // visible (non-inert) item of the resident stack. + useKeyboardShortcut({ + actionId: 'browser.find', + shortcut: { code: 'KeyF', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + openFind(); + return true; + }, + }); + useKeyboardShortcut({ + actionId: 'browser.zoom-in', + shortcut: { code: 'Equal', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + zoom('in'); + return true; + }, + }); + useKeyboardShortcut({ + actionId: 'browser.zoom-out', + shortcut: { code: 'Minus', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + zoom('out'); + return true; + }, + }); + useKeyboardShortcut({ + actionId: 'browser.zoom-reset', + shortcut: { code: 'Digit0', modifiers: ['primary'] }, + owner: rootRef, + when: whenNotLocal, + handler() { + zoom('reset'); + return true; + }, + }); + return ( - setBrowserTabUrl(tabId, next)} - onBack={() => webview?.goBack()} - onForward={() => webview?.goForward()} - onReload={() => webview?.reload()} - > - {url !== null && ( - - )} - +
+ setBrowserTabUrl(tabId, next)} + onBack={() => webview?.goBack()} + onForward={() => webview?.goForward()} + onReload={() => webview?.reload()} + onFindQueryChange={changeFindQuery} + onFindStep={stepFind} + onFindClose={closeFind} + onOpenFind={openFind} + onZoom={zoom} + onOpenDevTools={() => webview?.openDevTools()} + > + {url !== null && ( + + )} + +
); } diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 43c120a15..50805f5a3 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -25,6 +25,7 @@ import { useSelectedHostStore, WorkspaceServicesMenu, } from '@linkcode/workbench'; +import { toastManager } from 'coss-ui/components/toast'; import { useEffect as useAbortableEffect } from 'foxact/use-abortable-effect'; import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; import { useSingleton } from 'foxact/use-singleton'; @@ -243,6 +244,20 @@ export function DesktopShell({ [systemBridge, openBrowserTab], ); + const tBrowser = useTranslations('workbench.preview.browser'); + useAbortableEffect( + () => + systemBridge.browser.onDownloadDone(({ filename, state }) => { + // 'cancelled' is the user dismissing the save dialog — nothing to report. + if (state === 'completed') { + toastManager.add({ title: tBrowser('downloadCompleted', { filename }) }); + } else if (state === 'interrupted') { + toastManager.add({ title: tBrowser('downloadFailed', { filename }), type: 'error' }); + } + }), + [systemBridge, tBrowser], + ); + const active = activeSession; const titledSession = active?.title === undefined ? null : active; const hideMainTitle = draft !== null || (active === null ? false : titledSession === null); diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 3547279d8..ee44145bb 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -511,6 +511,19 @@ export const en = { placeholder: 'Enter an address, or open a preview from Services', empty: 'Open a preview from Services, or type an address', loadFailed: 'Failed to load the page ({error})', + pageMenu: 'Page menu', + findAction: 'Find in page', + findPlaceholder: 'Find…', + findMatches: '{active}/{total}', + findPrevious: 'Previous', + findNext: 'Next', + findClose: 'Close find', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + resetZoom: 'Reset zoom', + openDevTools: 'Open DevTools', + downloadCompleted: 'Downloaded {filename}', + downloadFailed: 'Download failed: {filename}', }, }, agentKind: { diff --git a/packages/i18n/src/locales/zh-cn.ts b/packages/i18n/src/locales/zh-cn.ts index 7f8378120..43ed031e9 100644 --- a/packages/i18n/src/locales/zh-cn.ts +++ b/packages/i18n/src/locales/zh-cn.ts @@ -501,6 +501,19 @@ export const zhCN = { placeholder: '输入地址,或从服务列表打开预览', empty: '从服务列表打开预览,或在地址栏输入网址', loadFailed: '页面加载失败({error})', + pageMenu: '页面菜单', + findAction: '在页面中查找', + findPlaceholder: '查找…', + findMatches: '{active}/{total}', + findPrevious: '上一个', + findNext: '下一个', + findClose: '关闭查找', + zoomIn: '放大', + zoomOut: '缩小', + resetZoom: '重置缩放', + openDevTools: '打开开发者工具', + downloadCompleted: '已下载 {filename}', + downloadFailed: '下载失败:{filename}', }, }, agentKind: { diff --git a/packages/ipc/src/bridge.ts b/packages/ipc/src/bridge.ts index 684d66ae3..7282f5e1e 100644 --- a/packages/ipc/src/bridge.ts +++ b/packages/ipc/src/bridge.ts @@ -1,4 +1,5 @@ import type { + BrowserDownloadDone, DesktopSettings, DesktopSettingsPatch, PickFileOptions, @@ -64,5 +65,7 @@ export interface SystemBridge { /** Subscribe to Browser-pane guest popups (window.open / target=_blank) redirected by main; * the renderer opens the URL in a new in-app browser tab. */ onOpenTab(cb: (url: string) => void): () => void; + /** Subscribe to finished Browser-pane downloads (main default download flow). */ + onDownloadDone(cb: (result: BrowserDownloadDone) => void): () => void; }; } diff --git a/packages/ipc/src/context.ts b/packages/ipc/src/context.ts index 963f767cc..5a6c2bab7 100644 --- a/packages/ipc/src/context.ts +++ b/packages/ipc/src/context.ts @@ -102,3 +102,9 @@ export const UpdaterStatusSchema = z.enum([ 'error', ]); export type UpdaterStatus = z.infer; + +/** Terminal state of a Browser-pane download, pushed main → renderer for a toast. */ +export interface BrowserDownloadDone { + filename: string; + state: 'completed' | 'cancelled' | 'interrupted'; +} diff --git a/packages/ipc/src/electron-renderer.ts b/packages/ipc/src/electron-renderer.ts index 14054a10e..baf5bfb5f 100644 --- a/packages/ipc/src/electron-renderer.ts +++ b/packages/ipc/src/electron-renderer.ts @@ -3,8 +3,9 @@ import { defineInvokes } from '@moeru/eventa'; import { createContext as createRendererContext } from '@moeru/eventa/adapters/electron/renderer'; import type { IpcRenderer } from 'electron'; import type { SystemBridge } from './bridge'; -import type { DesktopSettings, UpdaterStatus } from './context'; +import type { BrowserDownloadDone, DesktopSettings, UpdaterStatus } from './context'; import { + BROWSER_DOWNLOAD_DONE_CHANNEL, BROWSER_OPEN_TAB_CHANNEL, DAEMON_RUNTIME_CHANGED_CHANNEL, DAEMON_URL_SNAPSHOT_CHANNEL, @@ -106,6 +107,21 @@ export function createElectronSystemBridge( ipcRenderer.on(BROWSER_OPEN_TAB_CHANNEL, handler); return () => ipcRenderer.removeListener(BROWSER_OPEN_TAB_CHANNEL, handler); }, + onDownloadDone(cb) { + const handler: IpcRendererListener = (_event, value: unknown) => { + // Main constructs the payload; a shape check is enough in the zod-free preload. + if ( + typeof value === 'object' && + value !== null && + 'filename' in value && + 'state' in value + ) { + cb(value as BrowserDownloadDone); + } + }; + ipcRenderer.on(BROWSER_DOWNLOAD_DONE_CHANNEL, handler); + return () => ipcRenderer.removeListener(BROWSER_DOWNLOAD_DONE_CHANNEL, handler); + }, }, }; } diff --git a/packages/ipc/src/events.ts b/packages/ipc/src/events.ts index f9771e621..0f025566b 100644 --- a/packages/ipc/src/events.ts +++ b/packages/ipc/src/events.ts @@ -21,6 +21,8 @@ export const DAEMON_RUNTIME_CHANGED_CHANNEL = 'linkcode.system.daemon.runtimeCha export const NOTIFICATION_CLICKED_CHANNEL = 'linkcode.system.notifications.clicked'; /** Main → renderer push: a Browser-pane guest page opened a popup; payload is its http(s) URL. */ export const BROWSER_OPEN_TAB_CHANNEL = 'linkcode.system.browser.openTab'; +/** Main → renderer push: a Browser-pane download finished (any terminal state). */ +export const BROWSER_DOWNLOAD_DONE_CHANNEL = 'linkcode.system.browser.downloadDone'; export const systemIpcEvents = { windowMinimize: defineInvokeEventa('linkcode.system.window.minimize'), diff --git a/packages/ui/src/shell/browser/browser-pane.tsx b/packages/ui/src/shell/browser/browser-pane.tsx index 233011b5b..dc4d675f4 100644 --- a/packages/ui/src/shell/browser/browser-pane.tsx +++ b/packages/ui/src/shell/browser/browser-pane.tsx @@ -1,9 +1,27 @@ -import { ArrowLeftIcon, ArrowRightIcon, GlobeIcon, RotateCwIcon } from 'lucide-react'; +import { Button } from 'coss-ui/components/button'; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from 'coss-ui/components/menu'; +import { + ArrowLeftIcon, + ArrowRightIcon, + ChevronDownIcon, + ChevronUpIcon, + EllipsisVerticalIcon, + GlobeIcon, + RotateCwIcon, + SearchIcon, + XIcon, +} from 'lucide-react'; import { useTranslations } from 'use-intl'; import { WebPreviewNavigationButton, WebPreviewUrl } from '../../chat/web-preview'; import { cn } from '../../lib/cn'; import { isAllowedBrowserUrl, normalizeBrowserUrl } from './normalize'; +export interface BrowserFindState { + query: string; + /** 1-based active ordinal / total matches; null before the first result arrives. */ + matches: { active: number; total: number } | null; +} + export interface BrowserPaneProps { /** Current address; null renders the empty state. */ url: string | null; @@ -16,6 +34,15 @@ export interface BrowserPaneProps { onBack: () => void; onForward: () => void; onReload: () => void; + /** Presence renders the find-in-page bar (the host owns open/close state). */ + find?: BrowserFindState | null; + onFindQueryChange?: (query: string) => void; + onFindStep?: (forward: boolean) => void; + onFindClose?: () => void; + /** Presence renders the overflow page menu (find / zoom / devtools). */ + onOpenFind?: () => void; + onZoom?: (action: 'in' | 'out' | 'reset') => void; + onOpenDevTools?: () => void; /** The host's actual browsing surface (desktop: the Electron webview). */ children?: React.ReactNode; className?: string; @@ -32,6 +59,13 @@ export function BrowserPane({ onBack, onForward, onReload, + find, + onFindQueryChange, + onFindStep, + onFindClose, + onOpenFind, + onZoom, + onOpenDevTools, children, className, }: BrowserPaneProps): React.ReactNode { @@ -72,7 +106,82 @@ export function BrowserPane({ placeholder={t('placeholder')} onCommit={commit} /> + {onZoom !== undefined && ( + + + + + } + /> + + + {t('findAction')} + + + onZoom('in')}> + {t('zoomIn')} + + onZoom('out')}> + {t('zoomOut')} + + onZoom('reset')}> + {t('resetZoom')} + + + + {t('openDevTools')} + + + + )}
+ {find != null && ( +
+ + onFindQueryChange?.(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onFindStep?.(!event.shiftKey); + else if (event.key === 'Escape') onFindClose?.(); + }} + /> + {find.matches !== null && ( + + {t('findMatches', { active: find.matches.active, total: find.matches.total })} + + )} + onFindStep?.(false)} + > + + + onFindStep?.(true)} + > + + + onFindClose?.()}> + + +
+ )}
{children} {url === null && ( diff --git a/packages/ui/src/shell/browser/index.ts b/packages/ui/src/shell/browser/index.ts index 18f8f7a1c..21d98d00d 100644 --- a/packages/ui/src/shell/browser/index.ts +++ b/packages/ui/src/shell/browser/index.ts @@ -1,3 +1,3 @@ -export type { BrowserPaneProps } from './browser-pane'; +export type { BrowserFindState, BrowserPaneProps } from './browser-pane'; export { BrowserPane } from './browser-pane'; export { isAllowedBrowserUrl, normalizeBrowserUrl } from './normalize'; From a512697a3b5a07e78b1ef403e91b16cf6def8178 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 20:45:00 +0800 Subject: [PATCH 4/8] fix(desktop): attach-time allowpopups, event-safe add-tab action, browser-tabs E2E (CODE-266) --- apps/desktop/e2e/browser-tabs.e2e.mts | 204 ++++++++++++++++++ apps/desktop/package.json | 1 + .../shell/browser/browser-webview-pane.tsx | 9 +- .../src/renderer/src/shell/store/store.ts | 8 +- 4 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/e2e/browser-tabs.e2e.mts diff --git a/apps/desktop/e2e/browser-tabs.e2e.mts b/apps/desktop/e2e/browser-tabs.e2e.mts new file mode 100644 index 000000000..a44cade5b --- /dev/null +++ b/apps/desktop/e2e/browser-tabs.e2e.mts @@ -0,0 +1,204 @@ +/** + * Multi-tab Browser section E2E (CODE-266): boots an isolated daemon + the built desktop app, + * opens the right panel's Browser section, and asserts the tab strip seeds an empty tab, + * address-bar navigation retitles the tab, "New tab" adds an instance, a guest `target="_blank"` + * popup lands in a new in-app tab, closing works, and tabs survive an app restart. + * Run `pnpm -F @linkcode/desktop e2e:browser-tabs` after building daemon and desktop. + */ + +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { homedir, tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { falseFn, noop } from 'foxts/noop'; +import { wait } from 'foxts/wait'; +import type { ElectronApplication, Page } from 'playwright-core'; +import { _electron } from 'playwright-core'; + +const require = createRequire(import.meta.url); +const desktopDir = resolve(import.meta.dirname, '..'); +const daemonDir = resolve(desktopDir, '../daemon'); +const electronBinary = require('electron') as unknown as string; + +const PORT = 44000 + (process.pid % 1000); + +/** Isolates userData + single-instance lock from any real install: `--user-data-dir` is + * overridden by the app's identity resolution, so a dedicated profile is the only lever. */ +const PROFILE = `e2e-browser-${process.pid}`; + +function fail(message: string): never { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +async function waitForDaemon(): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + await fetch(`http://127.0.0.1:${PORT}/socket.io/?EIO=4&transport=polling`); + return; + } catch { + await wait(250); + } + } + fail(`daemon did not come up on port ${PORT}`); +} + +function startPageServer(): Promise<{ server: Server; port: number }> { + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/html'); + if (req.url === '/popup') { + res.end('E2E Popup Pagepopup'); + return; + } + res.end( + 'E2E Page One' + + 'open popup', + ); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') fail('page server has no port'); + resolve({ server, port: address.port }); + }); + }); +} + +async function waitForTab(win: Page, name: string): Promise { + const button = win.getByRole('button', { name, exact: true }); + await button.waitFor({ state: 'visible', timeout: 15000 }); +} + +async function run(win: Page, app: ElectronApplication, pagePort: number): Promise { + const composer = win.locator('textarea').first(); + await composer.waitFor({ state: 'visible', timeout: 30000 }); + await win.waitForTimeout(1000); + + // Fresh profile: the right panel starts closed — open it, then switch to the Browser section. + await win.getByRole('button', { name: 'Toggle side panel', exact: true }).click(); + await win.getByRole('button', { name: 'Browser', exact: true }).click(); + await win.waitForTimeout(500); + + // Entering the section seeds one empty tab with the index-derived label. + await waitForTab(win, 'Browser 1'); + console.log('browser section seeded one empty tab'); + + // Address-bar navigation: the loaded page's title becomes the tab label. + const address = win.getByPlaceholder('Enter an address, or open a preview from Services'); + await address.fill(`http://127.0.0.1:${pagePort}/`); + await address.press('Enter'); + await waitForTab(win, 'E2E Page One'); + console.log('navigation retitled the tab from the page title'); + + // New tab: a second, empty instance appears and becomes active. + await win.getByRole('button', { name: 'New tab', exact: true }).click(); + await waitForTab(win, 'Browser 2'); + console.log('added a second (empty) tab'); + + // Popup: click the target=_blank link inside the FIRST tab's webview guest page. The guest + // is a separate Playwright page; main must reroute the popup into a new in-app tab. + await win.getByRole('button', { name: 'E2E Page One', exact: true }).click(); + const guest = app.windows().find((page) => page.url().startsWith(`http://127.0.0.1:${pagePort}`)); + if (guest === undefined) fail('did not find the webview guest page'); + await guest.click('#pop'); + await waitForTab(win, 'E2E Popup Page'); + console.log('guest popup landed in a new in-app tab'); + + // Close the popup tab; the neighbor becomes active and the button disappears. + await win.getByRole('button', { name: 'Close E2E Popup Page', exact: true }).click(); + await win.waitForTimeout(500); + const closed = await win + .getByRole('button', { name: 'E2E Popup Page', exact: true }) + .isVisible() + .catch(falseFn); + if (closed) fail('closing the popup tab did not remove it'); + console.log('closed the popup tab'); +} + +async function assertRestored(win: Page): Promise { + const composer = win.locator('textarea').first(); + await composer.waitFor({ state: 'visible', timeout: 30000 }); + // activeSection persisted as browser; the navigated tab reloads and re-reports its title. + await waitForTab(win, 'E2E Page One'); + await waitForTab(win, 'Browser 2'); + console.log('tabs restored after restart'); +} + +async function main(): Promise { + if (!existsSync(join(daemonDir, 'dist/index.js'))) { + fail('apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first'); + } + if (!existsSync(join(desktopDir, 'out/main/index.js'))) { + fail('apps/desktop/out is missing — run `pnpm -F @linkcode/desktop build` first'); + } + + const home = mkdtempSync(join(tmpdir(), 'linkcode-e2e-home-')); + const userData = mkdtempSync(join(tmpdir(), 'linkcode-e2e-userdata-')); + const { server, port } = await startPageServer(); + console.log(`page server on :${port}`); + + let daemon: ChildProcess | null = null; + let app: ElectronApplication | null = null; + let passed = false; + try { + daemon = spawn(process.execPath, ['dist/index.js'], { + cwd: daemonDir, + env: { ...process.env, HOME: home, LINKCODE_PORT: String(PORT), LINKCODE_PROFILE: PROFILE }, + stdio: 'ignore', + }); + await waitForDaemon(); + console.log(`daemon up on :${PORT} (HOME=${home}, profile=${PROFILE})`); + + const launch = (): Promise => + _electron.launch({ + executablePath: electronBinary, + // --lang pins navigator.languages (the renderer's locale source) to English so the + // role-name locators are deterministic on non-English machines. + args: [desktopDir, '--use-mock-keychain', '--lang=en-US'], + env: { ...process.env, HOME: home, LINKCODE_PROFILE: PROFILE }, + }); + + app = await launch(); + let win = await app.firstWindow(); + try { + await run(win, app, port); + await app.close(); + app = await launch(); + win = await app.firstWindow(); + await assertRestored(win); + } catch (error) { + const shot = join(tmpdir(), `linkcode-e2e-browser-tabs-${process.pid}.png`); + await win.screenshot({ path: shot }).catch(noop); + console.error(`screenshot: ${shot}`); + throw error; + } + passed = true; + console.log('PASS'); + } finally { + await app?.close().catch(noop); + daemon?.kill('SIGTERM'); + server.close(); + // Identity resolution ignores $HOME for appData, so the profile's userData lands in the + // real Application Support; always remove that profile universe. + const profileUserData = join( + homedir(), + 'Library/Application Support', + `LinkCode Development (${PROFILE})`, + ); + rmSync(profileUserData, { recursive: true, force: true }); + if (passed) { + rmSync(home, { recursive: true, force: true }); + rmSync(userData, { recursive: true, force: true }); + } else { + console.error(`kept for debugging: HOME=${home} userData=${userData}`); + process.exitCode = 1; + } + } +} + +void main(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 698892f01..14d5361f7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,6 +16,7 @@ "package:devshell": "pnpm run stage:host-runtime && node scripts/build.mts --mode devshell && node scripts/package-app.mts --devshell", "e2e:notifications": "node e2e/notifications.e2e.mts", "e2e:file-tree": "node e2e/file-tree.e2e.mts", + "e2e:browser-tabs": "node e2e/browser-tabs.e2e.mts", "lint": "pnpm --dir ../.. exec eslint --format=sukka apps/desktop" }, "author": "ArcBox Labs ", diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index aaab97e7e..ae71fda39 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -53,9 +53,6 @@ export function BrowserWebviewPane({ // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. const captureWebview = (element: HTMLWebViewElement | null): void => { - // React 19 rejects a boolean value for this custom content attribute. Electron only - // cares about its presence, so set it directly before the guest navigation begins. - element?.toggleAttribute('allowpopups', true); setWebview(element as WebviewTag | null); }; @@ -211,6 +208,12 @@ export function BrowserWebviewPane({ ref={captureWebview} src={url} partition={BROWSER_PARTITION} + // Must be present BEFORE the element attaches — Electron snapshots webview params at + // attach time, and a post-mount toggle leaves popups silently blocked (the guest + // window-open handler is then never consulted; verified via main-process probe). + // @ts-expect-error -- React types this boolean, but React 19 only forwards the + // string form; the empty string is the boolean-attribute-present form. + allowpopups="" className="h-full w-full" /> )} diff --git a/apps/desktop/src/renderer/src/shell/store/store.ts b/apps/desktop/src/renderer/src/shell/store/store.ts index 7c9dc5a14..dfa213a34 100644 --- a/apps/desktop/src/renderer/src/shell/store/store.ts +++ b/apps/desktop/src/renderer/src/shell/store/store.ts @@ -56,8 +56,8 @@ interface DesktopShellActions { openBrowserUrl: (url: string) => void; /** Open `url` in a NEW active browser tab and bring the section forward (guest popups). */ openBrowserTab: (url: string) => void; - /** Open a new browser tab (empty unless a URL is given) and make it active. */ - addRightBrowserTab: (url?: string) => void; + /** Open a new empty browser tab and make it active. */ + addRightBrowserTab: () => void; closeRightBrowserTab: (id: string) => void; setActiveRightBrowserTab: (id: string) => void; /** Track a navigation that happened inside a tab's webview (keeps the address bar honest). */ @@ -321,8 +321,8 @@ export const useDesktopShellStore = create()( })); }, - addRightBrowserTab(url) { - const tab = createRightBrowserTab(url ?? null); + addRightBrowserTab() { + const tab = createRightBrowserTab(); updateShellState((current) => ({ ...current, rightPanel: { From afdd7ca227e764a9878fa38785d54efa9f563e15 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 22:02:09 +0800 Subject: [PATCH 5/8] feat(schema,transport,engine): browser broker wire contract + host routing + broker service (CODE-267) --- .../src/__tests__/browser-broker.test.ts | 134 ++++++++++++++++++ packages/engine/src/browser/broker.ts | 96 +++++++++++++ packages/engine/src/engine.ts | 26 ++++ packages/schema/src/browser.ts | 89 ++++++++++++ packages/schema/src/index.ts | 1 + .../schema/src/wire/__tests__/browser.test.ts | 80 +++++++++++ packages/schema/src/wire/browser.ts | 50 +++++++ packages/schema/src/wire/index.ts | 4 +- packages/transport/src/__tests__/hub.test.ts | 106 ++++++++++++++ packages/transport/src/hub.ts | 43 ++++++ 10 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 packages/engine/src/__tests__/browser-broker.test.ts create mode 100644 packages/engine/src/browser/broker.ts create mode 100644 packages/schema/src/browser.ts create mode 100644 packages/schema/src/wire/__tests__/browser.test.ts create mode 100644 packages/schema/src/wire/browser.ts diff --git a/packages/engine/src/__tests__/browser-broker.test.ts b/packages/engine/src/__tests__/browser-broker.test.ts new file mode 100644 index 000000000..a3d1bbecb --- /dev/null +++ b/packages/engine/src/__tests__/browser-broker.test.ts @@ -0,0 +1,134 @@ +import type { WireMessage } from '@linkcode/schema'; +import type { Transport, Unsubscribe } from '@linkcode/transport'; +import { noop } from 'foxts/noop'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserBrokerService } from '../browser/broker'; + +class FakeTransport implements Transport { + readonly sent: WireMessage[] = []; + + connect(): Promise { + return Promise.resolve(); + } + + send(msg: WireMessage): void { + this.sent.push(msg); + } + + onMessage(): Unsubscribe { + return noop; + } + + onClose(): Unsubscribe { + return noop; + } + + close = noop; +} + +function lastCommandId(transport: FakeTransport): string { + const command = transport.sent.findLast((m) => m.payload.kind === 'browser.command'); + if (command?.payload.kind !== 'browser.command') throw new Error('no browser.command sent'); + return command.payload.commandId; +} + +describe('BrowserBrokerService', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('fails fast with host-unavailable when no host is registered', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + + const result = await broker.dispatch('tabs.list', {}); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('host-unavailable'); + expect(transport.sent).toHaveLength(0); + }); + + it('correlates a settlement back to its dispatch', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tabs.list', {}); + broker.settle(lastCommandId(transport), { ok: true, data: { tabs: [] } }); + + const result = await pending; + expect(result).toEqual({ ok: true, data: { tabs: [] } }); + }); + + it('times out an unanswered command with a retryable closed code', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tab.snapshot', { tabId: 't1' }); + vi.advanceTimersByTime(15000); + + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('timeout'); + expect(result.error.retryable).toBe(true); + } + // A settlement arriving after the timeout is ignored, not crashed on. + broker.settle(lastCommandId(transport), { ok: true }); + }); + + it('fails all pending commands when the host detaches', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tabs.list', {}); + broker.detachHost('host-1'); + + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('host-unavailable'); + expect(broker.available).toBe(false); + }); + + it('ignores a stale detach for a superseded host', () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + broker.registerHost('host-2'); + + broker.detachHost('host-1'); + + expect(broker.available).toBe(true); + }); + + it('fails commands pending against a superseded host on re-registration', async () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + broker.registerHost('host-1'); + + const pending = broker.dispatch('tabs.list', {}); + broker.registerHost('host-2'); + + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('host-unavailable'); + }); + + it('broadcasts availability transitions', () => { + const transport = new FakeTransport(); + const broker = new BrowserBrokerService(transport); + + broker.registerHost('host-1'); + broker.detachHost('host-1'); + + const availability = transport.sent.flatMap((m) => + m.payload.kind === 'browser.host.changed' ? [m.payload.available] : [], + ); + expect(availability).toEqual([true, false]); + }); +}); diff --git a/packages/engine/src/browser/broker.ts b/packages/engine/src/browser/broker.ts new file mode 100644 index 000000000..0ea064b8a --- /dev/null +++ b/packages/engine/src/browser/broker.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto'; +import type { BrowserCommandArgs, BrowserCommandResult, BrowserOp } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; + +const COMMAND_TIMEOUT_MS = 15000; + +interface PendingCommand { + resolve: (result: BrowserCommandResult) => void; + timer: NodeJS.Timeout; +} + +/** + * Dispatches browser ops to the single registered desktop host (CODE-267). The Hub owns + * connection-level targeting (`browser.command` frames reach only the host connection); this + * service owns the request/settlement correlation, the timeout, and availability broadcasts. + * `dispatch` never rejects — every failure is a closed-code {@link BrowserCommandResult}. + */ +export class BrowserBrokerService { + private hostId: string | null = null; + private readonly pending = new Map(); + + constructor(private readonly transport: Transport) {} + + get available(): boolean { + return this.hostId !== null; + } + + /** Last registration wins; commands still pending against the previous host are failed. */ + registerHost(hostId: string): void { + if (this.hostId !== null && this.hostId !== hostId) { + this.failAllPending('superseded by a new browser host'); + } + this.hostId = hostId; + this.broadcastAvailability(); + } + + /** Hub-synthesized on host disconnect; a stale (already superseded) hostId is ignored. */ + detachHost(hostId: string): void { + if (this.hostId !== hostId) return; + this.hostId = null; + this.failAllPending('the browser host disconnected'); + this.broadcastAvailability(); + } + + settle(commandId: string, result: BrowserCommandResult): void { + const pending = this.pending.get(commandId); + if (!pending) return; + this.pending.delete(commandId); + clearTimeout(pending.timer); + pending.resolve(result); + } + + dispatch(op: BrowserOp, args: BrowserCommandArgs): Promise { + if (this.hostId === null) { + return Promise.resolve( + failure('host-unavailable', 'no desktop client is registered as the browser host'), + ); + } + const commandId = randomUUID(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(commandId); + resolve( + failure('timeout', `the browser host did not answer within ${COMMAND_TIMEOUT_MS}ms`), + ); + }, COMMAND_TIMEOUT_MS); + timer.unref(); + this.pending.set(commandId, { resolve, timer }); + this.transport.send(createWireMessage({ kind: 'browser.command', commandId, op, args })); + }); + } + + shutdown(): void { + this.failAllPending('the daemon is shutting down'); + this.hostId = null; + } + + private failAllPending(message: string): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.resolve(failure('host-unavailable', message)); + } + this.pending.clear(); + } + + private broadcastAvailability(): void { + this.transport.send( + createWireMessage({ kind: 'browser.host.changed', available: this.available }), + ); + } +} + +function failure(code: 'host-unavailable' | 'timeout', message: string): BrowserCommandResult { + return { ok: false, error: { code, message, retryable: true } }; +} diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index e0d149f85..36f80a7e2 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -44,6 +44,7 @@ import { ScheduleService, watchTurn, } from './automation'; +import { BrowserBrokerService } from './browser/broker'; import { readWorkspaceFile } from './file-service'; import { FileSuggestService } from './file-suggest-service'; import { GitService } from './git/git-service'; @@ -167,6 +168,7 @@ export class Engine { private readonly scheduler: ScheduleService; private readonly loops: LoopService; private readonly artifactHost: ArtifactHostService; + private readonly browserBroker: BrowserBrokerService; /** Boot snapshot, replaced by every {@link enqueueRuntimesCollect} pass (install/login/auth * events and read-triggered revalidation alike). */ private agentRuntimes: AgentRuntimes; @@ -216,6 +218,7 @@ export class Engine { ) : undefined; this.artifactHost = new ArtifactHostService(routes); + this.browserBroker = new BrowserBrokerService(transport); this.scheduler = new ScheduleService( transport, deps.scheduleStore ?? new InMemoryScheduleStore(), @@ -999,6 +1002,28 @@ export class Engine { }); break; } + case 'browser.host.register': { + this.browserBroker.registerHost(p.hostId); + this.sendSuccess(p.clientReqId); + break; + } + case 'browser.host.detached': { + this.browserBroker.detachHost(p.hostId); + break; + } + case 'browser.command.result': { + this.browserBroker.settle(p.commandId, p.result); + break; + } + case 'browser.execute': { + // Awaiting here never blocks the message loop: handle() runs per-message, so the + // host's browser.command.result is processed while this dispatch is in flight. + const result = await this.browserBroker.dispatch(p.op, p.args); + this.transport.send( + createWireMessage({ kind: 'browser.executed', replyTo: p.clientReqId, result }), + ); + break; + } case 'agent-login.start': { const logins = this.logins; if (!logins) { @@ -1030,6 +1055,7 @@ export class Engine { // Stop launching new automation sessions before the session-teardown sweep runs. this.scheduler.shutdown(); this.loops.shutdown(); + this.browserBroker.shutdown(); await Promise.all( Array.from(this.sessions.values(), async (session) => { session.unsub(); diff --git a/packages/schema/src/browser.ts b/packages/schema/src/browser.ts new file mode 100644 index 000000000..d81c459dc --- /dev/null +++ b/packages/schema/src/browser.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +/** + * Browser automation contracts (CODE-267): a desktop client registers as THE browser host and + * executes broker-dispatched operations in its Browser-pane webviews. Single active host — + * commands have side effects, so they are never fanned out; the last registration wins. + */ + +/** Closed set of operations the browser host executes. `tab.*` ops take `tabId` in args. */ +export const BrowserOpSchema = z.enum([ + 'tabs.list', + 'tabs.open', + 'tabs.select', + 'tab.navigate', + 'tab.back', + 'tab.reload', + 'tab.close', + 'tab.snapshot', + 'tab.click', + 'tab.type', + 'tab.screenshot', + 'tab.evaluate', +]); +export type BrowserOp = z.infer; + +/** Envelope-level shape only; the executor validates per-op args in depth. */ +export const BrowserCommandArgsSchema = z.record(z.string(), z.unknown()); +export type BrowserCommandArgs = z.infer; + +export const BrowserCommandErrorCodeSchema = z.enum([ + 'host-unavailable', + 'timeout', + 'invalid-args', + 'no-such-tab', + 'stale-ref', + 'navigation-failed', + 'execution-failed', + 'not-allowed', +]); +export type BrowserCommandErrorCode = z.infer; + +export const BrowserCommandErrorSchema = z.object({ + code: BrowserCommandErrorCodeSchema, + message: z.string(), + retryable: z.boolean(), +}); +export type BrowserCommandError = z.infer; + +/** Settlement of one command; `data` is op-specific (tab list, snapshot, screenshot, …). */ +export const BrowserCommandResultSchema = z.discriminatedUnion('ok', [ + z.object({ ok: z.literal(true), data: z.unknown().optional() }), + z.object({ ok: z.literal(false), error: BrowserCommandErrorSchema }), +]); +export type BrowserCommandResult = z.infer; + +/** Host identity is a client-minted capability (mirrors terminal attachments): it travels + * client → host on registration only and is never echoed in replies or broadcasts. */ +export const BrowserHostCredentialsSchema = z.object({ + hostId: z.string().min(1).max(128), + hostSecret: z.string().min(1).max(256), +}); +export type BrowserHostCredentials = z.infer; + +// Op-payload shapes the desktop executor produces inside `data` (envelope-validated as unknown; +// the executor is first-party, so consumers narrow with these types instead of re-parsing). + +export interface BrowserTabInfo { + id: string; + url: string | null; + title: string | null; + active: boolean; +} + +/** One interactive element captured by `tab.snapshot`; `ref` (`@eN`) keys click/type targets + * and goes stale as soon as the page navigates. */ +export interface BrowserSnapshotNode { + ref: string; + role: string; + name: string; + value?: string; +} + +export interface BrowserSnapshot { + url: string; + title: string; + nodes: BrowserSnapshotNode[]; + /** True when the node list was cut at the collection cap. */ + truncated: boolean; +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index ab011fd87..143e76d42 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,6 +9,7 @@ export * from './account'; export * from './agent'; export * from './agent-runtime'; export * from './artifact'; +export * from './browser'; export * from './common'; export * from './content'; export * from './daemon-runtime'; diff --git a/packages/schema/src/wire/__tests__/browser.test.ts b/packages/schema/src/wire/__tests__/browser.test.ts new file mode 100644 index 000000000..d6e83d3e6 --- /dev/null +++ b/packages/schema/src/wire/__tests__/browser.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { parseWireMessage, WIRE_PROTOCOL_VERSION } from '../index'; + +function envelope(payload: unknown) { + return { v: WIRE_PROTOCOL_VERSION, id: 'message-1', ts: 1, payload }; +} + +describe('browser wire schema', () => { + it('accepts a host registration with client-minted credentials', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.host.register', + clientReqId: 'request-1', + hostId: 'host-1', + hostSecret: 's'.repeat(32), + }), + ); + expect(parsed.success).toBe(true); + }); + + it('rejects a command with an op outside the closed set', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.command', + commandId: 'command-1', + op: 'tab.delete-cookies', + args: {}, + }), + ); + expect(parsed.success).toBe(false); + }); + + it('round-trips a failed command settlement with a closed error code', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.command.result', + commandId: 'command-1', + result: { + ok: false, + error: { code: 'host-unavailable', message: 'no host', retryable: true }, + }, + }), + ); + expect(parsed.success).toBe(true); + if (!parsed.success || parsed.data.payload.kind !== 'browser.command.result') return; + expect(parsed.data.payload.result.ok).toBe(false); + }); + + it('rejects a settlement with an unknown error code', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'browser.command.result', + commandId: 'command-1', + result: { ok: false, error: { code: 'mystery', message: 'x', retryable: false } }, + }), + ); + expect(parsed.success).toBe(false); + }); + + it('accepts the client-side execute request and its data-carrying reply', () => { + const request = parseWireMessage( + envelope({ + kind: 'browser.execute', + clientReqId: 'request-1', + op: 'tab.snapshot', + args: { tabId: 'right-browser-1' }, + }), + ); + expect(request.success).toBe(true); + + const reply = parseWireMessage( + envelope({ + kind: 'browser.executed', + replyTo: 'request-1', + result: { ok: true, data: { nodes: [] } }, + }), + ); + expect(reply.success).toBe(true); + }); +}); diff --git a/packages/schema/src/wire/browser.ts b/packages/schema/src/wire/browser.ts new file mode 100644 index 000000000..f7b3b10a1 --- /dev/null +++ b/packages/schema/src/wire/browser.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { + BrowserCommandArgsSchema, + BrowserCommandResultSchema, + BrowserHostCredentialsSchema, + BrowserOpSchema, +} from '../browser'; + +/** Browser-broker wire variants (CODE-267): a desktop client registers as THE single active + * browser host (last registration wins); the daemon dispatches commands to that connection only + * and correlates settlements by the daemon-minted `commandId`. */ +export const browserWireVariants = [ + // Client → host: claim the browser-host role for this connection (replies via + // request.succeeded / request.failed; the Hub starts targeting on the success reply). + z.object({ + kind: z.literal('browser.host.register'), + clientReqId: z.string().min(1), + ...BrowserHostCredentialsSchema.shape, + }), + // Synthesized by the Hub when the registered host's connection closes (mirrors the + // synthetic terminal.detach); hostId guards against a stale disconnect clearing a newer host. + z.object({ kind: z.literal('browser.host.detached'), hostId: z.string().min(1) }), + // Host → clients broadcast: whether a browser host is currently registered. + z.object({ kind: z.literal('browser.host.changed'), available: z.boolean() }), + // Host push (delivered ONLY to the registered browser-host connection): execute one op. + z.object({ + kind: z.literal('browser.command'), + commandId: z.string().min(1), + op: BrowserOpSchema, + args: BrowserCommandArgsSchema, + }), + // Browser host → daemon: settle one command. + z.object({ + kind: z.literal('browser.command.result'), + commandId: z.string().min(1), + result: BrowserCommandResultSchema, + }), + // Any client → daemon (the B-2 stdio bridge): run one op through the broker. + z.object({ + kind: z.literal('browser.execute'), + clientReqId: z.string().min(1), + op: BrowserOpSchema, + args: BrowserCommandArgsSchema, + }), + z.object({ + kind: z.literal('browser.executed'), + replyTo: z.string().min(1), + result: BrowserCommandResultSchema, + }), +] as const; diff --git a/packages/schema/src/wire/index.ts b/packages/schema/src/wire/index.ts index 56a24242d..b0dfdc989 100644 --- a/packages/schema/src/wire/index.ts +++ b/packages/schema/src/wire/index.ts @@ -4,6 +4,7 @@ import { MessageIdSchema, SessionIdSchema, TimestampSchema } from '../common'; import { agentLoginWireVariants } from './agent-login'; import { agentRuntimeWireVariants } from './agent-runtime'; import { artifactWireVariants } from './artifact'; +import { browserWireVariants } from './browser'; import { configWireVariants } from './config'; import { fileWireVariants } from './file'; import { gitWireVariants } from './git'; @@ -33,7 +34,7 @@ export { * so request/response control messages correlate via `clientReqId` → `replyTo`. */ -export const WIRE_PROTOCOL_VERSION = 37 as const; +export const WIRE_PROTOCOL_VERSION = 38 as const; /** Envelope payload: a discriminated union keyed by `kind`. */ export const WirePayloadSchema = z.discriminatedUnion('kind', [ @@ -50,6 +51,7 @@ export const WirePayloadSchema = z.discriminatedUnion('kind', [ ...scheduleWireVariants, ...loopWireVariants, ...artifactWireVariants, + ...browserWireVariants, // ── Data plane ── z.object({ diff --git a/packages/transport/src/__tests__/hub.test.ts b/packages/transport/src/__tests__/hub.test.ts index 663134d1f..2914f5bc5 100644 --- a/packages/transport/src/__tests__/hub.test.ts +++ b/packages/transport/src/__tests__/hub.test.ts @@ -306,3 +306,109 @@ describe('Hub terminal routing', () => { ]); }); }); + +describe('Hub browser host routing', () => { + function register(clientReqId: string, hostId: string): WireMessage { + return createWireMessage({ + kind: 'browser.host.register', + clientReqId, + hostId, + hostSecret: 's'.repeat(32), + }); + } + + function succeeded(replyTo: string): WireMessage { + return createWireMessage({ kind: 'request.succeeded', replyTo }); + } + + function command(commandId: string): WireMessage { + return createWireMessage({ kind: 'browser.command', commandId, op: 'tabs.list', args: {} }); + } + + it('targets browser.command only at the registered host connection', () => { + const hub = new Hub(); + const host = new FakeConn(); + const other = new FakeConn(); + hub.addConnection(host); + hub.addConnection(other); + + host.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + hub.send(command('c1')); + + expect(host.sent.map((m) => m.payload.kind)).toEqual(['request.succeeded', 'browser.command']); + expect(other.sent).toHaveLength(0); + }); + + it('drops browser.command when no host is registered instead of broadcasting', () => { + const hub = new Hub(); + const conn = new FakeConn(); + hub.addConnection(conn); + + hub.send(command('c1')); + + expect(conn.sent).toHaveLength(0); + }); + + it('lets the last successful registration win', () => { + const hub = new Hub(); + const first = new FakeConn(); + const second = new FakeConn(); + hub.addConnection(first); + hub.addConnection(second); + + first.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + second.emit(register('r2', 'host-2')); + hub.send(succeeded('r2')); + hub.send(command('c1')); + + expect(first.sent.map((m) => m.payload.kind)).toEqual(['request.succeeded']); + expect(second.sent.map((m) => m.payload.kind)).toEqual([ + 'request.succeeded', + 'browser.command', + ]); + }); + + it('synthesizes browser.host.detached when the host connection drops', () => { + const hub = new Hub(); + const host = new FakeConn(); + hub.addConnection(host); + const forwarded: WireMessage[] = []; + hub.onMessage((msg) => forwarded.push(msg)); + + host.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + hub.removeConnection(host); + hub.send(command('c1')); + + expect(forwarded.map((m) => m.payload.kind)).toEqual([ + 'browser.host.register', + 'browser.host.detached', + ]); + expect(host.sent.map((m) => m.payload.kind)).toEqual(['request.succeeded']); + }); + + it('does not detach a newer host when a superseded connection drops', () => { + const hub = new Hub(); + const first = new FakeConn(); + const second = new FakeConn(); + hub.addConnection(first); + hub.addConnection(second); + const forwarded: WireMessage[] = []; + hub.onMessage((msg) => forwarded.push(msg)); + + first.emit(register('r1', 'host-1')); + hub.send(succeeded('r1')); + second.emit(register('r2', 'host-2')); + hub.send(succeeded('r2')); + hub.removeConnection(first); + hub.send(command('c1')); + + expect(forwarded.some((m) => m.payload.kind === 'browser.host.detached')).toBe(false); + expect(second.sent.map((m) => m.payload.kind)).toEqual([ + 'request.succeeded', + 'browser.command', + ]); + }); +}); diff --git a/packages/transport/src/hub.ts b/packages/transport/src/hub.ts index 37f1adf5b..7f13bc155 100644 --- a/packages/transport/src/hub.ts +++ b/packages/transport/src/hub.ts @@ -22,6 +22,11 @@ interface PendingTerminalRequest extends TerminalAttachmentCredentials { conn: Transport; } +interface BrowserHostRegistration { + conn: Transport; + hostId: string; +} + /** * Composes many client connections into the single `Transport` the daemon's `Host` consumes. * Correlated replies return only to their request's connection; session events keep their @@ -38,6 +43,9 @@ export class Hub implements Transport { /** Kept through origin disconnect so a late host reply cannot collide with a reused request id. */ private readonly pendingReplies = new Map(); private readonly pendingTerminals = new Map(); + private readonly pendingBrowserHosts = new Map(); + /** The single active browser host (last successful registration wins). */ + private browserHost: BrowserHostRegistration | null = null; private readonly inbound = new Listeners(); private readonly closed = new Listeners(); @@ -57,6 +65,11 @@ export class Hub implements Transport { this.unsubs.get(conn)?.(); this.unsubs.delete(conn); this.conns.delete(conn); + if (this.browserHost?.conn === conn) { + const { hostId } = this.browserHost; + this.browserHost = null; + this.inbound.emit(createWireMessage({ kind: 'browser.host.detached', hostId })); + } const subscription = this.subscriptions.get(conn); this.subscriptions.delete(conn); if (!subscription) return; @@ -129,6 +142,9 @@ export class Hub implements Transport { attachmentSecret: p.attachmentSecret, }); } + if (p.kind === 'browser.host.register') { + this.pendingBrowserHosts.set(p.clientReqId, { conn, hostId: p.hostId }); + } } this.inbound.emit(msg); } @@ -158,10 +174,35 @@ export class Hub implements Transport { ); } } + const pendingBrowserHost = this.pendingBrowserHosts.get(p.replyTo); + this.pendingBrowserHosts.delete(p.replyTo); + if (pendingBrowserHost && p.kind === 'request.succeeded') { + if (this.conns.has(pendingBrowserHost.conn)) { + // Last successful registration wins; a superseded host simply stops being targeted. + this.browserHost = pendingBrowserHost; + } else { + // Registration completed after its peer disconnected: release the role immediately. + this.inbound.emit( + createWireMessage({ + kind: 'browser.host.detached', + hostId: pendingBrowserHost.hostId, + }), + ); + } + } + if (conn && this.conns.has(conn)) bestEffort(() => conn.send(msg)); return; } + // Browser commands target exactly the registered host connection — never broadcast + // (they have side effects, and only the host owns webviews). + if (p.kind === 'browser.command') { + const host = this.browserHost; + if (host && this.conns.has(host.conn)) bestEffort(() => host.conn.send(msg)); + return; + } + if ( p.kind === 'terminal.output' || p.kind === 'terminal.resized' || @@ -222,6 +263,8 @@ export class Hub implements Transport { this.subscriptions.clear(); this.pendingReplies.clear(); this.pendingTerminals.clear(); + this.pendingBrowserHosts.clear(); + this.browserHost = null; this.inbound.clear(); this.closed.emit(); } From 502dbac0b498268d40e9da59ac90d5afbc2234a8 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 22:14:05 +0800 Subject: [PATCH 6/8] feat(client-core,workbench,desktop): browser host channel + webview command executor (CODE-267) --- .../shell/browser/browser-command-executor.ts | 299 ++++++++++++++++++ .../shell/browser/browser-webview-pane.tsx | 5 +- .../src/shell/browser/webview-registry.ts | 17 + .../src/renderer/src/shell/desktop-shell.tsx | 11 + packages/client-core/src/client.ts | 13 + .../src/client/browser-host-channel.ts | 85 +++++ .../workbench/src/browser/use-browser-host.ts | 18 ++ packages/workbench/src/index.ts | 1 + 8 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/src/shell/browser/browser-command-executor.ts create mode 100644 apps/desktop/src/renderer/src/shell/browser/webview-registry.ts create mode 100644 packages/client-core/src/client/browser-host-channel.ts create mode 100644 packages/workbench/src/browser/use-browser-host.ts diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-command-executor.ts b/apps/desktop/src/renderer/src/shell/browser/browser-command-executor.ts new file mode 100644 index 000000000..31fd634de --- /dev/null +++ b/apps/desktop/src/renderer/src/shell/browser/browser-command-executor.ts @@ -0,0 +1,299 @@ +import type { + BrowserCommandArgs, + BrowserCommandError, + BrowserCommandErrorCode, + BrowserCommandResult, + BrowserOp, + BrowserSnapshot, + BrowserTabInfo, +} from '@linkcode/schema'; +import { isAllowedBrowserUrl } from '@linkcode/ui/shell/browser'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { z } from 'zod'; +import { useDesktopShellStore } from '../store/store'; +import { getBrowserWebview } from './webview-registry'; + +/** Snapshot ref marker attribute; selectors target it so refs survive without CSS-path math. */ +const REF_ATTRIBUTE = 'data-linkcode-ref'; +const SNAPSHOT_NODE_CAP = 200; + +const TabArgsSchema = z.object({ tabId: z.string().min(1) }); +const OpenArgsSchema = z.object({ url: z.string().min(1) }); +const NavigateArgsSchema = TabArgsSchema.extend({ url: z.string().min(1) }); +const RefArgsSchema = TabArgsSchema.extend({ ref: z.string().min(1) }); +const TypeArgsSchema = RefArgsSchema.extend({ text: z.string() }); +const EvaluateArgsSchema = TabArgsSchema.extend({ js: z.string().min(1) }); + +interface SnapshotRecord { + url: string; + refs: Set; +} + +const SNAPSHOT_SCRIPT = `(() => { + const SELECTOR = 'a[href], button, input, textarea, select, summary, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="combobox"], [role="menuitem"], [role="tab"], [contenteditable="true"]'; + for (const el of document.querySelectorAll('[${REF_ATTRIBUTE}]')) el.removeAttribute('${REF_ATTRIBUTE}'); + const nodes = []; + let truncated = false; + let n = 0; + for (const el of document.querySelectorAll(SELECTOR)) { + if (nodes.length >= ${SNAPSHOT_NODE_CAP}) { truncated = true; break; } + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + const style = getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') continue; + n += 1; + const ref = '@e' + n; + el.setAttribute('${REF_ATTRIBUTE}', ref); + const tag = el.tagName.toLowerCase(); + const role = el.getAttribute('role') || (tag === 'input' ? 'input:' + (el.getAttribute('type') || 'text') : tag); + const name = (el.getAttribute('aria-label') || el.innerText || el.getAttribute('placeholder') || el.getAttribute('title') || el.getAttribute('alt') || '').trim().slice(0, 80); + const node = { ref, role, name }; + if (tag === 'input' || tag === 'textarea' || tag === 'select') node.value = String(el.value ?? '').slice(0, 80); + nodes.push(node); + } + return { url: location.href, title: document.title, nodes, truncated }; +})()`; + +class CommandError extends Error { + override name = 'CommandError'; + + constructor( + readonly code: BrowserCommandErrorCode, + message: string, + readonly retryable = false, + ) { + super(message); + } +} + +function listTabs(): BrowserTabInfo[] { + const { browser } = useDesktopShellStore.getState().rightPanel; + return browser.tabs.map((tab) => ({ + id: tab.id, + url: tab.url, + title: tab.title, + active: tab.id === browser.activeTabId, + })); +} + +function assertTab(tabId: string): void { + const { browser } = useDesktopShellStore.getState().rightPanel; + if (!browser.tabs.some((tab) => tab.id === tabId)) { + throw new CommandError('no-such-tab', `no browser tab ${tabId}`); + } +} + +function requireWebview(tabId: string) { + assertTab(tabId); + const webview = getBrowserWebview(tabId); + // eslint-disable-next-line sukka/prefer-nullthrow -- the throw must carry a closed BrowserCommandErrorCode, which nullthrow/invariant cannot attach + if (!webview) { + throw new CommandError( + 'no-such-tab', + `tab ${tabId} has no page loaded yet (navigate it first)`, + ); + } + return webview; +} + +/** + * Executes broker-dispatched browser ops against the SAME tabs/webviews the user sees: + * tab management goes through the desktop shell store; page ops run inside the live webview + * via `executeJavaScript`; screenshots use `capturePage`. Snapshot refs (`@eN`) are marker + * attributes in the page plus a per-tab record here — a navigation makes them stale. + */ +export class BrowserCommandExecutor { + private readonly snapshots = new Map(); + + async execute(op: BrowserOp, args: BrowserCommandArgs): Promise { + try { + return { ok: true, data: await this.run(op, args) }; + } catch (err) { + return { ok: false, error: toCommandError(err) }; + } + } + + private async run(op: BrowserOp, args: BrowserCommandArgs): Promise { + switch (op) { + case 'tabs.list': + return listTabs(); + case 'tabs.open': { + const { url } = parseArgs(OpenArgsSchema, args); + assertAllowedUrl(url); + useDesktopShellStore.getState().openBrowserTab(url); + return listTabs(); + } + case 'tabs.select': { + const { tabId } = parseArgs(TabArgsSchema, args); + assertTab(tabId); + const store = useDesktopShellStore.getState(); + store.openRightPanelSection('browser'); + store.setActiveRightBrowserTab(tabId); + return listTabs(); + } + case 'tab.navigate': { + const { tabId, url } = parseArgs(NavigateArgsSchema, args); + assertTab(tabId); + assertAllowedUrl(url); + this.snapshots.delete(tabId); + useDesktopShellStore.getState().setBrowserTabUrl(tabId, url); + return null; + } + case 'tab.back': { + const { tabId } = parseArgs(TabArgsSchema, args); + this.snapshots.delete(tabId); + requireWebview(tabId).goBack(); + return null; + } + case 'tab.reload': { + const { tabId } = parseArgs(TabArgsSchema, args); + this.snapshots.delete(tabId); + requireWebview(tabId).reload(); + return null; + } + case 'tab.close': { + const { tabId } = parseArgs(TabArgsSchema, args); + assertTab(tabId); + this.snapshots.delete(tabId); + useDesktopShellStore.getState().closeRightBrowserTab(tabId); + return listTabs(); + } + case 'tab.snapshot': + return this.snapshot(parseArgs(TabArgsSchema, args).tabId); + case 'tab.click': { + const { tabId, ref } = parseArgs(RefArgsSchema, args); + await this.runOnRef(tabId, ref, clickScript(ref)); + return null; + } + case 'tab.type': { + const { tabId, ref, text } = parseArgs(TypeArgsSchema, args); + await this.runOnRef(tabId, ref, typeScript(ref, text)); + return null; + } + case 'tab.screenshot': + return screenshot(parseArgs(TabArgsSchema, args).tabId); + case 'tab.evaluate': { + const { tabId, js } = parseArgs(EvaluateArgsSchema, args); + const webview = requireWebview(tabId); + try { + return await webview.executeJavaScript(js); + } catch (err) { + throw new CommandError('execution-failed', extractErrorMessage(err) ?? 'evaluate failed'); + } + } + default: + throw new CommandError('invalid-args', `unsupported op ${op as string}`); + } + } + + private async snapshot(tabId: string): Promise { + const webview = requireWebview(tabId); + const result = (await webview.executeJavaScript(SNAPSHOT_SCRIPT).catch((err: unknown) => { + throw new CommandError('execution-failed', extractErrorMessage(err) ?? 'snapshot failed'); + })) as BrowserSnapshot; + this.snapshots.set(tabId, { + url: result.url, + refs: new Set(result.nodes.map((node) => node.ref)), + }); + return result; + } + + private async runOnRef(tabId: string, ref: string, script: string): Promise { + const webview = requireWebview(tabId); + const record = this.snapshots.get(tabId); + if (!record?.refs.has(ref)) { + throw new CommandError('stale-ref', `unknown ref ${ref}; take a fresh tab.snapshot first`); + } + if (webview.getURL() !== record.url) { + this.snapshots.delete(tabId); + throw new CommandError( + 'stale-ref', + 'the page navigated since the last snapshot; take a fresh tab.snapshot', + true, + ); + } + const found = (await webview.executeJavaScript(script).catch((err: unknown) => { + throw new CommandError('execution-failed', extractErrorMessage(err) ?? 'interaction failed'); + })) as boolean; + if (!found) { + this.snapshots.delete(tabId); + throw new CommandError( + 'stale-ref', + `ref ${ref} is gone from the page; take a fresh tab.snapshot`, + true, + ); + } + } +} + +async function screenshot(tabId: string): Promise<{ mimeType: string; base64: string }> { + // capturePage renders the compositor's view: bring the tab forward first so a hidden + // (visibility-toggled) webview doesn't come back blank. + const store = useDesktopShellStore.getState(); + store.openRightPanelSection('browser'); + store.setActiveRightBrowserTab(tabId); + const webview = requireWebview(tabId); + const image = await webview.capturePage(); + return { mimeType: 'image/png', base64: image.toPNG().toString('base64') }; +} + +function parseArgs(schema: Schema, args: unknown): z.infer { + const parsed = schema.safeParse(args); + if (!parsed.success) { + throw new CommandError('invalid-args', parsed.error.issues[0]?.message ?? 'invalid args'); + } + return parsed.data; +} + +function assertAllowedUrl(url: string): void { + if (!isAllowedBrowserUrl(url)) { + throw new CommandError('not-allowed', 'only http(s) URLs can be opened in the browser'); + } +} + +function toCommandError(err: unknown): BrowserCommandError { + if (err instanceof CommandError) { + return { code: err.code, message: err.message, retryable: err.retryable }; + } + return { + code: 'execution-failed', + message: extractErrorMessage(err) ?? 'Unknown executor error', + retryable: false, + }; +} + +function refSelector(ref: string): string { + return `[${REF_ATTRIBUTE}=${JSON.stringify(ref)}]`; +} + +/** Resolves `true` when the ref still exists (false → stale), throwing nothing page-side. */ +function clickScript(ref: string): string { + return `(() => { + const el = document.querySelector(${JSON.stringify(refSelector(ref))}); + if (!el) return false; + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.click(); + return true; +})()`; +} + +function typeScript(ref: string, text: string): string { + return `(() => { + const el = document.querySelector(${JSON.stringify(refSelector(ref))}); + if (!el) return false; + el.scrollIntoView({ block: 'center', inline: 'center' }); + el.focus(); + const tag = el.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea') { + const proto = tag === 'input' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; + setter.call(el, ${JSON.stringify(text)}); + el.dispatchEvent(new InputEvent('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } else { + el.textContent = ${JSON.stringify(text)}; + el.dispatchEvent(new InputEvent('input', { bubbles: true })); + } + return true; +})()`; +} diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index ae71fda39..6becabf71 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -6,6 +6,7 @@ import { useEffect as useAbortableEffect } from 'foxact/use-abortable-effect'; import { useRef, useState } from 'react'; import { useTranslations } from 'use-intl'; import { useDesktopShellStore } from '../store/store'; +import { registerBrowserWebview } from './webview-registry'; /** All in-app pages share one persisted session (cookies/storage survive restarts). */ const BROWSER_PARTITION = 'persist:linkcode-browser'; @@ -53,7 +54,9 @@ export function BrowserWebviewPane({ // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. const captureWebview = (element: HTMLWebViewElement | null): void => { - setWebview(element as WebviewTag | null); + const webviewElement = element as WebviewTag | null; + registerBrowserWebview(tabId, webviewElement); + setWebview(webviewElement); }; useAbortableEffect( diff --git a/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts b/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts new file mode 100644 index 000000000..2b41aca0f --- /dev/null +++ b/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts @@ -0,0 +1,17 @@ +import type { WebviewTag } from 'electron'; + +/** + * Live `` elements by browser-tab id. Panes register on mount so the browser command + * executor can drive the same webviews the user sees; entries exist only while a tab has a URL + * (an empty tab renders no webview). + */ +const webviews = new Map(); + +export function registerBrowserWebview(tabId: string, webview: WebviewTag | null): void { + if (webview === null) webviews.delete(tabId); + else webviews.set(tabId, webview); +} + +export function getBrowserWebview(tabId: string): WebviewTag | undefined { + return webviews.get(tabId); +} diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 50805f5a3..d211bd97c 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -21,6 +21,7 @@ import { isAbsoluteFilePath, locateFileArtifact, TerminalPanel, + useBrowserHostRegistration, useCloudHosts, useSelectedHostStore, WorkspaceServicesMenu, @@ -35,6 +36,7 @@ import { useFormatter, useTranslations } from 'use-intl'; import { useShallow } from 'zustand/react/shallow'; import { DesktopThreadImMenu } from '../cloud-auth/thread-im-menu'; import { useCloudAccount } from '../cloud-auth/use-cloud-account'; +import { BrowserCommandExecutor } from './browser/browser-command-executor'; import { BrowserWebviewPane } from './browser/browser-webview-pane'; import { DesktopChrome } from './chrome/chrome'; import { DiffStatChip } from './chrome/diff-stat-chip'; @@ -244,6 +246,15 @@ export function DesktopShell({ [systemBridge, openBrowserTab], ); + // Desktop is the browser host: broker commands from the daemon drive the same webview + // tabs the user sees. The executor is stateless across generations; registration re-runs + // per connection generation via the workbench hook. + const { current: browserExecutor } = useSingleton(() => { + const executor = new BrowserCommandExecutor(); + return executor.execute.bind(executor); + }); + useBrowserHostRegistration(browserExecutor); + const tBrowser = useTranslations('workbench.preview.browser'); useAbortableEffect( () => diff --git a/packages/client-core/src/client.ts b/packages/client-core/src/client.ts index 50c5f61b2..e6a79c7c4 100644 --- a/packages/client-core/src/client.ts +++ b/packages/client-core/src/client.ts @@ -52,6 +52,8 @@ import { extractErrorMessage } from 'foxts/extract-error-message'; import { noop } from 'foxts/noop'; import type { AgentLoginHandlers } from './client/agent-login-channel'; import { AgentLoginChannel } from './client/agent-login-channel'; +import type { BrowserCommandExecutor } from './client/browser-host-channel'; +import { BrowserHostChannel } from './client/browser-host-channel'; import type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; import { ControlChannel } from './client/control-channel'; import type { SequencedAgentEvent } from './client/event-buffer'; @@ -62,6 +64,7 @@ import { PendingRegistry, resolveRandomUUID } from './client/pending-registry'; import { TerminalChannel } from './client/terminal-channel'; export type { AgentLoginHandlers, AgentLoginSettled } from './client/agent-login-channel'; +export type { BrowserCommandExecutor } from './client/browser-host-channel'; export type { HistoryListClientOptions, HistoryReadClientOptions } from './client/control-channel'; export type { SequencedAgentEvent } from './client/event-buffer'; @@ -131,6 +134,7 @@ export class LinkCodeClient { private readonly control: ControlChannel; private readonly events = new EventBuffer(); private readonly terminals: TerminalChannel; + private readonly browserHost: BrowserHostChannel; private readonly agentLogin: AgentLoginChannel; private readonly scriptStatusSubs = new Set(); private readonly scheduleEventSubs = new Set(); @@ -156,6 +160,7 @@ export class LinkCodeClient { this.pending = new PendingRegistry(randomUUID); this.control = new ControlChannel(transport, this.pending); this.terminals = new TerminalChannel(transport, this.pending, randomUUID); + this.browserHost = new BrowserHostChannel(transport, this.pending, randomUUID); this.agentLogin = new AgentLoginChannel(transport, this.pending); } @@ -422,6 +427,9 @@ export class LinkCodeClient { case 'terminal.exit': this.terminals.handleMessage(p); break; + case 'browser.command': + this.browserHost.handleMessage(p); + break; case 'agent-login.started': case 'agent-login.url': case 'agent-login.settled': @@ -760,6 +768,11 @@ export class LinkCodeClient { return this.events.snapshot(sessionId); } + /** Register this client as THE browser host; `executor` runs broker commands (desktop only). */ + registerBrowserHost(executor: BrowserCommandExecutor): Promise { + return this.browserHost.register(executor); + } + openTerminal(opts: { cols: number; rows: number; diff --git a/packages/client-core/src/client/browser-host-channel.ts b/packages/client-core/src/client/browser-host-channel.ts new file mode 100644 index 000000000..4ec2c6728 --- /dev/null +++ b/packages/client-core/src/client/browser-host-channel.ts @@ -0,0 +1,85 @@ +import type { + BrowserCommandArgs, + BrowserCommandResult, + BrowserOp, + WirePayload, +} from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { createWireMessage } from '@linkcode/transport'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { noop } from 'foxts/noop'; +import type { PendingRegistry, RandomUUID } from './pending-registry'; +import { sendCorrelated } from './pending-registry'; + +export type BrowserCommandExecutor = ( + op: BrowserOp, + args: BrowserCommandArgs, +) => Promise; + +/** + * Registers this client as THE single active browser host (CODE-267) and runs + * broker-dispatched `browser.command` frames through the host-supplied executor. + * Only the desktop client registers; browser/mobile clients never do. + */ +export class BrowserHostChannel { + private executor: BrowserCommandExecutor | null = null; + private hostId: string | null = null; + private hostSecret: string | null = null; + + constructor( + private readonly transport: Transport, + private readonly pending: PendingRegistry, + private readonly randomUUID: RandomUUID, + ) {} + + /** Claim the browser-host role; call again after a reconnect (last registration wins daemon-side). */ + async register(executor: BrowserCommandExecutor): Promise { + this.executor = executor; + this.hostId ??= this.randomUUID(); + this.hostSecret ??= this.randomUUID(); + const hostId = this.hostId; + const hostSecret = this.hostSecret; + await sendCorrelated(this.transport, this.pending, 'ack', (clientReqId) => ({ + kind: 'browser.host.register', + clientReqId, + hostId, + hostSecret, + })); + } + + /** Route a `browser.*` host push. Returns false if `payload` wasn't one. */ + handleMessage(p: WirePayload): boolean { + if (p.kind !== 'browser.command') return false; + void this.run(p.commandId, p.op, p.args); + return true; + } + + private async run(commandId: string, op: BrowserOp, args: BrowserCommandArgs): Promise { + const executor = this.executor; + const result: BrowserCommandResult = executor + ? await executor(op, args).catch((err: unknown) => ({ + ok: false as const, + error: { + code: 'execution-failed' as const, + message: extractErrorMessage(err) ?? 'Unknown executor error', + retryable: false, + }, + })) + : { + ok: false, + error: { + code: 'execution-failed', + message: 'no browser executor is installed on this host', + retryable: false, + }, + }; + try { + // Best-effort settle: if the send is lost the broker's timeout answers instead. + await this.transport.send( + createWireMessage({ kind: 'browser.command.result', commandId, result }), + ); + } catch { + noop(); + } + } +} diff --git a/packages/workbench/src/browser/use-browser-host.ts b/packages/workbench/src/browser/use-browser-host.ts new file mode 100644 index 000000000..9269edb86 --- /dev/null +++ b/packages/workbench/src/browser/use-browser-host.ts @@ -0,0 +1,18 @@ +import type { BrowserCommandExecutor } from '@linkcode/client-core'; +import { useEffect as useAbortableEffect } from 'foxact/use-abortable-effect'; +import { noop } from 'foxts/noop'; +import { useWorkbenchSdkClient } from '../runtime/provider'; + +/** + * Registers this client as the daemon's browser host while an executor is supplied (desktop + * passes its webview-backed executor; webview/mobile pass nothing and never register). Mounted + * under the connection gate, so a reconnect generation remounts it and re-registers. + */ +export function useBrowserHostRegistration(executor: BrowserCommandExecutor | null): void { + const client = useWorkbenchSdkClient(); + useAbortableEffect(() => { + if (executor === null) return; + // Registration failure just leaves agent browser tools reporting host-unavailable. + void client.raw.registerBrowserHost(executor).catch(noop); + }, [client, executor]); +} diff --git a/packages/workbench/src/index.ts b/packages/workbench/src/index.ts index 5c6452f18..0bb50f833 100644 --- a/packages/workbench/src/index.ts +++ b/packages/workbench/src/index.ts @@ -11,6 +11,7 @@ export * from './automations/hooks'; export * from './automations/items'; export * from './automations/loop-items'; export * from './automations/store'; +export * from './browser/use-browser-host'; export * from './cloud'; export * from './files/hooks'; export * from './files/locate'; From 98bdf62fd207ff687ca75c00953add82b56eca67 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 22:25:13 +0800 Subject: [PATCH 7/8] feat(engine,agent-adapter,daemon): code-mode browser REPL + execute tool for claude/pi behind an opt-in gate (CODE-267) --- apps/daemon/src/index.ts | 2 + packages/agent-adapter/package.json | 1 + packages/agent-adapter/src/adapter.ts | 60 ++++++++++ .../agent-adapter/src/native/claude-code.ts | 51 ++++++++- packages/agent-adapter/src/native/pi.ts | 48 ++++++++ packages/engine/src/browser/repl-host.ts | 103 ++++++++++++++++++ packages/engine/src/engine.ts | 11 ++ pnpm-lock.yaml | 3 + 8 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/engine/src/browser/repl-host.ts diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 6cf9d3874..b58ec6891 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -181,6 +181,8 @@ async function main(): Promise { // every client sits on ECONNREFUSED for the whole probe. The engine seeds from the promise. const agentRuntimesReady = agentRuntimeProber.collect(); const engine = new Engine(hub, { + // Browser code-mode tools (CODE-267): opt-in env gate until the Settings toggle lands. + browserToolsEnabled: process.env.LINKCODE_BROWSER_TOOLS === '1', providerStore: store, ptyBackend: new SidecarPtyBackend(resolveSidecarPath()), sessionStore: createSessionStore(databasePath()), diff --git a/packages/agent-adapter/package.json b/packages/agent-adapter/package.json index 83d69cb6a..62f7c60da 100644 --- a/packages/agent-adapter/package.json +++ b/packages/agent-adapter/package.json @@ -20,6 +20,7 @@ "@linkcode/transport": "workspace:*", "@openai/codex": "^0.144.1", "@opencode-ai/sdk": "^1.17.18", + "@sinclair/typebox": "0.27.10", "cross-spawn": "^7.0.6", "foxts": "^5.8.0", "smol-toml": "^1.7.0", diff --git a/packages/agent-adapter/src/adapter.ts b/packages/agent-adapter/src/adapter.ts index e5e572104..293e4f9f8 100644 --- a/packages/agent-adapter/src/adapter.ts +++ b/packages/agent-adapter/src/adapter.ts @@ -14,6 +14,23 @@ import type { } from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; +/** One code-mode execution against the browser REPL (engine-owned; CODE-267). */ +export interface BrowserToolExecuteResult { + ok: boolean; + value?: unknown; + logs: string[]; + error?: string; +} + +/** A per-session persistent browser REPL, created lazily by adapters that host the execute tool. */ +export interface BrowserToolset { + /** Behavior documentation embedded verbatim in the execute tool's description. */ + readonly documentation: string; + execute(code: string): Promise; +} + +export type BrowserToolsetFactory = () => BrowserToolset; + /** * Unified adapter interface, one per coding agent (docs/ARCHITECTURE.md#key-contracts): no per-SDK * branching in upper layers (docs/ARCHITECTURE.md#core-principles). Implementations normalize @@ -25,6 +42,10 @@ export interface AgentAdapter { readonly capabilities: AgentCapabilities; /** History support advertised by this adapter. Unsupported operations must reject clearly. */ readonly historyCapabilities: AgentHistoryCapabilities; + /** Offer the browser code-mode toolset (CODE-267). Called before `start` when the feature is + * enabled; adapters with an in-process tool seam (claude-code, pi) register a single `execute` + * tool backed by a toolset created lazily at session start. Absent = agent has no seam yet. */ + attachBrowserTools?(createToolset: BrowserToolsetFactory): void; start(opts: StartOptions): Promise; /** List provider-local historical sessions, if supported. */ listHistory(opts?: AgentHistoryListOptions): Promise; @@ -38,6 +59,45 @@ export interface AgentAdapter { stop(): Promise; } +/** Cap on any single string rendered into the model-facing execute result. */ +const BROWSER_RESULT_STRING_CAP = 4000; + +/** What the execute tool hands back to the model: text plus an optional attached image + * (a completion value shaped like `browser.screenshot`'s `{ mimeType, base64 }`). */ +export interface BrowserToolRendered { + text: string; + image?: { mimeType: string; base64: string }; +} + +/** Shared model-facing rendering for both in-process hosts (claude-code MCP, pi custom tool). */ +export function renderBrowserToolResult(result: BrowserToolExecuteResult): BrowserToolRendered { + const logs = result.logs.length > 0 ? `\nconsole:\n${result.logs.join('\n')}` : ''; + if (!result.ok) return { text: `Error: ${result.error ?? 'unknown error'}${logs}` }; + const image = detectImageValue(result.value); + if (image) return { text: `[screenshot attached]${logs}`, image }; + const value = + result.value === undefined + ? 'undefined' + : (JSON.stringify(result.value, capLongStrings, 2) ?? 'undefined'); + return { text: `${value}${logs}` }; +} + +function capLongStrings(_key: string, value: unknown): unknown { + return typeof value === 'string' && value.length > BROWSER_RESULT_STRING_CAP + ? `${value.slice(0, BROWSER_RESULT_STRING_CAP)}… [${value.length} chars total]` + : value; +} + +function detectImageValue(value: unknown): { mimeType: string; base64: string } | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const candidate = value as { mimeType?: unknown; base64?: unknown }; + return typeof candidate.mimeType === 'string' && + candidate.mimeType.startsWith('image/') && + typeof candidate.base64 === 'string' + ? { mimeType: candidate.mimeType, base64: candidate.base64 } + : undefined; +} + let __seq = 0; function nextId(prefix: string): string { __seq += 1; diff --git a/packages/agent-adapter/src/native/claude-code.ts b/packages/agent-adapter/src/native/claude-code.ts index 88919659c..03b6c2967 100644 --- a/packages/agent-adapter/src/native/claude-code.ts +++ b/packages/agent-adapter/src/native/claude-code.ts @@ -5,6 +5,7 @@ import { env } from 'node:process'; import type { CanUseTool, HookCallback, + McpSdkServerConfigWithInstance, PermissionMode, PermissionResult, Query, @@ -55,7 +56,8 @@ import { import { extractErrorMessage } from 'foxts/extract-error-message'; import { invariant, nullthrow } from 'foxts/guard'; import { z } from 'zod'; -import { AUTH_FAILED_ERROR_CODE } from '../adapter'; +import type { BrowserToolsetFactory } from '../adapter'; +import { AUTH_FAILED_ERROR_CODE, renderBrowserToolResult } from '../adapter'; import { BaseAgentAdapter } from '../base'; import { claudeCodeEnv, readAgentCredential } from '../credential'; import { @@ -410,6 +412,49 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { /** The last published slash-command catalog — the alias authority for command interception * (`/cost` resolves to `/usage` via the provider's own aliases, not a hardcoded list). */ private commandCatalog: AgentCommand[] = []; + /** Browser code-mode toolset factory (CODE-267); set by the engine before start when the + * feature is enabled. The REPL is created once per Query and rides `options.mcpServers`. */ + private browserTools: BrowserToolsetFactory | undefined; + + attachBrowserTools(createToolset: BrowserToolsetFactory): void { + this.browserTools = createToolset; + } + + /** In-process MCP server exposing the single code-mode `execute` tool. */ + private async buildBrowserMcpServer(): Promise> { + const factory = nullthrow(this.browserTools, 'claude-code: browser tools not attached'); + const { createSdkMcpServer, tool } = await import('@anthropic-ai/claude-agent-sdk'); + const toolset = factory(); + return { + browser: createSdkMcpServer({ + name: 'browser', + tools: [ + tool( + 'execute', + toolset.documentation, + { code: z.string().describe('JavaScript for the persistent browser REPL') }, + async ({ code }) => { + const rendered = renderBrowserToolResult(await toolset.execute(code)); + return { + content: [ + { type: 'text' as const, text: rendered.text }, + ...(rendered.image + ? [ + { + type: 'image' as const, + data: rendered.image.base64, + mimeType: rendered.image.mimeType, + }, + ] + : []), + ], + }; + }, + ), + ], + }), + }; + } protected async onStart(opts: StartOptions): Promise { await this.loadSdk( @@ -611,11 +656,15 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { // The SDK has no apiKey/baseURL option — the resolved account reaches the subprocess via `env` // (see `claudeCodeEnv` for the replace-vs-spread and omit-to-inherit semantics). const credentialEnv = claudeCodeEnv(env, readAgentCredential(opts.config)); + // Browser code-mode toolset (CODE-267): an in-process MCP server per Query — the REPL's vm + // context lives exactly as long as the conversation process pairing it. + const mcpServers = this.browserTools ? await this.buildBrowserMcpServer() : undefined; const q = query({ prompt: queue, options: { cwd: opts.cwd, model: opts.model, + ...(mcpServers && { mcpServers }), // Bundled pair staged by the packaged host, else a detected user install (runtime-probe); // undefined in dev/standalone daemons, where the SDK resolves its own platform package. pathToClaudeCodeExecutable: agentRuntimeProber.resolveBinary('claude-code'), diff --git a/packages/agent-adapter/src/native/pi.ts b/packages/agent-adapter/src/native/pi.ts index a8294a3be..56e0f3fda 100644 --- a/packages/agent-adapter/src/native/pi.ts +++ b/packages/agent-adapter/src/native/pi.ts @@ -2,9 +2,13 @@ import type { AgentSession, AgentSessionEvent, PromptOptions, + ToolDefinition, } from '@earendil-works/pi-coding-agent'; import type { ContentBlock, StartOptions } from '@linkcode/schema'; +import { Type } from '@sinclair/typebox'; import { invariant } from 'foxts/guard'; +import type { BrowserToolsetFactory } from '../adapter'; +import { renderBrowserToolResult } from '../adapter'; import { BaseAgentAdapter } from '../base'; import { readAgentCredential } from '../credential'; import { contentToText, imageBlocksFrom, locationsFromToolInput, toolKindFromName } from '../util'; @@ -19,6 +23,49 @@ export class PiAdapter extends BaseAgentAdapter { private session: AgentSession | null = null; private unsub: (() => void) | null = null; + /** Browser code-mode toolset factory (CODE-267); pi has no MCP, so the execute tool rides + * `customTools`. NOTE: pi has no per-call approval seam — the default-off feature gate is + * the only guard (known limitation, recorded on the issue). */ + private browserTools: BrowserToolsetFactory | undefined; + + attachBrowserTools(createToolset: BrowserToolsetFactory): void { + this.browserTools = createToolset; + } + + private buildBrowserTool(): ToolDefinition { + const factory = this.browserTools; + invariant(factory, 'pi: browser tools not attached'); + const toolset = factory(); + return { + name: 'browser_execute', + label: 'Browser', + description: toolset.documentation, + parameters: Type.Object({ + code: Type.String({ description: 'JavaScript for the persistent browser REPL' }), + }), + async execute(_toolCallId, params) { + // Untyped seam: bare ToolDefinition erases Static; the TypeBox schema above + // guarantees the validated shape. + const { code } = params as { code: string }; + const rendered = renderBrowserToolResult(await toolset.execute(code)); + return { + content: [ + { type: 'text', text: rendered.text }, + ...(rendered.image + ? [ + { + type: 'image' as const, + data: rendered.image.base64, + mimeType: rendered.image.mimeType, + }, + ] + : []), + ], + details: undefined, + }; + }, + }; + } protected async onStart(opts: StartOptions): Promise { const pi = await this.loadSdk( @@ -55,6 +102,7 @@ export class PiAdapter extends BaseAgentAdapter { modelRegistry, model, tools: this.tools(), + ...(this.browserTools && { customTools: [this.buildBrowserTool()] }), }); this.session = session; this.unsub = session.subscribe((ev) => this.handleEvent(ev)); diff --git a/packages/engine/src/browser/repl-host.ts b/packages/engine/src/browser/repl-host.ts new file mode 100644 index 000000000..741caf3e3 --- /dev/null +++ b/packages/engine/src/browser/repl-host.ts @@ -0,0 +1,103 @@ +import type { Context } from 'node:vm'; +import { createContext, Script } from 'node:vm'; +import type { BrowserCommandArgs, BrowserCommandResult, BrowserOp } from '@linkcode/schema'; +import { extractErrorMessage } from 'foxts/extract-error-message'; + +export type BrowserOpDispatcher = ( + op: BrowserOp, + args: BrowserCommandArgs, +) => Promise; + +export interface BrowserExecuteResult { + ok: boolean; + value?: unknown; + logs: string[]; + error?: string; +} + +/** Behavior discipline embedded in the execute tool description (the Codex lesson: the model + * must read the rules before driving the page). */ +export const BROWSER_TOOL_DOCUMENTATION = `Run JavaScript in a persistent REPL that controls the user's in-app browser (you and the user share the same visible tabs). Variables persist across calls. Await every browser.* call. console.log output is returned alongside the completion value. + +API (all methods return promises; tab ids come from browser.tabs()): +- browser.tabs() → [{ id, url, title, active }] +- browser.open(url) → open a NEW tab (http/https only) and return the tab list +- browser.select(tabId) / browser.close(tabId) +- browser.navigate(tabId, url) / browser.back(tabId) / browser.reload(tabId) +- browser.snapshot(tabId) → { url, title, nodes: [{ ref, role, name, value? }], truncated } — the interactive elements +- browser.click(tabId, ref) / browser.type(tabId, ref, text) — ref is a "@eN" ref from the LAST snapshot +- browser.screenshot(tabId) → { mimeType, base64 } (also brings the tab forward) +- browser.evaluate(tabId, js) → run JS inside the page and return its JSON-serializable result + +Discipline: +1. ALWAYS take browser.snapshot(tabId) before click/type — refs only exist after a snapshot. +2. Refs go stale on ANY navigation (including ones your click caused). On a stale-ref error, re-snapshot and re-resolve the element; never retry a stale ref. +3. After an action that loads a page, snapshot again before the next interaction. +4. Prefer snapshot+refs over evaluate for interaction; use evaluate for reading page data. +5. Errors are thrown with a closed code prefix (e.g. "host-unavailable: …" when the desktop app is not running).`; + +/** + * Code-mode host (CODE-267, reviving the sky-mcp REPL skeleton): runs model-authored JS in a + * persistent vm context with only a `browser` namespace and a captured `console` injected. + * Context globals persist across `execute` calls, so the model can bind tab ids once. + * + * SECURITY: `node:vm` is NOT a security boundary — model code is semi-trusted, and effects are + * gated by the execute tool's approval flow plus the default-off feature gate, never here. + */ +export class BrowserReplHost { + /** Embedded verbatim in the execute tool's description (agent-adapter `BrowserToolset`). */ + readonly documentation = BROWSER_TOOL_DOCUMENTATION; + private readonly vmContext: Context; + private readonly logs: string[] = []; + + constructor(dispatch: BrowserOpDispatcher) { + const capturedConsole = { + log: (...args: unknown[]): void => { + this.logs.push(args.map(String).join(' ')); + }, + }; + this.vmContext = createContext({ + browser: buildBrowserNamespace(dispatch), + console: capturedConsole, + }); + } + + async execute(code: string): Promise { + this.logs.length = 0; + try { + const script = new Script(`(async () => {\n${code}\n})()`); + const value = (await script.runInContext(this.vmContext)) as unknown; + return { ok: true, value, logs: [...this.logs] }; + } catch (error) { + return { + ok: false, + logs: [...this.logs], + error: extractErrorMessage(error) ?? 'unknown error', + }; + } + } +} + +/** The `browser` object injected into the REPL; each method is one broker op. A failed result + * becomes a thrown Error carrying the closed code, so model code can catch or surface it. */ +function buildBrowserNamespace(dispatch: BrowserOpDispatcher) { + const call = async (op: BrowserOp, args: BrowserCommandArgs): Promise => { + const result = await dispatch(op, args); + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`); + return result.data; + }; + return { + tabs: () => call('tabs.list', {}), + open: (url: string) => call('tabs.open', { url }), + select: (tabId: string) => call('tabs.select', { tabId }), + navigate: (tabId: string, url: string) => call('tab.navigate', { tabId, url }), + back: (tabId: string) => call('tab.back', { tabId }), + reload: (tabId: string) => call('tab.reload', { tabId }), + close: (tabId: string) => call('tab.close', { tabId }), + snapshot: (tabId: string) => call('tab.snapshot', { tabId }), + click: (tabId: string, ref: string) => call('tab.click', { tabId, ref }), + type: (tabId: string, ref: string, text: string) => call('tab.type', { tabId, ref, text }), + screenshot: (tabId: string) => call('tab.screenshot', { tabId }), + evaluate: (tabId: string, js: string) => call('tab.evaluate', { tabId, js }), + }; +} diff --git a/packages/engine/src/engine.ts b/packages/engine/src/engine.ts index 36f80a7e2..91293d806 100644 --- a/packages/engine/src/engine.ts +++ b/packages/engine/src/engine.ts @@ -45,6 +45,7 @@ import { watchTurn, } from './automation'; import { BrowserBrokerService } from './browser/broker'; +import { BrowserReplHost } from './browser/repl-host'; import { readWorkspaceFile } from './file-service'; import { FileSuggestService } from './file-suggest-service'; import { GitService } from './git/git-service'; @@ -103,6 +104,9 @@ type AskRecord = /** Optional collaborators the daemon injects; each defaults to an in-memory/no-op implementation. */ export interface EngineDeps { factory?: AdapterFactory; + /** Browser code-mode tools for agents (CODE-267). Default OFF: when false/absent no adapter + * ever sees the execute tool; the broker itself always runs (host registration is harmless). */ + browserToolsEnabled?: boolean; sessionStore?: SessionStore; ptyBackend?: PtyBackend; providerStore?: ProviderConfigStore; @@ -169,6 +173,7 @@ export class Engine { private readonly loops: LoopService; private readonly artifactHost: ArtifactHostService; private readonly browserBroker: BrowserBrokerService; + private readonly browserToolsEnabled: boolean; /** Boot snapshot, replaced by every {@link enqueueRuntimesCollect} pass (install/login/auth * events and read-triggered revalidation alike). */ private agentRuntimes: AgentRuntimes; @@ -219,6 +224,7 @@ export class Engine { : undefined; this.artifactHost = new ArtifactHostService(routes); this.browserBroker = new BrowserBrokerService(transport); + this.browserToolsEnabled = deps.browserToolsEnabled ?? false; this.scheduler = new ScheduleService( transport, deps.scheduleStore ?? new InMemoryScheduleStore(), @@ -1084,6 +1090,11 @@ export class Engine { ): Promise { const sessionId = record.sessionId; const adapter = this.factory(record.kind); + if (this.browserToolsEnabled) { + adapter.attachBrowserTools?.( + () => new BrowserReplHost((op, args) => this.browserBroker.dispatch(op, args)), + ); + } const session: Session = { adapter, unsub: noop, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 490a5daab..0ba0fa824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -746,6 +746,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.17.18 version: 1.17.18 + '@sinclair/typebox': + specifier: 0.27.10 + version: 0.27.10 cross-spawn: specifier: ^7.0.6 version: 7.0.6 From 38e3923d2f269ac3d632b8965154c57748bd30dd Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 17 Jul 2026 22:31:07 +0800 Subject: [PATCH 8/8] test(desktop): browser broker end-to-end drive via a raw wire client (CODE-267) --- apps/desktop/e2e/browser-broker.e2e.mts | 212 ++++++++++++++++++++++++ apps/desktop/package.json | 1 + 2 files changed, 213 insertions(+) create mode 100644 apps/desktop/e2e/browser-broker.e2e.mts diff --git a/apps/desktop/e2e/browser-broker.e2e.mts b/apps/desktop/e2e/browser-broker.e2e.mts new file mode 100644 index 000000000..89aa38fed --- /dev/null +++ b/apps/desktop/e2e/browser-broker.e2e.mts @@ -0,0 +1,212 @@ +/** + * Browser broker E2E (CODE-267): boots an isolated daemon + the built desktop app (which + * registers itself as the browser host), then drives the broker as a raw wire client — + * `browser.execute` ops must round-trip daemon → desktop webview executor → back. Also asserts + * the closed host-unavailable error once the desktop quits. + * Run `pnpm -F @linkcode/desktop e2e:browser-broker` after building daemon and desktop. + */ + +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir, tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { noop } from 'foxts/noop'; +import { wait } from 'foxts/wait'; +import type { ElectronApplication } from 'playwright-core'; +import { _electron } from 'playwright-core'; +import { io } from 'socket.io-client'; + +const require = createRequire(import.meta.url); +const desktopDir = resolve(import.meta.dirname, '..'); +const daemonDir = resolve(desktopDir, '../daemon'); +const electronBinary = require('electron') as unknown as string; + +const PORT = 45000 + (process.pid % 1000); +const PROFILE = `e2e-broker-${process.pid}`; +/** Must match packages/schema/src/wire (Invariant 1 — a drift silently drops every frame). */ +const WIRE_VERSION = 38; + +function fail(message: string): never { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +async function waitForDaemon(): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + try { + await fetch(`http://127.0.0.1:${PORT}/socket.io/?EIO=4&transport=polling`); + return; + } catch { + await wait(250); + } + } + fail(`daemon did not come up on port ${PORT}`); +} + +interface WireFrame { + v: number; + id: string; + ts: number; + payload: Record; +} + +function createWireClient(): { + send: (payload: Record) => void; + next: ( + predicate: (payload: Record) => boolean, + ) => Promise>; + close: () => void; +} { + const socket = io(`http://127.0.0.1:${PORT}`, { transports: ['websocket'] }); + let seq = 0; + const backlog: Record[] = []; + const waiters: { + predicate: (payload: Record) => boolean; + resolve: (payload: Record) => void; + }[] = []; + socket.on('frame', (frame: WireFrame) => { + const index = waiters.findIndex((waiter) => waiter.predicate(frame.payload)); + if (index === -1) { + backlog.push(frame.payload); + return; + } + const [waiter] = waiters.splice(index, 1); + waiter.resolve(frame.payload); + }); + return { + send(payload) { + seq += 1; + socket.emit('frame', { v: WIRE_VERSION, id: `e2e-${seq}`, ts: Date.now(), payload }); + }, + next(predicate) { + const buffered = backlog.findIndex((payload) => predicate(payload)); + if (buffered !== -1) return Promise.resolve(backlog.splice(buffered, 1)[0]); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('wire wait timed out')), 20000); + waiters.push({ + predicate, + resolve(payload) { + clearTimeout(timer); + resolve(payload); + }, + }); + }); + }, + close() { + socket.close(); + }, + }; +} + +async function execute( + client: ReturnType, + reqId: string, + op: string, + args: Record, +): Promise> { + client.send({ kind: 'browser.execute', clientReqId: reqId, op, args }); + const reply = await client.next( + (payload) => payload.kind === 'browser.executed' && payload.replyTo === reqId, + ); + return reply.result as Record; +} + +async function main(): Promise { + if (!existsSync(join(daemonDir, 'dist/index.js'))) { + fail('apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first'); + } + if (!existsSync(join(desktopDir, 'out/main/index.js'))) { + fail('apps/desktop/out is missing — run `pnpm -F @linkcode/desktop build` first'); + } + + const home = mkdtempSync(join(tmpdir(), 'linkcode-e2e-home-')); + let daemon: ChildProcess | null = null; + let app: ElectronApplication | null = null; + let client: ReturnType | null = null; + let passed = false; + try { + daemon = spawn(process.execPath, ['dist/index.js'], { + cwd: daemonDir, + env: { + ...process.env, + HOME: home, + LINKCODE_PORT: String(PORT), + LINKCODE_PROFILE: PROFILE, + LINKCODE_BROWSER_TOOLS: '1', + }, + stdio: 'ignore', + }); + await waitForDaemon(); + console.log(`daemon up on :${PORT}`); + + app = await _electron.launch({ + executablePath: electronBinary, + args: [desktopDir, '--use-mock-keychain', '--lang=en-US'], + env: { ...process.env, HOME: home, LINKCODE_PROFILE: PROFILE }, + }); + const win = await app.firstWindow(); + await win.locator('textarea').first().waitFor({ state: 'visible', timeout: 30000 }); + // Give the workbench a beat to register itself as the browser host. + await win.waitForTimeout(2000); + + client = createWireClient(); + client.send({ kind: 'ping' }); + await client.next((payload) => payload.kind === 'pong'); + console.log('wire client handshake ok'); + + // tabs.open drives the desktop store; the seeded tab list must come back with the new tab. + const opened = await execute(client, 'r-open', 'tabs.open', { + url: 'https://example.com/', + }); + if (opened.ok !== true) fail(`tabs.open failed: ${JSON.stringify(opened)}`); + const tabs = opened.data as { id: string; url: string | null; active: boolean }[]; + const active = tabs.find((tab) => tab.active); + if (active?.url !== 'https://example.com/') { + fail(`expected an active example.com tab, got ${JSON.stringify(tabs)}`); + } + const activeTabId = active.id; + console.log(`tabs.open round-tripped through the desktop executor (${tabs.length} tabs)`); + + // Let the new tab's webview mount (it registers with the executor on render). + await wait(2000); + const invalid = await execute(client, 'r-bad', 'tab.click', { tabId: activeTabId, ref: '@e1' }); + if (invalid.ok !== false) fail('tab.click without a snapshot should fail'); + const error = invalid.error as { code: string }; + if (error.code !== 'stale-ref') fail(`expected stale-ref, got ${JSON.stringify(error)}`); + console.log('ref discipline enforced (stale-ref without a snapshot)'); + + // Quit the desktop: the broker must degrade to the closed host-unavailable code. + await app.close(); + app = null; + await wait(1000); + const offline = await execute(client, 'r-offline', 'tabs.list', {}); + if (offline.ok !== false) fail('tabs.list should fail once the host quit'); + const offlineError = offline.error as { code: string }; + if (offlineError.code !== 'host-unavailable') { + fail(`expected host-unavailable, got ${JSON.stringify(offlineError)}`); + } + console.log('host disconnect degrades to host-unavailable'); + + passed = true; + console.log('PASS'); + } finally { + client?.close(); + await app?.close().catch(noop); + daemon?.kill('SIGTERM'); + rmSync(join(homedir(), 'Library/Application Support', `LinkCode Development (${PROFILE})`), { + recursive: true, + force: true, + }); + if (passed) { + rmSync(home, { recursive: true, force: true }); + } else { + console.error(`kept for debugging: HOME=${home}`); + process.exitCode = 1; + } + } +} + +void main(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 14d5361f7..7e621839b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,6 +17,7 @@ "e2e:notifications": "node e2e/notifications.e2e.mts", "e2e:file-tree": "node e2e/file-tree.e2e.mts", "e2e:browser-tabs": "node e2e/browser-tabs.e2e.mts", + "e2e:browser-broker": "node e2e/browser-broker.e2e.mts", "lint": "pnpm --dir ../.. exec eslint --format=sukka apps/desktop" }, "author": "ArcBox Labs ",