orca/src/main/persistence.ts

687 lines
26 KiB
TypeScript

/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration,
load/save, and flush logic in one file so the full storage contract is reviewable
as a unit instead of being scattered across modules. */
import { app, safeStorage } from 'electron'
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs'
import { writeFile, rename, mkdir, rm } from 'fs/promises'
import { join, dirname } from 'path'
import { homedir } from 'os'
import { randomUUID } from 'node:crypto'
import type {
PersistedState,
Repo,
SparsePreset,
WorktreeMeta,
GlobalSettings
} from '../shared/types'
import type { SshTarget } from '../shared/ssh-types'
import { isFolderRepo } from '../shared/repo-kind'
import { getGitUsername } from './git/repo'
import {
getDefaultPersistedState,
getDefaultNotificationSettings,
getDefaultUIState,
getDefaultRepoHookSettings,
getDefaultWorkspaceSession
} from '../shared/constants'
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
return plaintext
}
try {
return safeStorage.encryptString(plaintext).toString('base64')
} catch (err) {
console.error('[persistence] Encryption failed:', err)
return plaintext
}
}
function decrypt(ciphertext: string): string {
if (!ciphertext || !safeStorage.isEncryptionAvailable()) {
return ciphertext
}
try {
return safeStorage.decryptString(Buffer.from(ciphertext, 'base64'))
} catch {
// Why: if decryption fails, it likely means the value was stored as
// plaintext (pre-encryption build) or the OS keychain changed. Fall
// back to the raw string so users don't lose their cookie after upgrade.
console.warn(
'[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.'
)
return ciphertext
}
}
// Why: the data-file path must not be a module-level constant. Module-level
// code runs at import time — before configureDevUserDataPath() redirects the
// userData path in index.ts — so a constant would capture the default (non-dev)
// path, causing dev and production instances to share the same file and silently
// overwrite each other.
//
// It also must not be resolved lazily on every call, because app.setName('Orca')
// runs before the Store constructor and would change the resolved path from
// lowercase 'orca' to uppercase 'Orca'. On case-sensitive filesystems (Linux)
// this would look in the wrong directory and lose existing user data.
//
// Solution: index.ts calls initDataPath() right after configureDevUserDataPath()
// but before app.setName(), capturing the correct path at the right moment.
let _dataFile: string | null = null
export function initDataPath(): void {
_dataFile = join(app.getPath('userData'), 'orca-data.json')
}
function getDataFile(): string {
if (!_dataFile) {
// Safety fallback — should not be hit in normal startup.
_dataFile = join(app.getPath('userData'), 'orca-data.json')
}
return _dataFile
}
function normalizeSortBy(sortBy: unknown): 'name' | 'smart' | 'recent' | 'repo' {
if (sortBy === 'smart' || sortBy === 'recent' || sortBy === 'repo' || sortBy === 'name') {
return sortBy
}
return getDefaultUIState().sortBy
}
// Why: old persisted targets predate configHost. Default to label-based lookup
// so imported SSH aliases keep resolving through ssh -G after upgrade.
function normalizeSshTarget(t: SshTarget): SshTarget {
return { ...t, configHost: t.configHost ?? t.label ?? t.host }
}
export class Store {
private state: PersistedState
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
private writeGeneration = 0
private gitUsernameCache = new Map<string, string>()
constructor() {
this.state = this.load()
}
private load(): PersistedState {
// Capture once, at the top: this is the unambiguous "has the user run
// Orca before?" signal used by the telemetry cohort migration below.
// Field-based inference (e.g., `settings.telemetry` presence) does not
// work on the telemetry release itself — `telemetry` is new here, so it
// would be absent on every pre-telemetry install and misclassify existing
// users as fresh, flipping them to default-on in violation of the
// social contract we installed them under.
const dataFile = getDataFile()
const fileExistedOnLoad = existsSync(dataFile)
let result: PersistedState | null = null
try {
if (fileExistedOnLoad) {
const raw = readFileSync(dataFile, 'utf-8')
const parsed = JSON.parse(raw) as PersistedState
// Why: opencodeSessionCookie is stored encrypted on disk via safeStorage.
// Decrypt at the load boundary so the rest of the app sees plaintext.
if (parsed.settings?.opencodeSessionCookie) {
parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie)
}
// Merge with defaults in case new fields were added
const defaults = getDefaultPersistedState(homedir())
// Why: before the layout-aware 'auto' mode shipped (issue #903),
// terminalMacOptionAsAlt defaulted to 'true' globally. That silently
// broke Option-layer characters (@ on Turkish via Option+Q, @ on
// German via Option+L, € on French via Option+E) for non-US users.
// We can't distinguish a persisted 'true' that the user chose
// explicitly from one they inherited from the old default — so on
// first launch after upgrade, flip 'true' back to 'auto' and let
// the renderer's keyboard-layout probe pick the right value per
// layout. US users land on 'true' via detection (no change); non-US
// users land on 'false' (correct). 'false'/'left'/'right' are
// definitionally explicit choices (they never matched the old
// default) so we carry those forward unchanged. The migrated flag
// guards against re-running this on subsequent launches.
const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt
const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true
const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated
? (rawOptionAsAlt ?? 'auto')
: rawOptionAsAlt === undefined || rawOptionAsAlt === 'true'
? 'auto'
: rawOptionAsAlt
result = {
...defaults,
...parsed,
settings: {
...defaults.settings,
...parsed.settings,
terminalMacOptionAsAlt: migratedOptionAsAlt,
terminalMacOptionAsAltMigrated: true,
notifications: {
...getDefaultNotificationSettings(),
...parsed.settings?.notifications
}
},
// Why: 'recent' used to mean the weighted smart sort. One-shot
// migration moves it to 'smart'; the flag prevents re-firing after
// a user intentionally selects the new last-activity 'recent' sort.
// Gate on the *raw* persisted value, not the normalized one: the
// default sortBy is now 'recent', so a fresh install with no
// persisted sortBy would otherwise be mis-migrated to 'smart'.
ui: (() => {
const rawSort = parsed.ui?.sortBy
const sort = normalizeSortBy(rawSort)
const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent'
// Why: the 'inline-agents' card property was added after the
// experimentalAgentDashboard toggle. Users who had the toggle on
// in a prior rc already had worktreeCardProperties persisted
// without the new entry, so a simple defaults merge wouldn't
// reach them and the inline agent list stayed hidden after
// upgrade. One-shot append 'inline-agents' to their persisted
// array when the experimental toggle is true; the flag prevents
// re-firing so a deliberate uncheck from the Workspaces view
// options menu sticks across restarts.
// The flag is stamped on every successful load — including when
// the experiment is off — so that a later flip-on is handled by
// the renderer's ExperimentalPane handler rather than re-firing
// this migration.
const rawCardProps = parsed.ui?.worktreeCardProperties
const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForExperiment === true
const experimentOn = parsed.settings?.experimentalAgentDashboard === true
const needsInlineAgentsMigration =
!inlineAgentsMigrated &&
experimentOn &&
Array.isArray(rawCardProps) &&
!rawCardProps.includes('inline-agents')
const migratedCardProps =
needsInlineAgentsMigration && Array.isArray(rawCardProps)
? [...rawCardProps, 'inline-agents' as const]
: undefined
return {
...defaults.ui,
...parsed.ui,
sortBy: migrate ? ('smart' as const) : sort,
_sortBySmartMigrated: true,
...(migratedCardProps !== undefined
? { worktreeCardProperties: migratedCardProps }
: {}),
_inlineAgentsDefaultedForExperiment: true
}
})(),
// Why: the workspace session is the most volatile persisted surface
// (schema evolves per release, daemon session IDs embedded in it).
// Zod-validate at the read boundary so a field-type flip from an
// older build — or a truncated write from a crash — gets rejected
// cleanly instead of poisoning Zustand state and crashing the
// renderer on mount. On validation failure, fall back to defaults
// and log; a corrupt session file shouldn't trap the user out.
workspaceSession: (() => {
if (parsed.workspaceSession === undefined) {
return defaults.workspaceSession
}
const result = parseWorkspaceSession(parsed.workspaceSession)
if (!result.ok) {
console.error(
'[persistence] Corrupt workspace session, using defaults:',
result.error
)
return defaults.workspaceSession
}
return { ...defaults.workspaceSession, ...result.value }
})(),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget)
}
}
} catch (err) {
console.error('[persistence] Failed to load state, using defaults:', err)
}
// Corrupt-file catch path and "no file on disk" path converge here. The
// telemetry migration below runs on whichever branch produced `result`,
// because a user whose `orca-data.json` got corrupted is not a fresh
// install of the telemetry release — they still count as existing and
// must see the opt-in banner, not the default-on toast.
if (result === null) {
result = getDefaultPersistedState(homedir())
}
return this.migrateTelemetry(result, fileExistedOnLoad)
}
// One-shot telemetry cohort migration. Runs on every `load()` but is a
// no-op once `existedBeforeTelemetryRelease` is set, so subsequent launches
// pay only the property lookup. Populates:
// - `existedBeforeTelemetryRelease` — cohort discriminator (drives the
// first-launch toast vs. banner in PR 3).
// - `optedIn` — new users start opted in; existing users are `null` until
// the banner resolves (the consent resolver returns `pending_banner`
// until then, so nothing transmits).
// - `installId` — anonymous UUID v4. Stable across launches; regenerable
// from the Privacy pane (PR 3).
private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState {
const existing = state.settings?.telemetry
// Why: the one-shot is complete only when all three invariants hold.
// Keying on `existedBeforeTelemetryRelease` alone would let a partially-
// written telemetry block (crash mid-save, hand-edit, future bug) short-
// circuit migration and leave `installId` undefined or `optedIn` wiped.
if (
typeof existing?.existedBeforeTelemetryRelease === 'boolean' &&
typeof existing.installId === 'string' &&
existing.installId.length > 0 &&
(existing.optedIn === true || existing.optedIn === false || existing.optedIn === null)
) {
return state
}
return {
...state,
settings: {
...state.settings,
telemetry: {
...existing,
existedBeforeTelemetryRelease:
typeof existing?.existedBeforeTelemetryRelease === 'boolean'
? existing.existedBeforeTelemetryRelease
: fileExistedOnLoad,
// Why: preserve an explicit opt-in/out if the user has ever resolved
// it. Only fall back to the cohort default (new users: on; existing
// users: undecided until the first-launch banner resolves) when
// optedIn is truly unset (undefined), never when it is `false`.
optedIn:
existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null
? existing.optedIn
: fileExistedOnLoad
? null
: true,
installId:
typeof existing?.installId === 'string' && existing.installId.length > 0
? existing.installId
: randomUUID()
}
}
}
}
private scheduleSave(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
}
this.writeTimer = setTimeout(() => {
this.writeTimer = null
this.pendingWrite = this.writeToDiskAsync()
.catch((err) => {
console.error('[persistence] Failed to write state:', err)
})
.finally(() => {
this.pendingWrite = null
})
}, 300)
}
/** Wait for any in-flight async disk write to complete. Used in tests. */
async waitForPendingWrite(): Promise<void> {
if (this.pendingWrite) {
await this.pendingWrite
}
}
// Why: async writes avoid blocking the main Electron thread on every
// debounced save (every 300ms during active use).
private async writeToDiskAsync(): Promise<void> {
const gen = this.writeGeneration
const dataFile = getDataFile()
const dir = dirname(dataFile)
await mkdir(dir, { recursive: true }).catch(() => {})
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
// Why: opencodeSessionCookie must be encrypted on disk. Clone state so
// the in-memory this.state stays plaintext for the rest of the app.
const stateToSave = {
...this.state,
settings: {
...this.state.settings,
opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie)
}
}
// Why: wrap write+rename in try/finally-on-error so any failure (ENOSPC,
// ENFILE, EIO, permission) removes the tmp file rather than leaving a
// multi-megabyte orphan behind. Successful rename consumes the tmp file.
let renamed = false
try {
await writeFile(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8')
// Why: if flush() ran while this async write was in-flight, it bumped
// writeGeneration and already wrote the latest state synchronously.
// Renaming this stale tmp file would overwrite the fresh data.
if (this.writeGeneration !== gen) {
return
}
await rename(tmpFile, dataFile)
renamed = true
} finally {
if (!renamed) {
await rm(tmpFile).catch(() => {})
}
}
}
// Why: synchronous variant kept only for flush() at shutdown, where the
// process may exit before an async write completes.
private writeToDiskSync(): void {
const dataFile = getDataFile()
const dir = dirname(dataFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
// Why: opencodeSessionCookie must be encrypted on disk. Clone state so
// the in-memory this.state stays plaintext for the rest of the app.
const stateToSave = {
...this.state,
settings: {
...this.state.settings,
opencodeSessionCookie: encrypt(this.state.settings.opencodeSessionCookie)
}
}
// Why: mirror the async path — on any failure between writeFileSync and
// renameSync, remove the tmp file so crashes during shutdown don't leak
// orphans into userData.
let renamed = false
try {
writeFileSync(tmpFile, JSON.stringify(stateToSave, null, 2), 'utf-8')
renameSync(tmpFile, dataFile)
renamed = true
} finally {
if (!renamed) {
try {
unlinkSync(tmpFile)
} catch {
// Best-effort cleanup; the write already failed, swallow secondary error.
}
}
}
}
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
getRepo(id: string): Repo | undefined {
const repo = this.state.repos.find((r) => r.id === id)
return repo ? this.hydrateRepo(repo) : undefined
}
addRepo(repo: Repo): void {
this.state.repos.push(repo)
this.scheduleSave()
}
removeRepo(id: string): void {
this.state.repos = this.state.repos.filter((r) => r.id !== id)
// Why: presets are repo-scoped, so removing the repo means the presets
// can never be referenced again — drop them with the parent.
delete this.state.sparsePresetsByRepo[id]
// Clean up worktree meta for this repo
const prefix = `${id}::`
for (const key of Object.keys(this.state.worktreeMeta)) {
if (key.startsWith(prefix)) {
delete this.state.worktreeMeta[key]
}
}
this.scheduleSave()
}
updateRepo(
id: string,
updates: Partial<
Pick<
Repo,
| 'displayName'
| 'badgeColor'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'
| 'issueSourcePreference'
>
>
): Repo | null {
const repo = this.state.repos.find((r) => r.id === id)
if (!repo) {
return null
}
// Why: `issueSourcePreference === undefined` in the patch means "reset to
// auto" (and the persisted record should drop the key, not preserve a
// stale explicit value via Object.assign's skip-on-undefined behavior).
// Without this delete branch, toggling explicit → auto would silently
// leave the old preference in place on disk.
if ('issueSourcePreference' in updates && updates.issueSourcePreference === undefined) {
delete repo.issueSourcePreference
const { issueSourcePreference: _drop, ...rest } = updates
Object.assign(repo, rest)
} else {
Object.assign(repo, updates)
}
this.scheduleSave()
return this.hydrateRepo(repo)
}
private hydrateRepo(repo: Repo): Repo {
const gitUsername = isFolderRepo(repo)
? ''
: (this.gitUsernameCache.get(repo.path) ??
(() => {
const username = getGitUsername(repo.path)
this.gitUsernameCache.set(repo.path, username)
return username
})())
return {
...repo,
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {
...getDefaultRepoHookSettings(),
...repo.hookSettings,
scripts: {
...getDefaultRepoHookSettings().scripts,
...repo.hookSettings?.scripts
}
}
}
}
// ── Sparse Presets ─────────────────────────────────────────────────
getSparsePresets(repoId: string): SparsePreset[] {
return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
left.name.localeCompare(right.name)
)
}
saveSparsePreset(preset: SparsePreset): SparsePreset {
const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? []
const index = existing.findIndex((entry) => entry.id === preset.id)
this.state.sparsePresetsByRepo[preset.repoId] =
index === -1
? [...existing, preset]
: existing.map((entry, i) => (i === index ? preset : entry))
this.scheduleSave()
return preset
}
removeSparsePreset(repoId: string, presetId: string): void {
const existing = this.state.sparsePresetsByRepo[repoId] ?? []
this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId)
this.scheduleSave()
}
// ── Worktree Meta ──────────────────────────────────────────────────
getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined {
return this.state.worktreeMeta[worktreeId]
}
getAllWorktreeMeta(): Record<string, WorktreeMeta> {
return this.state.worktreeMeta
}
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta()
const updated = { ...existing, ...meta }
this.state.worktreeMeta[worktreeId] = updated
this.scheduleSave()
return updated
}
removeWorktreeMeta(worktreeId: string): void {
delete this.state.worktreeMeta[worktreeId]
this.scheduleSave()
}
// ── Settings ───────────────────────────────────────────────────────
getSettings(): GlobalSettings {
return this.state.settings
}
updateSettings(updates: Partial<GlobalSettings>): GlobalSettings {
// Why: `telemetry` is deep-merged for the same reason `notifications` is —
// partial updates from the Privacy pane / consent flow (e.g., flipping
// only `optedIn`) must not clobber sibling fields like `installId` or
// `existedBeforeTelemetryRelease`. The field is optional, so we only
// synthesize a `telemetry` key on the result when at least one side has
// one.
const mergedTelemetry =
updates.telemetry !== undefined
? { ...this.state.settings.telemetry, ...updates.telemetry }
: this.state.settings.telemetry
this.state.settings = {
...this.state.settings,
...updates,
notifications: {
...this.state.settings.notifications,
...updates.notifications
},
...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {})
}
this.scheduleSave()
return this.state.settings
}
// ── UI State ───────────────────────────────────────────────────────
getUI(): PersistedState['ui'] {
return {
...getDefaultUIState(),
...this.state.ui,
sortBy: normalizeSortBy(this.state.ui?.sortBy)
}
}
updateUI(updates: Partial<PersistedState['ui']>): void {
this.state.ui = {
...this.state.ui,
...updates,
sortBy: updates.sortBy
? normalizeSortBy(updates.sortBy)
: normalizeSortBy(this.state.ui?.sortBy)
}
this.scheduleSave()
}
// ── GitHub Cache ──────────────────────────────────────────────────
getGitHubCache(): PersistedState['githubCache'] {
return this.state.githubCache
}
setGitHubCache(cache: PersistedState['githubCache']): void {
this.state.githubCache = cache
this.scheduleSave()
}
// ── Workspace Session ─────────────────────────────────────────────
getWorkspaceSession(): PersistedState['workspaceSession'] {
return this.state.workspaceSession ?? getDefaultWorkspaceSession()
}
setWorkspaceSession(session: PersistedState['workspaceSession']): void {
this.state.workspaceSession = session
this.scheduleSave()
}
// ── SSH Targets ────────────────────────────────────────────────────
getSshTargets(): SshTarget[] {
return (this.state.sshTargets ?? []).map(normalizeSshTarget)
}
getSshTarget(id: string): SshTarget | undefined {
const target = this.state.sshTargets?.find((t) => t.id === id)
return target ? normalizeSshTarget(target) : undefined
}
addSshTarget(target: SshTarget): void {
this.state.sshTargets ??= []
this.state.sshTargets.push(normalizeSshTarget(target))
this.scheduleSave()
}
updateSshTarget(id: string, updates: Partial<Omit<SshTarget, 'id'>>): SshTarget | null {
const target = this.state.sshTargets?.find((t) => t.id === id)
if (!target) {
return null
}
Object.assign(target, updates, normalizeSshTarget({ ...target, ...updates }))
this.scheduleSave()
return { ...target }
}
removeSshTarget(id: string): void {
if (!this.state.sshTargets) {
return
}
this.state.sshTargets = this.state.sshTargets.filter((t) => t.id !== id)
this.scheduleSave()
}
// ── Flush (for shutdown) ───────────────────────────────────────────
flush(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
// Why: bump writeGeneration so any in-flight async writeToDiskAsync skips
// its rename, preventing a stale snapshot from overwriting this sync write.
this.writeGeneration++
this.pendingWrite = null
try {
this.writeToDiskSync()
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}
}
}
function getDefaultWorktreeMeta(): WorktreeMeta {
return {
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: Date.now(),
lastActivityAt: 0
}
}