diff --git a/docs/systems/desktop.md b/docs/systems/desktop.md index 21de7179..13644539 100644 --- a/docs/systems/desktop.md +++ b/docs/systems/desktop.md @@ -937,6 +937,24 @@ GitHub releases are the update source. The `electron-updater` library handles ch ## Persisted Files (userData) +### userData Folder Location + +The runtime userData folder is named `Backspace` on every platform: + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/Backspace/` | +| Linux | `~/.config/Backspace/` | +| Windows | `%APPDATA%\Backspace\` | + +Electron's default `app.getName()` reads `package.json`'s `name`, which in this monorepo is `@backspace/desktop` — that would land userData under a nested `@backspace/desktop/` folder. To prevent the monorepo's internal package name from leaking into a user-facing filesystem path, `main.ts` calls `app.setName('Backspace')` at module load, before any `app.getPath('userData')` consumer runs. electron-builder's `productName: Backspace` only renames the bundle metadata (`Backspace.app`, executable, installer, app menu) — it does not affect runtime userData. + +### One-Time Migration + +Earlier builds wrote to `/@backspace/desktop/`. On first launch after the rename, `migrateUserData()` (in `userDataMigration.ts`) atomically moves that folder to `/Backspace/` and removes the now-empty `@backspace/` parent. The migration is conservative: if the new folder already exists and is non-empty, it skips the move rather than clobbering existing state. Failures are logged, not thrown — a failed migration leaves the user with a fresh-install state, which is degraded but not broken. + +### Files + | File | Content | Purpose | |------|---------|---------| | `instance-url.json` | `{ url: string }` | Saved instance URL | diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 71752b73..7c4e9cc7 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -37,6 +37,29 @@ import { buildAppMenuTemplate, type RecoveryState, } from './recovery'; +import { migrateUserData } from './userDataMigration'; + +// Override Electron's package.json-derived app name so userData lives at +// "/Backspace" instead of leaking the monorepo's "@backspace/desktop" +// package name. Must run before any app.getPath('userData') consumer. +app.setName('Backspace'); + +// One-time migration from the historical scoped path. After the move the old +// folder is gone, so subsequent launches hit the old-missing no-op branch. +{ + const appDataDir = app.getPath('appData'); + const oldParent = path.join(appDataDir, '@backspace'); + const result = migrateUserData({ + oldDir: path.join(oldParent, 'desktop'), + newDir: path.join(appDataDir, 'Backspace'), + oldParent, + }); + if (result.kind === 'migrated') { + console.log(`[userData] migrated ${result.from} → ${result.to}`); + } else if (result.kind === 'failed') { + console.error('[userData] migration failed:', result.error); + } +} let mainWindow: BrowserWindow | null = null; const keybindManager = new KeybindManager(); diff --git a/packages/desktop/src/userDataMigration.test.ts b/packages/desktop/src/userDataMigration.test.ts new file mode 100644 index 00000000..79f24f2d --- /dev/null +++ b/packages/desktop/src/userDataMigration.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { migrateUserData } from './userDataMigration'; + +let tmpRoot: string; +let oldParent: string; +let oldDir: string; +let newDir: string; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'backspace-migration-')); + oldParent = path.join(tmpRoot, '@backspace'); + oldDir = path.join(oldParent, 'desktop'); + newDir = path.join(tmpRoot, 'Backspace'); +}); + +afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +function seedOld(): void { + fs.mkdirSync(oldDir, { recursive: true }); + fs.writeFileSync(path.join(oldDir, 'instance-url.json'), '{"url":"https://nova.ddns.net"}'); + fs.mkdirSync(path.join(oldDir, 'IndexedDB')); + fs.writeFileSync(path.join(oldDir, 'IndexedDB', 'leveldb.log'), 'data'); +} + +describe('migrateUserData', () => { + it('returns no-op when oldDir does not exist', () => { + const result = migrateUserData({ oldDir, newDir, oldParent }); + expect(result).toEqual({ kind: 'no-op', reason: 'old-missing' }); + expect(fs.existsSync(newDir)).toBe(false); + }); + + it('returns no-op:identical when oldDir === newDir', () => { + fs.mkdirSync(oldDir, { recursive: true }); + const result = migrateUserData({ oldDir, newDir: oldDir, oldParent }); + expect(result).toEqual({ kind: 'no-op', reason: 'identical' }); + }); + + it('migrates and cleans empty parent when newDir is absent', () => { + seedOld(); + const result = migrateUserData({ oldDir, newDir, oldParent }); + expect(result).toEqual({ kind: 'migrated', from: oldDir, to: newDir }); + expect(fs.existsSync(oldDir)).toBe(false); + expect(fs.existsSync(oldParent)).toBe(false); + expect(fs.readFileSync(path.join(newDir, 'instance-url.json'), 'utf-8')) + .toBe('{"url":"https://nova.ddns.net"}'); + expect(fs.existsSync(path.join(newDir, 'IndexedDB', 'leveldb.log'))).toBe(true); + }); + + it('migrates when newDir exists but is empty', () => { + seedOld(); + fs.mkdirSync(newDir); + const result = migrateUserData({ oldDir, newDir, oldParent }); + expect(result.kind).toBe('migrated'); + expect(fs.readFileSync(path.join(newDir, 'instance-url.json'), 'utf-8')) + .toBe('{"url":"https://nova.ddns.net"}'); + }); + + it('returns no-op:new-populated and leaves both folders intact', () => { + seedOld(); + fs.mkdirSync(newDir); + fs.writeFileSync(path.join(newDir, 'preexisting.json'), '{}'); + const result = migrateUserData({ oldDir, newDir, oldParent }); + expect(result).toEqual({ kind: 'no-op', reason: 'new-populated' }); + expect(fs.existsSync(path.join(oldDir, 'instance-url.json'))).toBe(true); + expect(fs.existsSync(path.join(newDir, 'preexisting.json'))).toBe(true); + }); + + it('preserves oldParent when it has sibling subdirectories', () => { + seedOld(); + fs.mkdirSync(path.join(oldParent, 'other-pkg')); + fs.writeFileSync(path.join(oldParent, 'other-pkg', 'state.json'), '{}'); + const result = migrateUserData({ oldDir, newDir, oldParent }); + expect(result.kind).toBe('migrated'); + expect(fs.existsSync(oldParent)).toBe(true); + expect(fs.existsSync(path.join(oldParent, 'other-pkg', 'state.json'))).toBe(true); + }); +}); diff --git a/packages/desktop/src/userDataMigration.ts b/packages/desktop/src/userDataMigration.ts new file mode 100644 index 00000000..c5f436d4 --- /dev/null +++ b/packages/desktop/src/userDataMigration.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; + +export type MigrationResult = + | { kind: 'no-op'; reason: 'old-missing' | 'new-populated' | 'identical' } + | { kind: 'migrated'; from: string; to: string } + | { kind: 'failed'; error: Error }; + +export interface MigrationOptions { + oldDir: string; + newDir: string; + oldParent: string; +} + +function dirExists(dir: string): boolean { + try { + return fs.statSync(dir).isDirectory(); + } catch { + return false; + } +} + +function isEmpty(dir: string): boolean { + return fs.readdirSync(dir).length === 0; +} + +function copyDirRecursive(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDirRecursive(s, d); + } else if (entry.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(s), d); + } else { + fs.copyFileSync(s, d); + } + } +} + +export function migrateUserData(opts: MigrationOptions): MigrationResult { + const { oldDir, newDir, oldParent } = opts; + + try { + if (path.resolve(oldDir) === path.resolve(newDir)) { + return { kind: 'no-op', reason: 'identical' }; + } + if (!dirExists(oldDir)) { + return { kind: 'no-op', reason: 'old-missing' }; + } + if (dirExists(newDir)) { + if (!isEmpty(newDir)) { + return { kind: 'no-op', reason: 'new-populated' }; + } + fs.rmdirSync(newDir); + } + + fs.mkdirSync(path.dirname(newDir), { recursive: true }); + + try { + fs.renameSync(oldDir, newDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err; + copyDirRecursive(oldDir, newDir); + fs.rmSync(oldDir, { recursive: true, force: true }); + } + + try { + fs.rmdirSync(oldParent); + } catch { + // Parent has other children, or doesn't exist — both fine. + } + + return { kind: 'migrated', from: oldDir, to: newDir }; + } catch (err) { + return { kind: 'failed', error: err instanceof Error ? err : new Error(String(err)) }; + } +} diff --git a/scripts/smoke-recovery.sh b/scripts/smoke-recovery.sh index 15eb8ace..86e96038 100755 --- a/scripts/smoke-recovery.sh +++ b/scripts/smoke-recovery.sh @@ -34,7 +34,7 @@ set -euo pipefail # ─── Config ────────────────────────────────────────────────────────────── REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_BINARY="$REPO_ROOT/packages/desktop/dist-electron/mac-arm64/Backspace.app/Contents/MacOS/Backspace" -USER_DATA_DIR="$HOME/Library/Application Support/@backspace/desktop" +USER_DATA_DIR="$HOME/Library/Application Support/Backspace" INSTANCE_URL_FILE="$USER_DATA_DIR/instance-url.json" INSTANCE_URL_BACKUP="$INSTANCE_URL_FILE.smoketest-backup" TMP_DIR="$(mktemp -d -t backspace-smoke.XXXXXX)"