browser initial commit

This commit is contained in:
Arjun 2026-04-09 20:54:02 +05:30
parent e2c13f0f6f
commit b0f5ed85c6
7 changed files with 690 additions and 11 deletions

View file

@ -0,0 +1,69 @@
import { BrowserWindow } from 'electron';
import { ipc } from '@x/shared';
import { browserViewManager, type BrowserState } from './view.js';
type IPCChannels = ipc.IPCChannels;
type InvokeHandler<K extends keyof IPCChannels> = (
event: Electron.IpcMainInvokeEvent,
args: IPCChannels[K]['req'],
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
type BrowserHandlers = {
'browser:setBounds': InvokeHandler<'browser:setBounds'>;
'browser:setVisible': InvokeHandler<'browser:setVisible'>;
'browser:navigate': InvokeHandler<'browser:navigate'>;
'browser:back': InvokeHandler<'browser:back'>;
'browser:forward': InvokeHandler<'browser:forward'>;
'browser:reload': InvokeHandler<'browser:reload'>;
'browser:getState': InvokeHandler<'browser:getState'>;
};
/**
* Browser-specific IPC handlers, exported as a plain object so they can be
* spread into the main `registerIpcHandlers({...})` call in ipc.ts. This
* mirrors the convention of keeping feature handlers flat and namespaced by
* channel prefix (`browser:*`).
*/
export const browserIpcHandlers: BrowserHandlers = {
'browser:setBounds': async (_event, args) => {
browserViewManager.setBounds(args);
return { ok: true };
},
'browser:setVisible': async (_event, args) => {
browserViewManager.setVisible(args.visible);
return { ok: true };
},
'browser:navigate': async (_event, args) => {
return browserViewManager.navigate(args.url);
},
'browser:back': async () => {
return browserViewManager.back();
},
'browser:forward': async () => {
return browserViewManager.forward();
},
'browser:reload': async () => {
browserViewManager.reload();
return { ok: true };
},
'browser:getState': async () => {
return browserViewManager.getState();
},
};
/**
* Wire the BrowserViewManager's state-updated event to all renderer windows
* as a `browser:didUpdateState` push. Must be called once after the main
* window is created so the manager has a window to attach to.
*/
export function setupBrowserEventForwarding(): void {
browserViewManager.on('state-updated', (state: BrowserState) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('browser:didUpdateState', state);
}
}
});
}

View file

