diff --git a/src/cli/handlers/profile-state.test.ts b/src/cli/handlers/profile-state.test.ts index 7b12cec5b12..259a55d0368 100644 --- a/src/cli/handlers/profile-state.test.ts +++ b/src/cli/handlers/profile-state.test.ts @@ -178,10 +178,44 @@ describe('profile-state CLI recovery', () => { expect(output).not.toContain('revision:') }) + it('keeps current SQLite through CLI and rewrites the diverged JSON', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + rmSync(profile.databaseFile) + rmSync(`${profile.databaseFile}-wal`) + rmSync(profile.exportPath) + const source = openProfileStateDatabase(profile.databaseFile, 'profile-cli-recovery') + try { + importProfileStateJson(source.db, JSON.stringify({ settings: { theme: 'sqlite' } })) + } finally { + source.db.close() + } + writeFileSync(profile.dataFile, JSON.stringify({ settings: { theme: 'older-build' } })) + await main(['profile', 'state', 'rollback', '--current-sqlite'], profile.userDataPath) + expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual({ + settings: { theme: 'sqlite' } + }) + expect(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])).toContain( + 'source: current SQLite' + ) + }) + + it('refuses to keep an unreadable SQLite and leaves JSON untouched', async () => { + const profile = createProfile() + getDefaultUserDataPathMock.mockReturnValue(profile.userDataPath) + const json = readFileSync(profile.dataFile) + await main(['profile', 'state', 'rollback', '--current-sqlite', '--json'], profile.userDataPath) + expect(process.exitCode).toBe(1) + expect(readFileSync(profile.dataFile)).toEqual(json) + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('damaged sqlite primary') + }) + it.each([ ['--current-json', '--revision', '1'], ['--current-json', '--backup', '1'], - ['--current-json=false'] + ['--current-json', '--current-sqlite'], + ['--current-json=false'], + ['--current-sqlite=false'] ])('rejects ambiguous current JSON arguments: %s', async (...flags) => { getCliStatusMock.mockClear() const profile = createProfile() diff --git a/src/cli/handlers/profile-state.ts b/src/cli/handlers/profile-state.ts index b84cd694df6..be7e70413bb 100644 --- a/src/cli/handlers/profile-state.ts +++ b/src/cli/handlers/profile-state.ts @@ -50,6 +50,9 @@ function formatRollback(result: ProfileStateRollbackResult): string { return [ `profileId: ${result.profileId}`, result.revision === null ? 'source: current JSON' : `revision: ${result.revision}`, + ...(result.storage === 'sqlite' && result.backupId === undefined + ? ['source: current SQLite'] + : []), `storage: ${result.storage}`, `restored: ${result.restoredPath}`, `quarantine: ${result.quarantineDirectory}`, @@ -119,17 +122,20 @@ export const PROFILE_STATE_HANDLERS: Record = { } function parseSelector(flags: Map): ProfileStateRecoverySelector { - if (['revision', 'backup', 'current-json'].filter((flag) => flags.has(flag)).length !== 1) { + const selectors = ['revision', 'backup', 'current-json', 'current-sqlite'] as const + if (selectors.filter((flag) => flags.has(flag)).length !== 1) { throw new RuntimeClientError( 'invalid_argument', - 'Select exactly one of --revision, --backup, or --current-json.' + 'Select exactly one of --revision, --backup, --current-json, or --current-sqlite.' ) } - if (flags.has('current-json')) { - if (flags.get('current-json') !== true) { - throw new RuntimeClientError('invalid_argument', '--current-json does not take a value.') + for (const kind of ['current-json', 'current-sqlite'] as const) { + if (flags.has(kind)) { + if (flags.get(kind) !== true) { + throw new RuntimeClientError('invalid_argument', `--${kind} does not take a value.`) + } + return { kind } } - return { kind: 'current-json' } } if (!flags.has('backup')) { return { kind: 'json', revision: parseRevision(flags) } diff --git a/src/cli/specs/profile-state.test.ts b/src/cli/specs/profile-state.test.ts index ef331390ecf..53798b47de3 100644 --- a/src/cli/specs/profile-state.test.ts +++ b/src/cli/specs/profile-state.test.ts @@ -14,6 +14,12 @@ describe('profile state rollback discovery', () => { expect(() => validateCommandAndFlags(PROFILE_STATE_COMMAND_SPECS, parsed)).not.toThrow() }) + it('parses the current SQLite selector as a boolean', () => { + const parsed = parseArgs(['profile', 'state', 'rollback', '--current-sqlite']) + expect(parsed.flags.get('current-sqlite')).toBe(true) + expect(() => validateCommandAndFlags(PROFILE_STATE_COMMAND_SPECS, parsed)).not.toThrow() + }) + it('explains that adoption selects one full state and preserves both copies', () => { const spec = PROFILE_STATE_COMMAND_SPECS.find((item) => item.path.at(-1) === 'rollback') expect(spec?.usage).toContain('--current-json') diff --git a/src/cli/specs/profile-state.ts b/src/cli/specs/profile-state.ts index 030a5b57b22..89c9ed13315 100644 --- a/src/cli/specs/profile-state.ts +++ b/src/cli/specs/profile-state.ts @@ -11,20 +11,23 @@ export const PROFILE_STATE_COMMAND_SPECS: CommandSpec[] = [ { path: ['profile', 'state', 'rollback'], destructive: true, - summary: 'Restore a SQLite backup, retained JSON export, or current JSON profile', + summary: + 'Restore a SQLite backup or retained JSON export, or keep the current JSON or SQLite profile', usage: - 'orca profile state rollback (--backup | --revision | --current-json) [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json'], + 'orca profile state rollback (--backup | --revision | --current-json | --current-sqlite) [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'revision', 'backup', 'current-json', 'current-sqlite'], notes: [ 'Orca must be stopped. Recovery validates the selected artifact and archives the current database family, JSON, and retained recovery artifacts before replacing state.', '--backup restores SQLite authority; --revision restores a JSON export for an older compatible runtime.', - '--current-json keeps the current orca-data.json, including edits from an older build. It replaces SQLite state without merging; both copies are archived. The next SQLite-capable start imports the selected JSON.' + '--current-json keeps the current orca-data.json, including edits from an older build. It replaces SQLite state without merging; both copies are archived. The next SQLite-capable start imports the selected JSON.', + '--current-sqlite keeps the current SQLite state and discards JSON edits from an older build. The JSON is archived, then rewritten from SQLite.' ], examples: [ 'orca profile state exports', 'orca profile state rollback --backup ', 'orca profile state rollback --revision 1', - 'orca profile state rollback --current-json' + 'orca profile state rollback --current-json', + 'orca profile state rollback --current-sqlite' ] } ] diff --git a/src/main/index.ts b/src/main/index.ts index d4de6cac7e9..bd02d96abcc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,10 +16,16 @@ import { shouldActivateDesktopForSecondInstance } from './startup/single-instanc import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' import { formatProfileStateStartupFailure, + isDivergedProfileStateFailure, profileStateStartupFailureClass } from './persistence/profile-state/profile-state-startup-failure' import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' -import { presentProfileStateStartupRecoveryDialog } from './persistence/profile-state/profile-state-startup-recovery-dialog' +import { + chooseProfileStateCopy, + presentProfileStateStartupRecoveryDialog, + readProfileStateCopySavedTimes +} from './persistence/profile-state/profile-state-startup-recovery-dialog' +import { profileStateDesktopRecoveryArgs } from './startup/profile-state-recovery-preflight' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -132,6 +138,24 @@ if (preflightReady) { console.error(`[profile-state] ${message}`) if (!state.isServeMode && !isBackgroundLaunch()) { try { + if (isDivergedProfileStateFailure(error)) { + const userDataPath = app.getPath('userData') + const choice = await chooseProfileStateCopy({ + ...readProfileStateCopySavedTimes(userDataPath), + showMessageBox: (options) => dialog.showMessageBox(options) + }) + if (choice !== undefined) { + // Recovery needs both profile locks, which only a fresh process can own safely. + app.relaunch({ + args: profileStateDesktopRecoveryArgs(process.argv, { + userDataPath, + selector: { kind: choice } + }) + }) + } + app.exit(1) + return + } await presentProfileStateStartupRecoveryDialog({ message, ...(failureClass === 'recovery-required' || failureClass === 'ambiguous-authority' diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts index 29e21c94b70..7056b8aec9f 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.ts @@ -84,7 +84,8 @@ export function bootstrapProfileStateAuthority( : authority.readInitialState() if (initialState === undefined) { throw new ProfileStateAuthorityBootstrapError( - 'Profile state has both JSON and SQLite storage without a matching acceptance marker' + 'Profile state has both JSON and SQLite storage without a matching acceptance marker', + 'diverged-json' ) } return { classification, authority, initialState, migrated: false } diff --git a/src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts b/src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts new file mode 100644 index 00000000000..53aa67120e9 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-current-sqlite-command.test.ts @@ -0,0 +1,147 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acquireProfileStateMaintenance, + acquireProfileStateRuntimeAdmission +} from './profile-state-access' +import { rollbackProfileState } from './profile-state-recovery-command' +import { + bootstrapProfileStateAuthority, + ProfileStateAuthorityBootstrapError +} from './profile-state-authority-bootstrap' +import { migrateProfileStateToSqlite } from './profile-state-migration' +import { profileStateJsonExportPath } from './legacy-json/profile-state-export-path' +import { isDivergedProfileStateFailure } from './profile-state-startup-failure' + +const roots: string[] = [] +const profileId = 'current-sqlite-recovery' +const sqliteState = { settings: { theme: 'dark', electronHttp1CompatibilityMode: true } } +const editedJson = JSON.stringify({ settings: { theme: 'light' } }) + +beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-current-sqlite-')) + roots.push(root) + const directory = join(root, 'profiles', profileId) + mkdirSync(directory, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: profileId, profiles: [{ id: profileId }] }) + ) + const dataFile = join(directory, 'orca-data.json') + const databaseFile = join(directory, 'profile-state.db') + const originalJson = JSON.stringify(sqliteState) + writeFileSync(dataFile, originalJson) + migrateProfileStateToSqlite({ + dataFile, + databaseFile, + profileId, + expectedLegacyJson: originalJson, + serializedState: originalJson + }).authority.close() + writeFileSync(dataFile, editedJson) + return { root, dataFile, databaseFile, profileId } +} + +function rollback(profile: ReturnType) { + const maintenance = acquireProfileStateMaintenance(profile.root) + try { + return rollbackProfileState(profile.root, { kind: 'current-sqlite' }, maintenance) + } finally { + maintenance.release() + } +} + +describe('keeping SQLite over JSON edited by an older build', () => { + it('tags the startup failure as a user-resolvable divergence', () => { + const profile = fixture() + let failure: unknown + try { + bootstrapProfileStateAuthority(profile) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(ProfileStateAuthorityBootstrapError) + expect(isDivergedProfileStateFailure(failure)).toBe(true) + expect(isDivergedProfileStateFailure(new ProfileStateAuthorityBootstrapError('other'))).toBe( + false + ) + }) + + it('archives the diverged JSON and republishes JSON that SQLite accepts', () => { + const profile = fixture() + const databaseBefore = readFileSync(profile.databaseFile) + const result = rollback(profile) + expect(result).toMatchObject({ + storage: 'sqlite', + restoredPath: profile.databaseFile, + revision: 1, + removedDatabaseFiles: [] + }) + expect(result.backupId).toBeUndefined() + expect(readFileSync(join(result.quarantineDirectory, 'orca-data.json'), 'utf8')).toBe( + editedJson + ) + expect(readFileSync(join(result.quarantineDirectory, 'profile-state.db'))).toEqual( + databaseBefore + ) + expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual(sqliteState) + + const reopened = bootstrapProfileStateAuthority(profile) + try { + expect(reopened.migrated).toBe(false) + expect(JSON.parse(reopened.authority?.readSerializedState() ?? 'null')).toEqual(sqliteState) + } finally { + reopened.authority?.close() + } + acquireProfileStateRuntimeAdmission(profile.root).release() + }) + + it('replaces JSON that an older build left unparseable', () => { + const profile = fixture() + writeFileSync(profile.dataFile, 'not json') + const result = rollback(profile) + expect(readFileSync(join(result.quarantineDirectory, 'orca-data.json'), 'utf8')).toBe( + 'not json' + ) + expect(JSON.parse(readFileSync(profile.dataFile, 'utf8'))).toEqual(sqliteState) + }) + + it('leaves both copies untouched when SQLite is unreadable', () => { + const profile = fixture() + writeFileSync(profile.databaseFile, 'broken database') + expect(() => rollback(profile)).toThrow() + expect(readFileSync(profile.databaseFile, 'utf8')).toBe('broken database') + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + }) + + it('restores the diverged JSON when republishing fails', () => { + const profile = fixture() + writeFileSync(profileStateJsonExportPath(profile.dataFile, 1), '{"conflicting":true}') + expect(() => rollback(profile)).toThrow('already exists with different content') + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + }) + + it('refuses while a profile owner holds admission', () => { + const profile = fixture() + const admission = acquireProfileStateRuntimeAdmission(profile.root) + try { + expect(() => rollback(profile)).toThrow('in use') + expect(readFileSync(profile.dataFile, 'utf8')).toBe(editedJson) + expect(existsSync(profile.databaseFile)).toBe(true) + } finally { + admission.release() + } + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-recovery-command.ts b/src/main/persistence/profile-state/profile-state-recovery-command.ts index f6e2b9ead52..750df221f1c 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-command.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-command.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync } from 'node:fs' import { ProfileStateRecoveryCommandError, type ProfileStateRecoverySelector, @@ -13,6 +13,9 @@ import { import { profileStateDatabaseBackups } from './profile-state-backup-path' import { restoreProfileStateJsonExport } from './legacy-json/profile-state-recovery' import { restoreProfileStateDatabaseBackup } from './profile-state-database-recovery' +import { quarantineProfileStateDatabase } from './profile-state-database-quarantine' +import { ProfileStateSqliteAuthority } from './profile-state-sqlite-authority' +import { durableWriteTempPath, writeFileDurableSync } from '../../durable-file-write' import type { ProfileStateMaintenance } from './profile-state-access' import { readProfileStateDomain } from './profile-state-domain-reader' import { isRecord } from './profile-state-document-validation' @@ -54,6 +57,9 @@ export function rollbackProfileState( if (selector.kind === 'sqlite') { return restoreDatabaseBackup(userDataPath, result, selector.backupId, maintenance) } + if (selector.kind === 'current-sqlite') { + return adoptCurrentDatabase(userDataPath, result, maintenance) + } const revision = selector.kind === 'json' ? selector.revision : null const exportPath = revision === null ? result.dataFile : profileStateJsonExportPath(result.dataFile, revision) @@ -104,14 +110,7 @@ function restoreDatabaseBackup( profileId: result.profileId, beforeRestore: () => invalidateHttp1CompatibilityMarker(userDataPath) }) - const settings = readProfileStateDomain(result.databaseFile, result.profileId, 'settings') - if (settings.kind !== 'unreadable') { - const enabled = - settings.kind === 'value' && - isRecord(settings.value) && - settings.value.electronHttp1CompatibilityMode === true - writeHttp1CompatibilityMarker(userDataPath, enabled, result.profileId) - } + syncHttp1CompatibilityMarkerFromDatabase(userDataPath, result) return { ...result, storage: 'sqlite', @@ -123,6 +122,71 @@ function restoreDatabaseBackup( } } +/** Keep SQLite and replace diverged JSON (e.g. edited by an older build) with its export. */ +function adoptCurrentDatabase( + userDataPath: string, + result: ProfileStateExportsResult, + maintenance: ProfileStateMaintenance +): ProfileStateRollbackResult { + maintenance.assertProfile(result.profileId, result.dataFile, result.databaseFile) + const authority = new ProfileStateSqliteAuthority(result.databaseFile, result.profileId) + try { + // Validate before archiving so an unreadable database leaves both copies untouched. + authority.readInitialState() + if (authority.revision === 0) { + throw new ProfileStateRecoveryCommandError( + 'runtime_error', + 'SQLite profile state is empty. Keep the current JSON instead.' + ) + } + const quarantine = quarantineProfileStateDatabase( + result.databaseFile, + result.profileId, + undefined, + 'profile-state-adopt-current-sqlite', + existsSync(result.dataFile) ? [result.dataFile] : [] + ) + invalidateHttp1CompatibilityMarker(userDataPath) + // A retained JSON the marker never accepted would fail the compatibility export's fence. + const divergedJson = existsSync(result.dataFile) ? readFileSync(result.dataFile) : undefined + rmSync(result.dataFile, { force: true }) + let revision: number + try { + revision = authority.writeJsonCompatibilityExport(result.dataFile) ?? authority.revision + } catch (error) { + if (divergedJson !== undefined && !existsSync(result.dataFile)) { + writeFileDurableSync(durableWriteTempPath(result.dataFile), result.dataFile, divergedJson) + } + throw error + } + syncHttp1CompatibilityMarkerFromDatabase(userDataPath, result) + return { + ...result, + storage: 'sqlite', + restoredPath: result.databaseFile, + revision, + quarantineDirectory: quarantine.directory, + removedDatabaseFiles: [] + } + } finally { + authority.close() + } +} + +function syncHttp1CompatibilityMarkerFromDatabase( + userDataPath: string, + result: ProfileStateExportsResult +): void { + const settings = readProfileStateDomain(result.databaseFile, result.profileId, 'settings') + if (settings.kind !== 'unreadable') { + const enabled = + settings.kind === 'value' && + isRecord(settings.value) && + settings.value.electronHttp1CompatibilityMode === true + writeHttp1CompatibilityMarker(userDataPath, enabled, result.profileId) + } +} + function syncHttp1CompatibilityMarkerAfterRollback( userDataPath: string, dataFile: string, diff --git a/src/main/persistence/profile-state/profile-state-recovery-required.ts b/src/main/persistence/profile-state/profile-state-recovery-required.ts index 3c008a700cc..12a254f7f1e 100644 --- a/src/main/persistence/profile-state/profile-state-recovery-required.ts +++ b/src/main/persistence/profile-state/profile-state-recovery-required.ts @@ -12,7 +12,11 @@ type ProfileStateRecoveryLocation = { export class ProfileStateAuthorityBootstrapError extends Error { readonly code = 'ambiguous-profile-state' as const - constructor(message: string) { + /** `diverged-json`: both copies are readable, so the user can pick one at startup. */ + constructor( + message: string, + readonly divergence?: 'diverged-json' + ) { super(message) this.name = 'ProfileStateAuthorityBootstrapError' } diff --git a/src/main/persistence/profile-state/profile-state-startup-failure.ts b/src/main/persistence/profile-state/profile-state-startup-failure.ts index 6b67b42c145..4c8710cbca2 100644 --- a/src/main/persistence/profile-state/profile-state-startup-failure.ts +++ b/src/main/persistence/profile-state/profile-state-startup-failure.ts @@ -9,6 +9,7 @@ type ProfileStateRecoveryFailure = { type ProfileStateAuthorityFailure = { code: 'ambiguous-profile-state' message: string + divergence?: unknown } export type ProfileStateStartupFailureClass = @@ -46,6 +47,11 @@ export function profileStateStartupFailureClass( return undefined } +/** Both copies are readable and differ only because JSON changed outside SQLite. */ +export function isDivergedProfileStateFailure(error: unknown): boolean { + return isProfileStateAuthorityFailure(error) && error.divergence === 'diverged-json' +} + /** Format profile-state startup failures without exposing a generic fatal-error path. */ export function formatProfileStateStartupFailure(error: unknown): string | undefined { if (isProfileStateRecoveryFailure(error)) { diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts index 44fb9ccf4bf..80c8687bbcc 100644 --- a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { presentProfileStateStartupRecoveryDialog } from './profile-state-startup-recovery-dialog' +import { + chooseProfileStateCopy, + presentProfileStateStartupRecoveryDialog +} from './profile-state-startup-recovery-dialog' describe('profile state startup recovery dialog', () => { it('offers a copyable offline export command and does not mutate state', async () => { @@ -61,4 +64,36 @@ describe('profile state startup recovery dialog', () => { ) expect(copyToClipboard).not.toHaveBeenCalled() }) + + it.each([ + [0, 'current-sqlite'], + [1, 'current-json'], + [2, undefined] + ] as const)('maps choice button %i to %s', async (response, expected) => { + const showMessageBox = vi.fn().mockResolvedValue({ response }) + await expect( + chooseProfileStateCopy({ + sqliteSavedAt: new Date(1), + jsonSavedAt: new Date(2), + formatTime: (time) => `t${time.getTime()}`, + showMessageBox + }) + ).resolves.toBe(expected) + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: ['Use SQLite (Recommended)', 'Use JSON', 'Quit'], + defaultId: 0, + cancelId: 2 + }) + ) + const { detail } = showMessageBox.mock.calls[0][0] + expect(detail).toContain('Last saved t1.') + expect(detail).toContain('Last saved t2.') + }) + + it('omits save times it could not read', async () => { + const showMessageBox = vi.fn().mockResolvedValue({ response: 2 }) + await chooseProfileStateCopy({ showMessageBox }) + expect(showMessageBox.mock.calls[0][0].detail).not.toContain('Last saved') + }) }) diff --git a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts index d89fbbb7729..9201ab54e17 100644 --- a/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts +++ b/src/main/persistence/profile-state/profile-state-startup-recovery-dialog.ts @@ -1,4 +1,7 @@ +import { statSync } from 'node:fs' import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' +import { getActiveProfileStateLocation } from './profile-state-active-location' +import { profileStateDatabaseFiles } from './profile-state-storage-classification' export type ProfileStateStartupRecoveryDialogDeps = { message: string @@ -28,3 +31,70 @@ export async function presentProfileStateStartupRecoveryDialog( deps.copyToClipboard(deps.recoveryCommand) } } + +export type ProfileStateCopyChoice = 'current-sqlite' | 'current-json' + +export type ProfileStateCopyChoiceDialogDeps = { + sqliteSavedAt?: Date + jsonSavedAt?: Date + formatTime?: (time: Date) => string + showMessageBox: (options: MessageBoxOptions) => Promise +} + +/** Best-effort save times; SQLite's latest commit may live only in its WAL, and -shm changes on every open. */ +export function readProfileStateCopySavedTimes(userDataPath: string): { + sqliteSavedAt?: Date + jsonSavedAt?: Date +} { + try { + const location = getActiveProfileStateLocation(userDataPath) + if (location === undefined) { + return {} + } + const sqliteTimes = profileStateDatabaseFiles(location.databaseFile) + .filter((path) => !path.endsWith('-shm')) + .map(modifiedAt) + .filter((time) => time !== undefined) + const sqliteSavedAt = + sqliteTimes.length === 0 ? undefined : new Date(Math.max(...sqliteTimes.map(Number))) + const jsonSavedAt = modifiedAt(location.dataFile) + return { + ...(sqliteSavedAt === undefined ? {} : { sqliteSavedAt }), + ...(jsonSavedAt === undefined ? {} : { jsonSavedAt }) + } + } catch { + return {} + } +} + +function modifiedAt(path: string): Date | undefined { + return statSync(path, { throwIfNoEntry: false })?.mtime +} + +/** Ask which diverged copy to keep; undefined means quit without changing either. */ +export async function chooseProfileStateCopy( + deps: ProfileStateCopyChoiceDialogDeps +): Promise { + const format = deps.formatTime ?? ((time: Date) => time.toLocaleString()) + const savedAt = (time: Date | undefined): string => + time === undefined ? '' : ` Last saved ${format(time)}.` + const { response } = await deps.showMessageBox({ + type: 'warning', + buttons: ['Use SQLite (Recommended)', 'Use JSON', 'Quit'], + defaultId: 0, + cancelId: 2, + noLink: true, + title: 'Choose profile state', + message: 'This profile has two saved copies that don’t match.', + detail: [ + 'This usually happens after opening the profile in an older version of Orca.', + '', + `SQLite: what this version of Orca saved. Changes made in the older version are discarded.${savedAt(deps.sqliteSavedAt)}`, + '', + `JSON: includes changes made in the older version. Changes this version saved since then are discarded.${savedAt(deps.jsonSavedAt)}`, + '', + 'Orca archives both copies before switching, then restarts.' + ].join('\n') + }) + return response === 0 ? 'current-sqlite' : response === 1 ? 'current-json' : undefined +} diff --git a/src/main/startup/profile-state-recovery-preflight.test.ts b/src/main/startup/profile-state-recovery-preflight.test.ts index 443610929da..0170025ff21 100644 --- a/src/main/startup/profile-state-recovery-preflight.test.ts +++ b/src/main/startup/profile-state-recovery-preflight.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + PROFILE_STATE_DESKTOP_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_RESULT_PREFIX } from '../../shared/profile-state-recovery-command' @@ -31,17 +32,26 @@ import { } from '../persistence/profile-state/profile-state-documents' import { writeProfileStateDatabaseSnapshotAsync } from '../persistence/profile-state/profile-state-database-snapshot' import * as marker from './http1-compatibility-marker' -import { runProfileStateRecoveryPreflight } from './profile-state-recovery-preflight' +import { + profileStateDesktopRecoveryArgs, + runProfileStateRecoveryPreflight +} from './profile-state-recovery-preflight' const mocks = vi.hoisted(() => ({ setPath: vi.fn(), requestSingleInstanceLock: vi.fn(), on: vi.fn(), exit: vi.fn(), + relaunch: vi.fn(), + whenReady: vi.fn(), + showMessageBox: vi.fn(), background: vi.fn(), output: vi.fn() })) -vi.mock('electron', () => ({ app: mocks })) +vi.mock('electron', () => ({ + app: mocks, + dialog: { showMessageBox: mocks.showMessageBox } +})) vi.mock('../window/foreground-activation-policy', () => ({ applyBackgroundActivationPolicy: mocks.background })) @@ -63,6 +73,8 @@ const roots: string[] = [] beforeEach(() => { vi.clearAllMocks() mocks.requestSingleInstanceLock.mockReturnValue(true) + mocks.whenReady.mockResolvedValue(undefined) + mocks.showMessageBox.mockResolvedValue({ response: 0 }) vi.stubEnv('ORCA_USER_DATA_PATH', '/stale/inherited/root') vi.stubEnv('ORCA_BYPASS_SINGLE_INSTANCE_LOCK', '1') vi.stubEnv('ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK', '0') @@ -277,4 +289,54 @@ describe('Electron recovery preflight', () => { expect(mocks.requestSingleInstanceLock).not.toHaveBeenCalled() expect(mocks.exit).toHaveBeenCalledWith(1) }) + + describe('desktop choice relaunch', () => { + function desktopArgv(item: ReturnType) { + writeFileSync(item.dataFile, JSON.stringify(item.restored)) + return [ + 'Orca', + ...profileStateDesktopRecoveryArgs(['Orca', '--inspect', 'orca://share/1'], { + userDataPath: item.root, + selector: { kind: 'current-json' } + }) + ] + } + + it('applies the choice without writing a CLI response, then relaunches ordinary startup', () => { + const item = fixture() + expect(runProfileStateRecoveryPreflight(desktopArgv(item))).toBe(true) + expect(JSON.parse(readFileSync(item.dataFile, 'utf8'))).toEqual(item.restored) + expect(existsSync(item.databaseFile)).toBe(false) + expect(mocks.output).not.toHaveBeenCalled() + expect(mocks.background).not.toHaveBeenCalled() + expect(mocks.relaunch).toHaveBeenCalledWith({ args: ['--inspect', 'orca://share/1'] }) + expect(mocks.exit).toHaveBeenCalledWith(0) + acquireProfileStateRuntimeAdmission(item.root).release() + }) + + it('reports a failed choice instead of relaunching', async () => { + const item = fixture() + mocks.requestSingleInstanceLock.mockReturnValue(false) + vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(runProfileStateRecoveryPreflight(desktopArgv(item))).toBe(true) + await vi.waitFor(() => expect(mocks.exit).toHaveBeenCalledWith(1)) + expect(mocks.relaunch).not.toHaveBeenCalled() + expect(mocks.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.stringContaining('Stop Orca') }) + ) + expect(readFileSync(item.databaseFile, 'utf8')).toBe('broken database') + }) + + it.each([ + ['Orca', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}'], + ['Orca', '--serve', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}'], + ['Orca', PROFILE_STATE_DESKTOP_RECOVERY_FLAG, '{}', PROFILE_STATE_RECOVERY_FLAG, '{}'] + ])('fails closed for malformed desktop launch %j', async (...argv) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(runProfileStateRecoveryPreflight(argv)).toBe(true) + await vi.waitFor(() => expect(mocks.exit).toHaveBeenCalledWith(1)) + expect(mocks.setPath).not.toHaveBeenCalled() + expect(mocks.relaunch).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/startup/profile-state-recovery-preflight.ts b/src/main/startup/profile-state-recovery-preflight.ts index 68ccbe68a13..7629f6241b9 100644 --- a/src/main/startup/profile-state-recovery-preflight.ts +++ b/src/main/startup/profile-state-recovery-preflight.ts @@ -1,7 +1,8 @@ import { writeFileSync, realpathSync } from 'node:fs' import { isAbsolute } from 'node:path' -import { app } from 'electron' +import { app, dialog } from 'electron' import { + PROFILE_STATE_DESKTOP_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_FLAG, PROFILE_STATE_RECOVERY_RESULT_PREFIX, ProfileStateRecoveryCommandError, @@ -16,21 +17,81 @@ import { acquireSingleInstanceLock } from './single-instance-lock' /** The process owning both locks performs recovery before Electron can initialize a runtime. */ export function runProfileStateRecoveryPreflight(argv: readonly string[] = process.argv): boolean { + if (argv.includes(PROFILE_STATE_DESKTOP_RECOVERY_FLAG)) { + runDesktopRecovery(argv) + return true + } const index = argv.indexOf(PROFILE_STATE_RECOVERY_FLAG) if (index === -1) { return false } process.env.ORCA_BACKGROUND_LAUNCH = '1' applyBackgroundActivationPolicy() - let response: ProfileStateRecoveryResponse + const response = + !argv.includes('--serve') || argv.lastIndexOf(PROFILE_STATE_RECOVERY_FLAG) !== index + ? invalidLaunch() + : runRecoveryRequest(argv[index + 1]) + let exitCode = response.ok ? 0 : 1 try { - if (!argv.includes('--serve') || argv.lastIndexOf(PROFILE_STATE_RECOVERY_FLAG) !== index) { - throw new ProfileStateRecoveryCommandError( - 'invalid_argument', - 'Invalid profile-state recovery launch.' - ) + writeFileSync(1, `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify(response)}\n`) + } catch { + // The CLI may have exited while recovery held the locks; never open an Electron error dialog. + exitCode = 1 + } + app.exit(exitCode) + return true +} + +/** Build the relaunch argv that applies a startup-dialog choice before ordinary startup. */ +export function profileStateDesktopRecoveryArgs( + argv: readonly string[], + request: { userDataPath: string; selector: { kind: 'current-json' | 'current-sqlite' } } +): string[] { + return [ + ...stripRecoveryArgs(argv.slice(1)), + PROFILE_STATE_DESKTOP_RECOVERY_FLAG, + JSON.stringify(request) + ] +} + +function runDesktopRecovery(argv: readonly string[]): void { + const index = argv.indexOf(PROFILE_STATE_DESKTOP_RECOVERY_FLAG) + const response = + argv.lastIndexOf(PROFILE_STATE_DESKTOP_RECOVERY_FLAG) !== index || + argv.includes(PROFILE_STATE_RECOVERY_FLAG) || + argv.includes('--serve') + ? invalidLaunch() + : runRecoveryRequest(argv[index + 1]) + if (response.ok) { + // Why relaunch: ordinary startup must run in a process that never held maintenance. + app.relaunch({ args: stripRecoveryArgs(argv.slice(1)) }) + app.exit(0) + return + } + console.error(`[profile-state] Desktop recovery failed: ${response.message}`) + void app + .whenReady() + .then(() => + dialog.showMessageBox({ + type: 'error', + buttons: ['Quit'], + title: 'Orca profile state was not changed', + message: 'Orca could not apply the selected profile state.', + detail: `${response.message}\n\nReopen Orca to choose again.` + }) + ) + .catch((error: unknown) => console.warn('[profile-state] Recovery error dialog failed:', error)) + .finally(() => app.exit(1)) +} + +function runRecoveryRequest(payload: string | undefined): ProfileStateRecoveryResponse { + try { + let raw: unknown + try { + raw = JSON.parse(payload ?? '') + } catch { + raw = undefined } - const raw: unknown = JSON.parse(argv[index + 1] ?? '') const parsed = profileStateRecoveryRequestSchema.safeParse(raw) if (!parsed.success || !isAbsolute(parsed.data.userDataPath)) { throw new ProfileStateRecoveryCommandError( @@ -50,7 +111,7 @@ export function runProfileStateRecoveryPreflight(argv: readonly string[] = proce 'Stop Orca before profile-state rollback so no process can write the SQLite database.' ) } - response = { + return { ok: true, result: rollbackProfileState(userDataPath, parsed.data.selector, maintenance) } @@ -58,19 +119,33 @@ export function runProfileStateRecoveryPreflight(argv: readonly string[] = proce maintenance.release() } } catch (error) { - response = { + return { ok: false, code: isProfileStateRecoveryCommandError(error) ? error.code : 'runtime_error', message: error instanceof Error ? error.message : String(error) } } - let exitCode = response.ok ? 0 : 1 - try { - writeFileSync(1, `${PROFILE_STATE_RECOVERY_RESULT_PREFIX}${JSON.stringify(response)}\n`) - } catch { - // The CLI may have exited while recovery held the locks; never open an Electron error dialog. - exitCode = 1 +} + +function invalidLaunch(): ProfileStateRecoveryResponse { + return { + ok: false, + code: 'invalid_argument', + message: 'Invalid profile-state recovery launch.' } - app.exit(exitCode) - return true +} + +function stripRecoveryArgs(args: readonly string[]): string[] { + const kept: string[] = [] + for (let i = 0; i < args.length; i++) { + if ( + args[i] === PROFILE_STATE_DESKTOP_RECOVERY_FLAG || + args[i] === PROFILE_STATE_RECOVERY_FLAG + ) { + i++ + continue + } + kept.push(args[i]) + } + return kept } diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts index a3ad092c3c8..a1d02eb8ece 100644 --- a/src/shared/cli-argument-boundary.ts +++ b/src/shared/cli-argument-boundary.ts @@ -10,6 +10,7 @@ export const CLI_BOOLEAN_FLAGS = new Set([ 'connect', 'current', 'current-json', + 'current-sqlite', 'debug', 'dry-run', 'enter', diff --git a/src/shared/profile-state-recovery-command.ts b/src/shared/profile-state-recovery-command.ts index 726e034ef93..a18ec454414 100644 --- a/src/shared/profile-state-recovery-command.ts +++ b/src/shared/profile-state-recovery-command.ts @@ -2,11 +2,14 @@ import { z } from 'zod' export const PROFILE_STATE_RECOVERY_FLAG = '--profile-state-recovery' export const PROFILE_STATE_RECOVERY_RESULT_PREFIX = '[profile-state-recovery] ' +/** Desktop variant: applies a startup-dialog choice, then relaunches Orca normally. */ +export const PROFILE_STATE_DESKTOP_RECOVERY_FLAG = '--profile-state-desktop-recovery' const positiveInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) const selectorSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('json'), revision: positiveInteger }).strict(), z.object({ kind: z.literal('current-json') }).strict(), + z.object({ kind: z.literal('current-sqlite') }).strict(), z.object({ kind: z.literal('sqlite'), backupId: z.string().min(1) }).strict() ])