mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
Browser v2: workspace model, session profiles, and cookie import (#577)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 0x00–0xFF without replacement
|
||||
// characters that UTF-8 decoding would insert for invalid sequences.
|
||||
const value = Buffer.from(plaintextHex, 'hex').toString('latin1')
|
||||
|
||||
decryptedCookies.push({
|
||||
plaintextHex,
|
||||
value,
|
||||
domain,
|
||||
name,
|
||||
path,
|
||||
secure,
|
||||
httpOnly,
|
||||
sameSite,
|
||||
expirationDate: expiresUtc > 0 ? expiresUtc : undefined
|
||||
})
|
||||
|
||||
const values = targetCols
|
||||
.map((col, i) => {
|
||||
if (col === 'encrypted_value') {
|
||||
return "X''"
|
||||
}
|
||||
if (col === 'value') {
|
||||
return `X'${plaintextHex}'`
|
||||
}
|
||||
return cols[i]
|
||||
})
|
||||
.join(', ')
|
||||
sqlStatements.push(`INSERT OR REPLACE INTO cookies (${colList}) VALUES (${values});`)
|
||||
imported++
|
||||
}
|
||||
diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`)
|
||||
|
||||
sqlStatements.push('COMMIT;')
|
||||
diag(` prepared ${imported} INSERT statements, ${skipped} skipped`)
|
||||
|
||||
const sqlFilePath = join(tmpDir, 'import.sql')
|
||||
writeFileSync(sqlFilePath, sqlStatements.join('\n'))
|
||||
|
||||
execSync(`sqlite3 "${stagingCookiesPath}" < "${sqlFilePath}"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
maxBuffer: 500 * 1024 * 1024
|
||||
})
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`)
|
||||
|
||||
// Why: clearing the session's in-memory cookie store before loading imported
|
||||
// cookies prevents stale cookies from a previous Orca browsing session from
|
||||
// mixing with the imported set. Mixed state (some old, some imported) causes
|
||||
// sites like Google to detect inconsistent session cookies and reject them.
|
||||
await targetSession.clearStorageData({ storages: ['cookies'] })
|
||||
diag(
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
|
||||
// Why: loading cookies into memory via cookies.set() makes them available
|
||||
// immediately without requiring a restart. The staging DB is kept as a
|
||||
// fallback for any cookies that fail the cookies.set() validation.
|
||||
for (const cookie of decryptedCookies) {
|
||||
const url = deriveUrl(cookie.domain, cookie.secure)
|
||||
if (!url) {
|
||||
memoryFailed++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// Why: __Host- prefixed cookies must not have a domain attribute and
|
||||
// must have path=/. Chromium rejects them otherwise.
|
||||
const isHostPrefixed = cookie.name.startsWith('__Host-')
|
||||
await targetSession.cookies.set({
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
...(isHostPrefixed ? {} : { domain: cookie.domain }),
|
||||
path: isHostPrefixed ? '/' : cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
})
|
||||
memoryLoaded++
|
||||
} catch {
|
||||
memoryFailed++
|
||||
}
|
||||
}
|
||||
|
||||
diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`)
|
||||
|
||||
if (memoryFailed > 0) {
|
||||
// Why: some cookies couldn't be loaded via cookies.set() (non-ASCII values
|
||||
// or other validation failures). Keep the staging DB so the next cold start
|
||||
// picks them up from SQLite where CookieMonster reads them without validation.
|
||||
browserSessionRegistry.setPendingCookieImport(stagingCookiesPath)
|
||||
diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`)
|
||||
} else {
|
||||
try {
|
||||
unlinkSync(stagingCookiesPath)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
diag(` all cookies loaded in-memory — no restart needed`)
|
||||
}
|
||||
|
||||
const ua = getUserAgentForBrowser(browser.family)
|
||||
if (ua) {
|
||||
targetSession.setUserAgent(ua)
|
||||
browserSessionRegistry.setupClientHintsOverride(targetSession, ua)
|
||||
browserSessionRegistry.persistUserAgent(ua)
|
||||
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
|
||||
}
|
||||
|
||||
const summary: BrowserCookieImportSummary = {
|
||||
totalCookies: allRows.length,
|
||||
importedCookies: imported,
|
||||
skippedCookies: skipped,
|
||||
domains: [...domainSet].sort()
|
||||
}
|
||||
|
||||
return { ok: true, profileId: '', summary }
|
||||
} catch (err) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
// Why: if the import fails after the staging DB was created, clean it up
|
||||
// to avoid a stale staged import being applied on the next cold start.
|
||||
try {
|
||||
unlinkSync(stagingCookiesPath)
|
||||
} catch {
|
||||
/* may not exist yet */
|
||||
}
|
||||
diag(` SQLite import failed: ${err}`)
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Could not import cookies from ${browser.label}. ${err}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
+64
-6
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -157,7 +157,8 @@ describe('useIpcEvents updater integration', () => {
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {}
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -168,6 +168,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
|
||||
terminalLayoutsByTabId: {},
|
||||
openFilesByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {}
|
||||
|
||||
+69
-4
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user