Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/desktop-parallel-dev-instances.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge-desktop": patch
---

Dev: set `OK_INSTANCE=<name>` to run multiple desktop dev instances in parallel. Electron keys its single-instance lock on the `userData` directory, so two `electron-vite dev` processes normally collide and the second quits. `OK_INSTANCE` relocates each launch's `userData` to a named sibling directory (`Open Knowledge (<name>)`), giving every instance its own lock and its own isolated Chromium storage and recents. Honored only on unpackaged builds; packaged releases are unaffected.
20 changes: 20 additions & 0 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ import { EMBED_HOST_PATTERNS, rewriteEmbedRequestHeaders } from './embed-referer
import { discoverProject, validateFolderPick } from './folder-admission.ts';
import { ensureGitAvailable } from './git-preflight-handler.ts';
import { readCanonicalGitHubRemoteUrl } from './git-remote.ts';
import { deriveInstanceUserDataDir } from './instance-isolation.ts';
import { handleBuildAndOpen, handleDetectClaudeDesktop } from './ipc/install-skill.ts';
import {
createLocalOpState,
Expand Down Expand Up @@ -2420,6 +2421,25 @@ installStdioBrokenPipeGuard(process, {
},
});

if (!app.isPackaged && process.env.OK_INSTANCE) {
const relocatedUserData = deriveInstanceUserDataDir(
app.getPath('userData'),
process.env.OK_INSTANCE,
);
if (relocatedUserData) {
mkdirSync(relocatedUserData, { recursive: true });
app.setPath('userData', relocatedUserData);
getRootDesktopLogger().info(
{
event: 'desktop.parallel-instance',
instance: process.env.OK_INSTANCE,
userData: relocatedUserData,
},
'relocated userData for parallel dev instance',
);
}
}

if (isDriverBootSmokeMode(process.env)) {
app.whenReady().then(() => {
runDriverBootSmokeInProduction();
Expand Down
58 changes: 58 additions & 0 deletions packages/desktop/src/main/instance-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { deriveInstanceUserDataDir, sanitizeInstanceName } from './instance-isolation.ts';

describe('sanitizeInstanceName', () => {
test('passes simple alphanumeric names through unchanged', () => {
expect(sanitizeInstanceName('a')).toBe('a');
expect(sanitizeInstanceName('feature2')).toBe('feature2');
expect(sanitizeInstanceName('v1.2')).toBe('v1.2');
expect(sanitizeInstanceName('my_branch-3')).toBe('my_branch-3');
});

test('collapses disallowed characters (incl. path separators) to a dash', () => {
expect(sanitizeInstanceName('my work')).toBe('my-work');
expect(sanitizeInstanceName('a/b\\c')).toBe('a-b-c');
expect(sanitizeInstanceName('emoji🚀name')).toBe('emoji-name');
});

test('strips leading/trailing dots and dashes so traversal/dotfile names cannot survive', () => {
expect(sanitizeInstanceName('..')).toBe('');
expect(sanitizeInstanceName('../evil')).toBe('evil');
expect(sanitizeInstanceName('.hidden')).toBe('hidden');
expect(sanitizeInstanceName('-edge-')).toBe('edge');
});

test('returns empty for names that reduce to nothing', () => {
expect(sanitizeInstanceName('')).toBe('');
expect(sanitizeInstanceName(' ')).toBe('');
expect(sanitizeInstanceName('/')).toBe('');
});

test('bounds the length to 64 characters', () => {
expect(sanitizeInstanceName('x'.repeat(200))).toHaveLength(64);
});
});

describe('deriveInstanceUserDataDir', () => {
test('appends the sanitized name as a sibling directory suffix', () => {
expect(
deriveInstanceUserDataDir('/Users/me/Library/Application Support/Open Knowledge', 'b'),
).toBe('/Users/me/Library/Application Support/Open Knowledge (b)');
});

test('uses the sanitized form of the name', () => {
expect(deriveInstanceUserDataDir('/data/Open Knowledge', 'my work/2')).toBe(
'/data/Open Knowledge (my-work-2)',
);
});

test('returns null when the name sanitizes to empty (leave userData untouched)', () => {
expect(deriveInstanceUserDataDir('/data/Open Knowledge', '..')).toBeNull();
expect(deriveInstanceUserDataDir('/data/Open Knowledge', ' ')).toBeNull();
});

test('keeps the relocated dir a sibling of the base (no escape above its parent)', () => {
const out = deriveInstanceUserDataDir('/data/Open Knowledge', '../../etc');
expect(out).toBe('/data/Open Knowledge (etc)');
});
});
17 changes: 17 additions & 0 deletions packages/desktop/src/main/instance-isolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { basename, dirname, join } from 'node:path';

export function sanitizeInstanceName(raw: string): string {
return raw
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '')
.slice(0, 64);
}

export function deriveInstanceUserDataDir(
baseUserData: string,
rawInstanceName: string,
): string | null {
const safe = sanitizeInstanceName(rawInstanceName);
if (safe.length === 0) return null;
return join(dirname(baseUserData), `${basename(baseUserData)} (${safe})`);
}
3 changes: 2 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"dev:electron": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
"persistent": true,
"passThroughEnv": ["OK_INSTANCE"]
},
"build:desktop:dir": {
"dependsOn": ["build:desktop"],
Expand Down