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