From e7f6344e8939313cec4e994e675b461b4c4b73dc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:32 -0400 Subject: [PATCH] Browser v2: workspace model, session profiles, and cookie import (#577) --- .../browser/browser-cookie-import.test.ts | 232 ++++ src/main/browser/browser-cookie-import.ts | 836 ++++++++++++++ src/main/browser/browser-guest-ui.ts | 164 +-- src/main/browser/browser-manager-grab.test.ts | 6 +- src/main/browser/browser-manager.test.ts | 264 ++++- src/main/browser/browser-manager.ts | 518 ++++++++- .../browser/browser-session-registry.test.ts | 211 ++++ src/main/browser/browser-session-registry.ts | 354 ++++++ src/main/ipc/browser.test.ts | 79 +- src/main/ipc/browser.ts | 216 +++- src/main/ipc/register-core-handlers.ts | 7 + .../attach-main-window-services.test.ts | 39 +- .../window/attach-main-window-services.ts | 20 +- src/main/window/createMainWindow.ts | 13 +- src/preload/api-types.d.ts | 70 +- src/preload/index.ts | 231 +++- src/renderer/src/App.tsx | 22 +- src/renderer/src/components/Terminal.tsx | 67 +- .../components/browser-pane/BrowserPane.tsx | 896 ++++++++++++++- .../browser-pane/browser-notices.test.ts | 117 ++ .../browser-pane/browser-notices.ts | 90 ++ .../components/browser-pane/useGrabMode.ts | 32 +- .../src/components/editor/EditorPanel.tsx | 9 +- .../src/components/settings/GeneralPane.tsx | 233 +++- .../src/components/settings/general-search.ts | 22 + .../src/components/tab-bar/BrowserTab.tsx | 3 +- .../src/components/tab-bar/TabBar.tsx | 41 +- .../components/tab-group/TabGroupPanel.tsx | 515 +++++++++ .../components/terminal-pane/TerminalPane.tsx | 2 + .../terminal-link-handlers.test.ts | 7 +- .../terminal-pane/terminal-link-handlers.ts | 11 +- src/renderer/src/hooks/useIpcEvents.test.ts | 3 +- src/renderer/src/hooks/useIpcEvents.ts | 30 +- .../src/lib/workspace-session.test.ts | 17 + src/renderer/src/lib/workspace-session.ts | 26 +- src/renderer/src/store/slices/browser.test.ts | 55 + src/renderer/src/store/slices/browser.ts | 1011 ++++++++++++++--- src/renderer/src/store/slices/editor.ts | 4 +- .../src/store/slices/store-test-helpers.ts | 1 + .../src/store/slices/tabs-helpers.test.ts | 1 + .../src/store/slices/tabs-hydration.test.ts | 5 + .../src/store/slices/tabs-hydration.ts | 2 + src/renderer/src/store/slices/tabs.test.ts | 2 + src/renderer/src/store/slices/tabs.ts | 61 +- src/renderer/src/store/slices/ui.ts | 11 +- src/shared/browser-grab-types.ts | 10 +- src/shared/browser-guest-events.ts | 53 + src/shared/constants.ts | 1 + src/shared/types.ts | 73 +- 49 files changed, 6223 insertions(+), 470 deletions(-) create mode 100644 src/main/browser/browser-cookie-import.test.ts create mode 100644 src/main/browser/browser-cookie-import.ts create mode 100644 src/main/browser/browser-session-registry.test.ts create mode 100644 src/main/browser/browser-session-registry.ts create mode 100644 src/renderer/src/components/browser-pane/browser-notices.test.ts create mode 100644 src/renderer/src/components/browser-pane/browser-notices.ts create mode 100644 src/renderer/src/components/tab-group/TabGroupPanel.tsx create mode 100644 src/renderer/src/store/slices/browser.test.ts create mode 100644 src/shared/browser-guest-events.ts diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts new file mode 100644 index 00000000000..8a61bf7e6f9 --- /dev/null +++ b/src/main/browser/browser-cookie-import.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn() +})) + +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: vi.fn() }, + dialog: { showOpenDialog: dialogShowOpenDialogMock }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { importCookiesFromFile, detectInstalledBrowsers } from './browser-cookie-import' +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +describe('importCookiesFromFile', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockReturnValue({ + cookies: { set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeCookieFile(cookies: unknown[]): string { + const filePath = join(tmpDir, 'cookies.json') + writeFileSync(filePath, JSON.stringify(cookies)) + return filePath + } + + it('imports valid cookies', async () => { + const filePath = writeCookieFile([ + { + domain: '.github.com', + name: '_gh_sess', + value: 'abc123', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + expirationDate: 1800000000 + }, + { + domain: '.example.com', + name: 'test', + value: 'val', + path: '/', + secure: false, + httpOnly: false + } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + + expect(result.summary.totalCookies).toBe(2) + expect(result.summary.importedCookies).toBe(2) + expect(result.summary.skippedCookies).toBe(0) + expect(result.summary.domains).toContain('github.com') + expect(result.summary.domains).toContain('example.com') + + expect(cookiesSetMock).toHaveBeenCalledTimes(2) + const firstCall = cookiesSetMock.mock.calls[0][0] + expect(firstCall.name).toBe('_gh_sess') + expect(firstCall.domain).toBe('.github.com') + expect(firstCall.secure).toBe(true) + expect(firstCall.sameSite).toBe('lax') + }) + + it('rejects non-JSON files', async () => { + const filePath = join(tmpDir, 'bad.json') + writeFileSync(filePath, 'not json at all') + + const result = await importCookiesFromFile(filePath, 'persist:test') + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.reason).toContain('not valid JSON') + }) + + it('rejects non-array JSON', async () => { + const filePath = join(tmpDir, 'object.json') + writeFileSync(filePath, '{"domain": "test.com"}') + + const result = await importCookiesFromFile(filePath, 'persist:test') + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.reason).toContain('JSON array') + }) + + it('rejects empty array', async () => { + const filePath = writeCookieFile([]) + const result = await importCookiesFromFile(filePath, 'persist:test') + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.reason).toContain('empty') + }) + + it('skips entries with missing required fields', async () => { + const filePath = writeCookieFile([ + { domain: '.valid.com', name: 'ok', value: 'val' }, + { name: 'no-domain', value: 'val' }, + { domain: '.valid2.com', value: 'no-name' }, + { domain: '.valid3.com', name: 'no-value' }, + 'not an object', + 42 + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + + expect(result.summary.importedCookies).toBe(1) + expect(result.summary.skippedCookies).toBe(5) + }) + + it('reports all skipped when no valid cookies', async () => { + const filePath = writeCookieFile([ + { name: 'no-domain', value: 'val' }, + { domain: '', name: 'empty-domain', value: 'val' } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.reason).toContain('No valid cookies') + expect(result.reason).toContain('2 entries were skipped') + }) + + it('handles file read errors', async () => { + const result = await importCookiesFromFile('/nonexistent/path.json', 'persist:test') + expect(result.ok).toBe(false) + if (result.ok) { + return + } + expect(result.reason).toContain('Could not read') + }) + + it('normalizes sameSite values', async () => { + const filePath = writeCookieFile([ + { domain: '.test.com', name: 'a', value: '1', sameSite: 'None' }, + { domain: '.test.com', name: 'b', value: '2', sameSite: 'Lax' }, + { domain: '.test.com', name: 'c', value: '3', sameSite: 'Strict' }, + { domain: '.test.com', name: 'd', value: '4', sameSite: 'unknown' }, + { domain: '.test.com', name: 'e', value: '5' } + ]) + + await importCookiesFromFile(filePath, 'persist:test') + + expect(cookiesSetMock.mock.calls[0][0].sameSite).toBe('no_restriction') + expect(cookiesSetMock.mock.calls[1][0].sameSite).toBe('lax') + expect(cookiesSetMock.mock.calls[2][0].sameSite).toBe('strict') + expect(cookiesSetMock.mock.calls[3][0].sameSite).toBe('unspecified') + expect(cookiesSetMock.mock.calls[4][0].sameSite).toBe('unspecified') + }) + + it('derives correct URL from domain and secure flag', async () => { + const filePath = writeCookieFile([ + { domain: '.secure.com', name: 'a', value: '1', secure: true }, + { domain: '.insecure.com', name: 'b', value: '2', secure: false }, + { domain: 'nodot.com', name: 'c', value: '3' } + ]) + + await importCookiesFromFile(filePath, 'persist:test') + + expect(cookiesSetMock.mock.calls[0][0].url).toBe('https://secure.com/') + expect(cookiesSetMock.mock.calls[1][0].url).toBe('http://insecure.com/') + expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/') + }) + + it('counts cookies that fail to set', async () => { + cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed')) + + const filePath = writeCookieFile([ + { domain: '.a.com', name: 'ok', value: '1' }, + { domain: '.b.com', name: 'fail', value: '2' } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(1) + expect(result.summary.skippedCookies).toBe(1) + }) +}) + +describe('detectInstalledBrowsers', () => { + it('returns an array of detected browsers', () => { + const browsers = detectInstalledBrowsers() + expect(Array.isArray(browsers)).toBe(true) + for (const browser of browsers) { + expect(browser).toHaveProperty('family') + expect(browser).toHaveProperty('label') + expect(browser).toHaveProperty('cookiesPath') + expect(browser).toHaveProperty('keychainService') + expect(browser).toHaveProperty('keychainAccount') + } + }) + + it('each detected browser has a valid family', () => { + const browsers = detectInstalledBrowsers() + const validFamilies = ['chrome', 'edge', 'arc', 'chromium'] + for (const browser of browsers) { + expect(validFamilies).toContain(browser.family) + } + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts new file mode 100644 index 00000000000..3c5dc31e4ad --- /dev/null +++ b/src/main/browser/browser-cookie-import.ts @@ -0,0 +1,836 @@ +/* eslint-disable max-lines -- Why: cookie import is a single pipeline (detect → decrypt → stage → swap) + that must stay together so the encryption, schema, and staging steps remain in sync. */ +import { app, type BrowserWindow, dialog, session } from 'electron' +import { execFileSync, execSync } from 'node:child_process' +import { createDecipheriv, pbkdf2Sync } from 'node:crypto' +import { + appendFileSync, + copyFileSync, + existsSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why: writing to userData instead of tmpdir() so the diag log is only +// readable by the current user, not world-readable in /tmp. +let _diagLog: string | null = null +function getDiagLogPath(): string { + if (!_diagLog) { + try { + _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') + } catch { + _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') + } + } + return _diagLog +} +function diag(msg: string): void { + const line = `[${new Date().toISOString()}] ${msg}\n` + try { + appendFileSync(getDiagLogPath(), line) + } catch { + /* best-effort */ + } + console.log('[cookie-import]', msg) +} +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary, + BrowserSessionProfileSource +} from '../../shared/types' +import { browserSessionRegistry } from './browser-session-registry' + +// --------------------------------------------------------------------------- +// Browser detection +// --------------------------------------------------------------------------- + +export type DetectedBrowser = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + cookiesPath: string + keychainService: string + keychainAccount: string +} + +const CHROMIUM_BROWSERS: Omit[] = [ + { + family: 'chrome', + label: 'Google Chrome', + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome' + }, + { + family: 'edge', + label: 'Microsoft Edge', + keychainService: 'Microsoft Edge Safe Storage', + keychainAccount: 'Microsoft Edge' + }, + { + family: 'arc', + label: 'Arc', + keychainService: 'Arc Safe Storage', + keychainAccount: 'Arc' + }, + { + family: 'chromium', + label: 'Brave', + keychainService: 'Brave Safe Storage', + keychainAccount: 'Brave' + } +] + +function cookiesPathForBrowser(family: BrowserSessionProfileSource['browserFamily']): string { + const home = process.env.HOME ?? '' + switch (family) { + case 'chrome': + return join(home, 'Library/Application Support/Google/Chrome/Default/Cookies') + case 'edge': + return join(home, 'Library/Application Support/Microsoft Edge/Default/Cookies') + case 'arc': + return join(home, 'Library/Application Support/Arc/User Data/Default/Cookies') + case 'chromium': + return join(home, 'Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies') + default: + return '' + } +} + +export function detectInstalledBrowsers(): DetectedBrowser[] { + return CHROMIUM_BROWSERS.map((browser) => ({ + ...browser, + cookiesPath: cookiesPathForBrowser(browser.family) + })).filter((browser) => existsSync(browser.cookiesPath)) +} + +// --------------------------------------------------------------------------- +// Cookie validation (shared between file import and direct import) +// --------------------------------------------------------------------------- + +type RawCookieEntry = { + domain?: unknown + name?: unknown + value?: unknown + path?: unknown + secure?: unknown + httpOnly?: unknown + sameSite?: unknown + expirationDate?: unknown +} + +type ValidatedCookie = { + url: string + name: string + value: string + domain: string + path: string + secure: boolean + httpOnly: boolean + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + expirationDate: number | undefined +} + +function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + if (typeof raw === 'number') { + switch (raw) { + case 0: + return 'no_restriction' + case 1: + return 'lax' + case 2: + return 'strict' + default: + return 'unspecified' + } + } + if (typeof raw !== 'string') { + return 'unspecified' + } + const lower = raw.toLowerCase() + if (lower === 'lax') { + return 'lax' + } + if (lower === 'strict') { + return 'strict' + } + if (lower === 'none' || lower === 'no_restriction') { + return 'no_restriction' + } + return 'unspecified' +} + +// Why: Electron's cookies.set() requires a url field to determine the cookie's +// scope. Derive it from the domain + secure flag so the caller doesn't need +// to supply it. +function deriveUrl(domain: string, secure: boolean): string | null { + const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain + if (!cleanDomain || cleanDomain.includes(' ')) { + return null + } + const protocol = secure ? 'https' : 'http' + try { + const url = new URL(`${protocol}://${cleanDomain}/`) + return url.toString() + } catch { + return null + } +} + +function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { + if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { + return null + } + if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { + return null + } + if (typeof raw.value !== 'string') { + return null + } + + const domain = raw.domain.trim() + const secure = raw.secure === true || raw.secure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = + typeof raw.expirationDate === 'number' && raw.expirationDate > 0 + ? raw.expirationDate + : undefined + + return { + url, + name: raw.name.trim(), + value: raw.value, + domain, + path: typeof raw.path === 'string' ? raw.path : '/', + secure, + httpOnly: raw.httpOnly === true || raw.httpOnly === 1, + sameSite: normalizeSameSite(raw.sameSite), + expirationDate + } +} + +async function importValidatedCookies( + cookies: ValidatedCookie[], + totalInput: number, + targetPartition: string +): Promise { + diag( + `importValidatedCookies: ${cookies.length} validated of ${totalInput} total, partition="${targetPartition}"` + ) + const targetSession = session.fromPartition(targetPartition) + let importedCount = 0 + let skipped = totalInput - cookies.length + const domainSet = new Set() + + // Why: Electron's cookies.set() rejects any non-printable-ASCII byte. + // Strip from all string fields as a safety net. + const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + + for (const cookie of cookies) { + try { + await targetSession.cookies.set({ + url: cookie.url, + name: cookie.name, + value: stripNonPrintable(cookie.value), + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + expirationDate: cookie.expirationDate + }) + importedCount++ + // Why: surface only the domain — never name, value, or path — so the + // renderer can show a useful summary without leaking secret cookie data. + const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain + domainSet.add(cleanDomain) + } catch (err) { + skipped++ + if (skipped <= 5) { + // Find the exact offending character position and code + const val = cookie.value + let badInfo = 'none found' + for (let i = 0; i < val.length; i++) { + const code = val.charCodeAt(i) + if (code < 0x20 || code > 0x7e) { + badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')} context="${val.substring(Math.max(0, i - 5), i + 5)}"` + break + } + } + diag( + ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${err}` + ) + } + } + } + + diag( + `importValidatedCookies result: imported=${importedCount} skipped=${skipped} domains=${domainSet.size}` + ) + + const summary: BrowserCookieImportSummary = { + totalCookies: totalInput, + importedCookies: importedCount, + skippedCookies: skipped, + domains: [...domainSet].sort() + } + + return { ok: true, profileId: '', summary } +} + +// --------------------------------------------------------------------------- +// Import from JSON file +// --------------------------------------------------------------------------- + +// Why: source selection must be main-owned via a native open dialog so a +// compromised renderer cannot turn cookie import into arbitrary file reads. +export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { + const opts = { + title: 'Import Cookies', + filters: [ + { name: 'Cookie Files', extensions: ['json'] }, + { name: 'All Files', extensions: ['*'] } + ], + properties: ['openFile' as const] + } + const result = parentWindow + ? await dialog.showOpenDialog(parentWindow, opts) + : await dialog.showOpenDialog(opts) + + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] +} + +export async function importCookiesFromFile( + filePath: string, + targetPartition: string +): Promise { + let rawContent: string + try { + rawContent = await readFile(filePath, 'utf-8') + } catch { + return { ok: false, reason: 'Could not read the selected file.' } + } + + let parsed: unknown + try { + parsed = JSON.parse(rawContent) + } catch { + return { ok: false, reason: 'File is not valid JSON.' } + } + + if (!Array.isArray(parsed)) { + return { ok: false, reason: 'Expected a JSON array of cookie objects.' } + } + + if (parsed.length === 0) { + return { ok: false, reason: 'Cookie file is empty.' } + } + + const validated: ValidatedCookie[] = [] + let skipped = 0 + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + skipped++ + continue + } + const cookie = validateCookieEntry(entry as RawCookieEntry) + if (cookie) { + validated.push(cookie) + } else { + skipped++ + } + } + + if (validated.length === 0) { + return { + ok: false, + reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` + } + } + + return importValidatedCookies(validated, parsed.length, targetPartition) +} + +// --------------------------------------------------------------------------- +// Direct import from installed Chromium browser +// --------------------------------------------------------------------------- + +// Why: Google and other services bind auth cookies to the User-Agent that +// created them. We read the source browser's real version from its plist +// and construct a matching UA string so imported sessions aren't invalidated. +function getUserAgentForBrowser( + family: BrowserSessionProfileSource['browserFamily'] +): string | null { + const platform = 'Macintosh; Intel Mac OS X 10_15_7' + const chromeBase = 'AppleWebKit/537.36 (KHTML, like Gecko)' + + function readBrowserVersion( + appPath: string, + plistKey = 'CFBundleShortVersionString' + ): string | null { + try { + return ( + execFileSync('defaults', ['read', `${appPath}/Contents/Info`, plistKey], { + encoding: 'utf-8', + timeout: 5_000 + }).trim() || null + ) + } catch { + return null + } + } + + switch (family) { + case 'chrome': { + const v = readBrowserVersion('/Applications/Google Chrome.app') + return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + } + case 'edge': { + const v = readBrowserVersion('/Applications/Microsoft Edge.app') + return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36 Edg/${v}` : null + } + case 'arc': { + const v = readBrowserVersion('/Applications/Arc.app') + return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + } + case 'chromium': { + const v = readBrowserVersion('/Applications/Brave Browser.app') + return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + } + default: + return null + } +} + +const PBKDF2_ITERATIONS = 1003 +const PBKDF2_KEY_LENGTH = 16 +const PBKDF2_SALT = 'saltysalt' + +const CHROMIUM_EPOCH_OFFSET = 11644473600n + +function chromiumTimestampToUnix(chromiumTs: string): number { + if (!chromiumTs || chromiumTs === '0') { + return 0 + } + try { + const ts = BigInt(chromiumTs) + if (ts === 0n) { + return 0 + } + return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) + } catch { + return 0 + } +} + +function getEncryptionKey(keychainService: string, keychainAccount: string): Buffer | null { + try { + // Why: execFileSync bypasses shell interpretation, preventing command + // injection if keychainService/keychainAccount ever come from user input. + const raw = execFileSync( + 'security', + ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], + { encoding: 'utf-8', timeout: 30_000 } + ).trim() + return pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } catch { + return null + } +} + +// Why: Chromium 127+ prepends a 32-byte per-host HMAC to the cookie value +// before encrypting. After AES-CBC decryption, the raw output is: +// [32-byte HMAC] [actual cookie value] +// Detection: the HMAC is a hash, so roughly half its bytes are non-printable +// ASCII. Real cookie values are overwhelmingly printable. If ≥8 of the first +// 32 bytes are non-printable, it's an HMAC prefix. +const CHROMIUM_COOKIE_HMAC_LEN = 32 + +function hasHmacPrefix(buf: Buffer): boolean { + if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { + return false + } + let nonPrintable = 0 + for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { + if (buf[i] < 0x20 || buf[i] > 0x7e) { + nonPrintable++ + } + } + return nonPrintable >= 8 +} + +function decryptCookieValueRaw(encryptedBuffer: Buffer, key: Buffer): Buffer | null { + if (!encryptedBuffer || encryptedBuffer.length === 0) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + if (version !== 'v10' && version !== 'v11') { + // Why: unknown encryption version — skip rather than importing raw + // encrypted bytes as the cookie value. + return null + } + const iv = Buffer.alloc(16, ' ') + const ciphertext = encryptedBuffer.subarray(3) + try { + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return hasHmacPrefix(decrypted) ? decrypted.subarray(CHROMIUM_COOKIE_HMAC_LEN) : decrypted + } catch { + return null + } +} + +export async function importCookiesFromBrowser( + browser: DetectedBrowser, + targetPartition: string +): Promise { + diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) + if (!existsSync(browser.cookiesPath)) { + diag(` cookies DB not found: ${browser.cookiesPath}`) + return { ok: false, reason: `${browser.label} cookies database not found.` } + } + + // Why: the browser may hold a lock on the Cookies file. Copying to a temp + // location avoids lock contention and ensures we read a consistent snapshot. + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) + const tmpCookiesPath = join(tmpDir, 'Cookies') + + try { + copyFileSync(browser.cookiesPath, tmpCookiesPath) + } catch { + rmSync(tmpDir, { recursive: true, force: true }) + return { + ok: false, + reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` + } + } + + // Why: Electron's cookies.set() API rejects many valid cookie values (binary + // bytes > 0x7F etc). Instead, decrypt from the source browser and write + // plaintext directly to the SQLite `value` column. CookieMonster reads + // `value` as a raw byte string when `encrypted_value` is empty, bypassing + // all API-level validation. This works because Electron's CookieMonster in + // dev mode does not use os_crypt encryption — it stores cookies as plaintext. + // In packaged builds where os_crypt IS active, CookieMonster will re-encrypt + // plaintext cookies on its next flush, so this approach is safe in both modes. + + const sourceKey = getEncryptionKey(browser.keychainService, browser.keychainAccount) + if (!sourceKey) { + rmSync(tmpDir, { recursive: true, force: true }) + return { + ok: false, + reason: `Could not access ${browser.label} encryption key. macOS may have denied Keychain access.` + } + } + + // Why: CookieMonster holds the live DB's data in memory and overwrites it + // on flush/shutdown. Writing directly to the live DB is futile. Instead, + // copy the live DB to a staging location, populate it there, and let the + // next cold start swap it in before CookieMonster initializes. + const targetSession = session.fromPartition(targetPartition) + await targetSession.cookies.flushStore() + + const partitionName = targetPartition.replace('persist:', '') + const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies') + + if (!existsSync(liveCookiesPath)) { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + + const stagingCookiesPath = join(app.getPath('userData'), 'Cookies-staged') + try { + copyFileSync(liveCookiesPath, stagingCookiesPath) + } catch { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'Could not create staging cookie database.' } + } + + try { + // Get target schema columns + const targetColsRaw = execSync( + `sqlite3 "${stagingCookiesPath}" "PRAGMA table_info(cookies);"`, + { encoding: 'utf-8', timeout: 5_000 } + ).trim() + const targetCols = targetColsRaw + .split('\n') + .map((line) => line.split('|')[1]) + .filter(Boolean) + const colList = targetCols.join(', ') + + execSync(`sqlite3 "${stagingCookiesPath}" "DELETE FROM cookies;"`, { + encoding: 'utf-8', + timeout: 10_000 + }) + + // Why: sqlite3's text output corrupts rows containing tab/newline in + // values. Instead, build a SQL script entirely within sqlite3 that + // decrypts nothing — we export rows as hex blobs, decrypt in Node, + // and generate parameterized INSERT statements. + + // Read all non-blob columns + hex(value) + hex(encrypted_value) in one query + // Use a unique separator that can't appear in hex output + const SEP = '|||' + const selectCols = targetCols + .map((col) => { + if (col === 'value') { + return `hex(value)` + } + if (col === 'encrypted_value') { + return `hex(encrypted_value)` + } + return `quote(${col})` + }) + .join(` || '${SEP}' || `) + + const allRowsOutput = execSync( + `sqlite3 "${tmpCookiesPath}" "SELECT ${selectCols} FROM cookies ORDER BY rowid;"`, + { encoding: 'utf-8', maxBuffer: 500 * 1024 * 1024, timeout: 60_000 } + ).trim() + + const allRows = allRowsOutput.split('\n').filter(Boolean) + diag(` source has ${allRows.length} cookies`) + + if (allRows.length === 0) { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: `No cookies found in ${browser.label}.` } + } + + const colIdx = Object.fromEntries(targetCols.map((col, i) => [col, i])) + + function unquote(quoted: string): string { + return quoted.replace(/^'|'$/g, '').replace(/''/g, "'") + } + + function unquoteInt(quoted: string): number { + return parseInt(quoted, 10) || 0 + } + + // Why: Google's integrity cookies (SIDCC, __Secure-*PSIDCC, __Secure-STRP) + // are cryptographically bound to the source browser's TLS fingerprint and + // environment. Importing them into a different browser causes + // accounts.google.com to reject the session with CookieMismatch. Skipping + // them lets Google regenerate fresh integrity cookies on the first request. + const INTEGRITY_COOKIE_NAMES = new Set([ + 'SIDCC', + '__Secure-1PSIDCC', + '__Secure-3PSIDCC', + '__Secure-STRP', + 'AEC' + ]) + function isIntegrityCookie(name: string, domain: string): boolean { + if (!INTEGRITY_COOKIE_NAMES.has(name)) { + return false + } + const d = domain.startsWith('.') ? domain.slice(1) : domain + return d === 'google.com' || d.endsWith('.google.com') + } + + let imported = 0 + let skipped = 0 + let integritySkipped = 0 + let memoryLoaded = 0 + let memoryFailed = 0 + const domainSet = new Set() + const sqlStatements: string[] = ['BEGIN TRANSACTION;'] + + type DecryptedCookie = { + plaintextHex: string + value: string + domain: string + name: string + path: string + secure: boolean + httpOnly: boolean + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + expirationDate: number | undefined + } + + const decryptedCookies: DecryptedCookie[] = [] + + for (const row of allRows) { + const cols = row.split(SEP) + if (cols.length !== targetCols.length) { + skipped++ + continue + } + + const hexEncValue = cols[colIdx.encrypted_value] + const encBuf = Buffer.from(hexEncValue, 'hex') + const hexPlainValue = cols[colIdx.value] + + let plaintextHex: string + if (encBuf.length > 0) { + const rawDecrypted = decryptCookieValueRaw(encBuf, sourceKey) + if (rawDecrypted === null) { + skipped++ + continue + } + plaintextHex = rawDecrypted.toString('hex') + } else { + plaintextHex = hexPlainValue + } + + const domain = unquote(cols[colIdx.host_key]) + const name = unquote(cols[colIdx.name]) + + if (isIntegrityCookie(name, domain)) { + integritySkipped++ + continue + } + + const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain + domainSet.add(cleanDomain) + + const path = unquote(cols[colIdx.path]) + const secure = unquoteInt(cols[colIdx.is_secure]) === 1 + const httpOnly = unquoteInt(cols[colIdx.is_httponly]) === 1 + const sameSite = normalizeSameSite(unquoteInt(cols[colIdx.samesite])) + const expiresUtc = chromiumTimestampToUnix(unquote(cols[colIdx.expires_utc])) + // Why: cookie values are raw byte strings, not UTF-8 text. Using latin1 + // (ISO-8859-1) preserves all byte values 0x00–0xFF without replacement + // characters that UTF-8 decoding would insert for invalid sequences. + const value = Buffer.from(plaintextHex, 'hex').toString('latin1') + + decryptedCookies.push({ + plaintextHex, + value, + domain, + name, + path, + secure, + httpOnly, + sameSite, + expirationDate: expiresUtc > 0 ? expiresUtc : undefined + }) + + const values = targetCols + .map((col, i) => { + if (col === 'encrypted_value') { + return "X''" + } + if (col === 'value') { + return `X'${plaintextHex}'` + } + return cols[i] + }) + .join(', ') + sqlStatements.push(`INSERT OR REPLACE INTO cookies (${colList}) VALUES (${values});`) + imported++ + } + diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) + + sqlStatements.push('COMMIT;') + diag(` prepared ${imported} INSERT statements, ${skipped} skipped`) + + const sqlFilePath = join(tmpDir, 'import.sql') + writeFileSync(sqlFilePath, sqlStatements.join('\n')) + + execSync(`sqlite3 "${stagingCookiesPath}" < "${sqlFilePath}"`, { + encoding: 'utf-8', + timeout: 60_000, + maxBuffer: 500 * 1024 * 1024 + }) + + rmSync(tmpDir, { recursive: true, force: true }) + diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) + + // Why: clearing the session's in-memory cookie store before loading imported + // cookies prevents stale cookies from a previous Orca browsing session from + // mixing with the imported set. Mixed state (some old, some imported) causes + // sites like Google to detect inconsistent session cookies and reject them. + await targetSession.clearStorageData({ storages: ['cookies'] }) + diag( + ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` + ) + + // Why: loading cookies into memory via cookies.set() makes them available + // immediately without requiring a restart. The staging DB is kept as a + // fallback for any cookies that fail the cookies.set() validation. + for (const cookie of decryptedCookies) { + const url = deriveUrl(cookie.domain, cookie.secure) + if (!url) { + memoryFailed++ + continue + } + try { + // Why: __Host- prefixed cookies must not have a domain attribute and + // must have path=/. Chromium rejects them otherwise. + const isHostPrefixed = cookie.name.startsWith('__Host-') + await targetSession.cookies.set({ + url, + name: cookie.name, + value: cookie.value, + ...(isHostPrefixed ? {} : { domain: cookie.domain }), + path: isHostPrefixed ? '/' : cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + expirationDate: cookie.expirationDate + }) + memoryLoaded++ + } catch { + memoryFailed++ + } + } + + diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`) + + if (memoryFailed > 0) { + // Why: some cookies couldn't be loaded via cookies.set() (non-ASCII values + // or other validation failures). Keep the staging DB so the next cold start + // picks them up from SQLite where CookieMonster reads them without validation. + browserSessionRegistry.setPendingCookieImport(stagingCookiesPath) + diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) + } else { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + diag(` all cookies loaded in-memory — no restart needed`) + } + + const ua = getUserAgentForBrowser(browser.family) + if (ua) { + targetSession.setUserAgent(ua) + browserSessionRegistry.setupClientHintsOverride(targetSession, ua) + browserSessionRegistry.persistUserAgent(ua) + diag(` set UA for partition: ${ua.substring(0, 80)}...`) + } + + const summary: BrowserCookieImportSummary = { + totalCookies: allRows.length, + importedCookies: imported, + skippedCookies: skipped, + domains: [...domainSet].sort() + } + + return { ok: true, profileId: '', summary } + } catch (err) { + rmSync(tmpDir, { recursive: true, force: true }) + // Why: if the import fails after the staging DB was created, clean it up + // to avoid a stale staged import being applied on the next cold start. + try { + unlinkSync(stagingCookiesPath) + } catch { + /* may not exist yet */ + } + diag(` SQLite import failed: ${err}`) + return { + ok: false, + reason: `Could not import cookies from ${browser.label}. ${err}` + } + } +} diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 0ead7255235..12daf0ff26c 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -1,5 +1,8 @@ -import { clipboard, Menu, webContents } from 'electron' -import { normalizeExternalBrowserUrl } from '../../shared/browser-url' +import { webContents } from 'electron' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl +} from '../../shared/browser-url' import { isWindowShortcutModifierChord, resolveWindowShortcutAction @@ -10,84 +13,83 @@ type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null export function setupGuestContextMenu(args: { browserTabId: string guest: Electron.WebContents - openValidatedExternal: (rawUrl: string) => void - openDevTools: (browserTabId: string) => Promise + resolveRenderer: ResolveRenderer }): () => void { - const { browserTabId, guest, openValidatedExternal, openDevTools } = args + const { browserTabId, guest, resolveRenderer } = args const handler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { + const renderer = resolveRenderer(browserTabId) + if (!renderer) { + return + } const pageUrl = guest.getURL() - const linkUrl = params.linkURL || '' - - const template: Electron.MenuItemConstructorOptions[] = [] - - if (linkUrl) { - const externalLinkUrl = normalizeExternalBrowserUrl(linkUrl) - template.push( - { - label: 'Open Link In Default Browser', - enabled: Boolean(externalLinkUrl && externalLinkUrl !== 'about:blank'), - click: () => { - openValidatedExternal(linkUrl) - } - }, - { - label: 'Copy Link Address', - click: () => { - clipboard.writeText(linkUrl) - } - }, - { type: 'separator' } - ) + // Why: params.linkURL is empty when the user right-clicks non-link + // content. Normalizing an empty string through normalizeBrowserNavigationUrl + // produces the blank-page constant (a truthy string), which would trick the + // renderer into showing "Open Link…" items for every right-click. + const rawLinkUrl = params.linkURL || '' + const linkUrl = + rawLinkUrl.length > 0 + ? (normalizeExternalBrowserUrl(rawLinkUrl) ?? normalizeBrowserNavigationUrl(rawLinkUrl)) + : null + const sendContextMenu = (viewportX: number, viewportY: number): void => { + renderer.send('browser:context-menu-requested', { + browserPageId: browserTabId, + x: viewportX, + y: viewportY, + pageUrl, + linkUrl, + canGoBack: guest.canGoBack(), + canGoForward: guest.canGoForward() + }) } - const externalPageUrl = normalizeExternalBrowserUrl(pageUrl) + // Why: Electron reports guest context-menu coordinates in page space. + // Orca's renderer-owned menu needs viewport-relative coordinates so the + // menu appears under the cursor even after the page has scrolled. + if (typeof guest.executeJavaScript !== 'function') { + // Why: some tests and rare teardown edges only expose a minimal + // WebContents shape. Falling back to raw coordinates keeps the menu + // request best-effort instead of hard-failing on missing helpers. + sendContextMenu(params.x, params.y) + return + } - template.push( - { - label: 'Back', - enabled: guest.canGoBack(), - click: () => guest.goBack() - }, - { - label: 'Forward', - enabled: guest.canGoForward(), - click: () => guest.goForward() - }, - { - label: 'Reload', - click: () => guest.reload() - }, - { type: 'separator' }, - { - label: 'Open Page In Default Browser', - enabled: Boolean(externalPageUrl && externalPageUrl !== 'about:blank'), - click: () => { - openValidatedExternal(pageUrl) - } - }, - { - label: 'Copy Page URL', - enabled: Boolean(pageUrl), - click: () => { - clipboard.writeText(pageUrl) - } - }, - { type: 'separator' }, - { - label: 'Inspect Page', - click: () => { - void openDevTools(browserTabId) - } - } - ) - - Menu.buildFromTemplate(template).popup() + void guest + .executeJavaScript('({ scrollX: window.scrollX, scrollY: window.scrollY })', true) + .then((scroll) => { + const scrollX = + typeof scroll === 'object' && scroll && 'scrollX' in scroll + ? Number((scroll as { scrollX: unknown }).scrollX) || 0 + : 0 + const scrollY = + typeof scroll === 'object' && scroll && 'scrollY' in scroll + ? Number((scroll as { scrollY: unknown }).scrollY) || 0 + : 0 + sendContextMenu(params.x - scrollX, params.y - scrollY) + }) + .catch(() => { + // Why: if the guest is tearing down, best-effort fallback to the raw + // coordinates is better than dropping the Orca menu entirely. + sendContextMenu(params.x, params.y) + }) } guest.on('context-menu', handler) + const dismissHandler = (_event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + if (mouse.type !== 'mouseDown') { + return + } + const renderer = resolveRenderer(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:context-menu-dismissed', { browserPageId: browserTabId }) + } + guest.on('before-mouse-event', dismissHandler) return () => { try { guest.off('context-menu', handler) + guest.off('before-mouse-event', dismissHandler) } catch { // Why: browser tabs can outlive the guest webContents briefly during // teardown. Cleanup should be best-effort instead of throwing while the @@ -131,7 +133,7 @@ export function setupGrabShortcutForwarding(args: { // While grab mode is actively awaiting a pick, plain C/S belong to Orca's // copy/screenshot shortcuts rather than the page's typing behavior. event.preventDefault() - renderer.send('browser:grabActionShortcut', { browserTabId, key: bareKey }) + renderer.send('browser:grabActionShortcut', { browserPageId: browserTabId, key: bareKey }) return } @@ -222,7 +224,29 @@ export function setupGuestShortcutForwarding(args: { if (input.code === 'KeyB' && input.shift) { renderer.send('ui:newBrowserTab') } else if (input.code === 'KeyT' && !input.shift) { - renderer.send('ui:newTerminalTab') + // Why: once focus is inside a browser guest, Cmd/Ctrl+T should extend + // the current browser workspace with another internal page instead of + // creating a sibling Orca terminal tab. The renderer still decides + // whether that means "new page in this workspace" or "new workspace" + // based on the current active surface. + renderer.send('ui:newBrowserTab') + } else if (input.code === 'KeyL' && !input.shift) { + // Why: the address bar lives in the renderer chrome, not the guest + // page. Forward Cmd/Ctrl+L out of the guest so the active BrowserPane + // can focus its own input just like a standalone browser would. + renderer.send('ui:focusBrowserAddressBar') + } else if (input.code === 'KeyR' && input.shift) { + // Why: Cmd/Ctrl+Shift+R is the browser convention for hard reload + // (bypass cache). The guest would handle it natively, but Orca's webview + // reloadIgnoringCache() call must come from the renderer side so it goes + // through the same parked-webview ref that owns the guest surface. + renderer.send('ui:hardReloadBrowserPage') + } else if (input.code === 'KeyR' && !input.shift) { + // Why: same as above for soft reload — Cmd/Ctrl+R must be forwarded so + // the renderer can call reload() on its own webview ref rather than + // relying on the guest's built-in shortcut, which may not reach the + // parked-webview eviction logic. + renderer.send('ui:reloadBrowserPage') } else if (input.code === 'KeyW' && !input.shift) { renderer.send('ui:closeActiveTab') } else if (input.shift && (input.code === 'BracketRight' || input.code === 'BracketLeft')) { diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index 101e08cc593..f637398b633 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -85,7 +85,7 @@ describe('browserManager grab operations', () => { browserManager.attachGuestPolicies(guest) browserManager.registerGuest({ - browserTabId: 'tab-1', + browserPageId: 'tab-1', webContentsId: 101, rendererWebContentsId }) @@ -208,7 +208,7 @@ describe('browserManager grab operations', () => { expect(preventDefault).toHaveBeenCalledTimes(1) expect(rendererSendMock).toHaveBeenCalledWith('browser:grabActionShortcut', { - browserTabId: 'tab-1', + browserPageId: 'tab-1', key: 's' }) }) @@ -659,7 +659,7 @@ describe('browserManager grab operations', () => { }) browserManager.attachGuestPolicies(replacementGuest) browserManager.registerGuest({ - browserTabId: 'tab-1', + browserPageId: 'tab-1', webContentsId: 202, rendererWebContentsId }) diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index cc21403b3b0..3c187926a75 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -74,6 +74,126 @@ describe('browserManager', () => { expect(shellOpenExternalMock).toHaveBeenCalledWith('http://localhost:3000/') }) + it('routes safe popup URLs into a new Orca browser tab for the owning renderer', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 103, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { action: 'deny' } + expect(handler({ url: 'https://example.com/login' })).toEqual({ action: 'deny' }) + + expect(shellOpenExternalMock).not.toHaveBeenCalled() + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-1', + url: 'https://example.com/login' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://example.com', + action: 'opened-in-orca' + }) + }) + + it('falls back to opening popup URLs externally before a guest is registered', () => { + const guest = { + id: 105, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { action: 'deny' } + expect(handler({ url: 'https://example.com/login' })).toEqual({ action: 'deny' }) + + expect(shellOpenExternalMock).toHaveBeenCalledWith('https://example.com/login') + }) + + it('offers opening a link in another Orca browser tab from the guest context menu', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 104, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://example.com'), + canGoBack: vi.fn(() => false), + canGoForward: vi.fn(() => false), + reload: vi.fn() + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const contextMenuHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'context-menu' + )?.[1] as ((event: unknown, params: Electron.ContextMenuParams) => void) | undefined + + contextMenuHandler?.({}, { linkURL: 'https://example.com/docs' } as Electron.ContextMenuParams) + + expect(rendererSendMock).toHaveBeenCalledWith( + 'browser:context-menu-requested', + expect.objectContaining({ + browserPageId: 'browser-1', + pageUrl: 'https://example.com', + linkUrl: 'https://example.com/docs', + canGoBack: false, + canGoForward: false + }) + ) + }) + it('blocks non-web guest navigations after attach', () => { const guest = { isDestroyed: vi.fn(() => false), @@ -113,7 +233,7 @@ describe('browserManager', () => { browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ - browserTabId: 'browser-1', + browserPageId: 'browser-1', webContentsId: 101, // Why: registrations now record which renderer owns each guest so main // can route load failures back to the correct window instead of dropping @@ -122,7 +242,7 @@ describe('browserManager', () => { }) browserManager.attachGuestPolicies({ ...guest, id: 102 } as never) browserManager.registerGuest({ - browserTabId: 'browser-2', + browserPageId: 'browser-2', webContentsId: 102, rendererWebContentsId }) @@ -150,7 +270,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReturnValue(mainWindowContents) browserManager.registerGuest({ - browserTabId: 'browser-evil', + browserPageId: 'browser-evil', webContentsId: 1, rendererWebContentsId }) @@ -175,7 +295,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReturnValue(guest) browserManager.registerGuest({ - browserTabId: 'browser-1', + browserPageId: 'browser-1', webContentsId: 777, rendererWebContentsId }) @@ -252,14 +372,14 @@ describe('browserManager', () => { expect(rendererSendMock).not.toHaveBeenCalled() browserManager.registerGuest({ - browserTabId: 'browser-1', + browserPageId: 'browser-1', webContentsId: 404, rendererWebContentsId }) expect(rendererSendMock).toHaveBeenCalledTimes(1) expect(rendererSendMock).toHaveBeenCalledWith('browser:guest-load-failed', { - browserTabId: 'browser-1', + browserPageId: 'browser-1', loadError: { code: -105, description: 'Name not resolved', @@ -268,6 +388,68 @@ describe('browserManager', () => { }) }) + it('queues permission denials and download requests until the guest registers', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 407, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + const item = { + pause: vi.fn(), + getFilename: vi.fn(() => 'report.csv'), + getTotalBytes: vi.fn(() => 2048), + getMimeType: vi.fn(() => 'text/csv'), + getURL: vi.fn(() => 'https://example.com/report.csv') + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.notifyPermissionDenied({ + guestWebContentsId: guest.id, + permission: 'media', + rawUrl: 'https://example.com/account' + }) + browserManager.handleGuestWillDownload({ guestWebContentsId: guest.id, item: item as never }) + + expect(rendererSendMock).not.toHaveBeenCalled() + + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { + browserPageId: 'browser-1', + permission: 'media', + origin: 'https://example.com' + }) + expect(rendererSendMock).toHaveBeenCalledWith( + 'browser:download-requested', + expect.objectContaining({ + browserPageId: 'browser-1', + filename: 'report.csv', + origin: 'https://example.com', + totalBytes: 2048, + mimeType: 'text/csv' + }) + ) + }) + it('does not forward ctrl/cmd+r or readline chords from browser guests', () => { const rendererSendMock = vi.fn() const guest = { @@ -293,7 +475,7 @@ describe('browserManager', () => { browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ - browserTabId: 'browser-1', + browserPageId: 'browser-1', webContentsId: guest.id, rendererWebContentsId }) @@ -306,16 +488,23 @@ describe('browserManager', () => { expect(beforeInputHandler).toBeTypeOf('function') - for (const input of [ - { - type: 'keyDown', - code: 'KeyR', - key: 'r', - meta: false, - control: true, - alt: false, - shift: false - }, + // Why: on Linux, Ctrl is the shortcut modifier, so Ctrl+R is the reload + // shortcut (not a readline chord). Only test Ctrl+R as a readline passthrough + // on macOS where Cmd is the modifier and Ctrl+R is genuinely a readline chord. + const readlineChords = [ + ...(process.platform === 'darwin' + ? [ + { + type: 'keyDown', + code: 'KeyR', + key: 'r', + meta: false, + control: true, + alt: false, + shift: false + } + ] + : []), { type: 'keyDown', code: 'KeyU', @@ -343,7 +532,8 @@ describe('browserManager', () => { alt: false, shift: false } - ]) { + ] + for (const input of readlineChords) { const preventDefault = vi.fn() beforeInputHandler?.({ preventDefault }, input) expect(preventDefault).not.toHaveBeenCalled() @@ -378,7 +568,7 @@ describe('browserManager', () => { browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ - browserTabId: 'browser-1', + browserPageId: 'browser-1', webContentsId: guest.id, rendererWebContentsId }) @@ -436,6 +626,33 @@ describe('browserManager', () => { control: !isDarwin, alt: false, shift: false + }, + { + type: 'keyDown', + code: 'KeyL', + key: 'l', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: false + }, + { + type: 'keyDown', + code: 'KeyR', + key: 'r', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: false + }, + { + type: 'keyDown', + code: 'KeyR', + key: 'r', + meta: isDarwin, + control: !isDarwin, + alt: false, + shift: true } ] @@ -446,10 +663,13 @@ describe('browserManager', () => { } expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:newBrowserTab') - expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newTerminalTab') + expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:newBrowserTab') expect(rendererSendMock).toHaveBeenNthCalledWith(3, 'ui:closeActiveTab') expect(rendererSendMock).toHaveBeenNthCalledWith(4, 'ui:switchTab', 1) expect(rendererSendMock).toHaveBeenNthCalledWith(5, 'ui:openQuickOpen') + expect(rendererSendMock).toHaveBeenNthCalledWith(6, 'ui:focusBrowserAddressBar') + expect(rendererSendMock).toHaveBeenNthCalledWith(7, 'ui:reloadBrowserPage') + expect(rendererSendMock).toHaveBeenNthCalledWith(8, 'ui:hardReloadBrowserPage') }) it('cleans up prior guest listeners before re-registering the same tab', () => { @@ -474,7 +694,7 @@ describe('browserManager', () => { browserManager.attachGuestPolicies(guest as never) browserManager.registerGuest({ - browserTabId: 'browser-1', + browserPageId: 'browser-1', webContentsId: 808, rendererWebContentsId }) @@ -482,7 +702,7 @@ describe('browserManager', () => { guestOffMock.mockClear() browserManager.registerGuest({ - browserTabId: 'browser-1', + browserPageId: 'browser-1', webContentsId: 808, rendererWebContentsId }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 60207d44ea8..67b86a6748a 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -2,11 +2,20 @@ single privileged facade for guest registration, authorization, and lifecycle cleanup even after extracting the grab/session helpers. Keeping that ownership in one file avoids scattering the browser security boundary across modules. */ +import { randomUUID } from 'node:crypto' + import { shell, webContents } from 'electron' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl } from '../../shared/browser-url' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadRequestedEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' import type { BrowserGrabCancelReason, BrowserGrabPayload, @@ -26,11 +35,42 @@ import { } from './browser-guest-ui' export type BrowserGuestRegistration = { - browserTabId: string + browserPageId?: string + browserTabId?: string + workspaceId?: string webContentsId: number rendererWebContentsId: number } +type PendingPermissionEvent = Omit +type PendingPopupEvent = Omit + +type ActiveDownload = { + downloadId: string + guestWebContentsId: number + browserTabId: string | null + rendererWebContentsId: number | null + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + item: Electron.DownloadItem + state: 'requested' | 'downloading' + savePath: string | null + pendingCancelTimer: ReturnType | null + cleanup: (() => void) | null +} + +function safeOrigin(rawUrl: string): string { + const external = normalizeExternalBrowserUrl(rawUrl) + const urlToParse = external ?? rawUrl + try { + return new URL(urlToParse).origin + } catch { + return external ?? 'unknown' + } +} + class BrowserManager { private readonly webContentsIdByTabId = new Map() private readonly rendererWebContentsIdByTabId = new Map() @@ -42,13 +82,29 @@ class BrowserManager { number, { code: number; description: string; validatedUrl: string } >() + private readonly pendingPermissionEventsByGuestId = new Map() + private readonly pendingPopupEventsByGuestId = new Map() + private readonly pendingDownloadIdsByGuestId = new Map() + private readonly downloadsById = new Map() private readonly grabSessionController = new BrowserGrabSessionController() - private openValidatedExternal(rawUrl: string): void { - const externalUrl = normalizeExternalBrowserUrl(rawUrl) - if (externalUrl) { - void shell.openExternal(externalUrl) + private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { + return ( + [...this.webContentsIdByTabId.entries()].find(([, id]) => id === guestWebContentsId)?.[0] ?? + null + ) + } + + private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { + const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) + if (!rendererWebContentsId) { + return null } + const renderer = webContents.fromId(rendererWebContentsId) + if (!renderer || renderer.isDestroyed()) { + return null + } + return renderer } attachGuestPolicies(guest: Electron.WebContents): void { @@ -58,11 +114,36 @@ class BrowserManager { this.policyAttachedGuestIds.add(guest.id) guest.setBackgroundThrottling(true) guest.setWindowOpenHandler(({ url }) => { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + const browserUrl = normalizeBrowserNavigationUrl(url) + const externalUrl = normalizeExternalBrowserUrl(url) + // Why: popup-capable guests are required for OAuth and target=_blank - // flows, but Orca still does not host child windows itself. Convert those - // attempts into a controlled external-open path instead of letting them - // silently fail or spawn unmanaged windows. - this.openValidatedExternal(url) + // flows, but Orca still does not host child windows itself. For normal + // web URLs, route the request into Orca's own browser-tab model first so + // the user stays in the IDE. Only fall back to the system browser when + // Orca cannot safely host the destination or when the guest is not yet + // associated with a trusted browser tab/renderer. + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } else if (externalUrl) { + void shell.openExternal(externalUrl) + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-external' + }) + } else { + // Why: popup attempts can carry auth redirects and one-time tokens. + // Surface only sanitized origin metadata so the renderer can explain + // the blocked action without persisting sensitive URL details. + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(url), + action: 'blocked' + }) + } return { action: 'deny' } }) @@ -98,10 +179,15 @@ class BrowserManager { } registerGuest({ - browserTabId, + browserPageId, + browserTabId: legacyBrowserTabId, webContentsId, rendererWebContentsId }: BrowserGuestRegistration): void { + const browserTabId = browserPageId ?? legacyBrowserTabId + if (!browserTabId) { + return + } // Why: re-registering the same browser tab can happen when Chromium swaps // or recreates the underlying guest surface. Any active grab is bound to // the old guest's listeners and teardown path, so keeping it alive would @@ -141,6 +227,9 @@ class BrowserManager { this.setupGrabShortcut(browserTabId, guest) this.setupShortcutForwarding(browserTabId, guest) this.flushPendingLoadFailure(browserTabId, webContentsId) + this.flushPendingPermissionEvents(browserTabId, webContentsId) + this.flushPendingPopupEvents(browserTabId, webContentsId) + this.flushPendingDownloadRequests(browserTabId, webContentsId) } unregisterGuest(browserTabId: string): void { @@ -164,6 +253,14 @@ class BrowserManager { fwdCleanup() this.shortcutForwardingCleanupByTabId.delete(browserTabId) } + // Why: paused downloads wait for explicit product approval. If the owning + // browser tab disappears first, cancel the request so the app does not + // retain orphaned download items or write files after context is gone. + for (const [downloadId, download] of this.downloadsById.entries()) { + if (download.browserTabId === browserTabId && download.state === 'requested') { + this.cancelDownloadInternal(downloadId, 'Tab closed before download was accepted.') + } + } this.webContentsIdByTabId.delete(browserTabId) this.rendererWebContentsIdByTabId.delete(browserTabId) } @@ -171,17 +268,204 @@ class BrowserManager { unregisterAll(): void { // Cancel all active grab ops before tearing down registrations this.grabSessionController.cancelAll('evicted') + for (const downloadId of this.downloadsById.keys()) { + this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') + } for (const browserTabId of this.webContentsIdByTabId.keys()) { this.unregisterGuest(browserTabId) } this.policyAttachedGuestIds.clear() this.pendingLoadFailuresByGuestId.clear() + this.pendingPermissionEventsByGuestId.clear() + this.pendingPopupEventsByGuestId.clear() + this.pendingDownloadIdsByGuestId.clear() } getGuestWebContentsId(browserTabId: string): number | null { return this.webContentsIdByTabId.get(browserTabId) ?? null } + notifyPermissionDenied(args: { + guestWebContentsId: number + permission: string + rawUrl: string + }): void { + this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { + permission: args.permission, + origin: safeOrigin(args.rawUrl) + }) + } + + handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { + const { guestWebContentsId, item } = args + const downloadId = randomUUID() + const filename = (() => { + try { + return item.getFilename() || 'download' + } catch { + return 'download' + } + })() + const totalBytes = (() => { + try { + const total = item.getTotalBytes() + return total > 0 ? total : null + } catch { + return null + } + })() + const mimeType = (() => { + try { + const mime = item.getMimeType() + return mime || null + } catch { + return null + } + })() + const origin = (() => { + try { + return safeOrigin(item.getURL()) + } catch { + return 'unknown' + } + })() + + try { + item.pause() + } catch { + // Why: some interrupted downloads throw if paused immediately. Keep + // tracking the item anyway so Orca can still explain the failure path. + } + + const download: ActiveDownload = { + downloadId, + guestWebContentsId, + browserTabId: null, + rendererWebContentsId: null, + origin, + filename, + totalBytes, + mimeType, + item, + state: 'requested', + savePath: null, + pendingCancelTimer: null, + cleanup: null + } + this.downloadsById.set(downloadId, download) + + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (browserTabId) { + this.bindDownloadToTab(downloadId, browserTabId) + this.sendDownloadRequested(downloadId) + } else { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] + pending.push(downloadId) + this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) + } + + // Why: fail closed if the user never explicitly accepts or cancels. This + // prevents a compromised or crashed renderer from leaving paused downloads + // alive until app shutdown and later resuming them without context. + download.pendingCancelTimer = setTimeout(() => { + this.cancelDownloadInternal(downloadId, 'Timed out waiting for user approval.') + }, 60_000) + } + + getDownloadPrompt(downloadId: string, senderWebContentsId: number): { filename: string } | null { + const download = this.downloadsById.get(downloadId) + if (!download || download.rendererWebContentsId !== senderWebContentsId) { + return null + } + return { filename: download.filename } + } + + acceptDownload(args: { + downloadId: string + senderWebContentsId: number + savePath: string + }): { ok: true } | { ok: false; reason: string } { + const download = this.downloadsById.get(args.downloadId) + if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { + return { ok: false, reason: 'not-authorized' } + } + if (download.state !== 'requested' || !download.browserTabId) { + return { ok: false, reason: 'not-ready' } + } + + if (download.pendingCancelTimer) { + clearTimeout(download.pendingCancelTimer) + download.pendingCancelTimer = null + } + + try { + download.item.setSavePath(args.savePath) + download.savePath = args.savePath + } catch { + this.cancelDownloadInternal(args.downloadId, 'Failed to set download destination.') + return { ok: false, reason: 'not-ready' } + } + + download.state = 'downloading' + const cleanup = (): void => { + try { + download.item.removeAllListeners('updated') + download.item.removeAllListeners('done') + } catch { + // Why: completed DownloadItems can already be finalized when cleanup + // runs. Cleanup must stay best-effort so UI teardown never crashes main. + } + } + download.cleanup = cleanup + + download.item.on('updated', (_event, state) => { + if (state !== 'progressing') { + return + } + this.sendDownloadProgress(download.browserTabId, { + downloadId: download.downloadId, + receivedBytes: download.item.getReceivedBytes(), + totalBytes: download.totalBytes + }) + }) + + download.item.once('done', (_event, state) => { + const status: BrowserDownloadFinishedEvent['status'] = + state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' + this.sendDownloadFinished(download.browserTabId, { + downloadId: download.downloadId, + status, + savePath: download.savePath, + error: + status === 'failed' + ? state === 'interrupted' + ? 'Download was interrupted.' + : 'Download failed.' + : null + }) + cleanup() + this.downloadsById.delete(download.downloadId) + }) + + try { + download.item.resume() + } catch { + this.cancelDownloadInternal(args.downloadId, 'Failed to start download.') + return { ok: false, reason: 'not-ready' } + } + + return { ok: true } + } + + cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { + const download = this.downloadsById.get(args.downloadId) + if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { + return false + } + this.cancelDownloadInternal(args.downloadId, 'Canceled.') + return true + } + // Why: guest browser surfaces are intentionally isolated from Orca's preload // bridge, so renderer code cannot directly call Electron WebContents APIs on // them. Main owns the devtools escape hatch and only after tab→guest lookup. @@ -324,10 +608,7 @@ class BrowserManager { setupGuestContextMenu({ browserTabId, guest, - openValidatedExternal: (rawUrl) => { - this.openValidatedExternal(rawUrl) - }, - openDevTools: async (tabId) => this.openDevTools(tabId) + resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) }) ) } @@ -398,6 +679,184 @@ class BrowserManager { this.sendGuestLoadFailure(browserTabId, loadError) } + private forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPermissionDenied(browserTabId, event) + } + + private flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPermissionDenied(browserTabId, event) + } + } + + private sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:permission-denied', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPermissionDeniedEvent) + } + + private forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPopupEvent(browserTabId, event) + } + + private flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPopupEvent(browserTabId, event) + } + } + + private sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:popup', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPopupEvent) + } + + private bindDownloadToTab(downloadId: string, browserTabId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + download.browserTabId = browserTabId + download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null + } + + private flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + for (const downloadId of pending) { + this.bindDownloadToTab(downloadId, browserTabId) + this.sendDownloadRequested(downloadId) + } + } + + private sendDownloadRequested(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download?.browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(download.browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-requested', { + browserPageId: download.browserTabId, + downloadId: download.downloadId, + origin: download.origin, + filename: download.filename, + totalBytes: download.totalBytes, + mimeType: download.mimeType + } satisfies BrowserDownloadRequestedEvent) + } + + private sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-progress', payload) + } + + private sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-finished', payload) + } + + private cancelDownloadInternal(downloadId: string, reason: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + + if (download.pendingCancelTimer) { + clearTimeout(download.pendingCancelTimer) + download.pendingCancelTimer = null + } + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + + try { + download.item.cancel() + } catch { + // Why: DownloadItem.cancel can throw after the item has already + // finalized. Cleanup here is best-effort because the UI state is the + // source of truth for whether Orca still considers the request active. + } + + if (download.browserTabId) { + this.sendDownloadFinished(download.browserTabId, { + downloadId: download.downloadId, + status: 'canceled', + savePath: download.savePath, + error: reason || null + }) + } + + this.downloadsById.delete(downloadId) + } + private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) if (!pending) { @@ -411,21 +870,36 @@ class BrowserManager { browserTabId: string, loadError: { code: number; description: string; validatedUrl: string } ): void { - const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) - if (!rendererWebContentsId) { - return - } - - const renderer = webContents.fromId(rendererWebContentsId) - if (!renderer || renderer.isDestroyed()) { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { return } renderer.send('browser:guest-load-failed', { - browserTabId, + browserPageId: browserTabId, loadError }) } + + private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === 'about:blank') { + return false + } + // Why: the guest context menu knows which browser tab the click came from, + // but only the renderer owns the worktree/tab model. Forward the validated + // URL back to that renderer so it can open a sibling Orca browser tab in + // the same worktree without letting the guest process mutate app state. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } } export const browserManager = new BrowserManager() diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts new file mode 100644 index 00000000000..8aee78523c7 --- /dev/null +++ b/src/main/browser/browser-session-registry.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { sessionFromPartitionMock } = vi.hoisted(() => ({ + sessionFromPartitionMock: vi.fn() +})) + +vi.mock('electron', () => ({ + session: { + fromPartition: sessionFromPartitionMock + } +})) + +vi.mock('./browser-manager', () => ({ + browserManager: { + notifyPermissionDenied: vi.fn(), + handleGuestWillDownload: vi.fn() + } +})) + +import { browserSessionRegistry } from './browser-session-registry' +import { ORCA_BROWSER_PARTITION } from '../../shared/constants' + +describe('BrowserSessionRegistry', () => { + beforeEach(() => { + sessionFromPartitionMock.mockReset() + sessionFromPartitionMock.mockReturnValue({ + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn(), + on: vi.fn(), + clearStorageData: vi.fn().mockResolvedValue(undefined), + clearCache: vi.fn().mockResolvedValue(undefined) + }) + }) + + it('has a default profile on construction', () => { + const defaultProfile = browserSessionRegistry.getDefaultProfile() + expect(defaultProfile.id).toBe('default') + expect(defaultProfile.scope).toBe('default') + expect(defaultProfile.partition).toBe(ORCA_BROWSER_PARTITION) + }) + + it('allows the default partition', () => { + expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(true) + }) + + it('rejects unknown partitions', () => { + expect(browserSessionRegistry.isAllowedPartition('persist:evil-partition')).toBe(false) + }) + + it('creates an isolated profile with a unique partition', () => { + const profile = browserSessionRegistry.createProfile('isolated', 'Test Isolated') + expect(profile.scope).toBe('isolated') + expect(profile.partition).toMatch(/^persist:orca-browser-session-/) + expect(profile.partition).not.toBe(ORCA_BROWSER_PARTITION) + expect(profile.label).toBe('Test Isolated') + expect(profile.source).toBeNull() + }) + + it('allows created profile partitions', () => { + const profile = browserSessionRegistry.createProfile('isolated', 'Allowed') + expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(true) + }) + + it('creates an imported profile', () => { + const profile = browserSessionRegistry.createProfile('imported', 'My Import') + expect(profile.scope).toBe('imported') + expect(profile.partition).toMatch(/^persist:orca-browser-session-/) + }) + + it('resolves partition for a known profile', () => { + const profile = browserSessionRegistry.createProfile('isolated', 'Resolve Test') + expect(browserSessionRegistry.resolvePartition(profile.id)).toBe(profile.partition) + }) + + it('resolves default partition for null/undefined profileId', () => { + expect(browserSessionRegistry.resolvePartition(null)).toBe(ORCA_BROWSER_PARTITION) + expect(browserSessionRegistry.resolvePartition(undefined)).toBe(ORCA_BROWSER_PARTITION) + }) + + it('resolves default partition for unknown profileId', () => { + expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION) + }) + + it('lists all profiles', () => { + const before = browserSessionRegistry.listProfiles().length + browserSessionRegistry.createProfile('isolated', 'List Test') + const after = browserSessionRegistry.listProfiles() + expect(after.length).toBe(before + 1) + }) + + it('updates profile source', () => { + const profile = browserSessionRegistry.createProfile('imported', 'Source Test') + const updated = browserSessionRegistry.updateProfileSource(profile.id, { + browserFamily: 'edge', + importedAt: Date.now() + }) + expect(updated).not.toBeNull() + expect(updated!.source?.browserFamily).toBe('edge') + }) + + it('deletes a non-default profile', async () => { + const profile = browserSessionRegistry.createProfile('isolated', 'Delete Test') + expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(true) + const deleted = await browserSessionRegistry.deleteProfile(profile.id) + expect(deleted).toBe(true) + expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(false) + expect(browserSessionRegistry.getProfile(profile.id)).toBeNull() + }) + + it('refuses to delete the default profile', async () => { + const deleted = await browserSessionRegistry.deleteProfile('default') + expect(deleted).toBe(false) + expect(browserSessionRegistry.getDefaultProfile()).not.toBeNull() + }) + + it('hydrates profiles from persisted data', () => { + const fakeProfile = { + id: 'hydrate-test-id', + scope: 'imported' as const, + partition: 'persist:orca-browser-session-hydrate-test-id', + label: 'Hydrated', + source: { browserFamily: 'manual' as const, importedAt: 1000 } + } + browserSessionRegistry.hydrateFromPersisted([fakeProfile]) + expect(browserSessionRegistry.getProfile('hydrate-test-id')).not.toBeNull() + expect(browserSessionRegistry.isAllowedPartition(fakeProfile.partition)).toBe(true) + }) + + it('sets up session policies for new partitions', () => { + browserSessionRegistry.createProfile('isolated', 'Policy Test') + expect(sessionFromPartitionMock).toHaveBeenCalled() + const mockSession = sessionFromPartitionMock.mock.results[0]?.value + expect(mockSession?.setPermissionRequestHandler).toHaveBeenCalled() + expect(mockSession?.setPermissionCheckHandler).toHaveBeenCalled() + }) + + describe('setupClientHintsOverride', () => { + it('overrides sec-ch-ua headers for Edge UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const edgeUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5' + + browserSessionRegistry.setupClientHintsOverride(mockSess, edgeUa) + + expect(onBeforeSendHeaders).toHaveBeenCalledWith( + { urls: ['https://*/*'] }, + expect.any(Function) + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { requestHeaders: { 'sec-ch-ua': 'old', 'sec-ch-ua-full-version-list': 'old' } }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified['sec-ch-ua']).toContain('Microsoft Edge') + expect(modified['sec-ch-ua']).toContain('"147"') + expect(modified['sec-ch-ua-full-version-list']).toContain('147.0.3210.5') + }) + + it('overrides sec-ch-ua headers for Chrome UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const chromeUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + + browserSessionRegistry.setupClientHintsOverride(mockSess, chromeUa) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener({ requestHeaders: { 'sec-ch-ua': 'old' } }, callback) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified['sec-ch-ua']).toContain('Google Chrome') + expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge') + }) + + it('does not register handler for non-Chrome UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + + browserSessionRegistry.setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 (compatible; MSIE 10.0)' + ) + + expect(onBeforeSendHeaders).not.toHaveBeenCalled() + }) + + it('leaves non-Client-Hints headers unchanged', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + browserSessionRegistry.setupClientHintsOverride( + mockSess, + 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { requestHeaders: { Cookie: 'abc=123', 'sec-ch-ua': 'old', Accept: 'text/html' } }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified.Cookie).toBe('abc=123') + expect(modified.Accept).toBe('text/html') + }) + }) +}) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts new file mode 100644 index 00000000000..562d12dd538 --- /dev/null +++ b/src/main/browser/browser-session-registry.ts @@ -0,0 +1,354 @@ +import { app, type Session, session } from 'electron' +import { randomUUID } from 'node:crypto' +import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types' +import { browserManager } from './browser-manager' + +type BrowserSessionMeta = { + defaultSource: BrowserSessionProfile['source'] + userAgent: string | null + pendingCookieDbPath: string | null +} + +// Why: the registry is the single source of truth for which Electron partitions +// are valid. will-attach-webview consults it to decide whether a guest's +// requested partition is allowed. This prevents a compromised renderer from +// smuggling an arbitrary partition string into a guest surface. + +class BrowserSessionRegistry { + private readonly profiles = new Map() + + constructor() { + const persisted = this.loadPersistedSource() + this.profiles.set('default', { + id: 'default', + scope: 'default', + partition: ORCA_BROWSER_PARTITION, + label: 'Default', + source: persisted + }) + } + + // Why: the default profile's source metadata (what browser was imported, + // when) must survive app restarts so the Settings UI can show the import + // status. Cookies themselves persist in the Electron partition's SQLite DB, + // but the registry is in-memory only. + private get metadataPath(): string { + return join(app.getPath('userData'), 'browser-session-meta.json') + } + + private loadPersistedSource(): BrowserSessionProfile['source'] { + return this.loadPersistedMeta().defaultSource + } + + private persistMeta(updates: Partial): void { + try { + const existing = this.loadPersistedMeta() + writeFileSync(this.metadataPath, JSON.stringify({ ...existing, ...updates })) + } catch { + // best-effort + } + } + + private persistSource(source: BrowserSessionProfile['source'], userAgent?: string | null): void { + this.persistMeta({ + defaultSource: source, + ...(userAgent !== undefined ? { userAgent } : {}) + }) + } + + private loadPersistedMeta(): BrowserSessionMeta { + try { + const raw = readFileSync(this.metadataPath, 'utf-8') + const data = JSON.parse(raw) + return { + defaultSource: data?.defaultSource ?? null, + userAgent: data?.userAgent ?? null, + pendingCookieDbPath: data?.pendingCookieDbPath ?? null + } + } catch { + return { defaultSource: null, userAgent: null, pendingCookieDbPath: null } + } + } + + // Why: the User-Agent must be set on the session BEFORE any webview loads, + // otherwise the first request uses Electron's default UA and the server may + // invalidate the imported session cookies. + // + // Why this also refreshes defaultSource: the singleton constructor runs at + // module-import time, which may be before app.isReady(). app.getPath('userData') + // is not guaranteed before ready, so the constructor's loadPersistedSource() + // silently returns null. Re-reading here (called from registerCoreHandlers, + // after app is ready) ensures the default profile's source is populated. + restorePersistedUserAgent(): void { + const meta = this.loadPersistedMeta() + if (meta.userAgent) { + const sess = session.fromPartition(ORCA_BROWSER_PARTITION) + sess.setUserAgent(meta.userAgent) + this.setupClientHintsOverride(sess, meta.userAgent) + } + if (meta.defaultSource) { + const current = this.profiles.get('default') + if (current && current.source === null) { + this.profiles.set('default', { ...current, source: meta.defaultSource }) + } + } + } + + // Why: Electron's actual Chromium version (e.g. 134) differs from the source + // browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers + // reveal the real version, creating a mismatch that Google's anti-fraud + // detection flags as CookieMismatch on accounts.google.com. Override Client + // Hints on outgoing requests to match the source browser's UA. + setupClientHintsOverride(sess: Session, ua: string): void { + const chromeMatch = ua.match(/Chrome\/([\d.]+)/) + if (!chromeMatch) { + return + } + const fullChromeVersion = chromeMatch[1] + const majorVersion = fullChromeVersion.split('.')[0] + + let brand = 'Google Chrome' + let brandFullVersion = fullChromeVersion + + const edgeMatch = ua.match(/Edg\/([\d.]+)/) + if (edgeMatch) { + brand = 'Microsoft Edge' + brandFullVersion = edgeMatch[1] + } + const brandMajor = brandFullVersion.split('.')[0] + + const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"` + const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"` + + sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { + const headers = details.requestHeaders + for (const key of Object.keys(headers)) { + const lower = key.toLowerCase() + if (lower === 'sec-ch-ua') { + headers[key] = secChUa + } else if (lower === 'sec-ch-ua-full-version-list') { + headers[key] = secChUaFull + } + } + callback({ requestHeaders: headers }) + }) + } + + // Why: the import writes cookies to a staging DB because CookieMonster holds + // the live DB's data in memory and would overwrite our changes on its next + // flush. This method MUST run before any session.fromPartition() call so + // CookieMonster reads the staged cookies instead of the stale live DB. + applyPendingCookieImport(): void { + try { + const meta = this.loadPersistedMeta() + if (!meta.pendingCookieDbPath) { + return + } + if (!existsSync(meta.pendingCookieDbPath)) { + this.persistMeta({ pendingCookieDbPath: null }) + return + } + + const partitionName = ORCA_BROWSER_PARTITION.replace('persist:', '') + const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies') + + copyFileSync(meta.pendingCookieDbPath, liveCookiesPath) + // Why: SQLite WAL mode stores uncommitted data in sidecar files. + // Stale WAL/SHM from a previous session could corrupt CookieMonster's + // read of the freshly swapped DB. + for (const suffix of ['-wal', '-shm']) { + try { + unlinkSync(liveCookiesPath + suffix) + } catch { + /* may not exist */ + } + const stagingSidecar = meta.pendingCookieDbPath + suffix + if (existsSync(stagingSidecar)) { + try { + copyFileSync(stagingSidecar, liveCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + for (const ext of ['', '-wal', '-shm']) { + try { + unlinkSync(`${meta.pendingCookieDbPath}${ext}`) + } catch { + /* best-effort */ + } + } + this.persistMeta({ pendingCookieDbPath: null }) + } catch { + // best-effort — if this fails, CookieMonster loads the old DB + } + } + + setPendingCookieImport(stagingDbPath: string): void { + this.persistMeta({ pendingCookieDbPath: stagingDbPath }) + } + + persistUserAgent(userAgent: string | null): void { + const defaultProfile = this.profiles.get('default') + this.persistSource(defaultProfile?.source ?? null, userAgent) + } + + getDefaultProfile(): BrowserSessionProfile { + return this.profiles.get('default')! + } + + getProfile(profileId: string): BrowserSessionProfile | null { + return this.profiles.get(profileId) ?? null + } + + listProfiles(): BrowserSessionProfile[] { + return [...this.profiles.values()] + } + + isAllowedPartition(partition: string): boolean { + if (partition === ORCA_BROWSER_PARTITION) { + return true + } + return [...this.profiles.values()].some((p) => p.partition === partition) + } + + resolvePartition(profileId: string | null | undefined): string { + if (!profileId) { + return ORCA_BROWSER_PARTITION + } + return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION + } + + createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile { + const id = randomUUID() + // Why: partition names are deterministic from the profile id so main can + // reconstruct the allowlist on restart from persisted profile metadata + // without needing a separate partition→profile mapping. + const partition = + scope === 'default' ? ORCA_BROWSER_PARTITION : `persist:orca-browser-session-${id}` + const profile: BrowserSessionProfile = { + id, + scope, + partition, + label, + source: null + } + this.profiles.set(id, profile) + if (partition !== ORCA_BROWSER_PARTITION) { + this.setupSessionPolicies(partition) + } + return profile + } + + updateProfileSource( + profileId: string, + source: BrowserSessionProfile['source'] + ): BrowserSessionProfile | null { + const profile = this.profiles.get(profileId) + if (!profile) { + return null + } + const updated = { ...profile, source } + this.profiles.set(profileId, updated) + if (profileId === 'default') { + this.persistSource(source) + } + return updated + } + + async deleteProfile(profileId: string): Promise { + const profile = this.profiles.get(profileId) + if (!profile || profile.scope === 'default') { + return false + } + this.profiles.delete(profileId) + + // Why: clearing the partition's storage prevents orphaned cookies/cache from + // lingering after the user deletes an imported or isolated session profile. + try { + const sess = session.fromPartition(profile.partition) + await sess.clearStorageData() + await sess.clearCache() + } catch { + // Why: partition cleanup is best-effort. The profile is already removed + // from the registry so it won't be allowed by will-attach-webview. + } + return true + } + + // Why: clearing cookies from the default partition lets users undo a cookie + // import without deleting the default profile itself. + async clearDefaultSessionCookies(): Promise { + try { + // Why: persist metadata BEFORE clearing storage so that if the app quits + // mid-clear, the next launch won't show a stale "imported from X" badge + // for cookies that were partially or fully removed. + const defaultProfile = this.profiles.get('default') + if (defaultProfile) { + this.profiles.set('default', { ...defaultProfile, source: null }) + } + this.persistMeta({ defaultSource: null, userAgent: null, pendingCookieDbPath: null }) + + const sess = session.fromPartition(ORCA_BROWSER_PARTITION) + await sess.clearStorageData({ storages: ['cookies'] }) + return true + } catch { + return false + } + } + + // Why: on startup, main must reconstruct the set of valid partitions from + // persisted session profiles so restored webviews are not denied by + // will-attach-webview before the renderer mounts them. + hydrateFromPersisted(profiles: BrowserSessionProfile[]): void { + for (const profile of profiles) { + if (profile.id === 'default') { + continue + } + this.profiles.set(profile.id, profile) + if (profile.partition !== ORCA_BROWSER_PARTITION) { + this.setupSessionPolicies(profile.partition) + } + } + } + + // Why: each non-default partition needs the same deny-by-default permission + // and download policies as the shared partition. Without this, newly created + // session partitions would silently allow permissions and downloads that the + // shared partition correctly denies. + private readonly configuredPartitions = new Set() + + private setupSessionPolicies(partition: string): void { + if (this.configuredPartitions.has(partition)) { + return + } + this.configuredPartitions.add(partition) + + const sess = session.fromPartition(partition) + sess.setPermissionRequestHandler((webContents, permission, callback) => { + const allowed = permission === 'fullscreen' + if (!allowed) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + } + callback(allowed) + }) + sess.setPermissionCheckHandler((_webContents, permission) => { + return permission === 'fullscreen' + }) + sess.setDisplayMediaRequestHandler((_request, callback) => { + callback({ video: undefined, audio: undefined }) + }) + sess.on('will-download', (_event, item, webContents) => { + browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) + }) + } +} + +export const browserSessionRegistry = new BrowserSessionRegistry() diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index f67bf7c92f0..9ac3ace32bd 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -1,15 +1,36 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { removeHandlerMock, handleMock, registerGuestMock, unregisterGuestMock, openDevToolsMock } = - vi.hoisted(() => ({ - removeHandlerMock: vi.fn(), - handleMock: vi.fn(), - registerGuestMock: vi.fn(), - unregisterGuestMock: vi.fn(), - openDevToolsMock: vi.fn().mockResolvedValue(true) - })) +const { + removeHandlerMock, + handleMock, + registerGuestMock, + unregisterGuestMock, + openDevToolsMock, + getDownloadPromptMock, + acceptDownloadMock, + cancelDownloadMock, + showSaveDialogMock, + browserWindowFromWebContentsMock +} = vi.hoisted(() => ({ + removeHandlerMock: vi.fn(), + handleMock: vi.fn(), + registerGuestMock: vi.fn(), + unregisterGuestMock: vi.fn(), + openDevToolsMock: vi.fn().mockResolvedValue(true), + getDownloadPromptMock: vi.fn(), + acceptDownloadMock: vi.fn(), + cancelDownloadMock: vi.fn(), + showSaveDialogMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn() +})) vi.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: browserWindowFromWebContentsMock + }, + dialog: { + showSaveDialog: showSaveDialogMock + }, ipcMain: { removeHandler: removeHandlerMock, handle: handleMock @@ -20,7 +41,10 @@ vi.mock('../browser/browser-manager', () => ({ browserManager: { registerGuest: registerGuestMock, unregisterGuest: unregisterGuestMock, - openDevTools: openDevToolsMock + openDevTools: openDevToolsMock, + getDownloadPrompt: getDownloadPromptMock, + acceptDownload: acceptDownloadMock, + cancelDownload: cancelDownloadMock } })) @@ -33,6 +57,11 @@ describe('registerBrowserHandlers', () => { registerGuestMock.mockReset() unregisterGuestMock.mockReset() openDevToolsMock.mockReset() + getDownloadPromptMock.mockReset() + acceptDownloadMock.mockReset() + cancelDownloadMock.mockReset() + showSaveDialogMock.mockReset() + browserWindowFromWebContentsMock.mockReset() openDevToolsMock.mockResolvedValue(true) }) @@ -57,4 +86,36 @@ describe('registerBrowserHandlers', () => { expect(result).toBe(false) expect(registerGuestMock).not.toHaveBeenCalled() }) + + it('accepts downloads through a main-owned save dialog', async () => { + getDownloadPromptMock.mockReturnValue({ filename: 'report.csv' }) + acceptDownloadMock.mockReturnValue({ ok: true }) + showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/tmp/report.csv' }) + + registerBrowserHandlers() + + const acceptHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:acceptDownload' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { downloadId: string } + ) => Promise<{ ok: true } | { ok: false; reason: string }> + + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + const result = await acceptHandler({ sender }, { downloadId: 'download-1' }) + + expect(showSaveDialogMock).toHaveBeenCalledTimes(1) + expect(acceptDownloadMock).toHaveBeenCalledWith({ + downloadId: 'download-1', + senderWebContentsId: 91, + savePath: '/tmp/report.csv' + }) + expect(result).toEqual({ ok: true }) + }) }) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 80626561575..1d2cf57fecb 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -1,5 +1,15 @@ -import { ipcMain } from 'electron' +/* eslint-disable max-lines -- Why: browser IPC handlers must be registered together so the + trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */ +import { BrowserWindow, dialog, ipcMain } from 'electron' import { browserManager } from '../browser/browser-manager' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { + pickCookieFile, + importCookiesFromFile, + detectInstalledBrowsers, + importCookiesFromBrowser +} from '../browser/browser-cookie-import' +import type { DetectedBrowser } from '../browser/browser-cookie-import' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -11,6 +21,11 @@ import type { BrowserExtractHoverArgs, BrowserExtractHoverResult } from '../../shared/browser-grab-types' +import type { + BrowserCookieImportResult, + BrowserSessionProfile, + BrowserSessionProfileScope +} from '../../shared/types' let trustedBrowserRendererWebContentsId: number | null = null @@ -42,6 +57,8 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:registerGuest') ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:openDevTools') + ipcMain.removeHandler('browser:acceptDownload') + ipcMain.removeHandler('browser:cancelDownload') ipcMain.removeHandler('browser:setGrabMode') ipcMain.removeHandler('browser:awaitGrabSelection') ipcMain.removeHandler('browser:cancelGrab') @@ -50,7 +67,7 @@ export function registerBrowserHandlers(): void { ipcMain.handle( 'browser:registerGuest', - (event, args: { browserTabId: string; webContentsId: number }) => { + (event, args: { browserPageId: string; workspaceId: string; webContentsId: number }) => { if (!isTrustedBrowserRenderer(event.sender)) { return false } @@ -62,19 +79,57 @@ export function registerBrowserHandlers(): void { } ) - ipcMain.handle('browser:unregisterGuest', (event, args: { browserTabId: string }) => { + ipcMain.handle('browser:unregisterGuest', (event, args: { browserPageId: string }) => { if (!isTrustedBrowserRenderer(event.sender)) { return false } - browserManager.unregisterGuest(args.browserTabId) + browserManager.unregisterGuest(args.browserPageId) return true }) - ipcMain.handle('browser:openDevTools', (event, args: { browserTabId: string }) => { + ipcMain.handle('browser:openDevTools', (event, args: { browserPageId: string }) => { if (!isTrustedBrowserRenderer(event.sender)) { return false } - return browserManager.openDevTools(args.browserTabId) + return browserManager.openDevTools(args.browserPageId) + }) + + ipcMain.handle('browser:acceptDownload', async (event, args: { downloadId: string }) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'not-authorized' as const } + } + const prompt = browserManager.getDownloadPrompt(args.downloadId, event.sender.id) + if (!prompt) { + return { ok: false, reason: 'not-ready' as const } + } + + const parent = BrowserWindow.fromWebContents(event.sender) + const result = parent + ? await dialog.showSaveDialog(parent, { defaultPath: prompt.filename }) + : await dialog.showSaveDialog({ defaultPath: prompt.filename }) + if (result.canceled || !result.filePath) { + browserManager.cancelDownload({ + downloadId: args.downloadId, + senderWebContentsId: event.sender.id + }) + return { ok: false, reason: 'canceled' as const } + } + + return browserManager.acceptDownload({ + downloadId: args.downloadId, + senderWebContentsId: event.sender.id, + savePath: result.filePath + }) + }) + + ipcMain.handle('browser:cancelDownload', (event, args: { downloadId: string }) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserManager.cancelDownload({ + downloadId: args.downloadId, + senderWebContentsId: event.sender.id + }) }) // --- Browser Context Grab IPC --- @@ -85,11 +140,11 @@ export function registerBrowserHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return { ok: false, reason: 'not-authorized' } } - const guest = browserManager.getAuthorizedGuest(args.browserTabId, event.sender.id) + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!guest) { return { ok: false, reason: 'not-ready' } } - const success = await browserManager.setGrabMode(args.browserTabId, args.enabled, guest) + const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest) return success ? { ok: true } : { ok: false, reason: 'not-ready' } } ) @@ -100,7 +155,7 @@ export function registerBrowserHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return { opId: args.opId, kind: 'error', reason: 'Not authorized' } } - const guest = browserManager.getAuthorizedGuest(args.browserTabId, event.sender.id) + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!guest) { return { opId: args.opId, kind: 'error', reason: 'Guest not ready' } } @@ -108,7 +163,7 @@ export function registerBrowserHandlers(): void { // the conflict by cancelling the previous op. Blocking at the IPC layer // would create a race window where rearm() fails if the previous IPC call // hasn't fully resolved yet. - return browserManager.awaitGrabSelection(args.browserTabId, args.opId, guest) + return browserManager.awaitGrabSelection(args.browserPageId, args.opId, guest) } ) @@ -118,11 +173,11 @@ export function registerBrowserHandlers(): void { } // Why: verify the sender actually owns this tab, consistent with the // authorization check in setGrabMode/awaitGrabSelection/captureScreenshot. - const guest = browserManager.getAuthorizedGuest(args.browserTabId, event.sender.id) + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!guest) { return false } - browserManager.cancelGrabOp(args.browserTabId, 'user') + browserManager.cancelGrabOp(args.browserPageId, 'user') return true }) @@ -135,12 +190,12 @@ export function registerBrowserHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return { ok: false, reason: 'Not authorized' } } - const guest = browserManager.getAuthorizedGuest(args.browserTabId, event.sender.id) + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!guest) { return { ok: false, reason: 'Guest not ready' } } const screenshot = await browserManager.captureSelectionScreenshot( - args.browserTabId, + args.browserPageId, args.rect, guest ) @@ -157,15 +212,144 @@ export function registerBrowserHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return { ok: false, reason: 'Not authorized' } } - const guest = browserManager.getAuthorizedGuest(args.browserTabId, event.sender.id) + const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) if (!guest) { return { ok: false, reason: 'Guest not ready' } } - const payload = await browserManager.extractHoverPayload(args.browserTabId, guest) + const payload = await browserManager.extractHoverPayload(args.browserPageId, guest) if (!payload) { return { ok: false, reason: 'No element hovered' } } return { ok: true, payload } } ) + + // --- Browser Session Profile IPC --- + + ipcMain.removeHandler('browser:session:listProfiles') + ipcMain.removeHandler('browser:session:createProfile') + ipcMain.removeHandler('browser:session:deleteProfile') + ipcMain.removeHandler('browser:session:importCookies') + ipcMain.removeHandler('browser:session:resolvePartition') + + ipcMain.handle('browser:session:listProfiles', (event): BrowserSessionProfile[] => { + if (!isTrustedBrowserRenderer(event.sender)) { + return [] + } + return browserSessionRegistry.listProfiles() + }) + + ipcMain.handle( + 'browser:session:createProfile', + ( + event, + args: { scope: BrowserSessionProfileScope; label: string } + ): BrowserSessionProfile | null => { + if (!isTrustedBrowserRenderer(event.sender)) { + return null + } + return browserSessionRegistry.createProfile(args.scope, args.label) + } + ) + + ipcMain.handle( + 'browser:session:deleteProfile', + async (event, args: { profileId: string }): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserSessionRegistry.deleteProfile(args.profileId) + } + ) + + ipcMain.handle( + 'browser:session:importCookies', + async (event, args: { profileId: string }): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'Not authorized' } + } + const profile = browserSessionRegistry.getProfile(args.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + + const parent = BrowserWindow.fromWebContents(event.sender) + const filePath = await pickCookieFile(parent) + if (!filePath) { + return { ok: false, reason: 'canceled' } + } + + const result = await importCookiesFromFile(filePath, profile.partition) + if (result.ok) { + browserSessionRegistry.updateProfileSource(args.profileId, { + browserFamily: 'manual', + importedAt: Date.now() + }) + return { ...result, profileId: args.profileId } + } + return result + } + ) + + ipcMain.handle( + 'browser:session:resolvePartition', + (event, args: { profileId: string | null }): string | null => { + if (!isTrustedBrowserRenderer(event.sender)) { + return null + } + return browserSessionRegistry.resolvePartition(args.profileId) + } + ) + + ipcMain.removeHandler('browser:session:clearDefaultCookies') + + ipcMain.handle('browser:session:clearDefaultCookies', async (event): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserSessionRegistry.clearDefaultSessionCookies() + }) + + ipcMain.removeHandler('browser:session:detectBrowsers') + ipcMain.removeHandler('browser:session:importFromBrowser') + + ipcMain.handle('browser:session:detectBrowsers', (event): DetectedBrowser[] => { + if (!isTrustedBrowserRenderer(event.sender)) { + return [] + } + return detectInstalledBrowsers() + }) + + ipcMain.handle( + 'browser:session:importFromBrowser', + async ( + event, + args: { profileId: string; browserFamily: string } + ): Promise => { + if (!isTrustedBrowserRenderer(event.sender)) { + return { ok: false, reason: 'Not authorized' } + } + const profile = browserSessionRegistry.getProfile(args.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + + const browsers = detectInstalledBrowsers() + const browser = browsers.find((b) => b.family === args.browserFamily) + if (!browser) { + return { ok: false, reason: 'Browser not found on this system.' } + } + + const result = await importCookiesFromBrowser(browser, profile.partition) + if (result.ok) { + browserSessionRegistry.updateProfileSource(args.profileId, { + browserFamily: browser.family, + profileName: 'Default', + importedAt: Date.now() + }) + return { ...result, profileId: args.profileId } + } + return result + } + ) } diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index eee3d8320ba..83689efc1c0 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -16,6 +16,7 @@ import { setTrustedBrowserRendererWebContentsId } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' import { registerBrowserHandlers } from './browser' +import { browserSessionRegistry } from '../browser/browser-session-registry' import { registerShellHandlers } from './shell' import { registerUIHandlers } from './ui' import { registerCodexAccountHandlers } from './codex-accounts' @@ -62,6 +63,12 @@ export function registerCoreHandlers( registerNotificationHandlers(store) registerSettingsHandlers(store) registerBrowserHandlers() + // Why: applyPendingCookieImport MUST run before restorePersistedUserAgent + // because the latter calls session.fromPartition() which initializes + // CookieMonster. The pending import replaces the live DB file so + // CookieMonster reads the imported cookies on first access. + browserSessionRegistry.applyPendingCookieImport() + browserSessionRegistry.restorePersistedUserAgent() registerShellHandlers() registerSessionHandlers(store) registerUIHandlers(store) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index e6094a2c27e..8b9cc429daf 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -11,7 +11,9 @@ const { registerPtyHandlersMock, setupAutoUpdaterMock, sessionFromPartitionMock, - browserManagerUnregisterAllMock + browserManagerUnregisterAllMock, + browserManagerNotifyPermissionDeniedMock, + browserManagerHandleGuestWillDownloadMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), @@ -23,7 +25,9 @@ const { registerPtyHandlersMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), sessionFromPartitionMock: vi.fn(), - browserManagerUnregisterAllMock: vi.fn() + browserManagerUnregisterAllMock: vi.fn(), + browserManagerNotifyPermissionDeniedMock: vi.fn(), + browserManagerHandleGuestWillDownloadMock: vi.fn() })) vi.mock('electron', () => ({ @@ -54,6 +58,8 @@ vi.mock('../ipc/pty', () => ({ vi.mock('../browser/browser-manager', () => ({ browserManager: { + notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, + handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, unregisterAll: browserManagerUnregisterAllMock } })) @@ -81,6 +87,8 @@ describe('attachMainWindowServices', () => { setupAutoUpdaterMock.mockReset() sessionFromPartitionMock.mockReset() browserManagerUnregisterAllMock.mockReset() + browserManagerNotifyPermissionDeniedMock.mockReset() + browserManagerHandleGuestWillDownloadMock.mockReset() sessionFromPartitionMock.mockReturnValue({ setPermissionRequestHandler: setPermissionRequestHandlerMock, setPermissionCheckHandler: setPermissionCheckHandlerMock, @@ -156,10 +164,17 @@ describe('attachMainWindowServices', () => { callback: (allowed: boolean) => void ) => void const permissionCallback = vi.fn() - browserPermissionHandler(null, 'fullscreen', permissionCallback) - browserPermissionHandler(null, 'media', permissionCallback) + const guestWebContents = { id: 401, getURL: vi.fn(() => 'https://example.com/account') } + browserPermissionHandler(guestWebContents, 'fullscreen', permissionCallback) + browserPermissionHandler(guestWebContents, 'media', permissionCallback) expect(permissionCallback.mock.calls).toEqual([[true], [false]]) + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledTimes(1) + expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ + guestWebContentsId: 401, + permission: 'media', + rawUrl: 'https://example.com/account' + }) const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[0][0] as ( wc: unknown, @@ -178,10 +193,18 @@ describe('attachMainWindowServices', () => { const willDownloadHandler = browserSessionOnMock.mock.calls.find( ([eventName]) => eventName === 'will-download' - )?.[1] as (event: { preventDefault: () => void }) => void - const preventDefault = vi.fn() - willDownloadHandler({ preventDefault }) - expect(preventDefault).toHaveBeenCalledTimes(1) + )?.[1] as ( + event: unknown, + item: { getFilename: () => string }, + webContents: { id: number } + ) => void + const item = { getFilename: vi.fn(() => 'report.pdf') } + willDownloadHandler({}, item, { id: 402 }) + expect(browserManagerHandleGuestWillDownloadMock).toHaveBeenCalledTimes(1) + expect(browserManagerHandleGuestWillDownloadMock).toHaveBeenCalledWith({ + guestWebContentsId: 402, + item + }) }) it('clears browser guest registrations when the main window closes', () => { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 4267ade1315..4d80abc0339 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -65,11 +65,19 @@ export function attachMainWindowServices( ) const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION) - browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { + browserSession.setPermissionRequestHandler((webContents, permission, callback) => { // Why: the in-app browser is for dev previews and lightweight browsing, not // trusted desktop-app privileges. Denying by default keeps arbitrary sites // from silently escalating into camera/mic/notification prompts inside Orca. - callback(permission === 'fullscreen') + const allowed = permission === 'fullscreen' + if (!allowed) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl: webContents.getURL() + }) + } + callback(allowed) }) browserSession.setPermissionCheckHandler((_webContents, permission) => { return permission === 'fullscreen' @@ -82,11 +90,11 @@ export function attachMainWindowServices( // signature while still denying the request. callback({ video: undefined, audio: undefined }) }) - browserSession.on('will-download', (event) => { + browserSession.on('will-download', (_event, item, webContents) => { // Why: browser-tab downloads need explicit product UX before arbitrary sites - // can write files through Orca. Until that exists, cancel downloads instead - // of inheriting Electron's default save behavior invisibly. - event.preventDefault() + // can write files through Orca. Pause the item and route it through + // BrowserManager so the user must explicitly accept the save path first. + browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) }) mainWindow.on('closed', () => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 2c9de1c647c..71270df6eef 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -5,7 +5,7 @@ import icon from '../../../resources/icon.png?asset' import devIcon from '../../../resources/icon-dev.png?asset' import type { Store } from '../persistence' import { browserManager } from '../browser/browser-manager' -import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import { browserSessionRegistry } from '../browser/browser-session-registry' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl @@ -145,7 +145,11 @@ export function createMainWindow( // non-browser partition into the guest and widen the app privilege boundary. // The one allowed data URL is Orca's inert blank-tab bootstrap page; deny // every other data URL so the renderer cannot inject arbitrary inline HTML. - if (!normalizedSrc || partition !== ORCA_BROWSER_PARTITION) { + // Why: session profiles use per-profile partitions (e.g. + // persist:orca-browser-session-). The registry is the sole authority + // for which partitions are valid — renderer-provided strings that are not + // in the allowlist are rejected. + if (!normalizedSrc || !browserSessionRegistry.isAllowedPartition(partition)) { event.preventDefault() return } @@ -162,7 +166,10 @@ export function createMainWindow( webPreferences.allowRunningInsecureContent = false webPreferences.contextIsolation = true webPreferences.sandbox = true - webPreferences.partition = ORCA_BROWSER_PARTITION + // Why: preserve the registry-validated partition instead of forcing the + // legacy constant. This lets imported/isolated session profiles use their + // own cookie/storage partition while keeping all other hardening intact. + webPreferences.partition = partition }) mainWindow.webContents.on('did-attach-webview', (_event, guest) => { diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 13c84c9d4d9..72dd3511e80 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -1,6 +1,11 @@ /* eslint-disable max-lines -- Why: the preload contract is intentionally centralized in one declaration file so renderer and preload stay in lockstep when IPC surfaces change. */ import type { + BrowserCookieImportResult, + BrowserCookieImportSummary, BrowserLoadError, + BrowserSessionProfile, + BrowserSessionProfileScope, + BrowserSessionProfileSource, CodexRateLimitAccountsState, CreateWorktreeResult, DirEntry, @@ -41,6 +46,15 @@ import type { BrowserExtractHoverArgs, BrowserExtractHoverResult } from '../../shared/browser-grab-types' +import type { + BrowserContextMenuDismissedEvent, + BrowserContextMenuRequestedEvent, + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadRequestedEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' import type { CliInstallStatus } from '../../shared/cli-install-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types' import type { @@ -66,12 +80,34 @@ import type { } from '../../shared/codex-usage-types' export type BrowserApi = { - registerGuest: (args: { browserTabId: string; webContentsId: number }) => Promise - unregisterGuest: (args: { browserTabId: string }) => Promise - openDevTools: (args: { browserTabId: string }) => Promise + registerGuest: (args: { + browserPageId: string + workspaceId: string + webContentsId: number + }) => Promise + unregisterGuest: (args: { browserPageId: string }) => Promise + openDevTools: (args: { browserPageId: string }) => Promise onGuestLoadFailed: ( - callback: (args: { browserTabId: string; loadError: BrowserLoadError }) => void + callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void ) => () => void + onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void + onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void + onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void + onDownloadProgress: (callback: (event: BrowserDownloadProgressEvent) => void) => () => void + onDownloadFinished: (callback: (event: BrowserDownloadFinishedEvent) => void) => () => void + onContextMenuRequested: ( + callback: (event: BrowserContextMenuRequestedEvent) => void + ) => () => void + onContextMenuDismissed: ( + callback: (event: BrowserContextMenuDismissedEvent) => void + ) => () => void + onOpenLinkInOrcaTab: ( + callback: (event: { browserPageId: string; url: string }) => void + ) => () => void + acceptDownload: (args: { + downloadId: string + }) => Promise<{ ok: true } | { ok: false; reason: string }> + cancelDownload: (args: { downloadId: string }) => Promise setGrabMode: (args: BrowserSetGrabModeArgs) => Promise awaitGrabSelection: (args: BrowserAwaitGrabSelectionArgs) => Promise cancelGrab: (args: BrowserCancelGrabArgs) => Promise @@ -79,10 +115,29 @@ export type BrowserApi = { args: BrowserCaptureSelectionScreenshotArgs ) => Promise extractHoverPayload: (args: BrowserExtractHoverArgs) => Promise - onGrabModeToggle: (callback: (browserTabId: string) => void) => () => void + onGrabModeToggle: (callback: (browserPageId: string) => void) => () => void onGrabActionShortcut: ( - callback: (args: { browserTabId: string; key: 'c' | 's' }) => void + callback: (args: { browserPageId: string; key: 'c' | 's' }) => void ) => () => void + sessionListProfiles: () => Promise + sessionCreateProfile: (args: { + scope: BrowserSessionProfileScope + label: string + }) => Promise + sessionDeleteProfile: (args: { profileId: string }) => Promise + sessionImportCookies: (args: { profileId: string }) => Promise + sessionResolvePartition: (args: { profileId: string | null }) => Promise + sessionDetectBrowsers: () => Promise + sessionImportFromBrowser: (args: { + profileId: string + browserFamily: string + }) => Promise + sessionClearDefaultCookies: () => Promise +} + +export type DetectedBrowserInfo = { + family: BrowserSessionProfileSource['browserFamily'] + label: string } export type PreflightStatus = { @@ -365,6 +420,9 @@ export type PreloadApi = { onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void onNewBrowserTab: (callback: () => void) => () => void onNewTerminalTab: (callback: () => void) => () => void + onFocusBrowserAddressBar: (callback: () => void) => () => void + onReloadBrowserPage: (callback: () => void) => () => void + onHardReloadBrowserPage: (callback: () => void) => () => void onCloseActiveTab: (callback: () => void) => () => void onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void onToggleStatusBar: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index dd15a711efe..60c3f73f968 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -335,25 +335,28 @@ const api = { }, browser: { - registerGuest: (args: { browserTabId: string; webContentsId: number }): Promise => - ipcRenderer.invoke('browser:registerGuest', args), + registerGuest: (args: { + browserPageId: string + workspaceId: string + webContentsId: number + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), - unregisterGuest: (args: { browserTabId: string }): Promise => + unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), - openDevTools: (args: { browserTabId: string }): Promise => + openDevTools: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:openDevTools', args), onGuestLoadFailed: ( callback: (args: { - browserTabId: string + browserPageId: string loadError: { code: number; description: string; validatedUrl: string } }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, data: { - browserTabId: string + browserPageId: string loadError: { code: number; description: string; validatedUrl: string } } ) => callback(data) @@ -361,46 +364,225 @@ const api = { return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) }, + onPermissionDenied: ( + callback: (event: { browserPageId: string; permission: string; origin: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; permission: string; origin: string } + ) => callback(data) + ipcRenderer.on('browser:permission-denied', listener) + return () => ipcRenderer.removeListener('browser:permission-denied', listener) + }, + + onPopup: ( + callback: (event: { + browserPageId: string + origin: string + action: 'opened-external' | 'blocked' + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + origin: string + action: 'opened-external' | 'blocked' + } + ) => callback(data) + ipcRenderer.on('browser:popup', listener) + return () => ipcRenderer.removeListener('browser:popup', listener) + }, + + onDownloadRequested: ( + callback: (event: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + } + ) => callback(data) + ipcRenderer.on('browser:download-requested', listener) + return () => ipcRenderer.removeListener('browser:download-requested', listener) + }, + + onDownloadProgress: ( + callback: (event: { + downloadId: string + receivedBytes: number + totalBytes: number | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { downloadId: string; receivedBytes: number; totalBytes: number | null } + ) => callback(data) + ipcRenderer.on('browser:download-progress', listener) + return () => ipcRenderer.removeListener('browser:download-progress', listener) + }, + + onDownloadFinished: ( + callback: (event: { + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + error: string | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + error: string | null + } + ) => callback(data) + ipcRenderer.on('browser:download-finished', listener) + return () => ipcRenderer.removeListener('browser:download-finished', listener) + }, + + onContextMenuRequested: ( + callback: (event: { + browserPageId: string + x: number + y: number + pageUrl: string + linkUrl: string | null + canGoBack: boolean + canGoForward: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + x: number + y: number + pageUrl: string + linkUrl: string | null + canGoBack: boolean + canGoForward: boolean + } + ) => callback(data) + ipcRenderer.on('browser:context-menu-requested', listener) + return () => ipcRenderer.removeListener('browser:context-menu-requested', listener) + }, + + onContextMenuDismissed: ( + callback: (event: { browserPageId: string }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) => + callback(data) + ipcRenderer.on('browser:context-menu-dismissed', listener) + return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener) + }, + + onOpenLinkInOrcaTab: ( + callback: (event: { browserPageId: string; url: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; url: string } + ) => callback(data) + ipcRenderer.on('browser:open-link-in-orca-tab', listener) + return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) + }, + + acceptDownload: (args: { + downloadId: string + }): Promise<{ ok: true } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:acceptDownload', args), + + cancelDownload: (args: { downloadId: string }): Promise => + ipcRenderer.invoke('browser:cancelDownload', args), + setGrabMode: (args: { - browserTabId: string + browserPageId: string enabled: boolean }): Promise<{ ok: true } | { ok: false; reason: string }> => ipcRenderer.invoke('browser:setGrabMode', args), - awaitGrabSelection: (args: { browserTabId: string; opId: string }): Promise => + awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => ipcRenderer.invoke('browser:awaitGrabSelection', args), - cancelGrab: (args: { browserTabId: string }): Promise => + cancelGrab: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:cancelGrab', args), captureSelectionScreenshot: (args: { - browserTabId: string + browserPageId: string rect: { x: number; y: number; width: number; height: number } }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => ipcRenderer.invoke('browser:captureSelectionScreenshot', args), extractHoverPayload: (args: { - browserTabId: string + browserPageId: string }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => ipcRenderer.invoke('browser:extractHoverPayload', args), - onGrabModeToggle: (callback: (browserTabId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, browserTabId: string) => - callback(browserTabId) + onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => + callback(browserPageId) ipcRenderer.on('browser:grabModeToggle', listener) return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) }, onGrabActionShortcut: ( - callback: (args: { browserTabId: string; key: 'c' | 's' }) => void + callback: (args: { browserPageId: string; key: 'c' | 's' }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { browserTabId: string; key: 'c' | 's' } + data: { browserPageId: string; key: 'c' | 's' } ) => callback(data) ipcRenderer.on('browser:grabActionShortcut', listener) return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) - } + }, + + sessionListProfiles: (): Promise => + ipcRenderer.invoke('browser:session:listProfiles'), + + sessionCreateProfile: (args: { + scope: 'default' | 'isolated' | 'imported' + label: string + }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), + + sessionDeleteProfile: (args: { profileId: string }): Promise => + ipcRenderer.invoke('browser:session:deleteProfile', args), + + sessionImportCookies: (args: { + profileId: string + }): Promise< + { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } + > => ipcRenderer.invoke('browser:session:importCookies', args), + + sessionResolvePartition: (args: { profileId: string | null }): Promise => + ipcRenderer.invoke('browser:session:resolvePartition', args), + + sessionDetectBrowsers: (): Promise => + ipcRenderer.invoke('browser:session:detectBrowsers'), + + sessionImportFromBrowser: (args: { + profileId: string + browserFamily: string + }): Promise< + { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } + > => ipcRenderer.invoke('browser:session:importFromBrowser', args), + + sessionClearDefaultCookies: (): Promise => + ipcRenderer.invoke('browser:session:clearDefaultCookies') }, hooks: { @@ -632,6 +814,21 @@ const api = { ipcRenderer.on('ui:newTerminalTab', listener) return () => ipcRenderer.removeListener('ui:newTerminalTab', listener) }, + onFocusBrowserAddressBar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:focusBrowserAddressBar', listener) + return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) + }, + onReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:reloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener) + }, + onHardReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:hardReloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener) + }, onCloseActiveTab: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:closeActiveTab', listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7c5e6932624..431bd6827f2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -94,6 +94,8 @@ function App(): React.JSX.Element { const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession) const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession) const hydrateBrowserSession = useAppStore((s) => s.hydrateBrowserSession) + const fetchBrowserSessionProfiles = useAppStore((s) => s.fetchBrowserSessionProfiles) + const fetchDetectedBrowsers = useAppStore((s) => s.fetchDetectedBrowsers) const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals) const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI) const openModal = useAppStore((s) => s.openModal) @@ -112,7 +114,11 @@ function App(): React.JSX.Element { const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree) const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) + const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) const activeBrowserTabIdByWorktree = useAppStore((s) => s.activeBrowserTabIdByWorktree) + const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) + const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) + const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree) // Right sidebar + editor state const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar) @@ -154,6 +160,8 @@ function App(): React.JSX.Element { hydrateWorkspaceSession(session) hydrateEditorSession(session) hydrateBrowserSession(session) + await fetchBrowserSessionProfiles() + await fetchDetectedBrowsers() await reconnectPersistedTerminals(abortController.signal) syncZoomCSSVar() } @@ -207,6 +215,8 @@ function App(): React.JSX.Element { hydrateWorkspaceSession, hydrateEditorSession, hydrateBrowserSession, + fetchBrowserSessionProfiles, + fetchDetectedBrowsers, reconnectPersistedTerminals ]) @@ -243,7 +253,11 @@ function App(): React.JSX.Element { activeFileIdByWorktree, activeTabTypeByWorktree, browserTabsByWorktree, - activeBrowserTabIdByWorktree + browserPagesByWorkspace, + activeBrowserTabIdByWorktree, + unifiedTabsByWorktree, + groupsByWorktree, + activeGroupIdByWorktree }) ) }, 150) @@ -261,7 +275,11 @@ function App(): React.JSX.Element { activeTabTypeByWorktree, activeTabIdByWorktree, browserTabsByWorktree, - activeBrowserTabIdByWorktree + browserPagesByWorkspace, + activeBrowserTabIdByWorktree, + unifiedTabsByWorktree, + groupsByWorktree, + activeGroupIdByWorktree ]) // On shutdown, capture terminal scrollback buffers and flush to disk. diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 9215a59633b..72009d5414c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -59,8 +59,6 @@ export default function Terminal(): React.JSX.Element | null { const createBrowserTab = useAppStore((s) => s.createBrowserTab) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab) - const updateBrowserTabPageState = useAppStore((s) => s.updateBrowserTabPageState) - const setBrowserTabUrl = useAppStore((s) => s.setBrowserTabUrl) const markFileDirty = useAppStore((s) => s.markFileDirty) const setTabBarOrder = useAppStore((s) => s.setTabBarOrder) @@ -243,7 +241,8 @@ export default function Terminal(): React.JSX.Element | null { if (!activeWorktreeId) { return } - createBrowserTab(activeWorktreeId, 'about:blank', { title: 'New Browser Tab' }) + const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + createBrowserTab(activeWorktreeId, defaultUrl, { title: 'New Browser Tab' }) }, [activeWorktreeId, createBrowserTab]) const handleCloseTab = useCallback( @@ -457,20 +456,6 @@ export default function Terminal(): React.JSX.Element | null { [setActiveBrowserTab, setActiveTabType] ) - const handleBrowserTabPageStateUpdate = useCallback( - (tabId: string, updates: Parameters[1]) => { - updateBrowserTabPageState(tabId, updates) - }, - [updateBrowserTabPageState] - ) - - const handleBrowserTabSetUrl = useCallback( - (tabId: string, url: string) => { - setBrowserTabUrl(tabId, url) - }, - [setBrowserTabUrl] - ) - // Keyboard shortcuts useEffect(() => { if (!activeWorktreeId) { @@ -480,10 +465,18 @@ export default function Terminal(): React.JSX.Element | null { const isMac = navigator.userAgent.includes('Mac') const onKeyDown = (e: KeyboardEvent): void => { const mod = isMac ? e.metaKey : e.ctrlKey - // Cmd/Ctrl+T - new terminal tab + // Why: when the browser workspace is the active surface, standard + // browser tab creation should stay inside that workspace. Reusing the + // same shortcut keeps Orca's embedded browser aligned with user + // expectations instead of unexpectedly mutating the outer tab strip. if (mod && e.key === 't' && !e.shiftKey && !e.repeat) { e.preventDefault() - handleNewTab() + const state = useAppStore.getState() + if (state.activeTabType === 'browser') { + handleNewBrowserTab() + } else { + handleNewTab() + } return } @@ -505,7 +498,7 @@ export default function Terminal(): React.JSX.Element | null { if (state.activeTabType === 'editor' && state.activeFileId) { handleCloseFile(state.activeFileId) } else if (state.activeTabType === 'browser' && state.activeBrowserTabId) { - handleCloseBrowserTab(state.activeBrowserTabId) + closeBrowserTab(state.activeBrowserTabId) } return } @@ -582,6 +575,7 @@ export default function Terminal(): React.JSX.Element | null { handleNewTab, handleCloseTab, handleCloseBrowserTab, + closeBrowserTab, handleCloseFile, setActiveTab ]) @@ -829,10 +823,9 @@ export default function Terminal(): React.JSX.Element | null { })} - {/* Browser panes container — hidden when active tab is not a browser tab. - Only the active browser tab for the active worktree is mounted; others - are parked in a hidden off-screen container by BrowserPane to preserve - their webview guest process across tab switches. */} + {/* Browser panes container — all browser panes for the active worktree + stay mounted so webview DOM state (scroll position, form inputs, etc.) + survives tab switches. BrowserPagePane uses isActive + CSS to show/hide. */}
@@ -848,18 +841,20 @@ export default function Terminal(): React.JSX.Element | null { className={isVisibleWorktree ? 'absolute inset-0' : 'absolute inset-0 hidden'} aria-hidden={!isVisibleWorktree} > - {isVisibleWorktree && activeTabType === 'browser' - ? browserTabs - .filter((browserTab) => browserTab.id === activeBrowserTabId) - .map((browserTab) => ( - - )) - : null} + {browserTabs.map((browserTab) => { + const isBrowserActive = + isVisibleWorktree && + activeTabType === 'browser' && + browserTab.id === activeBrowserTabId + return ( +
+ +
+ ) + })}
) })} diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index c085ac9db57..d217ae3b0c1 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -10,9 +10,11 @@ import { ExternalLink, Globe, Image, + Import, Loader2, OctagonX, RefreshCw, + Settings, SquareCode } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -26,8 +28,20 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' -import type { BrowserLoadError, BrowserTab as BrowserTabState } from '../../../../shared/types' +import type { + BrowserLoadError, + BrowserPage as BrowserPageState, + BrowserWorkspace as BrowserWorkspaceState +} from '../../../../shared/types' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl @@ -38,6 +52,11 @@ import { markEvictedBrowserTab, rememberLiveBrowserUrl } from './browser-runtime' +import type { + BrowserDownloadRequestedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadFinishedEvent +} from '../../../../shared/browser-guest-events' import type { BrowserGrabPayload, BrowserGrabScreenshot @@ -45,21 +64,34 @@ import type { import { useGrabMode } from './useGrabMode' import { formatGrabPayloadAsText } from './GrabConfirmationSheet' import { isEditableKeyboardTarget } from './browser-keyboard' +import { + formatByteCount, + formatDownloadFinishedNotice, + formatLoadFailureDescription, + formatLoadFailureRecoveryHint, + formatPermissionNotice, + formatPopupNotice +} from './browser-notices' type BrowserTabPageState = Partial< Pick< - BrowserTabState, + BrowserPageState, 'title' | 'loading' | 'faviconUrl' | 'canGoBack' | 'canGoForward' | 'loadError' > > +type BrowserDownloadState = BrowserDownloadRequestedEvent & { + receivedBytes: number + status: 'requested' | 'downloading' +} + const webviewRegistry = new Map() const registeredWebContentsIds = new Map() const parkedAtByTabId = new Map() let hiddenContainer: HTMLDivElement | null = null const DRAG_LISTENER_KEY = '__orcaBrowserPaneDragListeners' const MAX_PARKED_WEBVIEWS = 6 - +const EMPTY_BROWSER_PAGES: BrowserPageState[] = [] function getHiddenContainer(): HTMLDivElement { if (!hiddenContainer) { hiddenContainer = document.createElement('div') @@ -116,7 +148,7 @@ export function destroyPersistentWebview(browserTabId: string): void { clearLiveBrowserUrl(browserTabId) return } - void window.api.browser.unregisterGuest({ browserTabId }) + void window.api.browser.unregisterGuest({ browserPageId: browserTabId }) webview.remove() webviewRegistry.delete(browserTabId) registeredWebContentsIds.delete(browserTabId) @@ -140,6 +172,19 @@ function toDisplayUrl(url: string): string { return url === ORCA_BROWSER_BLANK_URL ? 'about:blank' : url } +function getBrowserDisplayTitle(title: string | null | undefined, url: string): string { + if ( + url === 'about:blank' || + url === ORCA_BROWSER_BLANK_URL || + title === 'about:blank' || + title === ORCA_BROWSER_BLANK_URL || + !title + ) { + return 'New Tab' + } + return title +} + function isChromiumErrorPage(url: string): boolean { return url.startsWith('chrome-error://') } @@ -166,16 +211,6 @@ function getLoadErrorMetadata(loadError: BrowserLoadError | null): { } } -function getFriendlyLoadErrorDescription(loadError: BrowserLoadError | null): string { - if (!loadError) { - return 'The page did not respond.' - } - if (loadError.code === 0) { - return loadError.description - } - return "We couldn't connect to this page." -} - function getOpenableExternalUrl( webview: Electron.WebviewTag | null, fallbackUrl: string @@ -195,9 +230,24 @@ function getOpenableExternalUrl( return normalizeExternalBrowserUrl(currentUrl) } +function getCurrentBrowserUrl(webview: Electron.WebviewTag | null, fallbackUrl: string): string { + let currentUrl = fallbackUrl + if (webview) { + try { + currentUrl = webview.getURL() || fallbackUrl + } catch { + // Why: toolbar actions still need a stable URL during early guest attach + // and restore. Fall back to the persisted tab URL instead of throwing + // and dropping browser actions on freshly restored tabs. + currentUrl = fallbackUrl + } + } + return toDisplayUrl(currentUrl) +} + function retryBrowserTabLoad( webview: Electron.WebviewTag | null, - browserTab: BrowserTabState, + browserTab: BrowserPageState, onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void ): void { if (!webview) { @@ -254,10 +304,50 @@ function evictParkedWebviews(excludedTabId: string | null = null): void { export default function BrowserPane({ browserTab, + isActive +}: { + browserTab: BrowserWorkspaceState + isActive: boolean +}): React.JSX.Element { + const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) + const browserPages = browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES + const activeBrowserPage = browserPages[0] ?? null + const updateBrowserPageState = useAppStore((s) => s.updateBrowserPageState) + const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl) + + return ( +
+ {activeBrowserPage ? ( +
+ +
+ ) : null} +
+ ) +} + +function BrowserPagePane({ + browserTab, + workspaceId, + worktreeId, + sessionProfileId, + isActive, onUpdatePageState, onSetUrl }: { - browserTab: BrowserTabState + browserTab: BrowserPageState + workspaceId: string + worktreeId: string + sessionProfileId: string | null + isActive: boolean onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void onSetUrl: (tabId: string, url: string) => void }): React.JSX.Element { @@ -271,14 +361,89 @@ export default function BrowserPane({ const browserTabUrlRef = useRef(browserTab.url) const activeLoadFailureRef = useRef(browserTab.loadError) const trackNextLoadingEventRef = useRef(false) + // Why: tracks the most recent URL the webview has navigated to or been + // observed at, from any source (navigation events, address bar, initial + // load). The URL sync effect checks this ref to avoid force-navigating + // the webview to an intermediate redirect URL — which would restart the + // redirect chain and cause an infinite loop. + const lastKnownWebviewUrlRef = useRef(null) const onUpdatePageStateRef = useRef(onUpdatePageState) const onSetUrlRef = useRef(onSetUrl) const [addressBarValue, setAddressBarValue] = useState(browserTab.url) const addressBarValueRef = useRef(browserTab.url) const [resourceNotice, setResourceNotice] = useState(null) + const [downloadState, setDownloadState] = useState(null) + const downloadStateRef = useRef(null) + const [contextMenu, setContextMenu] = useState<{ + x: number + y: number + linkUrl: string | null + pageUrl: string + } | null>(null) const grab = useGrabMode(browserTab.id) + const createBrowserTab = useAppStore((s) => s.createBrowserTab) const consumeAddressBarFocusRequest = useAppStore((s) => s.consumeAddressBarFocusRequest) + const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl) + const setBrowserDefaultUrl = useAppStore((s) => s.setBrowserDefaultUrl) + const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) + const sessionProfile = sessionProfileId + ? (browserSessionProfiles.find((p) => p.id === sessionProfileId) ?? null) + : null + const webviewPartition = sessionProfile?.partition ?? ORCA_BROWSER_PARTITION + const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) + const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) + const clearBrowserSessionImportState = useAppStore((s) => s.clearBrowserSessionImportState) + + useEffect(() => { + if (!browserSessionImportState) { + return + } + if (browserSessionImportState.status === 'success' && browserSessionImportState.summary) { + const { importedCookies, domains } = browserSessionImportState.summary + const domainPreview = domains.slice(0, 3).join(', ') + const more = domains.length > 3 ? ` +${domains.length - 3} more` : '' + setResourceNotice( + `Imported ${importedCookies} cookies for ${domainPreview}${more}. Reload the page to use them.` + ) + clearBrowserSessionImportState() + } else if (browserSessionImportState.status === 'error' && browserSessionImportState.error) { + setResourceNotice(`Cookie import failed: ${browserSessionImportState.error}`) + clearBrowserSessionImportState() + } + }, [browserSessionImportState, clearBrowserSessionImportState]) + + useEffect(() => { + if (!resourceNotice) { + return + } + const timer = setTimeout(() => setResourceNotice(null), 10_000) + return () => clearTimeout(timer) + }, [resourceNotice]) + const keepAddressBarFocusRef = useRef(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [homePageDraft, setHomePageDraft] = useState('') + + const saveHomePage = useCallback(() => { + const trimmed = homePageDraft.trim() + if (!trimmed) { + // Why: empty input treated as "clear" so the user can remove a home page + // without having to click the separate Clear button. + setBrowserDefaultUrl(null) + } else { + const normalized = normalizeBrowserNavigationUrl(trimmed) + if (normalized && normalized !== ORCA_BROWSER_BLANK_URL) { + setBrowserDefaultUrl(normalized) + } + // Why: if the URL is not navigable (e.g. plain text with no scheme), + // leave the draft as-is so the user can correct it rather than silently + // discarding their input. + if (!normalized || normalized === ORCA_BROWSER_BLANK_URL) { + return + } + } + setSettingsOpen(false) + }, [homePageDraft, setBrowserDefaultUrl]) // Inline toast that appears near the grabbed element instead of the global // bottom-right toaster, so feedback feels spatially connected to the action. @@ -299,6 +464,15 @@ export default function BrowserPane({ useEffect(() => { return () => clearTimeout(grabToastTimerRef.current) }, []) + + // Why: populate the home-page draft from the stored value each time the + // settings dialog opens so the user sees the current setting pre-filled + // rather than an empty field or a stale in-memory edit. + useEffect(() => { + if (settingsOpen) { + setHomePageDraft(browserDefaultUrl ?? '') + } + }, [settingsOpen, browserDefaultUrl]) const grabRef = useRef(grab) grabRef.current = grab @@ -356,6 +530,10 @@ export default function BrowserPane({ } }, [grab.state, grab.payload, grab.contextMenu, showGrabToast]) + useEffect(() => { + initialBrowserUrlRef.current = browserTab.url + }, [browserTab.id, browserTab.url]) + useEffect(() => { setAddressBarValue(toDisplayUrl(browserTab.url)) }, [browserTab.url]) @@ -372,14 +550,109 @@ export default function BrowserPane({ addressBarValueRef.current = addressBarValue }, [addressBarValue]) + useEffect(() => { + downloadStateRef.current = downloadState + }, [downloadState]) + useEffect(() => { setResourceNotice( consumeEvictedBrowserTab(browserTab.id) ? 'This tab reloaded to free browser resources.' : null ) + setDownloadState(null) }, [browserTab.id]) + useEffect(() => { + return window.api.browser.onPermissionDenied((event) => { + if (event.browserPageId !== browserTab.id) { + return + } + setResourceNotice(formatPermissionNotice(event)) + }) + }, [browserTab.id]) + + useEffect(() => { + return window.api.browser.onPopup((event) => { + if (event.browserPageId !== browserTab.id) { + return + } + setResourceNotice(formatPopupNotice(event)) + }) + }, [browserTab.id]) + + useEffect(() => { + return window.api.browser.onContextMenuRequested((event) => { + if (event.browserPageId !== browserTab.id) { + return + } + setContextMenu({ + x: event.x, + y: event.y, + linkUrl: event.linkUrl, + pageUrl: event.pageUrl + }) + }) + }, [browserTab.id]) + + useEffect(() => { + return window.api.browser.onContextMenuDismissed((event) => { + if (event.browserPageId !== browserTab.id) { + return + } + setContextMenu(null) + }) + }, [browserTab.id]) + + useEffect(() => { + return window.api.browser.onDownloadRequested((event) => { + if (event.browserPageId !== browserTab.id) { + return + } + // Why: downloads are approved per browser tab, not globally. Keep the + // request local to the owning BrowserPane so the user can see which page + // triggered the save prompt before Orca asks main to choose a path. + setDownloadState({ + ...event, + receivedBytes: 0, + status: 'requested' + }) + setResourceNotice(null) + }) + }, [browserTab.id]) + + useEffect(() => { + return window.api.browser.onDownloadProgress((event: BrowserDownloadProgressEvent) => { + setDownloadState((current) => { + if (!current || current.downloadId !== event.downloadId) { + return current + } + return { + ...current, + receivedBytes: event.receivedBytes, + totalBytes: event.totalBytes, + status: 'downloading' + } + }) + }) + }, []) + + useEffect(() => { + return window.api.browser.onDownloadFinished((event: BrowserDownloadFinishedEvent) => { + const current = downloadStateRef.current + if (!current || current.downloadId !== event.downloadId) { + return + } + setDownloadState((current) => { + if (!current || current.downloadId !== event.downloadId) { + return current + } + return null + }) + setResourceNotice(formatDownloadFinishedNotice(event)) + }) + }, []) + const focusAddressBarNow = useCallback(() => { const input = addressBarInputRef.current if (!input) { @@ -434,6 +707,66 @@ export default function BrowserPane({ } }, [browserTab.id, consumeAddressBarFocusRequest, focusAddressBarNow]) + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onFocusBrowserAddressBar(() => { + focusAddressBarNow() + }) + }, [focusAddressBarNow, isActive]) + + // Cmd/Ctrl+R — reload (renderer path: focus on browser chrome, not in guest) + // Why: when focus is inside the renderer chrome (address bar, toolbar buttons) + // rather than the webview guest, the guest shortcut forwarding in main never + // fires. Handle the chord directly here so reload works regardless of where + // focus sits within the browser pane. + useEffect(() => { + if (!isActive) { + return + } + const handleKeyDown = (e: KeyboardEvent): void => { + const isMod = navigator.userAgent.includes('Mac') ? e.metaKey : e.ctrlKey + if (!isMod || e.altKey || e.key.toLowerCase() !== 'r') { + return + } + if (isEditableKeyboardTarget(e.target)) { + return + } + e.preventDefault() + e.stopPropagation() + if (e.shiftKey) { + webviewRef.current?.reloadIgnoringCache() + } else { + webviewRef.current?.reload() + } + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [isActive]) + + // Cmd/Ctrl+R — reload (IPC path: focus inside webview guest) + // Why: a focused webview guest is a separate Chromium process so the renderer + // keydown handler above never fires. Main intercepts the chord and sends it + // back here so reload works whether focus is on the toolbar or the page. + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onReloadBrowserPage(() => { + webviewRef.current?.reload() + }) + }, [isActive]) + + useEffect(() => { + if (!isActive) { + return + } + return window.api.ui.onHardReloadBrowserPage(() => { + webviewRef.current?.reloadIgnoringCache() + }) + }, [isActive]) + useEffect(() => { onUpdatePageStateRef.current = onUpdatePageState onSetUrlRef.current = onSetUrl @@ -443,7 +776,10 @@ export default function BrowserPane({ (webview: Electron.WebviewTag): void => { try { onUpdatePageStateRef.current(browserTab.id, { - title: webview.getTitle() || webview.getURL() || 'Browser', + title: getBrowserDisplayTitle( + webview.getTitle(), + webview.getURL() || browserTabUrlRef.current + ), // Why: webview reclaim/attach can transiently report isLoading() even // when no user-visible navigation happened. If we sync that into the // tab model on every activation, switching tabs flashes the blue @@ -461,6 +797,12 @@ export default function BrowserPane({ [browserTab.id] ) + // Why: this effect manages the full lifecycle of the webview DOM element — + // creation, parking, event wiring, and teardown. browserTab.url is + // intentionally excluded — it changes on every navigation, and including it + // would destroy and recreate the webview on every page load. URL-dependent + // logic inside the effect reads from browserTabUrlRef instead. + // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment above useEffect(() => { const container = containerRef.current if (!container) { @@ -473,9 +815,15 @@ export default function BrowserPane({ container.appendChild(webview) parkedAtByTabId.delete(browserTab.id) syncNavigationState(webview) + // Why: seed the ref with the store URL so the URL sync effect does not + // force-navigate a reclaimed webview that is already on the right page. + // getURL() can throw briefly during reattach, so use the store URL which + // was set by the last navigation event before parking. + lastKnownWebviewUrlRef.current = + normalizeBrowserNavigationUrl(browserTabUrlRef.current) ?? null } else { webview = document.createElement('webview') as Electron.WebviewTag - webview.setAttribute('partition', ORCA_BROWSER_PARTITION) + webview.setAttribute('partition', webviewPartition) webview.setAttribute('allowpopups', '') webview.style.display = 'flex' webview.style.flex = '1' @@ -495,7 +843,8 @@ export default function BrowserPane({ if (registeredWebContentsIds.get(browserTab.id) !== webContentsId) { registeredWebContentsIds.set(browserTab.id, webContentsId) void window.api.browser.registerGuest({ - browserTabId: browserTab.id, + browserPageId: browserTab.id, + workspaceId, webContentsId }) } @@ -546,7 +895,7 @@ export default function BrowserPane({ // already knows this exact load failed. onUpdatePageStateRef.current(browserTab.id, { loading: false, - title: webview.getTitle() || currentUrl, + title: getBrowserDisplayTitle(webview.getTitle(), currentUrl), faviconUrl: faviconUrlRef.current, canGoBack: webview.canGoBack(), canGoForward: webview.canGoForward(), @@ -557,6 +906,7 @@ export default function BrowserPane({ } trackNextLoadingEventRef.current = false activeLoadFailureRef.current = null + lastKnownWebviewUrlRef.current = normalizeBrowserNavigationUrl(currentUrl) ?? currentUrl rememberLiveBrowserUrl(browserTab.id, currentUrl) setAddressBarValue(toDisplayUrl(currentUrl)) onSetUrlRef.current(browserTab.id, currentUrl) @@ -567,7 +917,7 @@ export default function BrowserPane({ } onUpdatePageStateRef.current(browserTab.id, { loading: false, - title: webview.getTitle() || currentUrl, + title: getBrowserDisplayTitle(webview.getTitle(), currentUrl), faviconUrl: faviconUrlRef.current, canGoBack: webview.canGoBack(), canGoForward: webview.canGoForward(), @@ -583,6 +933,7 @@ export default function BrowserPane({ if (isChromiumErrorPage(currentUrl)) { return } + lastKnownWebviewUrlRef.current = normalizeBrowserNavigationUrl(currentUrl) ?? currentUrl rememberLiveBrowserUrl(browserTab.id, currentUrl) setAddressBarValue(toDisplayUrl(currentUrl)) onSetUrlRef.current(browserTab.id, currentUrl) @@ -594,9 +945,13 @@ export default function BrowserPane({ } const handleTitleUpdate = (event: { title?: string }): void => { - onUpdatePageStateRef.current(browserTab.id, { - title: event.title ?? webview.getURL() ?? 'Browser' - }) + try { + onUpdatePageStateRef.current(browserTab.id, { + title: getBrowserDisplayTitle(event.title, webview.getURL() || browserTab.url) + }) + } catch { + // Why: title-updated can fire before dom-ready, making getURL() throw. + } } const handleFaviconUpdate = (event: { favicons?: string[] }): void => { @@ -651,11 +1006,11 @@ export default function BrowserPane({ // Only non-blank initial tabs should light up Orca's loading indicator; // reclaiming/activating a parked about:blank tab is not a meaningful // navigation and should not flash the tab-loading dot. - trackNextLoadingEventRef.current = - (normalizeBrowserNavigationUrl(initialBrowserUrlRef.current) ?? ORCA_BROWSER_BLANK_URL) !== - ORCA_BROWSER_BLANK_URL - webview.src = + const initialUrl = normalizeBrowserNavigationUrl(initialBrowserUrlRef.current) ?? ORCA_BROWSER_BLANK_URL + trackNextLoadingEventRef.current = initialUrl !== ORCA_BROWSER_BLANK_URL + lastKnownWebviewUrlRef.current = initialUrl + webview.src = initialUrl } return () => { @@ -678,7 +1033,20 @@ export default function BrowserPane({ evictParkedWebviews(browserTab.id) } } - }, [browserTab.id, focusAddressBarNow, focusWebviewNow, syncNavigationState]) + // Why: this effect mounts and wires up webview event listeners once per tab + // identity. browserTab.url and webviewPartition are intentionally excluded: + // re-running on URL changes would detach/reattach the webview, cancelling + // in-progress navigations. Callbacks use refs so they always see current values. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + browserTab.id, + workspaceId, + worktreeId, + createBrowserTab, + focusAddressBarNow, + focusWebviewNow, + syncNavigationState + ]) useEffect(() => { const webview = webviewRef.current @@ -689,11 +1057,36 @@ export default function BrowserPane({ if (!normalizedUrl) { return } - if (webview.src !== normalizedUrl && webview.getAttribute('src') !== normalizedUrl) { + // Why: navigation events (did-navigate, did-stop-loading) update both the + // store URL and this ref to the same value. If they match, the store URL + // change came from a navigation event — not a user action — so there is + // nothing to navigate to. Skipping here prevents the sync effect from + // force-navigating the webview back to an intermediate redirect URL, which + // would restart the redirect chain and cause an infinite loop. + if (lastKnownWebviewUrlRef.current === normalizedUrl) { + return + } + let liveUrl: string | null = null + try { + liveUrl = webview.getURL() || null + } catch { + // Why: reattached parked guests can briefly reject getURL() before the + // underlying guest is fully ready again. Skip entirely so we do not + // misinterpret a transient error as a URL mismatch and force-navigate. + return + } + const normalizedLiveUrl = liveUrl ? (normalizeBrowserNavigationUrl(liveUrl) ?? liveUrl) : null + const declaredSrc = webview.getAttribute('src') + if ( + normalizedLiveUrl !== normalizedUrl && + webview.src !== normalizedUrl && + declaredSrc !== normalizedUrl + ) { // Why: browserTab.url changes are Orca-driven navigations (address bar, // terminal link open, retry target update). Gate the next did-start-loading // event so only real navigations, not tab activation churn, show loading UI. trackNextLoadingEventRef.current = normalizedUrl !== ORCA_BROWSER_BLANK_URL + lastKnownWebviewUrlRef.current = normalizedUrl webview.src = normalizedUrl if (normalizedUrl !== ORCA_BROWSER_BLANK_URL) { keepAddressBarFocusRef.current = false @@ -752,6 +1145,12 @@ export default function BrowserPane({ // handled by the guest page itself (Chromium's built-in Cmd+C) and never // reaches the host renderer keydown listener. useEffect(() => { + // Why: without the isActive gate, every mounted BrowserPagePane registers + // a global keydown listener, so Cmd+C would toggle grab mode on all panes + // simultaneously — not just the active one. + if (!isActive) { + return + } const handleKeyDown = (e: KeyboardEvent): void => { // Why: let native Cmd+C work in text inputs (address bar, search fields, // contentEditable regions). Only intercept when focus is on a non-input @@ -767,7 +1166,27 @@ export default function BrowserPane({ } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [grab]) + }, [grab, isActive]) + + useEffect(() => { + if (!isActive) { + return + } + const handleKeyDown = (e: KeyboardEvent): void => { + const isMod = navigator.userAgent.includes('Mac') ? e.metaKey : e.ctrlKey + if (!isMod || e.shiftKey || e.altKey || e.key.toLowerCase() !== 'l') { + return + } + // Why: Cmd/Ctrl+L is a browser-local focus command. Capture it before + // the surrounding workspace or any embedded editor surface can treat the + // same chord as something else. + e.preventDefault() + e.stopPropagation() + focusAddressBarNow() + } + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [focusAddressBarNow, isActive]) // Why: a focused webview guest receives Cmd/Ctrl+C inside Chromium, not the // host renderer window. Main forwards the chord back only when the page @@ -827,7 +1246,7 @@ export default function BrowserPane({ // armed/awaiting — extract hovered element via IPC without clicking void (async () => { const result = await window.api.browser.extractHoverPayload({ - browserTabId: browserTabIdRef.current + browserPageId: browserTabIdRef.current }) if (!result.ok) { showGrabToast('No element hovered', 'error') @@ -838,7 +1257,7 @@ export default function BrowserPane({ if (key === 's') { try { const ssResult = await window.api.browser.captureSelectionScreenshot({ - browserTabId: browserTabIdRef.current, + browserPageId: browserTabIdRef.current, rect: payload.target.rectViewport }) if (ssResult.ok) { @@ -884,8 +1303,8 @@ export default function BrowserPane({ if (grab.state === 'idle' || grab.state === 'error') { return } - return window.api.browser.onGrabActionShortcut(({ browserTabId, key }) => { - if (browserTabId !== browserTab.id) { + return window.api.browser.onGrabActionShortcut(({ browserPageId, key }) => { + if (browserPageId !== browserTab.id) { return } handleGrabActionShortcut(key) @@ -941,7 +1360,11 @@ export default function BrowserPane({ setAddressBarValue(toDisplayUrl(nextUrl)) onSetUrlRef.current(browserTab.id, nextUrl) - onUpdatePageStateRef.current(browserTab.id, { loading: true, loadError: null, title: nextUrl }) + onUpdatePageStateRef.current(browserTab.id, { + loading: true, + loadError: null, + title: getBrowserDisplayTitle(nextUrl, nextUrl) + }) setResourceNotice(null) const webview = webviewRef.current @@ -949,6 +1372,7 @@ export default function BrowserPane({ return } trackNextLoadingEventRef.current = nextUrl !== ORCA_BROWSER_BLANK_URL + lastKnownWebviewUrlRef.current = nextUrl webview.src = nextUrl if (nextUrl !== ORCA_BROWSER_BLANK_URL) { focusWebviewNow() @@ -960,8 +1384,24 @@ export default function BrowserPane({ // Match both so the "New Browser Tab" overlay stays visible for blank tabs. const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) + const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url) const loadErrorMeta = getLoadErrorMetadata(browserTab.loadError) + const loadErrorHint = formatLoadFailureRecoveryHint(loadErrorMeta) const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab + const downloadProgressLabel = (() => { + if (!downloadState) { + return null + } + const received = formatByteCount(downloadState.receivedBytes) + const total = formatByteCount(downloadState.totalBytes) + if (received && total) { + return `${received} / ${total}` + } + if (total) { + return total + } + return received + })() useEffect(() => { const webview = webviewRef.current @@ -976,12 +1416,251 @@ export default function BrowserPane({ }, [showFailureOverlay]) return ( -
-
+
+ {/* IPC-driven context menu — main intercepts guest right-clicks and sends + the event here so Orca can offer link actions that require renderer/store access. */} + { + if (!open) { + setContextMenu(null) + } + }} + modal={false} + > + + + ))} + +
+ {sessionProfile && ( + + )} +
+ + + + + + +
{ event.preventDefault() submitAddressBar() @@ -1054,8 +1733,8 @@ export default function BrowserPane({ + + {sessionProfile && ( +
+ + {sessionProfile.label} +
+ )} + +
+ {downloadState ? ( +
+
+
{downloadState.filename}
+
+ {downloadState.status === 'requested' + ? `Download from ${downloadState.origin}` + : `Downloading from ${downloadState.origin}${downloadProgressLabel ? ` • ${downloadProgressLabel}` : ''}`} +
+
+ {downloadState.status === 'requested' ? ( + <> + + + + ) : ( + + {downloadProgressLabel ?? 'Downloading'} + + )} +
+ ) : null} {resourceNotice ? ( -
- {resourceNotice} +
+ {resourceNotice} +
) : null} {grab.state !== 'idle' ? ( @@ -1107,8 +1861,8 @@ export default function BrowserPane({ {grab.state === 'error' ? `Grab failed: ${grab.error ?? 'Unknown error'}` : grab.state === 'confirming' - ? 'Copied — press S for screenshot, or click another element' - : 'Click to copy, or hover and press C. S for screenshot.'} + ? 'Copied — press S to screenshot, or select another element' + : 'Click or hover an element, then press C to copy or S to screenshot.'} + + {externalUrl ? ( + + ) : null}
@@ -1165,7 +1959,7 @@ export default function BrowserPane({
-

New Browser Tab

+

New Tab

Type a URL above to start browsing.

diff --git a/src/renderer/src/components/browser-pane/browser-notices.test.ts b/src/renderer/src/components/browser-pane/browser-notices.test.ts new file mode 100644 index 00000000000..b08bca7ba3c --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-notices.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { + formatByteCount, + formatDownloadFinishedNotice, + formatLoadFailureDescription, + formatLoadFailureRecoveryHint, + formatPermissionNotice, + formatPopupNotice +} from './browser-notices' + +describe('browser notice formatting', () => { + it('formats denied permissions with safe copy', () => { + expect( + formatPermissionNotice({ + browserPageId: 'browser-1', + permission: 'media', + origin: 'https://example.com' + }) + ).toBe('https://example.com asked for camera or microphone access, and Orca denied it.') + }) + + it('formats popup outcomes', () => { + expect( + formatPopupNotice({ + browserPageId: 'browser-1', + origin: 'https://example.com', + action: 'opened-in-orca' + }) + ).toBe('https://example.com opened a new page in Orca.') + + expect( + formatPopupNotice({ + browserPageId: 'browser-1', + origin: 'https://example.com', + action: 'opened-external' + }) + ).toBe('https://example.com opened a new window in your default browser.') + + expect( + formatPopupNotice({ + browserPageId: 'browser-1', + origin: 'unknown', + action: 'blocked' + }) + ).toBe('A site tried to open a popup Orca does not support here.') + }) + + it('formats download completion and byte counts', () => { + expect( + formatDownloadFinishedNotice({ + downloadId: 'download-1', + status: 'completed', + savePath: '/tmp/report.csv', + error: null + }) + ).toBe('Downloaded to /tmp/report.csv.') + + expect( + formatDownloadFinishedNotice({ + downloadId: 'download-2', + status: 'failed', + savePath: null, + error: 'Download failed.' + }) + ).toBe('Download failed.') + + expect(formatByteCount(512)).toBe('512 B') + expect(formatByteCount(1024)).toBe('1.0 KB') + expect(formatByteCount(5 * 1024 * 1024)).toBe('5.0 MB') + }) + + it('formats load failure copy for localhost and remote pages', () => { + expect( + formatLoadFailureDescription( + { + code: -102, + description: 'ERR_CONNECTION_REFUSED', + validatedUrl: 'http://localhost:3000' + }, + { + host: 'localhost:3000', + isLocalhostLike: true + } + ) + ).toBe("We couldn't connect to your local server.") + + expect( + formatLoadFailureRecoveryHint({ + host: 'localhost:3000', + isLocalhostLike: true + }) + ).toBe( + 'If this should be a local app, make sure the server is running and listening on the expected port.' + ) + + expect( + formatLoadFailureDescription( + { + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + validatedUrl: 'https://example.com' + }, + { + host: 'example.com', + isLocalhostLike: false + } + ) + ).toBe("We couldn't connect to this page.") + + expect( + formatLoadFailureRecoveryHint({ + host: 'example.com', + isLocalhostLike: false + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-notices.ts b/src/renderer/src/components/browser-pane/browser-notices.ts new file mode 100644 index 00000000000..81e31c8d81b --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-notices.ts @@ -0,0 +1,90 @@ +import type { + BrowserDownloadFinishedEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../../../shared/browser-guest-events' +import type { BrowserLoadError } from '../../../../shared/types' + +type LoadFailureMeta = { + host: string | null + isLocalhostLike: boolean +} + +type BrowserLoadErrorLike = BrowserLoadError | null + +function humanizePermission(permission: string): string { + switch (permission) { + case 'media': + return 'camera or microphone access' + case 'pointerLock': + return 'pointer lock' + default: + return permission + } +} + +export function formatPermissionNotice(event: BrowserPermissionDeniedEvent): string { + const target = event.origin === 'unknown' ? 'this page' : event.origin + return `${target} asked for ${humanizePermission(event.permission)}, and Orca denied it.` +} + +export function formatPopupNotice(event: BrowserPopupEvent): string { + const target = event.origin === 'unknown' ? 'A site' : event.origin + if (event.action === 'opened-in-orca') { + return `${target} opened a new page in Orca.` + } + if (event.action === 'opened-external') { + return `${target} opened a new window in your default browser.` + } + return `${target} tried to open a popup Orca does not support here.` +} + +export function formatDownloadFinishedNotice(event: BrowserDownloadFinishedEvent): string { + if (event.status === 'completed') { + return event.savePath ? `Downloaded to ${event.savePath}.` : 'Download complete.' + } + if (event.status === 'failed') { + return event.error ?? 'Download failed.' + } + return event.error ?? 'Download canceled.' +} + +export function formatByteCount(bytes: number | null): string | null { + if (bytes == null || !Number.isFinite(bytes) || bytes < 0) { + return null + } + if (bytes < 1024) { + return `${bytes} B` + } + const units = ['KB', 'MB', 'GB', 'TB'] + let value = bytes / 1024 + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}` +} + +export function formatLoadFailureDescription( + loadError: BrowserLoadErrorLike, + meta: LoadFailureMeta +): string { + if (!loadError) { + return 'The page did not respond.' + } + if (meta.isLocalhostLike) { + return "We couldn't connect to your local server." + } + if (loadError.code === 0) { + return loadError.description + } + return "We couldn't connect to this page." +} + +export function formatLoadFailureRecoveryHint(meta: LoadFailureMeta): string | null { + if (!meta.isLocalhostLike) { + return null + } + return 'If this should be a local app, make sure the server is running and listening on the expected port.' +} diff --git a/src/renderer/src/components/browser-pane/useGrabMode.ts b/src/renderer/src/components/browser-pane/useGrabMode.ts index 8289c0d4d44..aba9c4b700e 100644 --- a/src/renderer/src/components/browser-pane/useGrabMode.ts +++ b/src/renderer/src/components/browser-pane/useGrabMode.ts @@ -31,40 +31,40 @@ function nextOpId(): string { } /** - * Hook that drives the browser grab lifecycle for a single browser tab. + * Hook that drives the browser grab lifecycle for a single browser page. * * The state machine: idle → armed → awaiting → confirming → idle/armed * ↘ error → idle */ -export function useGrabMode(browserTabId: string): GrabModeHook { +export function useGrabMode(browserPageId: string): GrabModeHook { const [state, setState] = useState('idle') const [payload, setPayload] = useState(null) const [error, setError] = useState(null) const [contextMenu, setContextMenu] = useState(false) const activeOpIdRef = useRef(null) - const browserTabIdRef = useRef(browserTabId) + const browserTabIdRef = useRef(browserPageId) useEffect(() => { - browserTabIdRef.current = browserTabId - }, [browserTabId]) + browserTabIdRef.current = browserPageId + }, [browserPageId]) - // Why: when the browser tab changes while grab is active, cancel the + // Why: when the browser page changes while grab is active, cancel the // current grab operation so stale overlays don't survive tab switches. useEffect(() => { return () => { if (activeOpIdRef.current) { - void window.api.browser.cancelGrab({ browserTabId }) + void window.api.browser.cancelGrab({ browserPageId }) activeOpIdRef.current = null } } - }, [browserTabId]) + }, [browserPageId]) const armAndAwait = useCallback(async () => { const tabId = browserTabIdRef.current // Enable grab mode — injects the overlay const setResult = await window.api.browser.setGrabMode({ - browserTabId: tabId, + browserPageId: tabId, enabled: true }) if (!setResult.ok) { @@ -81,7 +81,7 @@ export function useGrabMode(browserTabId: string): GrabModeHook { setState('awaiting') const result = await window.api.browser.awaitGrabSelection({ - browserTabId: tabId, + browserPageId: tabId, opId }) @@ -97,7 +97,7 @@ export function useGrabMode(browserTabId: string): GrabModeHook { let screenshot: BrowserGrabScreenshot | null = null try { const ssResult = await window.api.browser.captureSelectionScreenshot({ - browserTabId: tabId, + browserPageId: tabId, rect: result.payload.target.rectViewport }) if (ssResult.ok) { @@ -128,12 +128,12 @@ export function useGrabMode(browserTabId: string): GrabModeHook { } else { // Disable grab mode void window.api.browser.setGrabMode({ - browserTabId: browserTabIdRef.current, + browserPageId: browserTabIdRef.current, enabled: false }) if (activeOpIdRef.current) { void window.api.browser.cancelGrab({ - browserTabId: browserTabIdRef.current + browserPageId: browserTabIdRef.current }) activeOpIdRef.current = null } @@ -146,12 +146,12 @@ export function useGrabMode(browserTabId: string): GrabModeHook { const cancel = useCallback(() => { void window.api.browser.setGrabMode({ - browserTabId: browserTabIdRef.current, + browserPageId: browserTabIdRef.current, enabled: false }) if (activeOpIdRef.current) { void window.api.browser.cancelGrab({ - browserTabId: browserTabIdRef.current + browserPageId: browserTabIdRef.current }) activeOpIdRef.current = null } @@ -177,7 +177,7 @@ export function useGrabMode(browserTabId: string): GrabModeHook { const exit = useCallback(() => { void window.api.browser.setGrabMode({ - browserTabId: browserTabIdRef.current, + browserPageId: browserTabIdRef.current, enabled: false }) // Why: clear the active opId so that any in-flight result from the diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index b4456b8ccd5..2fcb5f68b1a 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -31,9 +31,14 @@ type FileContent = { type DiffContent = GitDiffResult -export default function EditorPanel(): React.JSX.Element | null { +export default function EditorPanel({ + activeFileId: activeFileIdProp +}: { + activeFileId?: string | null +} = {}): React.JSX.Element | null { const openFiles = useAppStore((s) => s.openFiles) - const activeFileId = useAppStore((s) => s.activeFileId) + const globalActiveFileId = useAppStore((s) => s.activeFileId) + const activeFileId = activeFileIdProp ?? globalActiveFileId const markFileDirty = useAppStore((s) => s.markFileDirty) const pendingEditorReveal = useAppStore((s) => s.pendingEditorReveal) const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 772974af8ac..0390fb49b9e 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -8,15 +8,17 @@ import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Separator } from '../ui/separator' -import { Download, FolderOpen, Loader2, Plus, RefreshCw, Timer, Trash2 } from 'lucide-react' +import { Download, FolderOpen, Import, Loader2, Plus, RefreshCw, Timer, Trash2 } from 'lucide-react' import { useAppStore } from '../../store' import { CliSection } from './CliSection' import { toast } from 'sonner' import { DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, MAX_EDITOR_AUTO_SAVE_DELAY_MS, - MIN_EDITOR_AUTO_SAVE_DELAY_MS + MIN_EDITOR_AUTO_SAVE_DELAY_MS, + ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url' import { clampNumber } from '@/lib/terminal-theme' import { GENERAL_BROWSER_SEARCH_ENTRIES, @@ -28,6 +30,13 @@ import { GENERAL_UPDATE_SEARCH_ENTRIES, GENERAL_WORKSPACE_SEARCH_ENTRIES } from './general-search' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { matchesSettingsSearch } from './settings-search' @@ -96,6 +105,14 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea const searchQuery = useAppStore((s) => s.settingsSearchQuery) const updateStatus = useAppStore((s) => s.updateStatus) const fetchSettings = useAppStore((s) => s.fetchSettings) + const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl) + const setBrowserDefaultUrl = useAppStore((s) => s.setBrowserDefaultUrl) + const detectedBrowsers = useAppStore((s) => s.detectedBrowsers) + const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles) + const browserSessionImportState = useAppStore((s) => s.browserSessionImportState) + const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default') + const orphanedProfiles = browserSessionProfiles.filter((p) => p.scope !== 'default') + const [homePageDraft, setHomePageDraft] = useState(browserDefaultUrl ?? '') const [appVersion, setAppVersion] = useState(null) const [autoSaveDelayDraft, setAutoSaveDelayDraft] = useState( String(settings.editorAutoSaveDelayMs) @@ -300,15 +317,59 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea

Browser

- Control how Orca handles links from the terminal. + Control how Orca handles links and browser workspace defaults.

+ +
+ +

+ URL opened when creating a new browser tab. Leave empty to open a blank tab. +

+
+ { + e.preventDefault() + const trimmed = homePageDraft.trim() + if (!trimmed) { + setBrowserDefaultUrl(null) + return + } + const normalized = normalizeBrowserNavigationUrl(trimmed) + if (normalized && normalized !== ORCA_BROWSER_BLANK_URL) { + setBrowserDefaultUrl(normalized) + setHomePageDraft(normalized) + toast.success('Home page saved.') + } + }} + > + setHomePageDraft(e.target.value)} + placeholder="https://google.com" + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + className="h-7 w-52 text-xs" + /> + + +
+
@@ -317,6 +378,170 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea in your system browser.

+ +
+ + +
+
+ +

+ Import cookies from your system browser to reuse existing logins inside Orca. +

+
+ + + + + + {detectedBrowsers.map((browser) => ( + { + const store = useAppStore.getState() + const result = await store.importCookiesFromBrowser('default', browser.family) + if (result.ok) { + toast.success( + `Imported ${result.summary.importedCookies} cookies from ${browser.label}.` + ) + } else { + toast.error(result.reason) + } + }} + > + From {browser.label} + + ))} + {detectedBrowsers.length > 0 && } + { + const store = useAppStore.getState() + const result = await store.importCookiesToProfile('default') + if (result.ok) { + toast.success(`Imported ${result.summary.importedCookies} cookies from file.`) + } else if (result.reason !== 'canceled') { + toast.error(result.reason) + } + }} + > + From File… + + + +
+ + {defaultProfile?.source ? ( +
+
+ + Imported from {defaultProfile.source.browserFamily} + {defaultProfile.source.profileName + ? ` (${defaultProfile.source.profileName})` + : ''} + + {defaultProfile.source.importedAt ? ( + + {new Date(defaultProfile.source.importedAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + })} + + ) : null} +
+ +
+ ) : null} + + {orphanedProfiles.length > 0 ? ( +
+ {orphanedProfiles.map((profile) => ( +
+
+ {profile.label} + + {profile.source + ? `Imported from ${profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}` + : 'Unused session'} + +
+ +
+ ))} +
+ ) : null}
) : null, diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts index 8f69f63dc3f..ae1521845bb 100644 --- a/src/renderer/src/components/settings/general-search.ts +++ b/src/renderer/src/components/settings/general-search.ts @@ -61,11 +61,33 @@ export const GENERAL_CACHE_TIMER_SEARCH_ENTRIES: SettingsSearchEntry[] = [ ] export const GENERAL_BROWSER_SEARCH_ENTRIES: SettingsSearchEntry[] = [ + { + title: 'Default Home Page', + description: 'URL opened when creating a new browser tab. Leave empty to open a blank tab.', + keywords: ['browser', 'home', 'homepage', 'default', 'url', 'new tab', 'blank', 'landing'] + }, { title: 'Terminal Link Routing', description: 'Cmd/Ctrl+click opens terminal http(s) links in Orca. Shift+Cmd/Ctrl+click uses the system browser.', keywords: ['browser', 'preview', 'links', 'localhost', 'webview', 'shift', 'cmd', 'ctrl'] + }, + { + title: 'Session & Cookies', + description: + 'Import cookies from Chrome, Edge, or other browsers to use existing logins inside Orca.', + keywords: [ + 'browser', + 'cookies', + 'session', + 'import', + 'auth', + 'login', + 'chrome', + 'edge', + 'arc', + 'profile' + ] } ] diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 283ba9900c0..15e87f2e6d3 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -15,7 +15,7 @@ import { getLiveBrowserUrl } from '../browser-pane/browser-runtime' function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { - return 'New Browser Tab' + return 'New Tab' } try { const parsed = new URL(url) @@ -34,7 +34,6 @@ function getBrowserTabLabel(tab: BrowserTabState): string { ) { return formatBrowserTabUrlLabel(tab.url) } - return tab.title || tab.url } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 60683920d3f..31136bcca5b 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -8,7 +8,7 @@ import { type DragEndEvent } from '@dnd-kit/core' import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable' -import { Globe, Plus, TerminalSquare } from 'lucide-react' +import { Columns2, Globe, Plus, Rows2, TerminalSquare } from 'lucide-react' import type { BrowserTab as BrowserTabState, TerminalTab, @@ -48,7 +48,7 @@ type TabBarProps = { onSetCustomTitle: (tabId: string, title: string | null) => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePaneExpand: (tabId: string) => void - editorFiles?: OpenFile[] + editorFiles?: (OpenFile & { tabId?: string })[] browserTabs?: BrowserTabState[] activeFileId?: string | null activeBrowserTabId?: string | null @@ -58,13 +58,14 @@ type TabBarProps = { onActivateBrowserTab?: (tabId: string) => void onCloseBrowserTab?: (tabId: string) => void onCloseAllFiles?: () => void - onPinFile?: (fileId: string) => void + onPinFile?: (fileId: string, tabId?: string) => void tabBarOrder?: string[] + onCreateSplitGroup?: (direction: 'right' | 'down') => void } type TabItem = | { type: 'terminal'; id: string; data: TerminalTab } - | { type: 'editor'; id: string; data: OpenFile } + | { type: 'editor'; id: string; data: OpenFile & { tabId?: string } } | { type: 'browser'; id: string; data: BrowserTabState } export default function TabBar({ @@ -93,7 +94,8 @@ export default function TabBar({ onCloseBrowserTab, onCloseAllFiles, onPinFile, - tabBarOrder + tabBarOrder, + onCreateSplitGroup }: TabBarProps): React.JSX.Element { const sensors = useSensors( useSensor(PointerSensor, { @@ -108,14 +110,17 @@ export default function TabBar({ ) const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs]) - const editorMap = useMemo(() => new Map((editorFiles ?? []).map((f) => [f.id, f])), [editorFiles]) + const editorMap = useMemo( + () => new Map((editorFiles ?? []).map((f) => [f.tabId ?? f.id, f])), + [editorFiles] + ) const browserMap = useMemo( () => new Map((browserTabs ?? []).map((t) => [t.id, t])), [browserTabs] ) const terminalIds = useMemo(() => tabs.map((t) => t.id), [tabs]) - const editorFileIds = useMemo(() => editorFiles?.map((f) => f.id) ?? [], [editorFiles]) + const editorFileIds = useMemo(() => editorFiles?.map((f) => f.tabId ?? f.id) ?? [], [editorFiles]) const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs]) // Build the unified ordered list, reconciling stored order with current items @@ -264,14 +269,14 @@ export default function TabBar({ onClose={() => onCloseFile?.(item.id)} onCloseToRight={() => onCloseToRight(item.id)} onCloseAll={() => onCloseAllFiles?.()} - onPin={() => onPinFile?.(item.id)} + onPin={() => onPinFile?.(item.data.id, item.data.tabId)} /> ) })}
- + + )} + + +
+ {groupTabs + .filter((item) => item.contentType === 'terminal') + .map((item) => ( + { + if (consumeSuppressedPtyExit(ptyId)) { + return + } + handleClose(item.id) + }} + onCloseTab={() => handleClose(item.id)} + /> + ))} + + {activeTab && + activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'browser' && ( +
+ {/* Why: split groups render editor/browser content inside a + plain relative pane body instead of the legacy flex column in + Terminal.tsx. Anchoring the surface to `absolute inset-0` + recreates the bounded viewport those panes expect, so plain + overflow containers like MarkdownPreview can actually scroll + instead of expanding to content height. */} + + Loading editor... +
+ } + > + + +
+ )} + + {browserItems.map((bt) => ( +
+ +
+ ))} + + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 066acf184b8..300a23f5b6a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -39,6 +39,7 @@ type TerminalPaneProps = { worktreeId: string cwd?: string isActive: boolean + isVisible?: boolean onPtyExit: (ptyId: string) => void onCloseTab: () => void } @@ -48,6 +49,7 @@ export default function TerminalPane({ worktreeId, cwd, isActive, + isVisible: _isVisible, onPtyExit, onCloseTab }: TerminalPaneProps): React.JSX.Element { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 70696b040f6..ccaee9764fe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -78,7 +78,7 @@ describe('handleOscLink', () => { expect(openUrlMock).not.toHaveBeenCalled() }) - it('uses Orca for cmd/ctrl+click even when the setting is off', () => { + it('routes to the system browser when openLinksInApp is off', () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false } const preventDefault = vi.fn() @@ -90,9 +90,8 @@ describe('handleOscLink', () => { deps ) - expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/') - expect(setActiveWorktreeMock).toHaveBeenCalledWith('wt-1') - expect(openUrlMock).not.toHaveBeenCalled() + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() expect(preventDefault).toHaveBeenCalled() expect(stopPropagation).toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 5337588e319..2b764f7a2a6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -197,10 +197,13 @@ export function handleOscLink( if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { const store = useAppStore.getState() - // Why: terminal URL clicks are now always worktree-scoped by default so - // Cmd/Ctrl+click reliably stays inside Orca's browser. Shift is the only - // escape hatch for opening the same URL in the system browser instead. - if (deps.worktreeId && !event?.shiftKey) { + // Why: openLinksInApp controls whether Cmd/Ctrl+click routes http(s) URLs + // into Orca's embedded browser or passes them to the system browser. + // Shift is always the explicit override to the system browser regardless of + // the setting. Default is true so new installs get in-app routing. + const routeToOrca = + deps.worktreeId && !event?.shiftKey && store.settings?.openLinksInApp !== false + if (routeToOrca) { store.setActiveWorktree(deps.worktreeId) store.createBrowserTab(deps.worktreeId, parsed.toString()) return diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 672a91b9e56..14fd8e329b7 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -157,7 +157,8 @@ describe('useIpcEvents updater integration', () => { onClearDismissal: () => () => {} }, browser: { - onGuestLoadFailed: () => () => {} + onGuestLoadFailed: () => () => {}, + onOpenLinkInOrcaTab: () => () => {} }, rateLimits: { get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b7275820cde..f142867ca85 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { useEffect } from 'react' import { useAppStore } from '../store' import { applyUIZoom } from '@/lib/ui-zoom' @@ -169,8 +170,8 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.browser.onGuestLoadFailed(({ browserTabId, loadError }) => { - useAppStore.getState().updateBrowserTabPageState(browserTabId, { + window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => { + useAppStore.getState().updateBrowserPageState(browserPageId, { loading: false, loadError, canGoBack: false, @@ -179,6 +180,23 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => { + const store = useAppStore.getState() + const sourcePage = Object.values(store.browserPagesByWorkspace) + .flat() + .find((page) => page.id === browserPageId) + if (!sourcePage) { + return + } + // Why: the guest process can request "open this link in Orca", but it + // does not own Orca's worktree/tab model. Resolve the source page's + // worktree and create a new outer browser tab so the link opens as a + // separate tab in the outer Orca tab bar. + store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) + }) + ) + // Shortcut forwarding for embedded browser guests whose webContents // capture keyboard focus and bypass the renderer's window-level keydown. unsubs.push( @@ -186,7 +204,9 @@ export function useIpcEvents(): void { const store = useAppStore.getState() const worktreeId = store.activeWorktreeId if (worktreeId) { - store.createBrowserTab(worktreeId, 'about:blank', { title: 'New Browser Tab' }) + store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', { + title: 'New Browser Tab' + }) } }) ) @@ -228,10 +248,6 @@ export function useIpcEvents(): void { unsubs.push( window.api.ui.onCloseActiveTab(() => { const store = useAppStore.getState() - // Why: this IPC fires only from browser guest webContents, so - // activeTabType is always 'browser'. We intentionally skip the - // editor case — closing dirty editor files requires the save - // confirmation dialog which lives in Terminal.tsx component state. if (store.activeTabType === 'browser' && store.activeBrowserTabId) { store.closeBrowserTab(store.activeBrowserTabId) } diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index d7e77ed38d2..144b2a8b986 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -56,6 +56,23 @@ function createSnapshot(overrides: Partial = {}): AppState { ] }, activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' }, + browserPagesByWorkspace: { + 'browser-1': [ + { + id: 'page-1', + workspaceId: 'browser-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: true, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: Date.now() + } + ] + }, ...overrides } as AppState } diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 6bf20a05871..06f32359e97 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -1,5 +1,6 @@ import type { - BrowserTab, + BrowserPage, + BrowserWorkspace, PersistedOpenFile, WorkspaceSessionState, WorkspaceVisibleTabType @@ -19,7 +20,11 @@ type WorkspaceSessionSnapshot = Pick< | 'activeFileIdByWorktree' | 'activeTabTypeByWorktree' | 'browserTabsByWorktree' + | 'browserPagesByWorkspace' | 'activeBrowserTabIdByWorktree' + | 'unifiedTabsByWorktree' + | 'groupsByWorktree' + | 'activeGroupIdByWorktree' > /** Build the editor-file portion of the workspace session for persistence. @@ -52,9 +57,13 @@ export function buildEditorSessionData( } export function buildBrowserSessionData( - browserTabsByWorktree: Record, + browserTabsByWorktree: Record, + browserPagesByWorkspace: Record, activeBrowserTabIdByWorktree: Record -): Pick { +): Pick< + WorkspaceSessionState, + 'browserTabsByWorktree' | 'browserPagesByWorkspace' | 'activeBrowserTabIdByWorktree' +> { return { // Why: browser tabs persist only lightweight chrome state. Live guest // webContents are recreated on restore, so loading is reset to false and @@ -65,6 +74,12 @@ export function buildBrowserSessionData( tabs.map((tab) => ({ ...tab, loading: false })) ]) ), + browserPagesByWorkspace: Object.fromEntries( + Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [ + workspaceId, + pages.map((page) => ({ ...page, loading: false })) + ]) + ), activeBrowserTabIdByWorktree } } @@ -94,7 +109,10 @@ export function buildWorkspaceSessionPayload( ), ...buildBrowserSessionData( snapshot.browserTabsByWorktree, + snapshot.browserPagesByWorkspace, snapshot.activeBrowserTabIdByWorktree - ) + ), + unifiedTabs: snapshot.unifiedTabsByWorktree, + tabGroups: snapshot.groupsByWorktree } } diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts new file mode 100644 index 00000000000..94277b6d6ac --- /dev/null +++ b/src/renderer/src/store/slices/browser.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { createTestStore, makeTabGroup, makeWorktree, seedStore } from './store-test-helpers' + +describe('browser slice', () => { + it('reopens the most recently closed browser tab in the same worktree', () => { + const store = createTestStore() + const worktreeId = 'repo1::/tmp/wt-1' + seedStore(store, { + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabType: 'browser', + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/tmp/wt-1' + }) + ] + }, + groupsByWorktree: { + [worktreeId]: [ + makeTabGroup({ + id: 'group-1', + worktreeId, + activeTabId: null, + tabOrder: [] + }) + ] + }, + activeGroupIdByWorktree: { + [worktreeId]: 'group-1' + }, + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {} + }) + + const created = store.getState().createBrowserTab(worktreeId, 'https://example.com/docs', { + title: 'Docs' + }) + store.getState().closeBrowserTab(created.id) + + expect(store.getState().browserTabsByWorktree[worktreeId]).toBeUndefined() + expect(store.getState().recentlyClosedBrowserTabsByWorktree[worktreeId]).toHaveLength(1) + + const reopened = store.getState().reopenClosedBrowserTab(worktreeId) + + expect(reopened).not.toBeNull() + expect(reopened?.id).not.toBe(created.id) + expect(reopened?.url).toBe('https://example.com/docs') + expect(reopened?.title).toBe('Docs') + expect(store.getState().browserTabsByWorktree[worktreeId]).toHaveLength(1) + expect(store.getState().recentlyClosedBrowserTabsByWorktree[worktreeId]).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 76d2dd4d165..e44026a2315 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1,12 +1,27 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { BrowserLoadError, BrowserTab, WorkspaceSessionState } from '../../../../shared/types' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary, + BrowserLoadError, + BrowserPage, + BrowserSessionProfile, + BrowserWorkspace, + WorkspaceSessionState +} from '../../../../shared/types' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import { pickNeighbor } from './tabs-helpers' type CreateBrowserTabOptions = { activate?: boolean title?: string + sessionProfileId?: string | null +} + +type CreateBrowserPageOptions = { + activate?: boolean + title?: string } type BrowserTabPageState = { @@ -18,22 +33,64 @@ type BrowserTabPageState = { loadError?: BrowserLoadError | null } +type ClosedBrowserWorkspaceSnapshot = { + workspace: BrowserWorkspace + pages: BrowserPage[] +} + export type BrowserSlice = { - browserTabsByWorktree: Record + browserTabsByWorktree: Record + browserPagesByWorkspace: Record activeBrowserTabId: string | null activeBrowserTabIdByWorktree: Record + recentlyClosedBrowserTabsByWorktree: Record + recentlyClosedBrowserPagesByWorkspace: Record pendingAddressBarFocusByTabId: Record + pendingAddressBarFocusByPageId: Record createBrowserTab: ( worktreeId: string, url: string, options?: CreateBrowserTabOptions - ) => BrowserTab + ) => BrowserWorkspace closeBrowserTab: (tabId: string) => void + reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null setActiveBrowserTab: (tabId: string) => void - consumeAddressBarFocusRequest: (tabId: string) => boolean - updateBrowserTabPageState: (tabId: string, updates: BrowserTabPageState) => void - setBrowserTabUrl: (tabId: string, url: string) => void + createBrowserPage: ( + workspaceId: string, + url: string, + options?: CreateBrowserPageOptions + ) => BrowserPage | null + closeBrowserPage: (pageId: string) => void + reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null + setActiveBrowserPage: (workspaceId: string, pageId: string) => void + consumeAddressBarFocusRequest: (pageId: string) => boolean + updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void + updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void + setBrowserTabUrl: (pageId: string, url: string) => void + setBrowserPageUrl: (pageId: string, url: string) => void hydrateBrowserSession: (session: WorkspaceSessionState) => void + browserSessionProfiles: BrowserSessionProfile[] + browserSessionImportState: { + profileId: string + status: 'idle' | 'importing' | 'success' | 'error' + summary: BrowserCookieImportSummary | null + error: string | null + } | null + fetchBrowserSessionProfiles: () => Promise + createBrowserSessionProfile: ( + scope: 'isolated' | 'imported', + label: string + ) => Promise + deleteBrowserSessionProfile: (profileId: string) => Promise + importCookiesToProfile: (profileId: string) => Promise + clearBrowserSessionImportState: () => void + detectedBrowsers: { family: string; label: string }[] + fetchDetectedBrowsers: () => Promise + importCookiesFromBrowser: ( + profileId: string, + browserFamily: string + ) => Promise + clearDefaultSessionCookies: () => Promise } function normalizeUrl(url: string): string { @@ -44,6 +101,105 @@ function normalizeUrl(url: string): string { return trimmed } +function normalizeBrowserTitle(title: string | null | undefined, url: string): string { + if ( + url === 'about:blank' || + url === ORCA_BROWSER_BLANK_URL || + title === 'about:blank' || + title === ORCA_BROWSER_BLANK_URL || + !title + ) { + // Why: blank pages render through Orca's inert data: URL guest. Persisting + // that internal bootstrap URL as the page/workspace title leaks an + // implementation detail into the tab strip and makes every blank page look + // broken. Keep the user-facing label stable as "New Tab" instead. + return 'New Tab' + } + return title +} + +function buildBrowserPage( + workspaceId: string, + worktreeId: string, + url: string, + title?: string +): BrowserPage { + const normalizedUrl = normalizeUrl(url) + return { + id: globalThis.crypto.randomUUID(), + workspaceId, + worktreeId, + url: normalizedUrl, + title: normalizeBrowserTitle(title, normalizedUrl), + // Why: blank pages mount an inert guest first. Treating them as loading + // would make an empty workspace flash the global loading affordance even + // though no real navigation happened yet. + loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: Date.now() + } +} + +function buildWorkspaceFromPage( + id: string, + worktreeId: string, + page: BrowserPage, + pageIds: string[], + sessionProfileId?: string | null +): BrowserWorkspace { + return { + id, + worktreeId, + sessionProfileId: sessionProfileId ?? null, + activePageId: page.id, + pageIds, + url: page.url, + title: page.title, + loading: page.loading, + faviconUrl: page.faviconUrl, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward, + loadError: page.loadError, + createdAt: page.createdAt + } +} + +function mirrorWorkspaceFromActivePage( + workspace: BrowserWorkspace, + pages: BrowserPage[] +): BrowserWorkspace { + const activePage = pages.find((page) => page.id === workspace.activePageId) ?? null + if (!activePage) { + return { + ...workspace, + activePageId: null, + pageIds: pages.map((page) => page.id), + url: 'about:blank', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null + } + } + return { + ...workspace, + activePageId: activePage.id, + pageIds: pages.map((page) => page.id), + url: activePage.url, + title: activePage.title, + loading: activePage.loading, + faviconUrl: activePage.faviconUrl, + canGoBack: activePage.canGoBack, + canGoForward: activePage.canGoForward, + loadError: activePage.loadError + } +} + function getFallbackTabTypeForWorktree( worktreeId: string, openFiles: AppState['openFiles'], @@ -62,42 +218,59 @@ function getFallbackTabTypeForWorktree( return 'terminal' } +function findWorkspace( + browserTabsByWorktree: Record, + workspaceId: string +): BrowserWorkspace | null { + return ( + Object.values(browserTabsByWorktree) + .flat() + .find((workspace) => workspace.id === workspaceId) ?? null + ) +} + +function findPage( + browserPagesByWorkspace: Record, + pageId: string +): BrowserPage | null { + return ( + Object.values(browserPagesByWorkspace) + .flat() + .find((page) => page.id === pageId) ?? null + ) +} + export const createBrowserSlice: StateCreator = (set, get) => ({ browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, activeBrowserTabId: null, activeBrowserTabIdByWorktree: {}, + recentlyClosedBrowserTabsByWorktree: {}, + recentlyClosedBrowserPagesByWorkspace: {}, pendingAddressBarFocusByTabId: {}, + pendingAddressBarFocusByPageId: {}, + browserSessionProfiles: [], + browserSessionImportState: null, createBrowserTab: (worktreeId, url, options) => { - const id = globalThis.crypto.randomUUID() - const now = Date.now() - const normalizedUrl = normalizeUrl(url) - let browserTab!: BrowserTab + const workspaceId = globalThis.crypto.randomUUID() + const page = buildBrowserPage(workspaceId, worktreeId, url, options?.title) + const browserTab = buildWorkspaceFromPage( + workspaceId, + worktreeId, + page, + [page.id], + options?.sessionProfileId + ) + set((s) => { const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] - browserTab = { - id, - worktreeId, - url: normalizedUrl, - title: options?.title ?? normalizedUrl, - // Why: blank tabs mount a parked/inert guest surface first. Marking - // them as loading at creation time makes every about:blank tab flash - // the browser loading dot even when no navigation was requested. - // Real navigations still flip loading via the browser pane events. - loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: now - } - const nextTabBarOrder = (() => { const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) const editorIds = s.openFiles .filter((file) => file.worktreeId === worktreeId) - .map((f) => f.id) + .map((file) => file.id) const browserIds = existingTabs.map((tab) => tab.id) const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) @@ -108,7 +281,7 @@ export const createBrowserSlice: StateCreator = inBase.add(entryId) } } - base.push(id) + base.push(workspaceId) return base })() @@ -116,66 +289,92 @@ export const createBrowserSlice: StateCreator = const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId const shouldFocusAddressBar = shouldUpdateGlobalActiveSurface && - (normalizedUrl === 'about:blank' || normalizedUrl === ORCA_BROWSER_BLANK_URL) + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) + return { browserTabsByWorktree: { ...s.browserTabsByWorktree, [worktreeId]: [...existingTabs, browserTab] }, + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: [page] + }, tabBarOrderByWorktree: { ...s.tabBarOrderByWorktree, [worktreeId]: nextTabBarOrder }, - activeBrowserTabId: shouldActivate ? id : s.activeBrowserTabId, + activeBrowserTabId: shouldActivate ? workspaceId : s.activeBrowserTabId, activeBrowserTabIdByWorktree: { ...s.activeBrowserTabIdByWorktree, - [worktreeId]: shouldActivate ? id : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) + [worktreeId]: shouldActivate + ? workspaceId + : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) }, - // Why: browser tabs live in the same visual strip as terminals and editors. - // Creating one should immediately select the browser surface for that - // worktree, but only the active worktree is allowed to drive Orca's - // global visible surface. Background worktrees keep their per-worktree - // browser selection without stealing the foreground pane. activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, activeTabTypeByWorktree: shouldActivate ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } : s.activeTabTypeByWorktree, - // Why: the active BrowserPane remounts on every browser-tab switch, so - // a plain autoFocus would keep stealing focus whenever the user - // revisits an existing tab. Queue a one-shot focus request only for a - // freshly created blank tab, then let BrowserPane consume it once. + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, pendingAddressBarFocusByTabId: shouldFocusAddressBar ? { ...s.pendingAddressBarFocusByTabId, - [id]: true + [workspaceId]: true, + [page.id]: true } : s.pendingAddressBarFocusByTabId } }) + + const state = get() + const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === workspaceId + ) + if (!alreadyHasUnifiedTab) { + state.createUnifiedTab(worktreeId, 'browser', { + entityId: workspaceId, + label: browserTab.title + }) + } return browserTab }, - closeBrowserTab: (tabId) => + closeBrowserTab: (tabId) => { set((s) => { let owningWorktreeId: string | null = null - const nextBrowserTabsByWorktree: Record = {} + let closedWorkspace: BrowserWorkspace | null = null + const nextBrowserTabsByWorktree: Record = {} for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const removedTab = tabs.find((tab) => tab.id === tabId) ?? null const filtered = tabs.filter((tab) => tab.id !== tabId) if (filtered.length !== tabs.length) { owningWorktreeId = worktreeId + closedWorkspace = removedTab } if (filtered.length > 0) { nextBrowserTabsByWorktree[worktreeId] = filtered } } - if (!owningWorktreeId) { + if (!owningWorktreeId || !closedWorkspace) { return s } + const closedPages = s.browserPagesByWorkspace[tabId] ?? [] + const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } + delete nextBrowserPagesByWorkspace[tabId] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] + const tabBarOrder = s.tabBarOrderByWorktree[owningWorktreeId] ?? [] + const neighborTabId = pickNeighbor(tabBarOrder, tabId) if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { - nextActiveBrowserTabIdByWorktree[owningWorktreeId] = remainingBrowserTabs[0]?.id ?? null + nextActiveBrowserTabIdByWorktree[owningWorktreeId] = + neighborTabId ?? remainingBrowserTabs[0]?.id ?? null } const nextTabBarOrder = { @@ -197,38 +396,106 @@ export const createBrowserSlice: StateCreator = ) nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { - // Why: the per-worktree restore map and the global active surface must - // stay in lockstep. Leaving activeTabType at "browser" after the last - // browser tab closes makes the workspace point at a surface that no - // longer exists, which later renders as a blank body until another - // caller repairs state opportunistically. nextActiveTabType = fallbackTabType } } + const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } + const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] + nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ + { workspace: closedWorkspace, pages: closedPages }, + ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) + ].slice(0, 10) + + const nextRecentlyClosedBrowserPagesByWorkspace = { + ...s.recentlyClosedBrowserPagesByWorkspace + } + delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] + + const nextPendingAddressBarFocusByPageId = Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pageId]) => !closedPages.some((page) => page.id === pageId) + ) + ) + const nextPendingAddressBarFocusByTabId = Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([focusId]) => focusId !== tabId && !closedPages.some((page) => page.id === focusId) + ) + ) + return { browserTabsByWorktree: nextBrowserTabsByWorktree, + browserPagesByWorkspace: nextBrowserPagesByWorkspace, activeBrowserTabId: s.activeBrowserTabId === tabId - ? (remainingBrowserTabs[0]?.id ?? null) + ? (neighborTabId ?? remainingBrowserTabs[0]?.id ?? null) : s.activeBrowserTabId, activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, tabBarOrderByWorktree: nextTabBarOrder, activeTabType: nextActiveTabType, - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingTabId]) => pendingTabId !== tabId - ) - ), - activeTabTypeByWorktree: nextActiveTabTypeByWorktree + pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace } - }), + }) - setActiveBrowserTab: (tabId) => + for (const tabs of Object.values(get().unifiedTabsByWorktree)) { + const workspaceItem = tabs.find( + (entry) => entry.contentType === 'browser' && entry.entityId === tabId + ) + if (workspaceItem) { + get().closeUnifiedTab(workspaceItem.id) + } + } + }, + + reopenClosedBrowserTab: (worktreeId) => { + const recentlyClosed = get().recentlyClosedBrowserTabsByWorktree[worktreeId] ?? [] + const entryToRestore = recentlyClosed[0] + if (!entryToRestore) { + return null + } + + set((s) => ({ + recentlyClosedBrowserTabsByWorktree: { + ...s.recentlyClosedBrowserTabsByWorktree, + [worktreeId]: (s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? []).slice(1) + } + })) + + const restored = get().createBrowserTab(worktreeId, entryToRestore.workspace.url, { + title: entryToRestore.workspace.title, + activate: true + }) + const restoredFirstPageId = restored.activePageId + const remainingPages = entryToRestore.pages.slice(1) + for (const page of remainingPages) { + get().createBrowserPage(restored.id, page.url, { + activate: false, + title: page.title + }) + } + if (restoredFirstPageId) { + const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] + const activeReplacement = restoredPages.find( + (page) => page.url === entryToRestore.workspace.url + ) + const targetActivePage = + restoredPages.find((page) => page.title === entryToRestore.workspace.title) ?? + activeReplacement ?? + restoredPages[0] + if (targetActivePage) { + get().setActiveBrowserPage(restored.id, targetActivePage.id) + } + } + return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + }, + + setActiveBrowserTab: (tabId) => { set((s) => { - const browserTab = Object.values(s.browserTabsByWorktree) - .flat() - .find((tab) => tab.id === tabId) + const browserTab = findWorkspace(s.browserTabsByWorktree, tabId) if (!browserTab) { return s } @@ -244,67 +511,333 @@ export const createBrowserSlice: StateCreator = [browserTab.worktreeId]: 'browser' } } - }), + }) - consumeAddressBarFocusRequest: (tabId) => { - if (!get().pendingAddressBarFocusByTabId[tabId]) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === tabId) + if (item) { + get().activateTab(item.id) + } + }, + + createBrowserPage: (workspaceId, url, options) => { + const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (!workspace) { + return null + } + const page = buildBrowserPage(workspaceId, workspace.worktreeId, url, options?.title) + + set((s) => { + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + const shouldActivate = options?.activate ?? true + const nextPages = [...pages, page] + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id), + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const shouldUpdateGlobalActiveSurface = + shouldActivate && + s.activeWorktreeId === workspace.worktreeId && + s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId + const shouldFocusAddressBar = + shouldUpdateGlobalActiveSurface && + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) + + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) => + tab.id === workspaceId ? nextWorkspace : tab + ) + }, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } + }) + + const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (nextWorkspace?.activePageId === page.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, page.title) + } + } + return page + }, + + closeBrowserPage: (pageId) => { + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] + const nextPages = currentPages.filter((entry) => entry.id !== pageId) + const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) + const nextActivePageId = + workspace.activePageId === pageId + ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) + : workspace.activePageId + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: nextActivePageId, + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) => + tab.id === workspace.id ? nextWorkspace : tab + ) + }, + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspace.id]: [ + page, + ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( + (entry) => entry.id !== page.id + ) + ].slice(0, 10) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ) + } + }) + + const page = findPage(get().browserPagesByWorkspace, pageId) + if (!page) { + return + } + const workspace = findWorkspace(get().browserTabsByWorktree, page.workspaceId) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === page.workspaceId) + if (item && workspace) { + get().setTabLabel(item.id, workspace.title) + } + }, + + reopenClosedBrowserPage: (workspaceId) => { + const recentlyClosed = get().recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? [] + const pageToRestore = recentlyClosed[0] + if (!pageToRestore) { + return null + } + + set((s) => ({ + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspaceId]: (s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? []).slice(1) + } + })) + + return get().createBrowserPage(workspaceId, pageToRestore.url, { + title: pageToRestore.title, + activate: true + }) + }, + + setActiveBrowserPage: (workspaceId, pageId) => { + set((s) => { + const workspace = findWorkspace(s.browserTabsByWorktree, workspaceId) + if (!workspace) { + return s + } + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + if (!pages.some((page) => page.id === pageId)) { + return s + } + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: pageId + }, + pages + ) + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) => + tab.id === workspaceId ? nextWorkspace : tab + ) + } + } + }) + + const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (!workspace) { + return + } + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, workspace.title) + } + }, + + consumeAddressBarFocusRequest: (pageId) => { + const state = get() + if ( + !state.pendingAddressBarFocusByPageId[pageId] && + !state.pendingAddressBarFocusByTabId[pageId] + ) { return false } set((s) => { - const next = { ...s.pendingAddressBarFocusByTabId } - delete next[tabId] - return { pendingAddressBarFocusByTabId: next } + const nextByPageId = { ...s.pendingAddressBarFocusByPageId } + delete nextByPageId[pageId] + const nextByTabId = { ...s.pendingAddressBarFocusByTabId } + delete nextByTabId[pageId] + return { + pendingAddressBarFocusByPageId: nextByPageId, + pendingAddressBarFocusByTabId: nextByTabId + } }) return true }, - updateBrowserTabPageState: (tabId, updates) => - set((s) => ({ - browserTabsByWorktree: Object.fromEntries( - Object.entries(s.browserTabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => - tab.id === tabId - ? { - ...tab, - title: updates.title ?? tab.title, - loading: updates.loading ?? tab.loading, - faviconUrl: - updates.faviconUrl === undefined ? tab.faviconUrl : updates.faviconUrl, - canGoBack: updates.canGoBack ?? tab.canGoBack, - canGoForward: updates.canGoForward ?? tab.canGoForward, - loadError: updates.loadError === undefined ? tab.loadError : updates.loadError - } - : tab - ) - ]) - ) - })), + updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates), - setBrowserTabUrl: (tabId, url) => - set((s) => ({ - browserTabsByWorktree: Object.fromEntries( - Object.entries(s.browserTabsByWorktree).map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => - tab.id === tabId - ? { - ...tab, - url: normalizeUrl(url), - loading: true, - loadError: null - } - : tab - ) - ]) + updateBrowserPageState: (pageId, updates) => { + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => + entry.id === pageId + ? { + ...entry, + title: + updates.title === undefined + ? entry.title + : normalizeBrowserTitle(updates.title, entry.url), + loading: updates.loading ?? entry.loading, + faviconUrl: updates.faviconUrl === undefined ? entry.faviconUrl : updates.faviconUrl, + canGoBack: updates.canGoBack ?? entry.canGoBack, + canGoForward: updates.canGoForward ?? entry.canGoForward, + loadError: updates.loadError === undefined ? entry.loadError : updates.loadError + } + : entry ) - })), + const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) => + tab.id === workspace.id ? nextWorkspace : tab + ) + } + } + }) - hydrateBrowserSession: (session) => + const page = findPage(get().browserPagesByWorkspace, pageId) + if (!page) { + return + } + const workspace = findWorkspace(get().browserTabsByWorktree, page.workspaceId) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === page.workspaceId) + if (item && workspace && workspace.activePageId === pageId && updates.title) { + get().setTabLabel(item.id, workspace.title) + } + }, + + setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), + + setBrowserPageUrl: (pageId, url) => + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => + entry.id === pageId + ? { + ...entry, + url: normalizeUrl(url), + title: normalizeBrowserTitle(entry.title, normalizeUrl(url)), + loading: true, + loadError: null + } + : entry + ) + const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map((tab) => + tab.id === workspace.id ? nextWorkspace : tab + ) + } + } + }), + + hydrateBrowserSession: (session) => { set((s) => { const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} + const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {} const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} const validWorktreeIds = new Set( @@ -313,20 +846,56 @@ export const createBrowserSlice: StateCreator = .map((worktree) => worktree.id) ) - const browserTabsByWorktree: Record = Object.fromEntries( - Object.entries(persistedTabsByWorktree) - .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) - .map(([worktreeId, tabs]) => [ - worktreeId, - tabs.map((tab) => ({ - ...tab, + const browserTabsByWorktree: Record = {} + const browserPagesByWorkspace: Record = {} + + for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { + if (!validWorktreeIds.has(worktreeId)) { + continue + } + const hydratedTabs: BrowserWorkspace[] = [] + for (const tab of tabs) { + const persistedPages = persistedPagesByWorkspace[tab.id] ?? [ + { + id: globalThis.crypto.randomUUID(), + workspaceId: tab.id, + worktreeId, url: normalizeUrl(tab.url), + title: tab.title, loading: false, - loadError: tab.loadError ?? null - })) - ]) - .filter(([, tabs]) => (tabs as BrowserTab[]).length > 0) - ) + faviconUrl: tab.faviconUrl ?? null, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + loadError: tab.loadError ?? null, + createdAt: tab.createdAt + } satisfies BrowserPage + ] + const nextPages = persistedPages.map((page) => ({ + ...page, + workspaceId: tab.id, + worktreeId, + url: normalizeUrl(page.url), + loading: false, + loadError: page.loadError ?? null + })) + browserPagesByWorkspace[tab.id] = nextPages + hydratedTabs.push( + mirrorWorkspaceFromActivePage( + { + ...tab, + activePageId: nextPages.some((page) => page.id === tab.activePageId) + ? (tab.activePageId ?? nextPages[0]?.id ?? null) + : (nextPages[0]?.id ?? null), + pageIds: nextPages.map((page) => page.id) + }, + nextPages + ) + ) + } + if (hydratedTabs.length > 0) { + browserTabsByWorktree[worktreeId] = hydratedTabs + } + } const validBrowserTabIds = new Set( Object.values(browserTabsByWorktree) @@ -349,11 +918,6 @@ export const createBrowserSlice: StateCreator = ? activeBrowserTabIdByWorktree[activeWorktreeId] : null - // Why: hydrateEditorSession may have returned early (no editor files), - // leaving activeTabTypeByWorktree as {}. We must merge in the 'browser' - // entries from the persisted session, otherwise setActiveWorktree will - // default to 'terminal' when switching to a worktree whose last-active - // tab was a browser tab — causing a blank screen. const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } for (const worktreeId of validWorktreeIds) { const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0 @@ -362,17 +926,10 @@ export const createBrowserSlice: StateCreator = hasBrowserTabs && !nextActiveTabTypeByWorktree[worktreeId] ) { - // Why: browser hydration runs after editor hydration and owns only the - // browser-visible restore path. Keep browser tab restores intact when - // the persisted session still has a valid browser tab for that worktree. nextActiveTabTypeByWorktree[worktreeId] = 'browser' continue } if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) { - // Why: older/broken sessions can retain "browser" as the remembered - // surface for a worktree after its browser tabs were closed. Leaving - // that stale marker behind makes Terminal render the browser surface - // with no matching tab, which looks like a blank app. nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree( worktreeId, s.openFiles, @@ -406,10 +963,198 @@ export const createBrowserSlice: StateCreator = return { browserTabsByWorktree, + browserPagesByWorkspace, activeBrowserTabIdByWorktree, activeBrowserTabId, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, activeTabType } }) + + const state = get() + for (const [worktreeId, browserTabs] of Object.entries(state.browserTabsByWorktree)) { + for (const bt of browserTabs) { + const exists = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === bt.id + ) + if (!exists) { + state.createUnifiedTab(worktreeId, 'browser', { + entityId: bt.id, + label: bt.title + }) + } + } + } + }, + + fetchBrowserSessionProfiles: async () => { + try { + const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] + set({ browserSessionProfiles: profiles }) + } catch { + /* best-effort — stale profile list is preferable to a crash */ + } + }, + + createBrowserSessionProfile: async (scope, label) => { + try { + const profile = (await window.api.browser.sessionCreateProfile({ + scope, + label + })) as BrowserSessionProfile | null + if (profile) { + set((s) => ({ + browserSessionProfiles: [...s.browserSessionProfiles, profile] + })) + } + return profile + } catch { + return null + } + }, + + deleteBrowserSessionProfile: async (profileId) => { + try { + const ok = await window.api.browser.sessionDeleteProfile({ profileId }) + if (ok) { + set((s) => ({ + browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId) + })) + } + return ok + } catch { + return false + } + }, + + importCookiesToProfile: async (profileId) => { + set({ + browserSessionImportState: { + profileId, + status: 'importing', + summary: null, + error: null + } + }) + try { + const result = (await window.api.browser.sessionImportCookies({ + profileId + })) as BrowserCookieImportResult + if (result.ok) { + set({ + browserSessionImportState: { + profileId, + status: 'success', + summary: result.summary, + error: null + } + }) + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } else { + set({ + browserSessionImportState: { + profileId, + status: result.reason === 'canceled' ? 'idle' : 'error', + summary: null, + error: result.reason === 'canceled' ? null : result.reason + } + }) + } + return result + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: reason + } + }) + return { ok: false as const, reason } + } + }, + + clearBrowserSessionImportState: () => { + set({ browserSessionImportState: null }) + }, + + detectedBrowsers: [], + + fetchDetectedBrowsers: async () => { + try { + const browsers = (await window.api.browser.sessionDetectBrowsers()) as { + family: string + label: string + }[] + set({ detectedBrowsers: browsers }) + } catch { + /* best-effort — empty list is acceptable fallback */ + } + }, + + importCookiesFromBrowser: async (profileId, browserFamily) => { + set({ + browserSessionImportState: { + profileId, + status: 'importing', + summary: null, + error: null + } + }) + try { + const result = (await window.api.browser.sessionImportFromBrowser({ + profileId, + browserFamily + })) as BrowserCookieImportResult + if (result.ok) { + set({ + browserSessionImportState: { + profileId, + status: 'success', + summary: result.summary, + error: null + } + }) + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } else { + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: result.reason + } + }) + } + return result + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set({ + browserSessionImportState: { + profileId, + status: 'error', + summary: null, + error: reason + } + }) + return { ok: false as const, reason } + } + }, + + clearDefaultSessionCookies: async () => { + try { + const ok = await window.api.browser.sessionClearDefaultCookies() + if (ok) { + await get().fetchBrowserSessionProfiles() + } + return ok + } catch { + return false + } + } }) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index c370b73de43..aba6604c5f7 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -146,7 +146,7 @@ export type EditorSlice = { activeTabType: WorkspaceVisibleTabType setActiveTabType: (type: WorkspaceVisibleTabType) => void openFile: (file: Omit, options?: { preview?: boolean }) => void - pinFile: (fileId: string) => void + pinFile: (fileId: string, tabId?: string) => void closeFile: (fileId: string) => void closeAllFiles: () => void setActiveFile: (fileId: string) => void @@ -471,7 +471,7 @@ export const createEditorSlice: StateCreator = (s } }), - pinFile: (fileId) => + pinFile: (fileId, _tabId) => set((s) => { const file = s.openFiles.find((f) => f.id === fileId) if (!file?.isPreview) { diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 1b933bebc4d..e27d2115b41 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -117,6 +117,7 @@ export function makeUnifiedTab( overrides: Partial & { id: string; worktreeId: string; groupId: string } ): Tab { return { + entityId: overrides.id, contentType: 'terminal', label: 'Terminal 1', customLabel: null, diff --git a/src/renderer/src/store/slices/tabs-helpers.test.ts b/src/renderer/src/store/slices/tabs-helpers.test.ts index 10c82f6cc30..315d1d9321c 100644 --- a/src/renderer/src/store/slices/tabs-helpers.test.ts +++ b/src/renderer/src/store/slices/tabs-helpers.test.ts @@ -11,6 +11,7 @@ import { function makeTab(overrides: Partial & { id: string; worktreeId: string }): Tab { return { + entityId: overrides.id, groupId: 'g1', contentType: 'terminal', label: overrides.id, diff --git a/src/renderer/src/store/slices/tabs-hydration.test.ts b/src/renderer/src/store/slices/tabs-hydration.test.ts index 0fbcd0e7094..5b4c2f3ddf1 100644 --- a/src/renderer/src/store/slices/tabs-hydration.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration.test.ts @@ -22,6 +22,7 @@ describe('buildHydratedTabState – unified format', () => { w1: [ { id: 't1', + entityId: 't1', groupId: 'g1', worktreeId: 'w1', contentType: 'terminal', @@ -33,6 +34,7 @@ describe('buildHydratedTabState – unified format', () => { }, { id: 'f1', + entityId: 'f1', groupId: 'g1', worktreeId: 'w1', contentType: 'editor', @@ -62,6 +64,7 @@ describe('buildHydratedTabState – unified format', () => { w1: [ { id: 't1', + entityId: 't1', groupId: 'g1', worktreeId: 'w1', contentType: 'terminal', @@ -75,6 +78,7 @@ describe('buildHydratedTabState – unified format', () => { w_gone: [ { id: 't2', + entityId: 't2', groupId: 'g2', worktreeId: 'w_gone', contentType: 'terminal', @@ -104,6 +108,7 @@ describe('buildHydratedTabState – unified format', () => { w1: [ { id: 't1', + entityId: 't1', groupId: 'g1', worktreeId: 'w1', contentType: 'terminal', diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index ee0cc2fd772..8159c23a61d 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -71,6 +71,7 @@ function hydrateLegacyFormat( for (const tt of terminalTabs) { tabs.push({ id: tt.id, + entityId: tt.id, groupId, worktreeId, contentType: 'terminal', @@ -88,6 +89,7 @@ function hydrateLegacyFormat( for (const ef of editorFiles) { tabs.push({ id: ef.filePath, + entityId: ef.filePath, groupId, worktreeId, contentType: 'editor', diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 6e6ce6f44a8..a8a59330f2a 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -559,6 +559,7 @@ describe('TabsSlice', () => { const tabs: Tab[] = [ { id: 't-1', + entityId: 't-1', groupId, worktreeId: WT, contentType: 'terminal', @@ -570,6 +571,7 @@ describe('TabsSlice', () => { }, { id: '/file.ts', + entityId: '/file.ts', groupId, worktreeId: WT, contentType: 'editor', diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 3c52f7a98b1..64d6912fa05 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: tab slice co-locates group-scoped state, + * focus, and split-group lifecycle to keep state transitions atomic. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { Tab, TabGroup, TabContentType, WorkspaceSessionState } from '../../../../shared/types' @@ -21,7 +23,9 @@ export type TabsSlice = { createUnifiedTab: ( worktreeId: string, contentType: TabContentType, - init?: Partial> + init?: Partial< + Pick + > ) => Tab closeUnifiedTab: ( tabId: string @@ -37,6 +41,13 @@ export type TabsSlice = { closeTabsToRight: (tabId: string) => string[] getActiveTab: (worktreeId: string) => Tab | null getTab: (tabId: string) => Tab | null + focusGroup: (worktreeId: string, groupId: string) => void + closeEmptyGroup: (worktreeId: string, groupId: string) => boolean + createEmptySplitGroup: ( + worktreeId: string, + sourceGroupId: string, + direction: 'right' | 'down' + ) => string | null hydrateTabsSession: (session: WorkspaceSessionState) => void } @@ -71,6 +82,7 @@ export const createTabsSlice: StateCreator = (set, tab = { id, + entityId: init?.entityId ?? id, groupId: group.id, worktreeId, contentType, @@ -340,6 +352,53 @@ export const createTabsSlice: StateCreator = (set, return found?.tab ?? null }, + focusGroup: (worktreeId, groupId) => { + set((s) => ({ + activeGroupIdByWorktree: { ...s.activeGroupIdByWorktree, [worktreeId]: groupId } + })) + }, + + closeEmptyGroup: (worktreeId, groupId) => { + const state = get() + const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (t) => t.groupId === groupId + ) + if (tabs.length > 0) { + return false + } + const groups = state.groupsByWorktree[worktreeId] ?? [] + const remaining = groups.filter((g) => g.id !== groupId) + if (remaining.length === 0) { + return false + } + set((s) => ({ + groupsByWorktree: { ...s.groupsByWorktree, [worktreeId]: remaining }, + activeGroupIdByWorktree: { + ...s.activeGroupIdByWorktree, + [worktreeId]: remaining[0].id + } + })) + return true + }, + + createEmptySplitGroup: (worktreeId, _sourceGroupId, _direction) => { + const newGroupId = globalThis.crypto.randomUUID() + const newGroup: TabGroup = { + id: newGroupId, + worktreeId, + activeTabId: null, + tabOrder: [] + } + set((s) => { + const existing = s.groupsByWorktree[worktreeId] ?? [] + return { + groupsByWorktree: { ...s.groupsByWorktree, [worktreeId]: [...existing, newGroup] }, + activeGroupIdByWorktree: { ...s.activeGroupIdByWorktree, [worktreeId]: newGroupId } + } + }) + return newGroupId + }, + hydrateTabsSession: (session) => { const state = get() const validWorktreeIds = new Set( diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ebf67b408fb..702c0aaa569 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -90,6 +90,9 @@ export type UISlice = { markUpdateReassuranceSeen: () => void isFullScreen: boolean setIsFullScreen: (v: boolean) => void + /** URL opened when a new browser tab is created. Null = blank tab (default). */ + browserDefaultUrl: string | null + setBrowserDefaultUrl: (url: string | null) => void } export const createUISlice: StateCreator = (set) => ({ @@ -187,6 +190,7 @@ export const createUISlice: StateCreator = (set) => ( statusBarVisible: ui.statusBarVisible ?? true, dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, updateReassuranceSeen: ui.updateReassuranceSeen ?? false, + browserDefaultUrl: ui.browserDefaultUrl ?? null, persistedUIReady: true } }), @@ -246,5 +250,10 @@ export const createUISlice: StateCreator = (set) => ( set({ updateReassuranceSeen: true }) }, isFullScreen: false, - setIsFullScreen: (v) => set({ isFullScreen: v }) + setIsFullScreen: (v) => set({ isFullScreen: v }), + browserDefaultUrl: null, + setBrowserDefaultUrl: (url) => { + void window.api.ui.set({ browserDefaultUrl: url }).catch(console.error) + set({ browserDefaultUrl: url }) + } }) diff --git a/src/shared/browser-grab-types.ts b/src/shared/browser-grab-types.ts index fda0ea86548..7d0f170ecd3 100644 --- a/src/shared/browser-grab-types.ts +++ b/src/shared/browser-grab-types.ts @@ -103,7 +103,7 @@ export type BrowserGrabResult = // --------------------------------------------------------------------------- export type BrowserSetGrabModeArgs = { - browserTabId: string + browserPageId: string enabled: boolean } @@ -113,16 +113,16 @@ export type BrowserGrabRejectReason = 'not-ready' | 'not-authorized' | 'already- export type BrowserSetGrabModeResult = { ok: true } | { ok: false; reason: BrowserGrabRejectReason } export type BrowserAwaitGrabSelectionArgs = { - browserTabId: string + browserPageId: string opId: string } export type BrowserCancelGrabArgs = { - browserTabId: string + browserPageId: string } export type BrowserCaptureSelectionScreenshotArgs = { - browserTabId: string + browserPageId: string rect: BrowserGrabRect } @@ -131,7 +131,7 @@ export type BrowserCaptureSelectionScreenshotResult = | { ok: false; reason: string } export type BrowserExtractHoverArgs = { - browserTabId: string + browserPageId: string } export type BrowserExtractHoverResult = diff --git a/src/shared/browser-guest-events.ts b/src/shared/browser-guest-events.ts new file mode 100644 index 00000000000..6d77a44059f --- /dev/null +++ b/src/shared/browser-guest-events.ts @@ -0,0 +1,53 @@ +export type BrowserPermissionDeniedEvent = { + browserPageId: string + /** Electron permission string (e.g. "media", "notifications"). */ + permission: string + /** Sanitized to origin/host so auth query params never leak into UI state. */ + origin: string +} + +export type BrowserPopupEvent = { + browserPageId: string + /** Sanitized to origin/host so auth query params never leak into UI state. */ + origin: string + /** Whether Orca opened the target in Orca, opened it externally, or blocked it as unsafe. */ + action: 'opened-in-orca' | 'opened-external' | 'blocked' +} + +export type BrowserDownloadRequestedEvent = { + browserPageId: string + downloadId: string + /** Sanitized to origin/host so auth query params never leak into UI state. */ + origin: string + filename: string + totalBytes: number | null + mimeType: string | null +} + +export type BrowserDownloadProgressEvent = { + downloadId: string + receivedBytes: number + totalBytes: number | null +} + +export type BrowserDownloadFinishedEvent = { + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + /** Human-readable UI copy only; must never contain secrets. */ + error: string | null +} + +export type BrowserContextMenuRequestedEvent = { + browserPageId: string + x: number + y: number + pageUrl: string + linkUrl: string | null + canGoBack: boolean + canGoForward: boolean +} + +export type BrowserContextMenuDismissedEvent = { + browserPageId: string +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 98c0813c5eb..4b42f6718d7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -168,6 +168,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState { terminalLayoutsByTabId: {}, openFilesByWorktree: {}, browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, activeBrowserTabIdByWorktree: {}, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {} diff --git a/src/shared/types.ts b/src/shared/types.ts index b4ad8eea2ff..0aaf426809b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -62,6 +62,7 @@ export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' export type Tab = { id: string // UUID for terminals, filePath for editors (preserves current convention) + entityId: string // ID of the backing content (terminal tab ID, file path, browser workspace ID) groupId: string worktreeId: string contentType: TabContentType @@ -101,8 +102,9 @@ export type BrowserLoadError = { validatedUrl: string } -export type BrowserTab = { +export type BrowserPage = { id: string + workspaceId: string worktreeId: string url: string title: string @@ -114,6 +116,64 @@ export type BrowserTab = { createdAt: number } +export type BrowserWorkspace = { + id: string + worktreeId: string + /** Stable display label for the outer Orca tab ("Browser 1", "Browser 2", …). + * Optional so sessions persisted before this field was added fall back + * gracefully to the URL-derived label in getBrowserTabLabel. */ + label?: string + // Why: each browser workspace binds to exactly one session profile at creation + // time. The profile determines which Electron partition (and thus which + // cookies/storage) the guest webview uses. Absent means the legacy shared + // partition, which keeps backward compat with workspaces persisted before + // session profiles existed. + sessionProfileId?: string | null + activePageId?: string | null + pageIds?: string[] + // Why: the active page owns real browser chrome state now, but the top-level + // Orca tab strip still renders one workspace entry. Mirror the active page's + // title/url/loading metadata here so existing workspace-level UI can stay + // stable while Phase 2 introduces nested browser pages. + url: string + title: string + loading: boolean + faviconUrl: string | null + canGoBack: boolean + canGoForward: boolean + loadError: BrowserLoadError | null + createdAt: number +} + +export type BrowserTab = BrowserWorkspace + +export type BrowserSessionProfileScope = 'default' | 'isolated' | 'imported' + +export type BrowserSessionProfileSource = { + browserFamily: 'chrome' | 'chromium' | 'arc' | 'edge' | 'manual' + profileName?: string + importedAt: number +} + +export type BrowserSessionProfile = { + id: string + scope: BrowserSessionProfileScope + partition: string + label: string + source: BrowserSessionProfileSource | null +} + +export type BrowserCookieImportSummary = { + totalCookies: number + importedCookies: number + skippedCookies: number + domains: string[] +} + +export type BrowserCookieImportResult = + | { ok: true; profileId: string; summary: BrowserCookieImportSummary } + | { ok: false; reason: string } + export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' export type TerminalPaneLayoutNode = @@ -168,9 +228,11 @@ export type WorkspaceSessionState = { openFilesByWorktree?: Record /** Per-worktree active editor file ID (filePath) at shutdown. */ activeFileIdByWorktree?: Record - /** Persisted browser tabs, keyed by worktree ID. */ - browserTabsByWorktree?: Record - /** Per-worktree active browser tab ID at shutdown. */ + /** Persisted browser workspaces, keyed by worktree ID. */ + browserTabsByWorktree?: Record + /** Persisted browser pages, keyed by workspace ID. */ + browserPagesByWorkspace?: Record + /** Per-worktree active browser workspace ID at shutdown. */ activeBrowserTabIdByWorktree?: Record /** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */ activeTabTypeByWorktree?: Record @@ -478,6 +540,9 @@ export type PersistedUIState = { /** Once the user has seen the "your sessions won't be interrupted" * reassurance card, we never show it again. */ updateReassuranceSeen?: boolean + /** URL to navigate to when a new browser tab is opened. Null means blank tab. + * Phase 3 will expand this to a full BrowserSessionProfile per workspace. */ + browserDefaultUrl?: string | null } // ─── Persistence shape ──────────────────────────────────────────────