diff --git a/electron/agent-hooks/runtime.ts b/electron/agent-hooks/runtime.ts index 065763dc..8239d78f 100644 --- a/electron/agent-hooks/runtime.ts +++ b/electron/agent-hooks/runtime.ts @@ -1,6 +1,7 @@ -import { app, type BrowserWindow } from 'electron'; +import { type BrowserWindow } from 'electron'; import path from 'path'; import { IPC } from '../ipc/channels.js'; +import { getUserDataDir } from '../user-data-dir.js'; import { setAgentHookRuntime } from '../ipc/pty.js'; import { error as logError, info as logInfo } from '../log.js'; import { emitAgentHookEvent } from './events.js'; @@ -15,7 +16,11 @@ let server: AgentHookServer | null = null; * Failure is logged and otherwise ignored: the PTY heuristics keep working. */ export async function startAgentHookRuntime(getWindow: () => BrowserWindow | null): Promise { - const dir = path.join(app.getPath('userData'), 'agent-hooks'); + // Per-instance, not raw userData: this directory holds endpoint.env (loopback + // port + bearer token), written only at startup, so sharing it between a dev + // run and an installed build hands every agent's hook events to whichever + // instance started last. + const dir = path.join(getUserDataDir(), 'agent-hooks'); // Resolved per event: the server starts before the window exists so that no // Claude launch can race it, and the window may be recreated later. const forward = (event: AgentHookEventPayload): void => { diff --git a/electron/ipc/persistence.ts b/electron/ipc/persistence.ts index df1d68be..803304f4 100644 --- a/electron/ipc/persistence.ts +++ b/electron/ipc/persistence.ts @@ -1,19 +1,9 @@ -import { app } from 'electron'; import fs from 'fs'; import path from 'path'; - -function getStateDir(): string { - let dir = app.getPath('userData'); - // Use separate dir for dev mode - if (!app.isPackaged) { - const base = path.basename(dir); - dir = path.join(path.dirname(dir), `${base}-dev`); - } - return dir; -} +import { getUserDataDir } from '../user-data-dir.js'; function getStatePath(): string { - return path.join(getStateDir(), 'state.json'); + return path.join(getUserDataDir(), 'state.json'); } export function saveAppState(json: string): void { @@ -52,7 +42,7 @@ export function saveAppState(json: string): void { } function getThemesDir(): string { - return path.join(getStateDir(), 'themes'); + return path.join(getUserDataDir(), 'themes'); } const VALID_THEME_ID = /^[a-zA-Z0-9_-]+$/; diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index aadab018..3d01257e 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -43,6 +43,7 @@ import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './ver import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/server.js'; import type { RemoteAttentionState } from '../remote/protocol.js'; import { atomicWriteFileSync } from '../mcp/atomic.js'; +import { getUserDataDir } from '../user-data-dir.js'; import { buildMcpLaunchArgs } from '../mcp/agent-args.js'; import { getSymlinkCandidates, @@ -766,29 +767,20 @@ export function registerAllHandlers(win: BrowserWindow): void { }); // --- Keybindings --- - function getKeybindingsDir(): string { - let dir = app.getPath('userData'); - if (!app.isPackaged) { - const base = path.basename(dir); - dir = path.join(path.dirname(dir), `${base}-dev`); - } - return dir; - } - ipcMain.handle(IPC.LoadKeybindings, () => { - return loadKeybindings(getKeybindingsDir()); + return loadKeybindings(getUserDataDir()); }); ipcMain.handle(IPC.SaveKeybindings, (_e, args) => { assertString(args?.json, 'json'); - saveKeybindings(getKeybindingsDir(), args.json); + saveKeybindings(getUserDataDir(), args.json); }); // --- Arena persistence --- ipcMain.handle(IPC.SaveArenaData, (_e, args) => { assertString(args.filename, 'filename'); assertString(args.json, 'json'); - const filePath = path.join(app.getPath('userData'), args.filename); + const filePath = path.join(getUserDataDir(), args.filename); const basename = path.basename(filePath); if (basename !== args.filename) throw new Error('Invalid filename'); if (!basename.startsWith('arena-') || !basename.endsWith('.json')) @@ -800,7 +792,7 @@ export function registerAllHandlers(win: BrowserWindow): void { ipcMain.handle(IPC.LoadArenaData, (_e, args) => { assertString(args.filename, 'filename'); - const filePath = path.join(app.getPath('userData'), args.filename); + const filePath = path.join(getUserDataDir(), args.filename); const basename = path.basename(filePath); if (basename !== args.filename) throw new Error('Invalid filename'); if (!basename.startsWith('arena-') || !basename.endsWith('.json')) diff --git a/electron/user-data-dir.test.ts b/electron/user-data-dir.test.ts new file mode 100644 index 00000000..191d17d0 --- /dev/null +++ b/electron/user-data-dir.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import path from 'path'; +import { resolveUserDataDir } from './user-data-dir.js'; + +describe('resolveUserDataDir', () => { + const userData = path.join('/home', 'someone', '.config', 'parallel-code'); + + it('uses the userData path as-is in a packaged build', () => { + expect(resolveUserDataDir(userData, true)).toBe(userData); + }); + + it('appends -dev to the last segment in a dev run', () => { + expect(resolveUserDataDir(userData, false)).toBe( + path.join('/home', 'someone', '.config', 'parallel-code-dev'), + ); + }); + + // Suffixing the whole path instead of the last segment would put dev's data + // in a sibling of the config root rather than beside the packaged profile. + it('keeps the parent directory', () => { + expect(path.dirname(resolveUserDataDir(userData, false))).toBe(path.dirname(userData)); + }); +}); diff --git a/electron/user-data-dir.ts b/electron/user-data-dir.ts new file mode 100644 index 00000000..69391ead --- /dev/null +++ b/electron/user-data-dir.ts @@ -0,0 +1,27 @@ +import { app } from 'electron'; +import path from 'path'; + +/** + * Per-instance data directory, given Electron's `userData` path. + * + * A dev run gets its own `-dev` directory so `npm run dev` does not read + * or write the installed build's data. Pure and parameterised so the rule can be + * tested without an Electron runtime, and so there is exactly one copy of it. + */ +export function resolveUserDataDir(userDataPath: string, isPackaged: boolean): string { + if (isPackaged) return userDataPath; + const base = path.basename(userDataPath); + return path.join(path.dirname(userDataPath), `${base}-dev`); +} + +/** + * The per-instance data directory for this process. + * + * Every file the app keeps under `userData` goes here rather than under + * `app.getPath('userData')` directly. The suffix is what keeps a dev run and an + * installed build from writing over each other, so a caller that reaches past + * this helper silently opts one of its files out of that separation. + */ +export function getUserDataDir(): string { + return resolveUserDataDir(app.getPath('userData'), app.isPackaged); +}