Browser v2: workspace model, session profiles, and cookie import (#577)

This commit is contained in:
Jinwoo Hong
2026-04-13 10:03:32 -07:00
committed by GitHub
parent 70ce38ce6e
commit e7f6344e89
49 changed files with 6223 additions and 470 deletions
@@ -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<typeof vi.fn>
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)
}
})
})
+836
View File
@@ -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<DetectedBrowser, 'cookiesPath'>[] = [
{
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<BrowserCookieImportResult> {
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<string>()
// 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<string | null> {
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<BrowserCookieImportResult> {
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<BrowserCookieImportResult> {
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<string>()
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 0x000xFF 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}`
}
}
}
+94 -70
View File
@@ -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<boolean>
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')) {
@@ -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
})
+242 -22
View File
@@ -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
})
+496 -22
View File
@@ -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<BrowserPermissionDeniedEvent, 'browserPageId'>
type PendingPopupEvent = Omit<BrowserPopupEvent, 'browserPageId'>
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<typeof setTimeout> | 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<string, number>()
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
@@ -42,13 +82,29 @@ class BrowserManager {
number,
{ code: number; description: string; validatedUrl: string }
>()
private readonly pendingPermissionEventsByGuestId = new Map<number, PendingPermissionEvent[]>()
private readonly pendingPopupEventsByGuestId = new Map<number, PendingPopupEvent[]>()
private readonly pendingDownloadIdsByGuestId = new Map<number, string[]>()
private readonly downloadsById = new Map<string, ActiveDownload>()
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()
@@ -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')
})
})
})
@@ -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<string, BrowserSessionProfile>()
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<BrowserSessionMeta>): 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<boolean> {
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<boolean> {
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<string>()
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()
+70 -9
View File
@@ -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 })
})
})
+200 -16
View File
@@ -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<boolean> => {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
return browserSessionRegistry.deleteProfile(args.profileId)
}
)
ipcMain.handle(
'browser:session:importCookies',
async (event, args: { profileId: string }): Promise<BrowserCookieImportResult> => {
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<boolean> => {
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<BrowserCookieImportResult> => {
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
}
)
}
+7
View File
@@ -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)
@@ -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', () => {
+14 -6
View File
@@ -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', () => {
+10 -3
View File
@@ -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-<uuid>). 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) => {
+64 -6
View File
@@ -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<void>
unregisterGuest: (args: { browserTabId: string }) => Promise<void>
openDevTools: (args: { browserTabId: string }) => Promise<boolean>
registerGuest: (args: {
browserPageId: string
workspaceId: string
webContentsId: number
}) => Promise<void>
unregisterGuest: (args: { browserPageId: string }) => Promise<void>
openDevTools: (args: { browserPageId: string }) => Promise<boolean>
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<boolean>
setGrabMode: (args: BrowserSetGrabModeArgs) => Promise<BrowserSetGrabModeResult>
awaitGrabSelection: (args: BrowserAwaitGrabSelectionArgs) => Promise<BrowserGrabResult>
cancelGrab: (args: BrowserCancelGrabArgs) => Promise<boolean>
@@ -79,10 +115,29 @@ export type BrowserApi = {
args: BrowserCaptureSelectionScreenshotArgs
) => Promise<BrowserCaptureSelectionScreenshotResult>
extractHoverPayload: (args: BrowserExtractHoverArgs) => Promise<BrowserExtractHoverResult>
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<BrowserSessionProfile[]>
sessionCreateProfile: (args: {
scope: BrowserSessionProfileScope
label: string
}) => Promise<BrowserSessionProfile | null>
sessionDeleteProfile: (args: { profileId: string }) => Promise<boolean>
sessionImportCookies: (args: { profileId: string }) => Promise<BrowserCookieImportResult>
sessionResolvePartition: (args: { profileId: string | null }) => Promise<string | null>
sessionDetectBrowsers: () => Promise<DetectedBrowserInfo[]>
sessionImportFromBrowser: (args: {
profileId: string
browserFamily: string
}) => Promise<BrowserCookieImportResult>
sessionClearDefaultCookies: () => Promise<boolean>
}
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
+214 -17
View File
@@ -335,25 +335,28 @@ const api = {
},
browser: {
registerGuest: (args: { browserTabId: string; webContentsId: number }): Promise<void> =>
ipcRenderer.invoke('browser:registerGuest', args),
registerGuest: (args: {
browserPageId: string
workspaceId: string
webContentsId: number
}): Promise<void> => ipcRenderer.invoke('browser:registerGuest', args),
unregisterGuest: (args: { browserTabId: string }): Promise<void> =>
unregisterGuest: (args: { browserPageId: string }): Promise<void> =>
ipcRenderer.invoke('browser:unregisterGuest', args),
openDevTools: (args: { browserTabId: string }): Promise<boolean> =>
openDevTools: (args: { browserPageId: string }): Promise<boolean> =>
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<boolean> =>
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<unknown> =>
awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise<unknown> =>
ipcRenderer.invoke('browser:awaitGrabSelection', args),
cancelGrab: (args: { browserTabId: string }): Promise<boolean> =>
cancelGrab: (args: { browserPageId: string }): Promise<boolean> =>
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<unknown[]> =>
ipcRenderer.invoke('browser:session:listProfiles'),
sessionCreateProfile: (args: {
scope: 'default' | 'isolated' | 'imported'
label: string
}): Promise<unknown> => ipcRenderer.invoke('browser:session:createProfile', args),
sessionDeleteProfile: (args: { profileId: string }): Promise<boolean> =>
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<string | null> =>
ipcRenderer.invoke('browser:session:resolvePartition', args),
sessionDetectBrowsers: (): Promise<unknown[]> =>
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<boolean> =>
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)
+20 -2
View File
@@ -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.
+31 -36
View File
@@ -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<typeof updateBrowserTabPageState>[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 {
})}
</div>
{/* 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. */}
<div
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType !== 'browser' ? 'hidden' : ''}`}
>
@@ -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) => (
<BrowserPane
key={browserTab.id}
browserTab={browserTab}
onUpdatePageState={handleBrowserTabPageStateUpdate}
onSetUrl={handleBrowserTabSetUrl}
/>
))
: null}
{browserTabs.map((browserTab) => {
const isBrowserActive =
isVisibleWorktree &&
activeTabType === 'browser' &&
browserTab.id === activeBrowserTabId
return (
<div
key={browserTab.id}
className={`absolute inset-0${isBrowserActive ? '' : ' pointer-events-none hidden'}`}
>
<BrowserPane browserTab={browserTab} isActive={isBrowserActive} />
</div>
)
})}
</div>
)
})}
File diff suppressed because it is too large Load Diff
@@ -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()
})
})
@@ -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.'
}
@@ -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<GrabModeState>('idle')
const [payload, setPayload] = useState<BrowserGrabPayload | null>(null)
const [error, setError] = useState<string | null>(null)
const [contextMenu, setContextMenu] = useState(false)
const activeOpIdRef = useRef<string | null>(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
@@ -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)
@@ -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<string | null>(null)
const [autoSaveDelayDraft, setAutoSaveDelayDraft] = useState(
String(settings.editorAutoSaveDelayMs)
@@ -300,15 +317,59 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
<div className="space-y-1">
<h3 className="text-sm font-semibold">Browser</h3>
<p className="text-xs text-muted-foreground">
Control how Orca handles links from the terminal.
Control how Orca handles links and browser workspace defaults.
</p>
</div>
<SearchableSetting
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']}
className="flex items-start justify-between gap-4 px-1 py-2"
>
<div className="min-w-0 shrink space-y-0.5">
<Label>Default Home Page</Label>
<p className="text-xs text-muted-foreground">
URL opened when creating a new browser tab. Leave empty to open a blank tab.
</p>
</div>
<form
className="flex shrink-0 items-center gap-2"
onSubmit={(e) => {
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.')
}
}}
>
<Input
value={homePageDraft}
onChange={(e) => setHomePageDraft(e.target.value)}
placeholder="https://google.com"
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
className="h-7 w-52 text-xs"
/>
<Button type="submit" size="sm" variant="outline" className="h-7 text-xs">
Save
</Button>
</form>
</SearchableSetting>
<SearchableSetting
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']}
className="px-1 py-2"
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Terminal Link Routing</Label>
@@ -317,6 +378,170 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
in your system browser.
</p>
</div>
<button
role="switch"
aria-checked={settings.openLinksInApp}
onClick={() => updateSettings({ openLinksInApp: !settings.openLinksInApp })}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.openLinksInApp ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
<SearchableSetting
title="Session & Cookies"
description="Import cookies from Chrome, Edge, or other browsers to use existing logins inside Orca."
keywords={[
'cookies',
'session',
'import',
'auth',
'login',
'chrome',
'edge',
'arc',
'profile'
]}
className="space-y-3 px-1 py-2"
>
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label>Session &amp; Cookies</Label>
<p className="text-xs text-muted-foreground">
Import cookies from your system browser to reuse existing logins inside Orca.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="xs"
className="shrink-0 gap-1.5"
disabled={browserSessionImportState?.status === 'importing'}
>
{browserSessionImportState?.status === 'importing' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Import className="size-3" />
)}
Import Cookies
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{detectedBrowsers.map((browser) => (
<DropdownMenuItem
key={browser.family}
onSelect={async () => {
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}
</DropdownMenuItem>
))}
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={async () => {
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
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{defaultProfile?.source ? (
<div className="flex w-full items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
Imported from {defaultProfile.source.browserFamily}
{defaultProfile.source.profileName
? ` (${defaultProfile.source.profileName})`
: ''}
</span>
{defaultProfile.source.importedAt ? (
<span className="truncate text-[11px] text-muted-foreground">
{new Date(defaultProfile.source.importedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}
</span>
) : null}
</div>
<Button
variant="ghost"
size="xs"
className="gap-1 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await useAppStore.getState().clearDefaultSessionCookies()
if (ok) {
toast.success('Cookies cleared.')
}
}}
>
<Trash2 className="size-3" />
Clear
</Button>
</div>
) : null}
{orphanedProfiles.length > 0 ? (
<div className="space-y-2">
{orphanedProfiles.map((profile) => (
<div
key={profile.id}
className="flex w-full items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2.5"
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{profile.label}</span>
<span className="truncate text-[11px] text-muted-foreground">
{profile.source
? `Imported from ${profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}`
: 'Unused session'}
</span>
</div>
<Button
variant="ghost"
size="xs"
className="gap-1 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await useAppStore
.getState()
.deleteBrowserSessionProfile(profile.id)
if (ok) {
toast.success('Session removed.')
}
}}
>
<Trash2 className="size-3" />
Remove
</Button>
</div>
))}
</div>
) : null}
</SearchableSetting>
</section>
) : null,
@@ -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'
]
}
]
@@ -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
}
+32 -9
View File
@@ -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)}
/>
)
})}
</div>
</SortableContext>
</DndContext>
<DropdownMenu modal={false}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="mx-1 my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
@@ -315,6 +320,24 @@ export default function TabBar({
New Browser Tab
<DropdownMenuShortcut>{NEW_BROWSER_SHORTCUT}</DropdownMenuShortcut>
</DropdownMenuItem>
{onCreateSplitGroup && (
<>
<DropdownMenuItem
onSelect={() => onCreateSplitGroup('right')}
className="gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium"
>
<Columns2 className="size-4 text-muted-foreground" />
New Group Right
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onCreateSplitGroup('down')}
className="gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium"
>
<Rows2 className="size-4 text-muted-foreground" />
New Group Down
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -0,0 +1,515 @@
/* eslint-disable max-lines -- Why: group panels intentionally co-locate group-scoped tab chrome, activation/close handlers, and surface rendering so split groups cannot drift into a separate behavior path from the original root group. */
import { lazy, Suspense, useCallback, useMemo } from 'react'
import { X } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import type { OpenFile } from '@/store/slices/editor'
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
import { useAppStore } from '../../store'
import TabBar from '../tab-bar/TabBar'
import TerminalPane from '../terminal-pane/TerminalPane'
import BrowserPane, { destroyPersistentWebview } from '../browser-pane/BrowserPane'
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
type GroupEditorItem = OpenFile & { tabId: string }
const EMPTY_GROUPS: readonly never[] = []
const EMPTY_TABS: readonly never[] = []
const EMPTY_RUNTIME_TERMINALS: readonly never[] = []
const EMPTY_BROWSER_TABS: readonly never[] = []
export default function TabGroupPanel({
groupId,
worktreeId,
isFocused,
hasSplitGroups
}: {
groupId: string
worktreeId: string
isFocused: boolean
hasSplitGroups: boolean
}): React.JSX.Element {
const worktreeGroups = useAppStore(
useShallow((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS)
)
const worktreeUnifiedTabs = useAppStore(
useShallow((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS)
)
const openFiles = useAppStore((state) => state.openFiles)
const worktree = useAppStore(
useShallow(
(state) =>
Object.values(state.worktreesByRepo)
.flat()
.find((candidate) => candidate.id === worktreeId) ?? null
)
)
const focusGroup = useAppStore((state) => state.focusGroup)
const activateTab = useAppStore((state) => state.activateTab)
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
const closeOtherTabs = useAppStore((state) => state.closeOtherTabs)
const closeTabsToRight = useAppStore((state) => state.closeTabsToRight)
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
const createTab = useAppStore((state) => state.createTab)
const closeTab = useAppStore((state) => state.closeTab)
const setActiveTab = useAppStore((state) => state.setActiveTab)
const setActiveFile = useAppStore((state) => state.setActiveFile)
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)
const consumeSuppressedPtyExit = useAppStore((state) => state.consumeSuppressedPtyExit)
const createBrowserTab = useAppStore((state) => state.createBrowserTab)
const closeFile = useAppStore((state) => state.closeFile)
const closeAllFiles = useAppStore((state) => state.closeAllFiles)
const pinFile = useAppStore((state) => state.pinFile)
const expandedPaneByTabId = useAppStore((state) => state.expandedPaneByTabId)
const browserTabsByWorktree = useAppStore((state) => state.browserTabsByWorktree)
const runtimeTerminalTabs = useAppStore(
(state) => state.tabsByWorktree[worktreeId] ?? EMPTY_RUNTIME_TERMINALS
)
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const group = useMemo(
() => worktreeGroups.find((item) => item.id === groupId) ?? null,
[groupId, worktreeGroups]
)
const groupTabs = useMemo(
() => worktreeUnifiedTabs.filter((item) => item.groupId === groupId),
[groupId, worktreeUnifiedTabs]
)
const activeItemId = group?.activeTabId ?? null
const activeTab = groupTabs.find((item) => item.id === activeItemId) ?? null
const terminalTabs = useMemo(
() =>
groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => ({
id: item.entityId,
ptyId: null,
worktreeId,
title: item.label,
customTitle: item.customLabel,
color: item.color,
sortOrder: item.sortOrder,
createdAt: item.createdAt
})),
[groupTabs, worktreeId]
)
const editorItems = useMemo<GroupEditorItem[]>(
() =>
groupTabs
.filter(
(item) =>
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review'
)
.map((item) => {
const file = openFiles.find((candidate) => candidate.id === item.entityId)
return file ? { ...file, tabId: item.id } : null
})
.filter((item): item is GroupEditorItem => item !== null),
[groupTabs, openFiles]
)
const worktreeBrowserTabs = useMemo(
() => browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
[browserTabsByWorktree, worktreeId]
)
const browserItems = useMemo(
() =>
groupTabs
.filter((item) => item.contentType === 'browser')
.map((item) => {
const bt = worktreeBrowserTabs.find((candidate) => candidate.id === item.entityId)
return bt ?? null
})
.filter((item): item is BrowserTabState => item !== null),
[groupTabs, worktreeBrowserTabs]
)
const activeBrowserTab = useMemo(
() =>
activeTab?.contentType === 'browser'
? (worktreeBrowserTabs.find((bt) => bt.id === activeTab.entityId) ?? null)
: null,
[activeTab, worktreeBrowserTabs]
)
const runtimeTerminalTabById = useMemo(
() => new Map(runtimeTerminalTabs.map((tab) => [tab.id, tab])),
[runtimeTerminalTabs]
)
const closeEditorIfUnreferenced = useCallback(
(entityId: string, closingTabId: string) => {
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
(item) =>
item.id !== closingTabId &&
item.entityId === entityId &&
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review')
)
if (!otherReference) {
closeFile(entityId)
}
},
[closeFile, worktreeId]
)
const handleActivateTerminal = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveTab(terminalId)
setActiveTabType('terminal')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveTab, setActiveTabType, worktreeId]
)
const handleActivateEditor = useCallback(
(tabId: string) => {
const item = groupTabs.find((candidate) => candidate.id === tabId)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveFile(item.entityId)
setActiveTabType('editor')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveFile, setActiveTabType, worktreeId]
)
const handleActivateBrowser = useCallback(
(browserTabId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
setActiveBrowserTab(browserTabId)
setActiveTabType('browser')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId]
)
const handleClose = useCallback(
(itemId: string) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
closeUnifiedTab(item.id)
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs]
)
const handleCloseGroup = useCallback(() => {
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
(item) => item.groupId === groupId
)
for (const item of items) {
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
closeUnifiedTab(item.id)
}
}
// Why: split creation can leave intentionally empty groups behind. Closing
// the group chrome must collapse those placeholders too, not just groups
// that still own tabs.
closeEmptyGroup(worktreeId, groupId)
}, [
closeBrowserTab,
closeEditorIfUnreferenced,
closeEmptyGroup,
closeTab,
closeUnifiedTab,
groupId,
worktreeId
])
const handleCreateSplitGroup = useCallback(
(direction: 'right' | 'down') => {
focusGroup(worktreeId, groupId)
createEmptySplitGroup(worktreeId, groupId, direction)
},
[createEmptySplitGroup, focusGroup, groupId, worktreeId]
)
const handleCloseOthers = useCallback(
(itemId: string) => {
const closedIds = closeOtherTabs(itemId)
for (const closedId of closedIds) {
const item = groupTabs.find((candidate) => candidate.id === closedId)
if (!item) {
continue
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
}
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeOtherTabs, closeTab, groupTabs]
)
const handleCloseToRight = useCallback(
(itemId: string) => {
const closedIds = closeTabsToRight(itemId)
for (const closedId of closedIds) {
const item = groupTabs.find((candidate) => candidate.id === closedId)
if (!item) {
continue
}
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyPersistentWebview(item.entityId)
closeBrowserTab(item.entityId)
} else {
closeEditorIfUnreferenced(item.entityId, item.id)
}
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTabsToRight, closeTab, groupTabs]
)
const tabBar = (
<TabBar
tabs={terminalTabs}
activeTabId={activeTab?.contentType === 'terminal' ? activeTab.entityId : null}
worktreeId={worktreeId}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={handleActivateTerminal}
onClose={(terminalId) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
handleClose(item.id)
}
}}
onCloseOthers={(terminalId) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
handleCloseOthers(item.id)
}
}}
onCloseToRight={(terminalId) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (item) {
handleCloseToRight(item.id)
}
}}
onReorder={(_, order) => {
if (!group) {
return
}
const itemOrder = order
.map(
(entityId) =>
groupTabs.find(
(item) => item.contentType === 'terminal' && item.entityId === entityId
)?.id
)
.filter((value): value is string => Boolean(value))
.concat(
group.tabOrder.filter(
(itemId) =>
!groupTabs.find((item) => item.contentType === 'terminal' && item.id === itemId)
)
)
reorderUnifiedTabs(groupId, itemOrder)
}}
onNewTerminalTab={() => {
const terminal = createTab(worktreeId)
setActiveTab(terminal.id)
setActiveTabType('terminal')
}}
onNewBrowserTab={() => {
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab' })
}}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
onTogglePaneExpand={() => {}}
editorFiles={editorItems}
browserTabs={browserItems}
activeFileId={
activeTab?.contentType === 'terminal' || activeTab?.contentType === 'browser'
? null
: activeTab?.id
}
activeBrowserTabId={activeTab?.contentType === 'browser' ? activeTab.entityId : null}
activeTabType={
activeTab?.contentType === 'terminal'
? 'terminal'
: activeTab?.contentType === 'browser'
? 'browser'
: 'editor'
}
onActivateFile={handleActivateEditor}
onCloseFile={handleClose}
onActivateBrowserTab={handleActivateBrowser}
onCloseBrowserTab={(browserTabId) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (item) {
handleClose(item.id)
}
}}
onCloseAllFiles={closeAllFiles}
onPinFile={(_fileId, tabId) => {
if (!tabId) {
return
}
const item = groupTabs.find((candidate) => candidate.id === tabId)
if (!item) {
return
}
pinFile(item.entityId, item.id)
}}
tabBarOrder={(group?.tabOrder ?? []).map((itemId) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return itemId
}
return item.contentType === 'terminal' ? item.entityId : item.id
})}
onCreateSplitGroup={handleCreateSplitGroup}
/>
)
return (
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups
? ` group/tab-group border ${isFocused ? 'border-accent' : 'border-border'}`
: ''
}`}
onPointerDown={() => focusGroup(worktreeId, groupId)}
>
{/* Why: every group, including the initial unsplit root, must render its
chrome inside the same panel stack. Portaling the first group's tabs
into the window titlebar created a second vertical frame of reference,
so the first split appeared to "jump down" when later groups rendered
inline below it. */}
<div className="flex items-stretch h-9 shrink-0 border-b border-border bg-card">
{tabBar}
{hasSplitGroups && (
<button
type="button"
aria-label="Close tab group"
title="Close tab group"
onClick={(event) => {
event.stopPropagation()
handleCloseGroup()
}}
className="mr-1 my-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/50 hover:text-foreground group-hover/tab-group:opacity-100 focus:opacity-100"
>
<X className="size-4" />
</button>
)}
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
{groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => (
<TerminalPane
key={`${item.entityId}-${runtimeTerminalTabById.get(item.entityId)?.generation ?? 0}`}
tabId={item.entityId}
worktreeId={worktreeId}
cwd={worktree?.path}
isActive={
isFocused && activeTab?.id === item.id && activeTab.contentType === 'terminal'
}
// Why: in multi-group splits, the active terminal in each group
// must remain visible (display:flex) so the user sees its output,
// but only the focused group's terminal should receive keyboard
// input. isVisible controls rendering; isActive controls focus.
isVisible={activeTab?.id === item.id && activeTab.contentType === 'terminal'}
onPtyExit={(ptyId) => {
if (consumeSuppressedPtyExit(ptyId)) {
return
}
handleClose(item.id)
}}
onCloseTab={() => handleClose(item.id)}
/>
))}
{activeTab &&
activeTab.contentType !== 'terminal' &&
activeTab.contentType !== 'browser' && (
<div className="absolute inset-0 flex min-h-0 min-w-0">
{/* 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. */}
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading editor...
</div>
}
>
<EditorPanel activeFileId={activeTab.entityId} />
</Suspense>
</div>
)}
{browserItems.map((bt) => (
<div
key={bt.id}
className="absolute inset-0 flex min-h-0 min-w-0"
style={{ display: activeBrowserTab?.id === bt.id ? undefined : 'none' }}
>
<BrowserPane browserTab={bt} isActive={activeBrowserTab?.id === bt.id} />
</div>
))}
</div>
</div>
)
}
@@ -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 {
@@ -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()
})
@@ -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
+2 -1
View File
@@ -157,7 +157,8 @@ describe('useIpcEvents updater integration', () => {
onClearDismissal: () => () => {}
},
browser: {
onGuestLoadFailed: () => () => {}
onGuestLoadFailed: () => () => {},
onOpenLinkInOrcaTab: () => () => {}
},
rateLimits: {
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
+23 -7
View File
@@ -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)
}
@@ -56,6 +56,23 @@ function createSnapshot(overrides: Partial<AppState> = {}): 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
}
+22 -4
View File
@@ -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<string, BrowserTab[]>,
browserTabsByWorktree: Record<string, BrowserWorkspace[]>,
browserPagesByWorkspace: Record<string, BrowserPage[]>,
activeBrowserTabIdByWorktree: Record<string, string | null>
): Pick<WorkspaceSessionState, 'browserTabsByWorktree' | 'activeBrowserTabIdByWorktree'> {
): 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
}
}
@@ -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)
})
})
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -146,7 +146,7 @@ export type EditorSlice = {
activeTabType: WorkspaceVisibleTabType
setActiveTabType: (type: WorkspaceVisibleTabType) => void
openFile: (file: Omit<OpenFile, 'id' | 'isDirty'>, 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<AppState, [], [], EditorSlice> = (s
}
}),
pinFile: (fileId) =>
pinFile: (fileId, _tabId) =>
set((s) => {
const file = s.openFiles.find((f) => f.id === fileId)
if (!file?.isPreview) {
@@ -117,6 +117,7 @@ export function makeUnifiedTab(
overrides: Partial<Tab> & { id: string; worktreeId: string; groupId: string }
): Tab {
return {
entityId: overrides.id,
contentType: 'terminal',
label: 'Terminal 1',
customLabel: null,
@@ -11,6 +11,7 @@ import {
function makeTab(overrides: Partial<Tab> & { id: string; worktreeId: string }): Tab {
return {
entityId: overrides.id,
groupId: 'g1',
contentType: 'terminal',
label: overrides.id,
@@ -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',
@@ -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',
@@ -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',
+60 -1
View File
@@ -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<Pick<Tab, 'id' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned'>>
init?: Partial<
Pick<Tab, 'id' | 'entityId' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned'>
>
) => 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<AppState, [], [], TabsSlice> = (set,
tab = {
id,
entityId: init?.entityId ?? id,
groupId: group.id,
worktreeId,
contentType,
@@ -340,6 +352,53 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (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(
+10 -1
View File
@@ -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<AppState, [], [], UISlice> = (set) => ({
@@ -187,6 +190,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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 })
}
})
+5 -5
View File
@@ -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 =
+53
View File
@@ -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
}
+1
View File
@@ -168,6 +168,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
terminalLayoutsByTabId: {},
openFilesByWorktree: {},
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
activeBrowserTabIdByWorktree: {},
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {}
+69 -4
View File
@@ -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<string, PersistedOpenFile[]>
/** Per-worktree active editor file ID (filePath) at shutdown. */
activeFileIdByWorktree?: Record<string, string | null>
/** Persisted browser tabs, keyed by worktree ID. */
browserTabsByWorktree?: Record<string, BrowserTab[]>
/** Per-worktree active browser tab ID at shutdown. */
/** Persisted browser workspaces, keyed by worktree ID. */
browserTabsByWorktree?: Record<string, BrowserWorkspace[]>
/** Persisted browser pages, keyed by workspace ID. */
browserPagesByWorkspace?: Record<string, BrowserPage[]>
/** Per-worktree active browser workspace ID at shutdown. */
activeBrowserTabIdByWorktree?: Record<string, string | null>
/** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */
activeTabTypeByWorktree?: Record<string, WorkspaceVisibleTabType>
@@ -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 ──────────────────────────────────────────────