From 8dcb234d22ee49c8c6ce7c3ec0a0f09e611bbe11 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:48:26 -0400 Subject: [PATCH] feat(browser): multi-profile session management with per-tab switching (#823) --- src/main/browser/browser-cookie-import.ts | 14 + .../browser/browser-session-registry.test.ts | 45 ++- src/main/browser/browser-session-registry.ts | 67 +++- .../components/browser-pane/BrowserPane.tsx | 40 +-- .../browser-pane/BrowserToolbarMenu.tsx | 305 +++++++++++++++++ .../src/components/settings/BrowserPane.tsx | 306 +++++++----------- .../components/settings/BrowserProfileRow.tsx | 192 +++++++++++ src/renderer/src/store/slices/browser.ts | 41 ++- src/shared/constants.ts | 11 + 9 files changed, 777 insertions(+), 244 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx create mode 100644 src/renderer/src/components/settings/BrowserProfileRow.tsx diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index bb840c14..7af9a3c7 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -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.' } diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 8aee7852..1bb7b1b4 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -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) }) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 562d12dd..3a0a136d 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -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): 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) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index f69f0c54..79b88793 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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({ - {sessionProfile && ( -
- - {sessionProfile.label} -
- )} - - + destroyPersistentWebview(browserTab.id)} + /> {downloadState ? (
diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx new file mode 100644 index 00000000..81a5d1bf --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -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( + 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 => { + 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 => { + 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 => { + 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 ( + <> + + + + + + {allProfiles.map((profile) => { + const isActive = profile.id === effectiveProfileId + return ( + handleSwitchProfile(profile.id === 'default' ? null : profile.id)} + > + + {profile.label} + {profile.source?.browserFamily && ( + + {BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? + profile.source.browserFamily} + + )} + + ) + })} + + + + setNewProfileDialogOpen(true)}> + + New Profile… + + + + + + + + Import Cookies + + + + {detectedBrowsers.map((browser) => + browser.profiles.length > 1 ? ( + + From {browser.label} + + + {browser.profiles.map((profile) => ( + + void handleImportFromBrowser(browser.family, profile.directory) + } + > + {profile.name} + + ))} + + + + ) : ( + void handleImportFromBrowser(browser.family)} + > + From {browser.label} + + ) + )} + {detectedBrowsers.length > 0 && } + void handleImportFromFile()}> + From File… + + + + + + + + { + useAppStore.getState().openSettingsTarget({ pane: 'browser', repoId: null }) + useAppStore.getState().openSettingsPage() + }} + > + + Browser Settings… + + + + + { + if (!open) { + setPendingSwitchProfileId(undefined) + } + }} + > + + + Switch Profile + + Switching profiles will reload this page. Any unsaved form data will be lost. + + + + + + + + + + + + + New Browser Profile + +
{ + e.preventDefault() + void handleCreateProfile() + }} + > + setNewProfileName(e.target.value)} + placeholder="Profile name" + autoFocus + maxLength={50} + className="mb-4" + /> + + + + +
+
+
+ + ) +} diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 465e21e0..7efea5ea 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -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 = { - 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 ? (

- 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 ··· toolbar menu.

- - - - - - {detectedBrowsers.map((browser) => - browser.profiles.length > 1 ? ( - - From {browser.label} - - - {browser.profiles.map((profile) => ( - { - 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} - - ))} - - - - ) : ( - { - const store = useAppStore.getState() - const result = await store.importCookiesFromBrowser( - 'default', - browser.family - ) - if (result.ok) { - toast.success( - `Imported ${result.summary.importedCookies} cookies from ${browser.label}.` - ) - } else { - toast.error(result.reason) - } - }} - > - From {browser.label} - - ) - )} - {detectedBrowsers.length > 0 && } - { - const store = useAppStore.getState() - const result = await store.importCookiesToProfile('default') - if (result.ok) { - toast.success(`Imported ${result.summary.importedCookies} cookies from file.`) - } else if (result.reason !== 'canceled') { - toast.error(result.reason) - } - }} - > - From File… - - - + - {defaultProfile?.source ? ( -
-
- - Imported from{' '} - {BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ?? - defaultProfile.source.browserFamily} - {defaultProfile.source.profileName - ? ` (${defaultProfile.source.profileName})` - : ''} - - {defaultProfile.source.importedAt ? ( - - {new Date(defaultProfile.source.importedAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - })} - - ) : null} -
- -
- ) : null} - - {orphanedProfiles.length > 0 ? ( -
- {orphanedProfiles.map((profile) => ( -
-
- {profile.label} - - {profile.source - ? `Imported from ${BROWSER_FAMILY_LABELS[profile.source.browserFamily] ?? profile.source.browserFamily}${profile.source.profileName ? ` (${profile.source.profileName})` : ''}` - : 'Unused session'} - -
- -
- ))} -
- ) : null} +
+ setDefaultBrowserSessionProfileId(null)} + isDefault + /> + {nonDefaultProfiles.map((profile) => ( + setDefaultBrowserSessionProfileId(profile.id)} + /> + ))} +
) : null} + + { + if (!open) { + setNewProfileDialogOpen(false) + setNewProfileName('') + } + }} + > + + + New Browser Profile + +
{ + 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) + } + }} + > + setNewProfileName(e.target.value)} + placeholder="Profile name" + autoFocus + maxLength={50} + className="mb-4" + /> + + + + +
+
+
) } diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx new file mode 100644 index 00000000..a172c1fd --- /dev/null +++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx @@ -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 => { + 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 => { + 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 ( + + + + {detectedBrowsers.map((browser) => + browser.profiles.length > 1 ? ( + + From {browser.label} + + + {browser.profiles.map((bp) => ( + + void handleImportFromBrowser(browser.family, bp.directory) + } + > + {bp.name} + + ))} + + + + ) : ( + void handleImportFromBrowser(browser.family)} + > + From {browser.label} + + ) + )} + {detectedBrowsers.length > 0 && } + void handleImportFromFile()}> + From File… + + + + {isDefault ? ( + + ) : ( + + )} + + + ) +} diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 7497095b..876e572e 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -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 = 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 = } }, + 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 = 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 diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0d1ce960..a1594e17 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 = { + 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.