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:
Jannis Braun
2026-05-03 14:36:34 +02:00
parent 309484dca9
commit 2b0f93ec62
5 changed files with 203 additions and 1 deletions
+23
View File
@@ -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
// "<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;
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);
});
});
+79
View File
@@ -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)) };
}
}