feat(browser): multi-profile session management with per-tab switching (#823)

This commit is contained in:
Jinwoo Hong 2026-04-19 22:48:26 -04:00 committed by GitHub
parent 096c1818f3
commit 8dcb234d22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 777 additions and 244 deletions

View file

@ -1323,6 +1323,20 @@ export async function importCookiesFromBrowser(
const partitionName = targetPartition.replace('persist:', '')
const liveCookiesPath = join(app.getPath('userData'), 'Partitions', partitionName, 'Cookies')
// Why: Electron only creates the partition's Cookies SQLite file after the
// session has actually stored a cookie. For newly created profiles that have
// never been used by a webview, the file won't exist yet. Setting and
// removing a throwaway cookie forces Electron to initialize the database.
if (!existsSync(liveCookiesPath)) {
try {
await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' })
await targetSession.cookies.remove('https://localhost', '__init')
await targetSession.cookies.flushStore()
} catch {
// ignore — the set/remove may fail but flushStore should still create the file
}
}
if (!existsSync(liveCookiesPath)) {
rmSync(tmpDir, { recursive: true, force: true })
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }

View file

@ -50,27 +50,36 @@ describe('BrowserSessionRegistry', () => {
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()
expect(profile).not.toBeNull()
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('rejects creating a profile with scope default', () => {
const profile = browserSessionRegistry.createProfile('default', 'Sneaky')
expect(profile).toBeNull()
})
it('allows created profile partitions', () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Allowed')
expect(browserSessionRegistry.isAllowedPartition(profile.partition)).toBe(true)
expect(profile).not.toBeNull()
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-/)
expect(profile).not.toBeNull()
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)
expect(profile).not.toBeNull()
expect(browserSessionRegistry.resolvePartition(profile!.id)).toBe(profile!.partition)
})
it('resolves default partition for null/undefined profileId', () => {
@ -91,7 +100,8 @@ describe('BrowserSessionRegistry', () => {
it('updates profile source', () => {
const profile = browserSessionRegistry.createProfile('imported', 'Source Test')
const updated = browserSessionRegistry.updateProfileSource(profile.id, {
expect(profile).not.toBeNull()
const updated = browserSessionRegistry.updateProfileSource(profile!.id, {
browserFamily: 'edge',
importedAt: Date.now()
})
@ -101,11 +111,12 @@ describe('BrowserSessionRegistry', () => {
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(profile).not.toBeNull()
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()
expect(browserSessionRegistry.isAllowedPartition(profile!.partition)).toBe(false)
expect(browserSessionRegistry.getProfile(profile!.id)).toBeNull()
})
it('refuses to delete the default profile', async () => {
@ -116,14 +127,14 @@ describe('BrowserSessionRegistry', () => {
it('hydrates profiles from persisted data', () => {
const fakeProfile = {
id: 'hydrate-test-id',
id: '00000000-0000-0000-0000-000000000001',
scope: 'imported' as const,
partition: 'persist:orca-browser-session-hydrate-test-id',
partition: 'persist:orca-browser-session-00000000-0000-0000-0000-000000000001',
label: 'Hydrated',
source: { browserFamily: 'manual' as const, importedAt: 1000 }
}
browserSessionRegistry.hydrateFromPersisted([fakeProfile])
expect(browserSessionRegistry.getProfile('hydrate-test-id')).not.toBeNull()
expect(browserSessionRegistry.getProfile('00000000-0000-0000-0000-000000000001')).not.toBeNull()
expect(browserSessionRegistry.isAllowedPartition(fakeProfile.partition)).toBe(true)
})

View file

@ -1,6 +1,13 @@
import { app, type Session, session } from 'electron'
import { randomUUID } from 'node:crypto'
import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
@ -10,6 +17,7 @@ type BrowserSessionMeta = {
defaultSource: BrowserSessionProfile['source']
userAgent: string | null
pendingCookieDbPath: string | null
profiles: BrowserSessionProfile[]
}
// Why: the registry is the single source of truth for which Electron partitions
@ -43,10 +51,14 @@ class BrowserSessionRegistry {
return this.loadPersistedMeta().defaultSource
}
// Why: write-to-temp-then-rename is atomic on all supported platforms.
// A crash mid-write would only lose the temp file, not corrupt the live one.
private persistMeta(updates: Partial<BrowserSessionMeta>): void {
try {
const existing = this.loadPersistedMeta()
writeFileSync(this.metadataPath, JSON.stringify({ ...existing, ...updates }))
const tmpPath = `${this.metadataPath}.tmp`
writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates }))
renameSync(tmpPath, this.metadataPath)
} catch {
// best-effort
}
@ -59,6 +71,13 @@ class BrowserSessionRegistry {
})
}
// Why: non-default profiles are in-memory only unless explicitly persisted.
// Without this, created profiles vanish on app restart.
private persistProfiles(): void {
const nonDefault = [...this.profiles.values()].filter((p) => p.id !== 'default')
this.persistMeta({ profiles: nonDefault })
}
private loadPersistedMeta(): BrowserSessionMeta {
try {
const raw = readFileSync(this.metadataPath, 'utf-8')
@ -66,10 +85,11 @@ class BrowserSessionRegistry {
return {
defaultSource: data?.defaultSource ?? null,
userAgent: data?.userAgent ?? null,
pendingCookieDbPath: data?.pendingCookieDbPath ?? null
pendingCookieDbPath: data?.pendingCookieDbPath ?? null,
profiles: Array.isArray(data?.profiles) ? data.profiles : []
}
} catch {
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null }
return { defaultSource: null, userAgent: null, pendingCookieDbPath: null, profiles: [] }
}
}
@ -95,6 +115,9 @@ class BrowserSessionRegistry {
this.profiles.set('default', { ...current, source: meta.defaultSource })
}
}
if (meta.profiles.length > 0) {
this.hydrateFromPersisted(meta.profiles)
}
}
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
@ -222,13 +245,19 @@ class BrowserSessionRegistry {
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
}
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile {
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
// Why: only the constructor may create the default profile. Allowing the
// renderer to pass scope:'default' would create a second profile sharing
// ORCA_BROWSER_PARTITION, causing confusion on delete (clearing storage
// for the shared partition).
if (scope === 'default') {
return null
}
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 partition = `persist:orca-browser-session-${id}`
const profile: BrowserSessionProfile = {
id,
scope,
@ -237,9 +266,8 @@ class BrowserSessionRegistry {
source: null
}
this.profiles.set(id, profile)
if (partition !== ORCA_BROWSER_PARTITION) {
this.setupSessionPolicies(partition)
}
this.setupSessionPolicies(partition)
this.persistProfiles()
return profile
}
@ -255,6 +283,8 @@ class BrowserSessionRegistry {
this.profiles.set(profileId, updated)
if (profileId === 'default') {
this.persistSource(source)
} else {
this.persistProfiles()
}
return updated
}
@ -265,6 +295,7 @@ class BrowserSessionRegistry {
return false
}
this.profiles.delete(profileId)
this.persistProfiles()
// Why: clearing the partition's storage prevents orphaned cookies/cache from
// lingering after the user deletes an imported or isolated session profile.
@ -303,9 +334,23 @@ class BrowserSessionRegistry {
// 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.
// Why: profiles are deserialized from a JSON file on disk. A corrupted or
// tampered file could inject an arbitrary partition into the allowlist that
// will-attach-webview trusts, so we validate the expected shape before
// registering anything.
private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/
hydrateFromPersisted(profiles: BrowserSessionProfile[]): void {
for (const profile of profiles) {
if (profile.id === 'default') {
if (profile.id === 'default' || profile.scope === 'default') {
continue
}
if (
typeof profile.id !== 'string' ||
typeof profile.partition !== 'string' ||
typeof profile.label !== 'string' ||
!BrowserSessionRegistry.PARTITION_RE.test(profile.partition)
) {
continue
}
this.profiles.set(profile.id, profile)

View file

@ -11,11 +11,9 @@ import {
ExternalLink,
Globe,
Image,
Import,
Loader2,
OctagonX,
RefreshCw,
Settings,
SquareCode
} from 'lucide-react'
import { Button } from '@/components/ui/button'
@ -57,6 +55,7 @@ import { useGrabMode } from './useGrabMode'
import { formatGrabPayloadAsText } from './GrabConfirmationSheet'
import { isEditableKeyboardTarget } from './browser-keyboard'
import BrowserAddressBar from './BrowserAddressBar'
import { BrowserToolbarMenu } from './BrowserToolbarMenu'
import BrowserFind from './BrowserFind'
import {
consumeBrowserFocusRequest,
@ -1207,12 +1206,16 @@ function BrowserPagePane({
}
}
// Why: this effect mounts and wires up webview event listeners once per tab
// identity. browserTab.url and webviewPartition are intentionally excluded:
// re-running on URL changes would detach/reattach the webview, cancelling
// in-progress navigations. Callbacks use refs so they always see current values.
// identity. browserTab.url is intentionally excluded: re-running on URL
// changes would detach/reattach the webview, cancelling in-progress
// navigations. Callbacks use refs so they always see current values.
// webviewPartition IS included: switching profiles changes the partition,
// which requires destroying and recreating the webview since Electron does
// not allow changing a webview's partition after creation.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
browserTab.id,
webviewPartition,
workspaceId,
worktreeId,
createBrowserTab,
@ -1817,28 +1820,11 @@ function BrowserPagePane({
<ExternalLink className="size-4" />
</Button>
{sessionProfile && (
<div
className="flex h-6 items-center gap-1 rounded-md bg-accent/60 px-2 text-[10px] font-medium text-muted-foreground"
title={`Session: ${sessionProfile.label}${sessionProfile.source?.browserFamily ? ` (${sessionProfile.source.browserFamily})` : ''}`}
>
<Import className="size-3" />
{sessionProfile.label}
</div>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
title="Browser Settings"
onClick={() => {
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
useAppStore.getState().openSettingsPage()
}}
>
<Settings className="size-4" />
</Button>
<BrowserToolbarMenu
currentProfileId={sessionProfileId}
workspaceId={workspaceId}
onDestroyWebview={() => destroyPersistentWebview(browserTab.id)}
/>
</div>
{downloadState ? (
<div className="flex items-center gap-3 border-b border-border/60 bg-amber-500/10 px-3 py-2 text-xs text-foreground/90">

View file

@ -0,0 +1,305 @@
import { useState } from 'react'
import { Check, Ellipsis, Import, Plus, Settings } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants'
type BrowserToolbarMenuProps = {
currentProfileId: string | null
workspaceId: string
onDestroyWebview: () => void
}
export function BrowserToolbarMenu({
currentProfileId,
workspaceId,
onDestroyWebview
}: BrowserToolbarMenuProps): React.JSX.Element {
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
const switchBrowserTabProfile = useAppStore((s) => s.switchBrowserTabProfile)
const createBrowserSessionProfile = useAppStore((s) => s.createBrowserSessionProfile)
const importCookiesFromBrowser = useAppStore((s) => s.importCookiesFromBrowser)
const importCookiesToProfile = useAppStore((s) => s.importCookiesToProfile)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const [newProfileDialogOpen, setNewProfileDialogOpen] = useState(false)
const [newProfileName, setNewProfileName] = useState('')
const [isCreatingProfile, setIsCreatingProfile] = useState(false)
const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState<string | null | undefined>(
undefined
)
const effectiveProfileId = currentProfileId ?? 'default'
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
// Why: Default profile always appears first in the list and cannot be deleted.
// Non-default profiles follow in their natural order.
const allProfiles = defaultProfile
? [defaultProfile, ...browserSessionProfiles.filter((p) => p.id !== 'default')]
: browserSessionProfiles
const handleSwitchProfile = (profileId: string | null): void => {
const targetId = profileId ?? 'default'
if (targetId === effectiveProfileId) {
return
}
setPendingSwitchProfileId(profileId)
}
const confirmSwitchProfile = (): void => {
if (pendingSwitchProfileId === undefined) {
return
}
const targetId = pendingSwitchProfileId ?? 'default'
// Why: Must destroy before store update. The webviewRegistry is keyed by
// workspace ID (stable across switches). Without explicit destroy, the mount
// effect would reclaim the old webview with the stale partition.
onDestroyWebview()
switchBrowserTabProfile(workspaceId, pendingSwitchProfileId)
const profile = browserSessionProfiles.find((p) => p.id === targetId)
toast.success(`Switched to ${profile?.label ?? 'Default'} profile`)
setPendingSwitchProfileId(undefined)
}
const handleCreateProfile = async (): Promise<void> => {
const trimmed = newProfileName.trim()
if (!trimmed) {
return
}
setIsCreatingProfile(true)
try {
const profile = await createBrowserSessionProfile('isolated', trimmed)
if (!profile) {
toast.error('Failed to create profile.')
return
}
setNewProfileDialogOpen(false)
setNewProfileName('')
onDestroyWebview()
switchBrowserTabProfile(workspaceId, profile.id)
toast.success(`Created and switched to ${profile.label} profile`)
} finally {
setIsCreatingProfile(false)
}
}
const handleImportFromBrowser = async (
browserFamily: string,
browserProfile?: string
): Promise<void> => {
const result = await importCookiesFromBrowser(effectiveProfileId, browserFamily, browserProfile)
if (result.ok) {
const browser = detectedBrowsers.find((b) => b.family === browserFamily)
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''}.`
)
} else {
toast.error(result.reason)
}
}
const handleImportFromFile = async (): Promise<void> => {
const result = await importCookiesToProfile(effectiveProfileId)
if (result.ok) {
toast.success(`Imported ${result.summary.importedCookies} cookies from file.`)
} else if (result.reason !== 'canceled') {
toast.error(result.reason)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" title="Browser menu">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{allProfiles.map((profile) => {
const isActive = profile.id === effectiveProfileId
return (
<DropdownMenuItem
key={profile.id}
onSelect={() => handleSwitchProfile(profile.id === 'default' ? null : profile.id)}
>
<Check
className={`mr-2 size-3.5 shrink-0 ${isActive ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="truncate">{profile.label}</span>
{profile.source?.browserFamily && (
<span className="ml-auto pl-2 text-[10px] text-muted-foreground">
{BROWSER_FAMILY_LABELS[profile.source.browserFamily] ??
profile.source.browserFamily}
</span>
)}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setNewProfileDialogOpen(true)}>
<Plus className="mr-2 size-3.5" />
New Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={browserSessionImportState?.status === 'importing'}>
<Import className="mr-2 size-3.5" />
Import Cookies
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{detectedBrowsers.map((browser) =>
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{browser.profiles.map((profile) => (
<DropdownMenuItem
key={profile.directory}
onSelect={() =>
void handleImportFromBrowser(browser.family, profile.directory)
}
>
{profile.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
From {browser.label}
</DropdownMenuItem>
)
)}
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={() => void handleImportFromFile()}>
From File
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null })
useAppStore.getState().openSettingsPage()
}}
>
<Settings className="mr-2 size-3.5" />
Browser Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
open={pendingSwitchProfileId !== undefined}
onOpenChange={(open) => {
if (!open) {
setPendingSwitchProfileId(undefined)
}
}}
>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base">Switch Profile</DialogTitle>
<DialogDescription className="text-xs">
Switching profiles will reload this page. Any unsaved form data will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingSwitchProfileId(undefined)}
>
Cancel
</Button>
<Button size="sm" onClick={confirmSwitchProfile}>
Switch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={newProfileDialogOpen} onOpenChange={setNewProfileDialogOpen}>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base">New Browser Profile</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault()
void handleCreateProfile()
}}
>
<Input
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name"
autoFocus
maxLength={50}
className="mb-4"
/>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setNewProfileDialogOpen(false)
setNewProfileName('')
}}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={!newProfileName.trim() || isCreatingProfile}
>
{isCreatingProfile ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -1,38 +1,18 @@
import { useEffect, useState } from 'react'
import { Import, Loader2, Trash2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'
import { useAppStore } from '../../store'
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import { normalizeBrowserNavigationUrl } from '../../../../shared/browser-url'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { BROWSER_PANE_SEARCH_ENTRIES } from './browser-search'
const BROWSER_FAMILY_LABELS: Record<string, string> = {
chrome: 'Google Chrome',
chromium: 'Chromium',
arc: 'Arc',
edge: 'Microsoft Edge',
brave: 'Brave',
firefox: 'Firefox',
safari: 'Safari',
manual: 'File'
}
import { BrowserProfileRow } from './BrowserProfileRow'
export { BROWSER_PANE_SEARCH_ENTRIES }
@ -45,12 +25,17 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
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 detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const defaultBrowserSessionProfileId = useAppStore((s) => s.defaultBrowserSessionProfileId)
const setDefaultBrowserSessionProfileId = useAppStore((s) => s.setDefaultBrowserSessionProfileId)
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
const orphanedProfiles = browserSessionProfiles.filter((p) => p.scope !== 'default')
const nonDefaultProfiles = browserSessionProfiles.filter((p) => p.scope !== 'default')
const [homePageDraft, setHomePageDraft] = useState(browserDefaultUrl ?? '')
const [newProfileDialogOpen, setNewProfileDialogOpen] = useState(false)
const [newProfileName, setNewProfileName] = useState('')
const [isCreatingProfile, setIsCreatingProfile] = useState(false)
// Why: sync draft with store value whenever it changes externally (e.g. the
// in-app browser tab's address bar saves a home page). Without this, the
@ -145,7 +130,7 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
{showCookies ? (
<SearchableSetting
title="Session & Cookies"
description="Import cookies from Chrome, Edge, or other browsers to use existing logins inside Orca."
description="Manage browser profiles and import cookies from Chrome, Edge, or other browsers."
keywords={[
'cookies',
'session',
@ -163,173 +148,120 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
<div className="space-y-0.5">
<Label>Session &amp; Cookies</Label>
<p className="text-xs text-muted-foreground">
Import cookies from your system browser to reuse existing logins inside Orca.
Select a default profile for new browser tabs. Import cookies and switch profiles
per-tab via the <strong>···</strong> toolbar menu.
</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) =>
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{browser.profiles.map((profile) => (
<DropdownMenuItem
key={profile.directory}
onSelect={async () => {
const store = useAppStore.getState()
const result = await store.importCookiesFromBrowser(
'default',
browser.family,
profile.directory
)
if (result.ok) {
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser.label} (${profile.name}).`
)
} else {
toast.error(result.reason)
}
}}
>
{profile.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<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>
<Button
variant="outline"
size="xs"
onClick={() => setNewProfileDialogOpen(true)}
className="shrink-0 gap-1.5"
>
<Plus className="size-3" />
Add Profile
</Button>
</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{' '}
{BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ??
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 ${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? 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}
<div className="space-y-2">
<BrowserProfileRow
profile={
defaultProfile ?? {
id: 'default',
scope: 'default',
partition: '',
label: 'Default',
source: null
}
}
detectedBrowsers={detectedBrowsers}
importState={browserSessionImportState}
isActive={(defaultBrowserSessionProfileId ?? 'default') === 'default'}
onSelect={() => setDefaultBrowserSessionProfileId(null)}
isDefault
/>
{nonDefaultProfiles.map((profile) => (
<BrowserProfileRow
key={profile.id}
profile={profile}
detectedBrowsers={detectedBrowsers}
importState={browserSessionImportState}
isActive={(defaultBrowserSessionProfileId ?? 'default') === profile.id}
onSelect={() => setDefaultBrowserSessionProfileId(profile.id)}
/>
))}
</div>
</SearchableSetting>
) : null}
<Dialog
open={newProfileDialogOpen}
onOpenChange={(open) => {
if (!open) {
setNewProfileDialogOpen(false)
setNewProfileName('')
}
}}
>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base">New Browser Profile</DialogTitle>
</DialogHeader>
<form
onSubmit={async (e) => {
e.preventDefault()
const trimmed = newProfileName.trim()
if (!trimmed) {
return
}
setIsCreatingProfile(true)
try {
const profile = await useAppStore
.getState()
.createBrowserSessionProfile('isolated', trimmed)
if (profile) {
setNewProfileDialogOpen(false)
setNewProfileName('')
toast.success(`Profile "${profile.label}" created.`)
} else {
toast.error('Failed to create profile.')
}
} finally {
setIsCreatingProfile(false)
}
}}
>
<Input
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name"
autoFocus
maxLength={50}
className="mb-4"
/>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setNewProfileDialogOpen(false)
setNewProfileName('')
}}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={!newProfileName.trim() || isCreatingProfile}
>
{isCreatingProfile ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,192 @@
import { Import, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import type { BrowserCookieImportSummary, BrowserSessionProfile } from '../../../../shared/types'
import { Button } from '../ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { useAppStore } from '../../store'
import { BROWSER_FAMILY_LABELS } from '../../../../shared/constants'
type DetectedBrowser = {
family: string
label: string
profiles: { name: string; directory: string }[]
selectedProfile: string
}
export type BrowserProfileRowProps = {
profile: BrowserSessionProfile
detectedBrowsers: DetectedBrowser[]
importState: {
profileId: string
status: 'idle' | 'importing' | 'success' | 'error'
summary: BrowserCookieImportSummary | null
error: string | null
} | null
isActive: boolean
onSelect: () => void
isDefault?: boolean
}
export function BrowserProfileRow({
profile,
detectedBrowsers,
importState,
isActive,
onSelect,
isDefault
}: BrowserProfileRowProps): React.JSX.Element {
const isImporting = importState?.profileId === profile.id && importState.status === 'importing'
const handleImportFromBrowser = async (
browserFamily: string,
browserProfile?: string
): Promise<void> => {
const result = await useAppStore
.getState()
.importCookiesFromBrowser(profile.id, browserFamily, browserProfile)
if (result.ok) {
const browser = detectedBrowsers.find((b) => b.family === browserFamily)
toast.success(
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''} into ${profile.label}.`
)
} else {
toast.error(result.reason)
}
}
const handleImportFromFile = async (): Promise<void> => {
const result = await useAppStore.getState().importCookiesToProfile(profile.id)
if (result.ok) {
toast.success(
`Imported ${result.summary.importedCookies} cookies from file into ${profile.label}.`
)
} else if (result.reason !== 'canceled') {
toast.error(result.reason)
}
}
const sourceLabel = profile.source
? `${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}`
: null
return (
<button
type="button"
onClick={onSelect}
className={`flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
isActive
? 'border-foreground/20 bg-accent/15'
: 'border-border/70 hover:border-border hover:bg-accent/8'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{profile.label}</span>
{isActive ? (
<span className="shrink-0 rounded border border-border/50 px-1.5 text-[10px] font-medium leading-4 text-foreground/80">
Active
</span>
) : null}
</div>
{sourceLabel ? (
<p className="truncate text-[11px] text-muted-foreground">{sourceLabel}</p>
) : (
<p className="text-[11px] text-muted-foreground">No cookies imported</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="xs"
className="h-6 gap-1 px-1.5 text-[11px] text-muted-foreground"
disabled={isImporting}
>
{isImporting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Import className="size-3" />
)}
Import Cookies
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{detectedBrowsers.map((browser) =>
browser.profiles.length > 1 ? (
<DropdownMenuSub key={browser.family}>
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{browser.profiles.map((bp) => (
<DropdownMenuItem
key={bp.directory}
onSelect={() =>
void handleImportFromBrowser(browser.family, bp.directory)
}
>
{bp.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
) : (
<DropdownMenuItem
key={browser.family}
onSelect={() => void handleImportFromBrowser(browser.family)}
>
From {browser.label}
</DropdownMenuItem>
)
)}
{detectedBrowsers.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={() => void handleImportFromFile()}>
From File
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isDefault ? (
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
disabled={!profile.source}
onClick={async () => {
const ok = await useAppStore.getState().clearDefaultSessionCookies()
if (ok) {
toast.success('Default cookies cleared.')
}
}}
>
<Trash2 className="size-3" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await useAppStore.getState().deleteBrowserSessionProfile(profile.id)
if (ok) {
toast.success(`Profile "${profile.label}" removed.`)
}
}}
>
<Trash2 className="size-3" />
</Button>
)}
</div>
</button>
)
}

View file

@ -70,6 +70,7 @@ export type BrowserSlice = {
setBrowserTabUrl: (pageId: string, url: string) => void
setBrowserPageUrl: (pageId: string, url: string) => void
hydrateBrowserSession: (session: WorkspaceSessionState) => void
switchBrowserTabProfile: (workspaceId: string, profileId: string | null) => void
browserSessionProfiles: BrowserSessionProfile[]
browserSessionImportState: {
profileId: string
@ -101,6 +102,8 @@ export type BrowserSlice = {
browserUrlHistory: BrowserHistoryEntry[]
addBrowserHistoryEntry: (url: string, title: string) => void
clearBrowserHistory: () => void
defaultBrowserSessionProfileId: string | null
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
}
const MAX_BROWSER_HISTORY_ENTRIES = 200
@ -293,16 +296,28 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
browserSessionProfiles: [],
browserSessionImportState: null,
browserUrlHistory: [],
defaultBrowserSessionProfileId: null,
setDefaultBrowserSessionProfileId: (profileId) => {
set({ defaultBrowserSessionProfileId: profileId })
},
createBrowserTab: (worktreeId, url, options) => {
const workspaceId = globalThis.crypto.randomUUID()
const page = buildBrowserPage(workspaceId, worktreeId, url, options?.title)
// Why: when no explicit profile is passed, inherit the user's chosen default
// profile. This lets users set a preferred profile in Settings that all new
// browser tabs use automatically.
const sessionProfileId =
options?.sessionProfileId !== undefined
? options.sessionProfileId
: get().defaultBrowserSessionProfileId
const browserTab = buildWorkspaceFromPage(
workspaceId,
worktreeId,
page,
[page.id],
options?.sessionProfileId
sessionProfileId
)
set((s) => {
@ -1064,6 +1079,25 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
}
},
switchBrowserTabProfile: (workspaceId, profileId) => {
set((s) => {
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
const tabIndex = tabs.findIndex((t) => t.id === workspaceId)
if (tabIndex !== -1) {
const updatedTabs = [...tabs]
updatedTabs[tabIndex] = { ...updatedTabs[tabIndex], sessionProfileId: profileId }
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: updatedTabs
}
}
}
}
return {}
})
},
fetchBrowserSessionProfiles: async () => {
try {
const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[]
@ -1095,7 +1129,10 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
const ok = await window.api.browser.sessionDeleteProfile({ profileId })
if (ok) {
set((s) => ({
browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId)
browserSessionProfiles: s.browserSessionProfiles.filter((p) => p.id !== profileId),
...(s.defaultBrowserSessionProfileId === profileId
? { defaultBrowserSessionProfileId: null }
: {})
}))
}
return ok

View file

@ -18,6 +18,17 @@ export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
// data URL while still rejecting arbitrary renderer-provided data URLs.
export const ORCA_BROWSER_BLANK_URL = 'data:text/html,'
export const BROWSER_FAMILY_LABELS: Record<string, string> = {
chrome: 'Google Chrome',
chromium: 'Chromium',
arc: 'Arc',
edge: 'Microsoft Edge',
brave: 'Brave',
firefox: 'Firefox',
safari: 'Safari',
manual: 'File'
}
// Pick a default terminal font that is likely to exist on the current OS.
// buildFontFamily() adds the full cross-platform fallback chain, so this only
// affects what users see in Settings as the initial value.