polish(desktop): rename userData folder to Backspace with first-launch migration
Electron derived userData from package.json's `@backspace/desktop` name, leaking the monorepo's pnpm scope into ~/Library/Application Support/. Now `app.setName` runs at module load before any userData consumer, and a one-shot migration atomically moves the historical folder to <appData>/Backspace, cleaning the empty @backspace/ parent. Conservative on conflict — never clobbers an existing populated target. EXDEV fallback to recursive copy. Smoke-recovery path flipped back to Backspace.
This commit is contained in:
@@ -937,6 +937,24 @@ GitHub releases are the update source. The `electron-updater` library handles ch
|
|||||||
|
|
||||||
## Persisted Files (userData)
|
## 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 `<appData>/@backspace/desktop/`. On first launch after the rename, `migrateUserData()` (in `userDataMigration.ts`) atomically moves that folder to `<appData>/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 |
|
| File | Content | Purpose |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| `instance-url.json` | `{ url: string }` | Saved instance URL |
|
| `instance-url.json` | `{ url: string }` | Saved instance URL |
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ import {
|
|||||||
buildAppMenuTemplate,
|
buildAppMenuTemplate,
|
||||||
type RecoveryState,
|
type RecoveryState,
|
||||||
} from './recovery';
|
} from './recovery';
|
||||||
|
import { migrateUserData } from './userDataMigration';
|
||||||
|
|
||||||
|
// Override Electron's package.json-derived app name so userData lives at
|
||||||
|
// "<appData>/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;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
const keybindManager = new KeybindManager();
|
const keybindManager = new KeybindManager();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ set -euo pipefail
|
|||||||
# ─── Config ──────────────────────────────────────────────────────────────
|
# ─── Config ──────────────────────────────────────────────────────────────
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
APP_BINARY="$REPO_ROOT/packages/desktop/dist-electron/mac-arm64/Backspace.app/Contents/MacOS/Backspace"
|
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_FILE="$USER_DATA_DIR/instance-url.json"
|
||||||
INSTANCE_URL_BACKUP="$INSTANCE_URL_FILE.smoketest-backup"
|
INSTANCE_URL_BACKUP="$INSTANCE_URL_FILE.smoketest-backup"
|
||||||
TMP_DIR="$(mktemp -d -t backspace-smoke.XXXXXX)"
|
TMP_DIR="$(mktemp -d -t backspace-smoke.XXXXXX)"
|
||||||
|
|||||||
Reference in New Issue
Block a user