diff --git a/apps/x/apps/main/src/browser/ipc.ts b/apps/x/apps/main/src/browser/ipc.ts index ce9404e0..fa3b1ac1 100644 --- a/apps/x/apps/main/src/browser/ipc.ts +++ b/apps/x/apps/main/src/browser/ipc.ts @@ -12,6 +12,9 @@ type InvokeHandler = ( type BrowserHandlers = { 'browser:setBounds': InvokeHandler<'browser:setBounds'>; 'browser:setVisible': InvokeHandler<'browser:setVisible'>; + 'browser:newTab': InvokeHandler<'browser:newTab'>; + 'browser:switchTab': InvokeHandler<'browser:switchTab'>; + 'browser:closeTab': InvokeHandler<'browser:closeTab'>; 'browser:navigate': InvokeHandler<'browser:navigate'>; 'browser:back': InvokeHandler<'browser:back'>; 'browser:forward': InvokeHandler<'browser:forward'>; @@ -34,6 +37,15 @@ export const browserIpcHandlers: BrowserHandlers = { browserViewManager.setVisible(args.visible); return { ok: true }; }, + 'browser:newTab': async (_event, args) => { + return browserViewManager.newTab(args.url); + }, + 'browser:switchTab': async (_event, args) => { + return browserViewManager.switchTab(args.tabId); + }, + 'browser:closeTab': async (_event, args) => { + return browserViewManager.closeTab(args.tabId); + }, 'browser:navigate': async (_event, args) => { return browserViewManager.navigate(args.url); }, diff --git a/apps/x/apps/main/src/browser/view.ts b/apps/x/apps/main/src/browser/view.ts index f5d3b38a..39f33a09 100644 --- a/apps/x/apps/main/src/browser/view.ts +++ b/apps/x/apps/main/src/browser/view.ts @@ -1,16 +1,17 @@ -import { BrowserWindow, WebContentsView, session, shell } from 'electron'; +import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; +import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron'; /** * Embedded browser pane implementation. * - * A single lazy-created WebContentsView is hosted on top of the main - * BrowserWindow's contentView, positioned by pixel bounds the renderer - * computes via ResizeObserver. + * Each browser tab owns its own WebContentsView. Only the active tab's view is + * attached to the main window at a time, but inactive tabs keep their own page + * history and loaded state in memory so switching tabs feels immediate. * - * The view uses a persistent session partition so cookies/localStorage/ - * form-fill state survive app restarts, and spoofs a standard Chrome UA so - * sites like Google (OAuth) don't reject it as an embedded browser. + * All tabs share one persistent session partition so cookies/localStorage/ + * form-fill state survive app restarts, and the browser surface spoofs a + * standard Chrome UA so sites like Google (OAuth) don't reject it. */ const PARTITION = 'persist:rowboat-browser'; @@ -29,7 +30,8 @@ export interface BrowserBounds { height: number; } -export interface BrowserState { +export interface BrowserTabState { + id: string; url: string; title: string; canGoBack: boolean; @@ -37,18 +39,28 @@ export interface BrowserState { loading: boolean; } +export interface BrowserState { + activeTabId: string | null; + tabs: BrowserTabState[]; +} + +type BrowserTab = { + id: string; + view: WebContentsView; +}; + const EMPTY_STATE: BrowserState = { - url: '', - title: '', - canGoBack: false, - canGoForward: false, - loading: false, + activeTabId: null, + tabs: [], }; export class BrowserViewManager extends EventEmitter { private window: BrowserWindow | null = null; - private view: WebContentsView | null = null; - private attached = false; + private browserSession: Session | null = null; + private tabs = new Map(); + private tabOrder: string[] = []; + private activeTabId: string | null = null; + private attachedTabId: string | null = null; private visible = false; private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 }; @@ -56,54 +68,78 @@ export class BrowserViewManager extends EventEmitter { this.window = window; window.on('closed', () => { this.window = null; - this.view = null; - this.attached = false; + this.browserSession = null; + this.tabs.clear(); + this.tabOrder = []; + this.activeTabId = null; + this.attachedTabId = null; this.visible = false; }); } - private ensureView(): WebContentsView { - if (this.view) return this.view; - if (!this.window) { - throw new Error('BrowserViewManager: no window attached'); - } - - // One shared session across all BrowserViewManager instances in this - // process, keyed by partition name. Setting the UA on the session covers - // requests the webContents issues before the first page is loaded. + private getSession(): Session { + if (this.browserSession) return this.browserSession; const browserSession = session.fromPartition(PARTITION); browserSession.setUserAgent(SPOOF_UA); + this.browserSession = browserSession; + return browserSession; + } + private emitState(): void { + this.emit('state-updated', this.snapshotState()); + } + + private getTab(tabId: string | null): BrowserTab | null { + if (!tabId) return null; + return this.tabs.get(tabId) ?? null; + } + + private getActiveTab(): BrowserTab | null { + return this.getTab(this.activeTabId); + } + + private normalizeUrl(rawUrl: string): string { + let url = rawUrl.trim(); + if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) { + url = `https://${url}`; + } + return url; + } + + private isEmbeddedTabUrl(url: string): boolean { + return /^https?:\/\//i.test(url) || url === 'about:blank'; + } + + private createView(tabId: string): WebContentsView { const view = new WebContentsView({ webPreferences: { - session: browserSession, + session: this.getSession(), contextIsolation: true, sandbox: true, nodeIntegration: false, }, }); - // Also set UA on the webContents directly — belt-and-braces for sites - // that inspect the request-level UA vs the navigator UA. view.webContents.setUserAgent(SPOOF_UA); - - this.wireEvents(view); - this.view = view; + this.wireEvents(tabId, view); return view; } - private wireEvents(view: WebContentsView): void { + private wireEvents(tabId: string, view: WebContentsView): void { const wc = view.webContents; - const emit = () => this.emit('state-updated', this.snapshotState()); + const emit = () => this.emitState(); - // Defensively re-apply bounds on navigation events. Electron's - // WebContentsView is known to occasionally reset its laid-out bounds on - // navigation (a behavior carried over from the deprecated BrowserView), - // which manifests as the view "spilling" outside its intended pane. - // Re-applying after every navigation/load event is cheap and idempotent. + // Electron occasionally drops WebContentsView layout on navigation. + // Re-applying the cached bounds is cheap and keeps the active tab pinned + // to the renderer-computed viewport. const reapplyBounds = () => { - if (this.attached && this.bounds.width > 0 && this.bounds.height > 0) { + if ( + this.attachedTabId === tabId && + this.visible && + this.bounds.width > 0 && + this.bounds.height > 0 + ) { view.setBounds(this.bounds); } }; @@ -118,105 +154,20 @@ export class BrowserViewManager extends EventEmitter { wc.on('did-fail-load', () => { reapplyBounds(); emit(); }); wc.on('page-title-updated', emit); - // Pop-ups / target="_blank" — hand off to the OS browser for now. - // The embedded pane is single-tab in v1. wc.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url); + if (this.isEmbeddedTabUrl(url)) { + void this.newTab(url); + } else { + void shell.openExternal(url); + } return { action: 'deny' }; }); } - setVisible(visible: boolean): void { - if (!this.window) return; - const view = visible ? this.ensureView() : this.view; - if (!view) return; - - const contentView = this.window.contentView; - - if (visible) { - // Order: attach FIRST, then setBounds. Calling setBounds on an - // unattached WebContentsView can leave it in a state where the next - // attach uses default bounds, blanking the renderer area. - if (!this.attached) { - contentView.addChildView(view); - this.attached = true; - } - // The renderer only asks us to show the view after it has pushed a - // fresh non-zero rect, so applying the cached bounds here should land - // the surface in the correct pane immediately on attach. - view.setBounds(this.bounds); - this.visible = true; - - // First-time load — land on a useful page rather than about:blank. - const currentUrl = view.webContents.getURL(); - if (!currentUrl || currentUrl === 'about:blank') { - void view.webContents.loadURL(HOME_URL); - } - } else { - if (this.attached) { - contentView.removeChildView(view); - this.attached = false; - } - this.visible = false; - } - } - - setBounds(bounds: BrowserBounds): void { - this.bounds = bounds; - // Only apply to the view if it's currently attached and visible. - // Applying to a detached view appears to put Electron's WebContentsView - // into a bad state on subsequent attach (renderer area blanks out). - if (this.view && this.attached && this.visible) { - this.view.setBounds(bounds); - } - } - - async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> { - try { - const view = this.ensureView(); - // If the user typed "example.com" without a scheme, assume https. - // Schemes are already filtered at the IPC boundary, so we know it's - // not file://, javascript:, etc. - let url = rawUrl.trim(); - if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) { - url = `https://${url}`; - } - await view.webContents.loadURL(url); - return { ok: true }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - back(): { ok: boolean } { - if (!this.view) return { ok: false }; - const history = this.view.webContents.navigationHistory; - if (!history.canGoBack()) return { ok: false }; - history.goBack(); - return { ok: true }; - } - - forward(): { ok: boolean } { - if (!this.view) return { ok: false }; - const history = this.view.webContents.navigationHistory; - if (!history.canGoForward()) return { ok: false }; - history.goForward(); - return { ok: true }; - } - - reload(): void { - if (!this.view) return; - this.view.webContents.reload(); - } - - getState(): BrowserState { - return this.snapshotState(); - } - - private snapshotState(): BrowserState { - if (!this.view) return { ...EMPTY_STATE }; - const wc = this.view.webContents; + private snapshotTabState(tab: BrowserTab): BrowserTabState { + const wc = tab.view.webContents; return { + id: tab.id, url: wc.getURL(), title: wc.getTitle(), canGoBack: wc.navigationHistory.canGoBack(), @@ -224,6 +175,189 @@ export class BrowserViewManager extends EventEmitter { loading: wc.isLoading(), }; } + + private syncAttachedView(): void { + if (!this.window) return; + + const contentView = this.window.contentView; + const activeTab = this.getActiveTab(); + + if (!this.visible || !activeTab) { + const attachedTab = this.getTab(this.attachedTabId); + if (attachedTab) { + contentView.removeChildView(attachedTab.view); + } + this.attachedTabId = null; + return; + } + + if (this.attachedTabId && this.attachedTabId !== activeTab.id) { + const attachedTab = this.getTab(this.attachedTabId); + if (attachedTab) { + contentView.removeChildView(attachedTab.view); + } + this.attachedTabId = null; + } + + if (this.attachedTabId !== activeTab.id) { + contentView.addChildView(activeTab.view); + this.attachedTabId = activeTab.id; + } + + if (this.bounds.width > 0 && this.bounds.height > 0) { + activeTab.view.setBounds(this.bounds); + } + } + + private createTab(initialUrl: string): BrowserTab { + if (!this.window) { + throw new Error('BrowserViewManager: no window attached'); + } + + const tabId = randomUUID(); + const tab: BrowserTab = { + id: tabId, + view: this.createView(tabId), + }; + + this.tabs.set(tabId, tab); + this.tabOrder.push(tabId); + this.activeTabId = tabId; + this.syncAttachedView(); + this.emitState(); + + const targetUrl = + initialUrl === 'about:blank' + ? HOME_URL + : this.normalizeUrl(initialUrl); + void tab.view.webContents.loadURL(targetUrl).catch(() => { + this.emitState(); + }); + + return tab; + } + + private ensureInitialTab(): BrowserTab { + const activeTab = this.getActiveTab(); + if (activeTab) return activeTab; + return this.createTab(HOME_URL); + } + + private destroyTab(tab: BrowserTab): void { + tab.view.webContents.removeAllListeners(); + if (!tab.view.webContents.isDestroyed()) { + tab.view.webContents.close(); + } + } + + setVisible(visible: boolean): void { + this.visible = visible; + if (visible) { + this.ensureInitialTab(); + } + this.syncAttachedView(); + } + + setBounds(bounds: BrowserBounds): void { + this.bounds = bounds; + const activeTab = this.getActiveTab(); + if (activeTab && this.attachedTabId === activeTab.id && this.visible) { + activeTab.view.setBounds(bounds); + } + } + + async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> { + try { + const tab = this.createTab(rawUrl?.trim() ? rawUrl : HOME_URL); + return { ok: true, tabId: tab.id }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + switchTab(tabId: string): { ok: boolean } { + if (!this.tabs.has(tabId)) return { ok: false }; + if (this.activeTabId === tabId) return { ok: true }; + this.activeTabId = tabId; + this.syncAttachedView(); + this.emitState(); + return { ok: true }; + } + + closeTab(tabId: string): { ok: boolean } { + const tab = this.tabs.get(tabId); + if (!tab) return { ok: false }; + if (this.tabOrder.length <= 1) return { ok: false }; + + const closingIndex = this.tabOrder.indexOf(tabId); + const nextActiveTabId = + this.activeTabId === tabId + ? this.tabOrder[closingIndex + 1] ?? this.tabOrder[closingIndex - 1] ?? null + : this.activeTabId; + + if (this.attachedTabId === tabId && this.window) { + this.window.contentView.removeChildView(tab.view); + this.attachedTabId = null; + } + + this.tabs.delete(tabId); + this.tabOrder = this.tabOrder.filter((id) => id !== tabId); + this.activeTabId = nextActiveTabId; + this.destroyTab(tab); + this.syncAttachedView(); + this.emitState(); + + return { ok: true }; + } + + async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> { + try { + const activeTab = this.getActiveTab() ?? this.ensureInitialTab(); + await activeTab.view.webContents.loadURL(this.normalizeUrl(rawUrl)); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + back(): { ok: boolean } { + const activeTab = this.getActiveTab(); + if (!activeTab) return { ok: false }; + const history = activeTab.view.webContents.navigationHistory; + if (!history.canGoBack()) return { ok: false }; + history.goBack(); + return { ok: true }; + } + + forward(): { ok: boolean } { + const activeTab = this.getActiveTab(); + if (!activeTab) return { ok: false }; + const history = activeTab.view.webContents.navigationHistory; + if (!history.canGoForward()) return { ok: false }; + history.goForward(); + return { ok: true }; + } + + reload(): void { + const activeTab = this.getActiveTab(); + if (!activeTab) return; + activeTab.view.webContents.reload(); + } + + getState(): BrowserState { + return this.snapshotState(); + } + + private snapshotState(): BrowserState { + if (this.tabOrder.length === 0) return { ...EMPTY_STATE }; + return { + activeTabId: this.activeTabId, + tabs: this.tabOrder + .map((tabId) => this.tabs.get(tabId)) + .filter((tab): tab is BrowserTab => tab != null) + .map((tab) => this.snapshotTabState(tab)), + }; + } } export const browserViewManager = new BrowserViewManager(); diff --git a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx index bdfd4b4a..20ca7e3a 100644 --- a/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/apps/x/apps/renderer/src/components/browser-pane/BrowserPane.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, RotateCw, Loader2, X } from 'lucide-react' +import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react' +import { TabBar } from '@/components/tab-bar' import { cn } from '@/lib/utils' /** @@ -9,11 +10,12 @@ import { cn } from '@/lib/utils' * Renders a transparent placeholder div whose bounds are reported to the * main process via `browser:setBounds`. The actual browsing surface is an * Electron WebContentsView layered on top of the renderer by the main - * process — this component only owns the chrome (address bar, nav, spinner) - * and the sizing/visibility lifecycle. + * process — this component only owns the chrome (tabs, address bar, nav + * buttons) and the sizing/visibility lifecycle. */ -interface BrowserState { +interface BrowserTabState { + id: string url: string title: string canGoBack: boolean @@ -21,58 +23,73 @@ interface BrowserState { loading: boolean } -const EMPTY_STATE: BrowserState = { - url: '', - title: '', - canGoBack: false, - canGoForward: false, - loading: false, +interface BrowserState { + activeTabId: string | null + tabs: BrowserTabState[] +} + +const EMPTY_STATE: BrowserState = { + activeTabId: null, + tabs: [], } -/** Placeholder height we subtract from the inner bounds for the chrome row. */ const CHROME_HEIGHT = 40 interface BrowserPaneProps { onClose: () => void } +const getActiveTab = (state: BrowserState) => + state.tabs.find((tab) => tab.id === state.activeTabId) ?? null + +const getBrowserTabTitle = (tab: BrowserTabState) => { + const title = tab.title.trim() + if (title) return title + const url = tab.url.trim() + if (!url) return 'New tab' + try { + const parsed = new URL(url) + return parsed.hostname || parsed.href + } catch { + return url.replace(/^https?:\/\//i, '') || 'New tab' + } +} + export function BrowserPane({ onClose }: BrowserPaneProps) { const [state, setState] = useState(EMPTY_STATE) const [addressValue, setAddressValue] = useState('') + const activeTabIdRef = useRef(null) const addressFocusedRef = useRef(false) const viewportRef = useRef(null) const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null) const viewVisibleRef = useRef(false) - // ── Subscribe to state updates from main ────────────────────────────────── - useEffect(() => { - const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => { - const next = incoming as BrowserState - setState(next) - if (!addressFocusedRef.current) { - setAddressValue(next.url) - } - }) - // Kick an initial state fetch so the chrome reflects wherever the view - // was left from a previous session (or empty, if never loaded). - void window.ipc.invoke('browser:getState', null).then((initial) => { - const next = initial as BrowserState - setState(next) - if (!addressFocusedRef.current) { - setAddressValue(next.url) - } - }) - return cleanup + const activeTab = getActiveTab(state) + + const applyState = useCallback((next: BrowserState) => { + const previousActiveTabId = activeTabIdRef.current + activeTabIdRef.current = next.activeTabId + setState(next) + + const nextActiveTab = getActiveTab(next) + if (!addressFocusedRef.current || next.activeTabId !== previousActiveTabId) { + setAddressValue(nextActiveTab?.url ?? '') + } }, []) - // ── Bounds tracking ─────────────────────────────────────────────────────── - // The main process needs pixel-accurate bounds *relative to the window - // content area*. getBoundingClientRect() returns viewport-relative coords, - // which in Electron with hiddenInset titleBar equal content-area coords. - // - // Reads layout synchronously and posts an IPC update only when the rect - // actually changed. Cheap enough to call from a RAF loop or observer. + useEffect(() => { + const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => { + applyState(incoming as BrowserState) + }) + + void window.ipc.invoke('browser:getState', null).then((initial) => { + applyState(initial as BrowserState) + }) + + return cleanup + }, [applyState]) + const setViewVisible = useCallback((visible: boolean) => { if (viewVisibleRef.current === visible) return viewVisibleRef.current = visible @@ -82,6 +99,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { const measureBounds = useCallback(() => { const el = viewportRef.current if (!el) return null + const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01) const rect = el.getBoundingClientRect() const chatSidebar = el.ownerDocument.querySelector('[data-chat-sidebar-root]') @@ -89,25 +107,25 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0 ? Math.min(rect.right, chatSidebarRect.left) : rect.right + // `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's - // native view bounds are in unzoomed window coordinates, so we have to - // convert back using the renderer zoom factor. + // native view bounds are in unzoomed window coordinates, so convert back + // using the renderer zoom factor before calling into the main process. const left = Math.ceil(rect.left * zoomFactor) const top = Math.ceil(rect.top * zoomFactor) const right = Math.floor(clampedRightCss * zoomFactor) const bottom = Math.floor(rect.bottom * zoomFactor) const width = right - left const height = bottom - top - // A zero-sized rect means the element isn't laid out yet or has been - // collapsed behind the chat pane — hide the native view in that case. + if (width <= 0 || height <= 0) return null - const bounds = { + + return { x: left, y: top, width, height, } - return bounds }, []) const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => { @@ -138,24 +156,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { return bounds }, [measureBounds, pushBounds, setViewVisible]) - // Defensively re-push bounds whenever the underlying page state changes. - // Electron's WebContentsView can drop its laid-out rect on navigation, - // and even though main re-applies on the same events, pushing from the - // renderer ensures we use the *current* layout (in case the chat sidebar - // or window resized while the page was loading). useEffect(() => { syncView() - }, [state.url, state.loading, syncView]) + }, [activeTab?.id, activeTab?.loading, activeTab?.url, syncView]) - // ── Visibility lifecycle ────────────────────────────────────────────────── - // Order matters: push bounds FIRST so the WCV is created at the right - // position, THEN setVisible. Otherwise the view gets attached at stale - // (or zero) cached bounds and visually spills until the next bounds push. - // - // We wait one animation frame after mount so React's commit + the browser's - // layout pass for any sibling that just mounted (e.g. the chat sidebar) are - // both reflected in getBoundingClientRect. A single RAF is enough — by then - // the renderer has painted the post-commit DOM at least once. useEffect(() => { let cancelled = false const rafId = requestAnimationFrame(() => { @@ -170,21 +174,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { } }, [setViewVisible, syncView]) - // Ongoing bounds tracking. Observes everything that can move our viewport: - // - the viewport div itself (local size changes) - // - the SidebarInset ancestor (changes when chat sidebar mounts/resizes) - // - the document root (window resize, devicePixelRatio changes) - // - // ResizeObserver fires after layout but before paint, so by the time we - // call pushBounds the rect is current. Multiple observers may fire in the - // same frame — RAF-coalesce them so we only post one IPC per frame. useEffect(() => { const el = viewportRef.current if (!el) return - // The sidebar-inset main element is the immediate flex parent whose - // width shrinks when a sibling appears. Walking up the tree is more - // robust than passing a ref through props. const sidebarInset = el.closest('[data-slot="sidebar-inset"]') const chatSidebar = el.ownerDocument.querySelector('[data-chat-sidebar-root]') const documentElement = el.ownerDocument.documentElement @@ -210,7 +203,23 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { } }, [syncView]) - // ── Actions ─────────────────────────────────────────────────────────────── + const handleNewTab = useCallback(() => { + void window.ipc.invoke('browser:newTab', {}).then((res) => { + const result = res as { ok: boolean; error?: string } + if (!result.ok && result.error) { + console.error('browser:newTab failed', result.error) + } + }) + }, []) + + const handleSwitchTab = useCallback((tabId: string) => { + void window.ipc.invoke('browser:switchTab', { tabId }) + }, []) + + const handleCloseTab = useCallback((tabId: string) => { + void window.ipc.invoke('browser:closeTab', { tabId }) + }, []) + const handleSubmitAddress = useCallback((e: React.FormEvent) => { e.preventDefault() const trimmed = addressValue.trim() @@ -236,10 +245,27 @@ export function BrowserPane({ onClose }: BrowserPaneProps) { }, []) return ( -
- {/* Chrome row: back / forward / reload / address bar */} +
+
+ tab.id} + onSwitchTab={handleSwitchTab} + onCloseTab={handleCloseTab} + layout="scroll" + /> + +
+
@@ -259,10 +285,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
- {/* Viewport placeholder — the WebContentsView is layered on top of this - area from the main process. The div itself stays transparent so the - user sees the web page, not a React element. */}
{ + const lower = u.trim().toLowerCase(); + if (lower.startsWith('javascript:')) return false; + if (lower.startsWith('file://')) return false; + if (lower.startsWith('chrome://')) return false; + if (lower.startsWith('chrome-extension://')) return false; + return true; + }, + { message: 'Unsafe URL scheme' }, + ).optional(), + }), + res: z.object({ + ok: z.boolean(), + tabId: z.string().optional(), + error: z.string().optional(), + }), + }, + 'browser:switchTab': { + req: z.object({ tabId: z.string().min(1) }), + res: z.object({ ok: z.boolean() }), + }, + 'browser:closeTab': { + req: z.object({ tabId: z.string().min(1) }), + res: z.object({ ok: z.boolean() }), + }, 'browser:navigate': { req: z.object({ url: z.string().min(1).refine( @@ -674,20 +702,28 @@ const ipcSchemas = { 'browser:getState': { req: z.null(), res: z.object({ - url: z.string(), - title: z.string(), - canGoBack: z.boolean(), - canGoForward: z.boolean(), - loading: z.boolean(), + activeTabId: z.string().nullable(), + tabs: z.array(z.object({ + id: z.string(), + url: z.string(), + title: z.string(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loading: z.boolean(), + })), }), }, 'browser:didUpdateState': { req: z.object({ - url: z.string(), - title: z.string(), - canGoBack: z.boolean(), - canGoForward: z.boolean(), - loading: z.boolean(), + activeTabId: z.string().nullable(), + tabs: z.array(z.object({ + id: z.string(), + url: z.string(), + title: z.string(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loading: z.boolean(), + })), }), res: z.null(), },