@ -0,0 +1,207 @@
import { BrowserWindow, WebContentsView, session, shell } from 'electron';
import { EventEmitter } from 'node:events';
/**
* 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.
*
* 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.
*/
const PARTITION = 'persist:rowboat-browser';
// Claims Chrome 130 on macOS — close enough to recent stable for OAuth servers
// that sniff the UA looking for "real browser" shapes.
const SPOOF_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
const HOME_URL = 'https://www.google.com';
export interface BrowserBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface BrowserState {
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
loading: boolean;
}
const EMPTY_STATE: BrowserState = {
url: '',
title: '',
canGoBack: false,
canGoForward: false,
loading: false,
};
export class BrowserViewManager extends EventEmitter {
private window: BrowserWindow | null = null;
private view: WebContentsView | null = null;
private attached = false;
private visible = false;
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
attach(window: BrowserWindow): void {
this.window = window;
window.on('closed', () => {
this.window = null;
this.view = null;
this.attached = false;
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.
const browserSession = session.fromPartition(PARTITION);
browserSession.setUserAgent(SPOOF_UA);
const view = new WebContentsView({
webPreferences: {
session: browserSession,
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;
return view;
}
private wireEvents(view: WebContentsView): void {
const wc = view.webContents;
const emit = () => this.emit('state-updated', this.snapshotState());
wc.on('did-navigate', emit);
wc.on('did-navigate-in-page', emit);
wc.on('did-start-loading', emit);
wc.on('did-stop-loading', emit);
wc.on('did-finish-load', emit);
wc.on('did-fail-load', 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);
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) {
if (!this.attached) {
contentView.addChildView(view);
this.attached = true;
}
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;
if (this.view && 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;
return {
url: wc.getURL(),
title: wc.getTitle(),
canGoBack: wc.navigationHistory.canGoBack(),
canGoForward: wc.navigationHistory.canGoForward(),
loading: wc.isLoading(),
};
}
}
export const browserViewManager = new BrowserViewManager();

View file

@ -52,6 +52,7 @@ import {
replaceTrackBlockYaml,
deleteTrackBlock,
} from '@x/core/dist/knowledge/track/fileops.js';
import { browserIpcHandlers } from './browser/ipc.js';
/**
* Convert markdown to a styled HTML document for PDF/DOCX export.
@ -825,5 +826,7 @@ export function setupIpcHandlers() {
'billing:getInfo': async () => {
return await getBillingInfo();
},
// Embedded browser handlers (WebContentsView + navigation)
...browserIpcHandlers,
});
}

View file

@ -31,6 +31,8 @@ import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import { browserViewManager } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
const execAsync = promisify(exec);
@ -175,6 +177,10 @@ function createWindow() {
}
});
// Attach the embedded browser pane manager to this window.
// The WebContentsView is created lazily on first `browser:setVisible`.
browserViewManager.attach(win);
if (app.isPackaged) {
win.loadURL("app://-/index.html");
} else {
@ -216,6 +222,7 @@ app.whenReady().then(async () => {
await initConfigs();
setupIpcHandlers();
setupBrowserEventForwarding();
createWindow();

View file

@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react';
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon, Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar';
@ -57,6 +57,7 @@ import { OnboardingModal } from '@/components/onboarding'
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
import { TrackModal } from '@/components/track-modal'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
import { VersionHistoryPanel } from '@/components/version-history-panel'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
@ -455,6 +456,8 @@ function FixedSidebarToggle({
meetingSummarizing,
meetingAvailable,
onToggleMeeting,
isBrowserOpen,
onToggleBrowser,
leftInsetPx,
}: {
onNewChat: () => void
@ -463,6 +466,8 @@ function FixedSidebarToggle({
meetingSummarizing: boolean
meetingAvailable: boolean
onToggleMeeting: () => void
isBrowserOpen: boolean
onToggleBrowser: () => void
leftInsetPx: number
}) {
const { toggleSidebar } = useSidebar()
@ -528,6 +533,49 @@ function FixedSidebarToggle({
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleBrowser}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md transition-colors",
isBrowserOpen
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
aria-label={isBrowserOpen ? "Close browser" : "Open browser"}
>
<Globe className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{isBrowserOpen ? 'Close browser' : 'Open browser'}</TooltipContent>
</Tooltip>
{/* Back / Forward navigation */}
{isCollapsed && (
<>
<button
type="button"
onClick={onNavigateBack}
disabled={!canNavigateBack}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
aria-label="Go back"
>
<ChevronLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={onNavigateForward}
disabled={!canNavigateForward}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Go forward"
>
<ChevronRightIcon className="size-5" />
</button>
</>
)}
</div>
)
}
@ -606,6 +654,7 @@ function App() {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
const [isGraphOpen, setIsGraphOpen] = useState(false)
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -2714,6 +2763,24 @@ function App() {
setIsChatSidebarOpen(prev => !prev)
}, [])
// Browser is an overlay on the middle pane: opening it forces the chat
// sidebar to be visible on the right; closing it restores whatever the
// middle pane was showing previously (file/graph/task/chat).
const handleToggleBrowser = useCallback(() => {
setIsBrowserOpen(prev => {
const next = !prev
if (next) {
setIsChatSidebarOpen(true)
setIsRightPaneMaximized(false)
}
return next
})
}, [])
const handleCloseBrowser = useCallback(() => {
setIsBrowserOpen(false)
}, [])
const toggleRightPaneMaximize = useCallback(() => {
setIsChatSidebarOpen(true)
setIsRightPaneMaximized(prev => !prev)
@ -2797,6 +2864,9 @@ function App() {
case 'file':
setSelectedBackgroundTask(null)
setIsGraphOpen(false)
// Navigating to a file dismisses the browser overlay so the file is
// visible in the middle pane.
setIsBrowserOpen(false)
setExpandedFrom(null)
// Preserve split vs knowledge-max mode when navigating knowledge files.
// Only exit chat-only maximize, because that would hide the selected file.
@ -2809,6 +2879,7 @@ function App() {
case 'graph':
setSelectedBackgroundTask(null)
setSelectedPath(null)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsGraphOpen(true)
ensureGraphFileTab()
@ -2819,6 +2890,7 @@ function App() {
case 'task':
setSelectedPath(null)
setIsGraphOpen(false)
setIsBrowserOpen(false)
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
@ -2826,6 +2898,8 @@ function App() {
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
// Don't touch isBrowserOpen here — chat navigation should land in
// the right sidebar when the browser overlay is active.
setExpandedFrom(null)
setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
@ -3101,7 +3175,7 @@ function App() {
}, [])
// Keyboard shortcut: Ctrl+L to toggle main chat view
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
@ -3964,7 +4038,7 @@ function App() {
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen)
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => {
@ -4006,7 +4080,7 @@ function App() {
onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
cancelRecordingIfActive()
if (selectedPath || isGraphOpen) {
if (selectedPath || isGraphOpen || isBrowserOpen) {
setIsChatSidebarOpen(true)
}
@ -4016,8 +4090,8 @@ function App() {
switchChatTab(existingTab.id)
return
}
// In two-pane mode, keep current knowledge/graph context and just swap chat context.
if (selectedPath || isGraphOpen) {
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
if (selectedPath || isGraphOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
loadRun(runIdToLoad)
return
@ -4041,14 +4115,14 @@ function App() {
} else {
// Only one tab, reset it to new chat
setChatTabs([{ id: tabForRun.id, runId: null }])
if (selectedPath || isGraphOpen) {
if (selectedPath || isGraphOpen || isBrowserOpen) {
handleNewChat()
} else {
void navigateToView({ type: 'chat', runId: null })
}
}
} else if (runId === runIdToDelete) {
if (selectedPath || isGraphOpen) {
if (selectedPath || isGraphOpen || isBrowserOpen) {
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
handleNewChat()
} else {
@ -4146,7 +4220,7 @@ function App() {
<TooltipContent side="bottom">Version history</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && !selectedTask && (
{!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4161,7 +4235,7 @@ function App() {
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)}
{!selectedPath && !isGraphOpen && expandedFrom && (
{!selectedPath && !isGraphOpen && !isBrowserOpen && expandedFrom && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -4195,7 +4269,9 @@ function App() {
)}
</ContentHeader>
{selectedPath && isBaseFilePath(selectedPath) ? (
{isBrowserOpen ? (
<BrowserPane onClose={handleCloseBrowser} />
) : selectedPath && isBaseFilePath(selectedPath) ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<BasesView
tree={tree}
@ -4568,6 +4644,8 @@ function App() {
meetingSummarizing={meetingSummarizing}
meetingAvailable={voiceAvailable}
onToggleMeeting={() => { void handleToggleMeeting() }}
isBrowserOpen={isBrowserOpen}
onToggleBrowser={handleToggleBrowser}
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
/>
</SidebarProvider>

View file

@ -0,0 +1,250 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, RotateCw, Loader2, X } from 'lucide-react'
import { cn } from '@/lib/utils'
/**
* Embedded browser pane.
*
* 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.
*/
interface BrowserState {
url: string
title: string
canGoBack: boolean
canGoForward: boolean
loading: boolean
}
const EMPTY_STATE: BrowserState = {
url: '',
title: '',
canGoBack: false,
canGoForward: false,
loading: false,
}
/** Placeholder height we subtract from the inner bounds for the chrome row. */
const CHROME_HEIGHT = 40
interface BrowserPaneProps {
onClose: () => void
}
export function BrowserPane({ onClose }: BrowserPaneProps) {
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
const [addressValue, setAddressValue] = useState('')
const [addressFocused, setAddressFocused] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement>(null)
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
// ── Subscribe to state updates from main ──────────────────────────────────
useEffect(() => {
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
const next = incoming as BrowserState
setState(next)
})
// 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) => {
setState(initial as BrowserState)
})
return cleanup
}, [])
// Keep the address bar in sync with the active page URL, but only when the
// input isn't focused — don't clobber whatever the user is typing.
useEffect(() => {
if (!addressFocused) {
setAddressValue(state.url)
}
}, [state.url, addressFocused])
// ── Visibility lifecycle ──────────────────────────────────────────────────
// Show on mount, hide on unmount. The WebContentsView is expensive to
// create but cheap to show/hide.
useEffect(() => {
void window.ipc.invoke('browser:setVisible', { visible: true })
return () => {
void window.ipc.invoke('browser:setVisible', { visible: false })
}
}, [])
// ── Bounds tracking ───────────────────────────────────────────────────────
// The main process needs pixel-accurate bounds *relative to the window
// content area*. getBoundingClientRect() returns viewport-relative coords,
// which in Electron with no custom chrome equal content-area coords.
const pushBounds = useCallback(() => {
const el = viewportRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const bounds = {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
}
const last = lastBoundsRef.current
if (
last &&
last.x === bounds.x &&
last.y === bounds.y &&
last.width === bounds.width &&
last.height === bounds.height
) {
return
}
lastBoundsRef.current = bounds
void window.ipc.invoke('browser:setBounds', bounds)
}, [])
useEffect(() => {
const el = viewportRef.current
if (!el) return
// Initial push + ResizeObserver for size changes.
pushBounds()
const ro = new ResizeObserver(() => pushBounds())
ro.observe(el)
// The container may move without resizing (sidebar collapse, window
// resize). Listen on window resize for that case.
const onWindowResize = () => pushBounds()
window.addEventListener('resize', onWindowResize)
// Also poll briefly during layout transitions (the sidebar uses CSS
// transitions that don't fire ResizeObserver every frame).
const interval = window.setInterval(pushBounds, 100)
return () => {
ro.disconnect()
window.removeEventListener('resize', onWindowResize)
window.clearInterval(interval)
}
}, [pushBounds])
// ── Actions ───────────────────────────────────────────────────────────────
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
e.preventDefault()
const trimmed = addressValue.trim()
if (!trimmed) return
void window.ipc.invoke('browser:navigate', { url: trimmed }).then((res) => {
const result = res as { ok: boolean; error?: string }
if (!result.ok && result.error) {
console.error('browser:navigate failed', result.error)
}
})
}, [addressValue])
const handleBack = useCallback(() => {
void window.ipc.invoke('browser:back', null)
}, [])
const handleForward = useCallback(() => {
void window.ipc.invoke('browser:forward', null)
}, [])
const handleReload = useCallback(() => {
void window.ipc.invoke('browser:reload', null)
}, [])
return (
<div
ref={containerRef}
className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"
>
{/* Chrome row: back / forward / reload / address bar */}
<div
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
style={{ minHeight: CHROME_HEIGHT }}
>
<button
type="button"
onClick={handleBack}
disabled={!state.canGoBack}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
state.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Back"
>
<ArrowLeft className="size-4" />
</button>
<button
type="button"
onClick={handleForward}
disabled={!state.canGoForward}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
state.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
)}
aria-label="Forward"
>
<ArrowRight className="size-4" />
</button>
<button
type="button"
onClick={handleReload}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Reload"
>
{state.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
)}
</button>
<form onSubmit={handleSubmitAddress} className="flex-1 min-w-0">
<input
type="text"
value={addressValue}
onChange={(e) => setAddressValue(e.target.value)}
onFocus={(e) => {
setAddressFocused(true)
e.currentTarget.select()
}}
onBlur={() => setAddressFocused(false)}
placeholder="Enter URL or search..."
className={cn(
'h-7 w-full rounded-md border border-transparent bg-background px-3 text-sm text-foreground',
'placeholder:text-muted-foreground/60',
'focus:border-border focus:outline-hidden',
)}
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
/>
</form>
{state.title && (
<div className="hidden max-w-[220px] shrink-0 truncate pl-2 text-xs text-muted-foreground sm:block">
{state.title}
</div>
)}
<button
type="button"
onClick={onClose}
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Close browser"
>
<X className="size-4" />
</button>
</div>
{/* 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. */}
<div
ref={viewportRef}
className="relative min-h-0 flex-1"
data-browser-viewport
/>
</div>
)
}

View file

@ -626,6 +626,71 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
// Embedded browser (WebContentsView) channels
'browser:setBounds': {
req: z.object({
x: z.number().int(),
y: z.number().int(),
width: z.number().int().nonnegative(),
height: z.number().int().nonnegative(),
}),
res: z.object({ ok: z.literal(true) }),
},
'browser:setVisible': {
req: z.object({ visible: z.boolean() }),
res: z.object({ ok: z.literal(true) }),
},
'browser:navigate': {
req: z.object({
url: z.string().min(1).refine(
(u) => {
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' },
),
}),
res: z.object({
ok: z.boolean(),
error: z.string().optional(),
}),
},
'browser:back': {
req: z.null(),
res: z.object({ ok: z.boolean() }),
},
'browser:forward': {
req: z.null(),
res: z.object({ ok: z.boolean() }),
},
'browser:reload': {
req: z.null(),
res: z.object({ ok: z.literal(true) }),
},
'browser:getState': {
req: z.null(),
res: z.object({
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(),
}),
res: z.null(),
},
// Billing channels
'billing:getInfo': {
req: z.null(),