From eafb72e2dc242bf585bbec62860d3d9bb6383964 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:08:46 -0700 Subject: [PATCH] feat(desktop): launch parallel desktop instances from the packaged app (#2170) Adds `bun run --filter=@inkeep/open-knowledge-desktop instances` (script packages/desktop/scripts/launch-instances.mjs) to start multiple isolated desktop instances at once. Each name=project gets its own --user-data-dir (its own single-instance lock plus isolated storage), opens its project by pre-seeding that instance's state.json lastOpenedProject, and is launched detached via `open -n` so it survives the launching process. This is the path that works for agent/automated launches, where dev-mode windows do not (they need a Vite dev server on a port and self-quit when detached). Launches stagger on each project's server.lock to avoid a same-millisecond boot race that drops one project to the Navigator. Documents both this packaged path and the existing dev-only OK_INSTANCE flag in the desktop README. GitOrigin-RevId: 9dac7ebd35033323e3279ac5877fd2436833b771 --- .changeset/desktop-instance-launcher.md | 5 + packages/desktop/package.json | 1 + packages/desktop/scripts/launch-instances.mjs | 150 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 .changeset/desktop-instance-launcher.md create mode 100644 packages/desktop/scripts/launch-instances.mjs diff --git a/.changeset/desktop-instance-launcher.md b/.changeset/desktop-instance-launcher.md new file mode 100644 index 000000000..85c154b72 --- /dev/null +++ b/.changeset/desktop-instance-launcher.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge-desktop": patch +--- + +Add `bun run --filter=@inkeep/open-knowledge-desktop instances` to launch multiple isolated desktop instances in parallel from the packaged app. Each `=` gets its own `--user-data-dir` (own single-instance lock + storage), opens its project, and is launched detached via `open -n` so it survives the launching process — the path that works for agent/automated launches, where dev-mode windows don't. Launches are staggered to avoid a boot race. Script: `packages/desktop/scripts/launch-instances.mjs`. diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 67b9183b9..cad30ae9a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,6 +20,7 @@ "postinstall": "node scripts/postinstall.mjs", "rebuild:native": "electron-builder install-app-deps", "smoke:mock-update": "OK_UPDATER_FORCE_DEV=1 node scripts/smoke-mock-update.mjs", + "instances": "node scripts/launch-instances.mjs", "typecheck": "tsc --noEmit", "test": "bun --conditions=development test" }, diff --git a/packages/desktop/scripts/launch-instances.mjs b/packages/desktop/scripts/launch-instances.mjs new file mode 100644 index 000000000..9ccd743ed --- /dev/null +++ b/packages/desktop/scripts/launch-instances.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; + +if (process.platform !== 'darwin') { + console.error( + 'launch-instances: macOS only (uses `open`). The OpenKnowledge desktop is macOS-only.', + ); + process.exit(1); +} + +function parseArgs(argv) { + const pairs = []; + let appPath = process.env.OK_DESKTOP_APP ?? null; + let userDataRoot = join(homedir(), '.ok', 'instances'); + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--app') { + appPath = argv[++i]; + } else if (arg === '--user-data-root') { + userDataRoot = expandHome(argv[++i]); + } else if (arg.includes('=')) { + const idx = arg.indexOf('='); + const name = arg.slice(0, idx); + const project = arg.slice(idx + 1); + if (!name || !project) + throw new Error(`Bad instance spec "${arg}" (expected =)`); + pairs.push({ name, project: resolve(expandHome(project)) }); + } else { + throw new Error(`Unrecognized argument "${arg}"`); + } + } + return { pairs, appPath, userDataRoot }; +} + +function expandHome(p) { + return p.startsWith('~') ? join(homedir(), p.slice(1)) : p; +} + +function resolveAppPath(flagPath) { + if (flagPath) { + const abs = resolve(expandHome(flagPath)); + if (!existsSync(abs)) throw new Error(`--app path not found: ${abs}`); + return abs; + } + const pkgRoot = resolve(import.meta.dirname, '..'); + const candidate = join(pkgRoot, 'dist-desktop', 'mac-arm64', 'OpenKnowledge.app'); + if (!existsSync(candidate)) { + throw new Error( + `No built app at ${candidate}.\n` + + `Build it first: bun run build:dir (from packages/desktop)\n` + + `or pass an explicit --app / set OK_DESKTOP_APP.`, + ); + } + return candidate; +} + +function ensureGitRepo(project) { + if (!existsSync(project)) mkdirSync(project, { recursive: true }); + if (existsSync(join(project, '.git'))) return; + execFileSync('git', ['-C', project, 'init', '-q']); +} + +function emptyState() { + return { + recentProjects: [], + lastOpenedProject: null, + versionPendingInstall: null, + attemptedInstall: null, + lastSeenVersion: null, + lastSuccessfulCheckAt: null, + stuckHintShown: false, + dismissedRepairForBundle: null, + projectSessions: {}, + schemaVersion: 1, + lastUsedProjectParent: null, + pendingWindowRestore: null, + spellCheckEnabled: true, + }; +} + +function seedState(userDataDir, project, name) { + mkdirSync(userDataDir, { recursive: true }); + const statePath = join(userDataDir, 'state.json'); + let state = emptyState(); + if (existsSync(statePath)) { + try { + state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8')) }; + } catch {} + } + const now = new Date().toISOString(); + const recents = Array.isArray(state.recentProjects) ? state.recentProjects : []; + const withoutThis = recents.filter((r) => r && r.path !== project); + state.recentProjects = [{ path: project, name, lastOpenedAt: now }, ...withoutThis].slice(0, 20); + state.lastOpenedProject = project; + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`); + return statePath; +} + +function launch(appPath, userDataDir) { + const child = spawn('open', ['-n', appPath, '--args', `--user-data-dir=${userDataDir}`], { + detached: true, + stdio: 'ignore', + }); + child.unref(); +} + +async function waitForServerLock(project, timeoutMs = 30000) { + const lock = join(project, '.ok', 'local', 'server.lock'); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(lock)) return true; + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +async function main() { + const { pairs, appPath, userDataRoot } = parseArgs(process.argv.slice(2)); + if (pairs.length === 0) { + console.error( + 'Usage: node scripts/launch-instances.mjs = [= ...]', + ); + process.exit(2); + } + const app = resolveAppPath(appPath); + console.log(`app: ${app}`); + for (const { name, project } of pairs) { + const userDataDir = isAbsolute(userDataRoot) + ? join(userDataRoot, name) + : resolve(userDataRoot, name); + ensureGitRepo(project); + seedState(userDataDir, project, name); + launch(app, userDataDir); + process.stdout.write(`launched "${name}" -> ${project} (userData: ${userDataDir}) … `); + const ready = await waitForServerLock(project); + console.log(ready ? 'ready' : 'still booting (continuing)'); + } + console.log( + `\n${pairs.length} instance(s) launched. Each runs independently; quit a window to stop it.`, + ); +} + +main().catch((err) => { + console.error(`launch-instances: ${err.message}`); + process.exit(1); +});