Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions electron/agent-hooks/runtime.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void> {
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 => {
Expand Down
16 changes: 3 additions & 13 deletions electron/ipc/persistence.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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_-]+$/;
Expand Down
18 changes: 5 additions & 13 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'))
Expand All @@ -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'))
Expand Down
23 changes: 23 additions & 0 deletions electron/user-data-dir.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
27 changes: 27 additions & 0 deletions electron/user-data-dir.ts
Original file line number Diff line number Diff line change
@@ -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 `<name>-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);
}