From 6b135879438808d61a2af0104569f83aa31e2b4c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 16:40:50 +0200 Subject: [PATCH] fix(desktop): allow concurrent worktree dev instances Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state. --- docs/development.md | 15 ++- packages/desktop/scripts/dev-runtime.mjs | 101 ++++++++++++++++++ packages/desktop/scripts/dev-runtime.test.mjs | 56 ++++++++++ packages/desktop/scripts/dev.ps1 | 19 ++-- packages/desktop/scripts/dev.sh | 10 +- 5 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 packages/desktop/scripts/dev-runtime.mjs create mode 100644 packages/desktop/scripts/dev-runtime.test.mjs diff --git a/docs/development.md b/docs/development.md index 31b35d0120..75faea55ca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -99,10 +99,17 @@ The iOS simulator shares the Mac's loopback, so `localhost:` reaches the h ### Desktop renderer profiling -`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on -`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP. -It launches its own Electron-flavored Expo server and passes that URL to Electron. -Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +`npm run dev:desktop` starts Electron with Chromium remote debugging enabled so +renderer CPU profiles can be captured through CDP. It prefers +`http://127.0.0.1:9223` and automatically selects a free port when that port is +already occupied. The startup banner prints the selected URL. It launches its own +Electron-flavored Expo server and passes that URL to Electron. Set +`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when a QA workflow requires a fixed port. + +Desktop dev also scopes Electron `userData` to the current dev root. This prevents +desktop-only environment inherited by terminals opened inside Paseo from coupling +a new worktree instance to the parent desktop instance's profile or single-instance +lock. With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen transition, and 751-pixel settings split with: diff --git a/packages/desktop/scripts/dev-runtime.mjs b/packages/desktop/scripts/dev-runtime.mjs new file mode 100644 index 0000000000..cba80fb536 --- /dev/null +++ b/packages/desktop/scripts/dev-runtime.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_REMOTE_DEBUGGING_PORT = 9223; + +export function resolveDevUserDataDir({ devRoot, inheritedUserDataDir, fallbackRoot }) { + if (devRoot) { + return path.join(devRoot, ".dev", "user-data"); + } + if (inheritedUserDataDir) { + return inheritedUserDataDir; + } + if (!fallbackRoot) { + throw new Error("A dev root or fallback root is required"); + } + return path.join(fallbackRoot, ".dev", "user-data"); +} + +export function buildElectronFlags(inheritedFlags, remoteDebuggingPort) { + const flags = (inheritedFlags ?? "") + .split(/\s+/) + .filter(Boolean) + .filter((flag) => !flag.startsWith("--remote-debugging-port=")); + flags.push(`--remote-debugging-port=${remoteDebuggingPort}`); + return flags.join(" "); +} + +function parsePort(value, name) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`${name} must be an integer between 1 and 65535`); + } + return port; +} + +export function findAvailablePort(preferredPort, host = "127.0.0.1") { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", (error) => { + if (error.code === "EADDRINUSE" || error.code === "EACCES") { + resolve(findAvailablePort(0, host)); + return; + } + reject(error); + }); + server.listen(preferredPort, host, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not resolve an available CDP port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +export async function selectRemoteDebuggingPort({ + configuredPort, + preferredPort = DEFAULT_REMOTE_DEBUGGING_PORT, + findAvailablePort: findPort = findAvailablePort, +} = {}) { + if (configuredPort) { + return parsePort(configuredPort, "PASEO_ELECTRON_REMOTE_DEBUGGING_PORT"); + } + return findPort(preferredPort); +} + +export async function resolveDevRuntime(env = process.env) { + const remoteDebuggingPort = await selectRemoteDebuggingPort({ + configuredPort: env.PASEO_ELECTRON_REMOTE_DEBUGGING_PORT, + }); + return { + electronFlags: buildElectronFlags(env.PASEO_ELECTRON_FLAGS, remoteDebuggingPort), + remoteDebuggingPort, + userDataDir: resolveDevUserDataDir({ + devRoot: env.PASEO_DEV_ROOT, + inheritedUserDataDir: env.PASEO_ELECTRON_USER_DATA_DIR, + fallbackRoot: env.PASEO_DEV_RUNTIME_FALLBACK_ROOT, + }), + }; +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + try { + process.stdout.write(JSON.stringify(await resolveDevRuntime())); + } catch (error) { + console.error(`[dev-runtime] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/packages/desktop/scripts/dev-runtime.test.mjs b/packages/desktop/scripts/dev-runtime.test.mjs new file mode 100644 index 0000000000..486a304489 --- /dev/null +++ b/packages/desktop/scripts/dev-runtime.test.mjs @@ -0,0 +1,56 @@ +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + buildElectronFlags, + resolveDevUserDataDir, + selectRemoteDebuggingPort, +} from "./dev-runtime.mjs"; + +describe("desktop dev runtime isolation", () => { + test("scopes inherited user data to the explicitly selected dev root", () => { + expect( + resolveDevUserDataDir({ + devRoot: "/worktrees/feature-a", + inheritedUserDataDir: "/checkouts/paseo/.dev/user-data", + }), + ).toBe(path.join("/worktrees/feature-a", ".dev", "user-data")); + }); + + test("keeps an explicit user data override outside a managed dev root", () => { + expect( + resolveDevUserDataDir({ + inheritedUserDataDir: "/tmp/paseo-user-data", + fallbackRoot: "/checkouts/paseo", + }), + ).toBe("/tmp/paseo-user-data"); + }); + + test("replaces an inherited CDP flag with this instance's selected port", () => { + expect(buildElectronFlags("--disable-gpu --remote-debugging-port=9223", 43127)).toBe( + "--disable-gpu --remote-debugging-port=43127", + ); + }); + + test("selects another CDP port when the default is occupied", async () => { + const selectedPort = await selectRemoteDebuggingPort({ + preferredPort: 9223, + findAvailablePort: async (preferredPort) => { + expect(preferredPort).toBe(9223); + return 43127; + }, + }); + + expect(selectedPort).toBe(43127); + }); + + test("honors an explicit CDP port without silently changing it", async () => { + const selectedPort = await selectRemoteDebuggingPort({ + configuredPort: "9333", + findAvailablePort: async () => { + throw new Error("must not probe an explicit port"); + }, + }); + + expect(selectedPort).toBe(9333); + }); +}); diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 22818528eb..bd495ca816 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -33,17 +33,13 @@ try { # Set EXPO_DEV_URL in the environment so Electron inherits it $env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" -$RemoteDebuggingPort = if ($env:PASEO_ELECTRON_REMOTE_DEBUGGING_PORT) { - $env:PASEO_ELECTRON_REMOTE_DEBUGGING_PORT -} else { - "9223" -} -$ExistingElectronFlags = if ($env:PASEO_ELECTRON_FLAGS) { - "$($env:PASEO_ELECTRON_FLAGS) " -} else { - "" -} -$env:PASEO_ELECTRON_FLAGS = "$($ExistingElectronFlags)--remote-debugging-port=$RemoteDebuggingPort" +$env:PASEO_DEV_ROOT = $RootDir +$env:PASEO_DEV_RUNTIME_FALLBACK_ROOT = $RootDir +$DevRuntime = node "$ScriptDir\dev-runtime.mjs" | ConvertFrom-Json +$RemoteDebuggingPort = $DevRuntime.remoteDebuggingPort +$env:PASEO_ELECTRON_FLAGS = $DevRuntime.electronFlags +$env:PASEO_ELECTRON_USER_DATA_DIR = $DevRuntime.userDataDir +Remove-Item Env:\PASEO_DEV_RUNTIME_FALLBACK_ROOT -ErrorAction SilentlyContinue # Allow any origin in dev so Electron on random ports works. # SECURITY: wildcard CORS is unsafe in production — only acceptable here because @@ -64,7 +60,6 @@ if (-not $env:PASEO_HOME) { } else { $PaseoHomeManaged = $false } -if (-not $env:PASEO_ELECTRON_USER_DATA_DIR) { $env:PASEO_ELECTRON_USER_DATA_DIR = "$DevStateDir\user-data" } New-Item -ItemType Directory -Force -Path $env:PASEO_HOME, $env:PASEO_ELECTRON_USER_DATA_DIR | Out-Null $DevDaemonPort = if ($env:PASEO_DEV_DAEMON_PORT) { $env:PASEO_DEV_DAEMON_PORT } else { "6788" } diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index 01f69236c7..5e3c2764b6 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -12,7 +12,13 @@ export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}" configure_dev_paseo_home DEV_ROOT="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}" -export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_ROOT/.dev/user-data}" +export PASEO_DEV_ROOT="$DEV_ROOT" +export PASEO_DEV_RUNTIME_FALLBACK_ROOT="$DEV_ROOT" +DEV_RUNTIME="$(node "$SCRIPT_DIR/dev-runtime.mjs")" +REMOTE_DEBUGGING_PORT="$(node -e 'process.stdout.write(String(JSON.parse(process.argv[1]).remoteDebuggingPort))' "$DEV_RUNTIME")" +export PASEO_ELECTRON_FLAGS="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).electronFlags)' "$DEV_RUNTIME")" +export PASEO_ELECTRON_USER_DATA_DIR="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).userDataDir)' "$DEV_RUNTIME")" +unset PASEO_DEV_RUNTIME_FALLBACK_ROOT mkdir -p "$PASEO_ELECTRON_USER_DATA_DIR" if [ -z "${EXPO_PORT:-}" ]; then @@ -24,8 +30,6 @@ export EXPO_DEV_URL="http://localhost:${EXPO_PORT}" DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)" export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT" -REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}" -export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--remote-debugging-port=$REMOTE_DEBUGGING_PORT" export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" npm run build:main