From 83b681cebbc9e1d6c900ea0b363dba446ab3949c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Mar 2026 17:38:10 +0700 Subject: [PATCH] fix(desktop): harden packaged launcher and desktop build --- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop/bin/paseo | 22 ++- packages/desktop/bin/paseo.cmd | 6 +- packages/desktop/package.json | 1 + packages/desktop/src/daemon/daemon-manager.ts | 22 +-- .../daemon/node-entrypoint-launcher.test.ts | 131 ++++++++++++++++++ .../src/daemon/node-entrypoint-launcher.ts | 78 +++++++++++ .../src/daemon/node-entrypoint-runner.ts | 21 +++ packages/desktop/src/daemon/runtime-paths.ts | 68 ++++++--- packages/expo-two-way-audio/package.json | 3 +- 11 files changed, 314 insertions(+), 42 deletions(-) create mode 100644 packages/desktop/src/daemon/node-entrypoint-launcher.test.ts create mode 100644 packages/desktop/src/daemon/node-entrypoint-launcher.ts create mode 100644 packages/desktop/src/daemon/node-entrypoint-runner.ts diff --git a/package.json b/package.json index 356dd6436..4f92e74cf 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ], "scripts": { "dev": "./scripts/dev.sh", - "dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev", + "dev:server": "npm run dev --workspace=@getpaseo/server", "dev:app": "npm run start --workspace=@getpaseo/app", "dev:website": "npm run dev --workspace=@getpaseo/website", "postinstall": "node scripts/postinstall-patches.mjs", diff --git a/packages/app/package.json b/packages/app/package.json index e00807fb8..752ed99be 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --prefix ../expo-two-way-audio", + "build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio", "eas-build-post-install": "npm run build:workspace-deps", "android": "npm run android:development", "android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug", diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index ae1134b40..5a4fa65de 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -1,7 +1,23 @@ #!/bin/sh set -eu -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +SCRIPT_PATH=$0 +case "$SCRIPT_PATH" in + /*) ;; + *) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;; +esac + +# Follow symlinks so `/usr/local/bin/paseo` resolves back into the app bundle. +while [ -L "$SCRIPT_PATH" ]; do + SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd) + LINK_TARGET=$(readlink "$SCRIPT_PATH") + case "$LINK_TARGET" in + /*) SCRIPT_PATH="$LINK_TARGET" ;; + *) SCRIPT_PATH="${SCRIPT_DIR}/${LINK_TARGET}" ;; + esac +done + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd) RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) # macOS: MacOS/Paseo is a sibling of Resources/ @@ -15,6 +31,4 @@ else exit 1 fi -CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js" - -exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@" +exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@" diff --git a/packages/desktop/bin/paseo.cmd b/packages/desktop/bin/paseo.cmd index af4a44e93..e0c4379ff 100644 --- a/packages/desktop/bin/paseo.cmd +++ b/packages/desktop/bin/paseo.cmd @@ -4,12 +4,10 @@ setlocal set "SCRIPT_DIR=%~dp0" set "RESOURCES_DIR=%SCRIPT_DIR%.." set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe" -set "CLI_ENTRY=%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" - if not exist "%APP_EXECUTABLE%" ( echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2 exit /b 1 ) -set "ELECTRON_RUN_AS_NODE=1" -"%APP_EXECUTABLE%" "%CLI_ENTRY%" %* +set "PASEO_DESKTOP_CLI=1" +"%APP_EXECUTABLE%" %* diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 47950a296..9716d6b39 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -8,6 +8,7 @@ "build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", "dev": "npm run build:main && wait-on tcp:8081 && electron .", + "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 5beea236e..f3055b357 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -16,7 +16,7 @@ import { sendLocalTransportMessage, closeLocalTransportSession, } from "./local-transport.js"; -import { createElectronNodeEnv, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; +import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; const DAEMON_LOG_FILENAME = "daemon.log"; const DAEMON_PID_FILENAME = "paseo.pid"; @@ -293,17 +293,23 @@ async function startDaemon(): Promise { const home = getPaseoHome(); const daemonRunner = resolveDaemonRunnerEntrypoint(); const corsOrigins = buildDesktopDaemonCorsOriginsEnv(); + const invocation = createNodeEntrypointInvocation({ + entrypoint: daemonRunner, + argvMode: "node-script", + args: [], + baseEnv: { + ...process.env, + PASEO_HOME: home, + ...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}), + }, + }); const child: ChildProcess = spawn( - process.execPath, - [...daemonRunner.execArgv, daemonRunner.entryPath], + invocation.command, + invocation.args, { detached: true, - env: createElectronNodeEnv({ - ...process.env, - PASEO_HOME: home, - ...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}), - }), + env: invocation.env, stdio: ["ignore", "ignore", "ignore"], }, ); diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts new file mode 100644 index 000000000..0336ec38b --- /dev/null +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + createNodeEntrypointInvocation, + parseCliPassthroughArgsFromArgv, + type NodeEntrypointSpec, +} from "./node-entrypoint-launcher"; + +const CLI_ENTRYPOINT: NodeEntrypointSpec = { + entryPath: "/tmp/paseo-cli.js", + execArgv: ["--import", "tsx"], +}; + +describe("node-entrypoint-launcher", () => { + describe("parseCliPassthroughArgsFromArgv", () => { + it("returns null when no CLI args are provided", () => { + expect( + parseCliPassthroughArgsFromArgv({ + argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"], + isDefaultApp: false, + forceCli: false, + }), + ).toBeNull(); + }); + + it("ignores macOS GUI launch arguments", () => { + expect( + parseCliPassthroughArgsFromArgv({ + argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "-psn_0_12345"], + isDefaultApp: false, + forceCli: false, + }), + ).toBeNull(); + }); + + it("preserves CLI flags for direct app invocations", () => { + expect( + parseCliPassthroughArgsFromArgv({ + argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"], + isDefaultApp: false, + forceCli: false, + }), + ).toEqual(["--version"]); + }); + + it("forces CLI mode for shim launches even without args", () => { + expect( + parseCliPassthroughArgsFromArgv({ + argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"], + isDefaultApp: false, + forceCli: true, + }), + ).toEqual([]); + }); + }); + + describe("createNodeEntrypointInvocation", () => { + it("uses the packaged runner when the desktop app is packaged", () => { + expect( + createNodeEntrypointInvocation({ + execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo", + isPackaged: true, + packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js", + entrypoint: CLI_ENTRYPOINT, + argvMode: "bare", + args: ["ls", "--json"], + baseEnv: { PATH: "/usr/bin" }, + }), + ).toEqual({ + command: "/Applications/Paseo.app/Contents/MacOS/Paseo", + args: [ + "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js", + "bare", + "/tmp/paseo-cli.js", + "ls", + "--json", + ], + env: { + PATH: "/usr/bin", + ELECTRON_RUN_AS_NODE: "1", + }, + }); + }); + + it("uses the entrypoint directly in development", () => { + expect( + createNodeEntrypointInvocation({ + execPath: "/opt/homebrew/bin/electron", + isPackaged: false, + packagedRunnerPath: null, + entrypoint: CLI_ENTRYPOINT, + argvMode: "bare", + args: ["ls"], + baseEnv: { PATH: "/usr/bin" }, + }), + ).toEqual({ + command: "/opt/homebrew/bin/electron", + args: ["--import", "tsx", "/tmp/paseo-cli.js", "ls"], + env: { + PATH: "/usr/bin", + ELECTRON_RUN_AS_NODE: "1", + }, + }); + }); + + it("keeps node-style argv for packaged script entrypoints", () => { + expect( + createNodeEntrypointInvocation({ + execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo", + isPackaged: true, + packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js", + entrypoint: CLI_ENTRYPOINT, + argvMode: "node-script", + args: ["--dev"], + baseEnv: { PATH: "/usr/bin" }, + }), + ).toEqual({ + command: "/Applications/Paseo.app/Contents/MacOS/Paseo", + args: [ + "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js", + "node-script", + "/tmp/paseo-cli.js", + "--dev", + ], + env: { + PATH: "/usr/bin", + ELECTRON_RUN_AS_NODE: "1", + }, + }); + }); + }); +}); diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.ts new file mode 100644 index 000000000..27750740a --- /dev/null +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.ts @@ -0,0 +1,78 @@ +const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_"; + +export const DESKTOP_CLI_ENV = "PASEO_DESKTOP_CLI"; + +export type NodeEntrypointSpec = { + entryPath: string; + execArgv: string[]; +}; + +export type NodeEntrypointInvocation = { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; +}; + +export type NodeEntrypointArgvMode = "bare" | "node-script"; + +type CreateNodeEntrypointInvocationInput = { + execPath: string; + isPackaged: boolean; + packagedRunnerPath: string | null; + entrypoint: NodeEntrypointSpec; + argvMode: NodeEntrypointArgvMode; + args: string[]; + baseEnv: NodeJS.ProcessEnv; +}; + +type ParseCliPassthroughArgsFromArgvInput = { + argv: string[]; + isDefaultApp: boolean; + forceCli: boolean; +}; + +export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { + ...baseEnv, + ELECTRON_RUN_AS_NODE: "1", + }; +} + +export function parseCliPassthroughArgsFromArgv( + input: ParseCliPassthroughArgsFromArgvInput, +): string[] | null { + const startIndex = input.isDefaultApp ? 2 : 1; + const effective = input.argv + .slice(startIndex) + .filter((arg) => !arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX)); + + if (input.forceCli) { + return effective; + } + + return effective.length > 0 ? effective : null; +} + +export function createNodeEntrypointInvocation( + input: CreateNodeEntrypointInvocationInput, +): NodeEntrypointInvocation { + const env = createElectronNodeEnv(input.baseEnv); + + if (input.isPackaged) { + if (!input.packagedRunnerPath) { + throw new Error("Packaged node entrypoint runner is required for desktop launches."); + } + + return { + command: input.execPath, + args: [input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args], + env, + }; + } + + return { + command: input.execPath, + args: [...input.entrypoint.execArgv, input.entrypoint.entryPath, ...input.args], + env, + }; +} diff --git a/packages/desktop/src/daemon/node-entrypoint-runner.ts b/packages/desktop/src/daemon/node-entrypoint-runner.ts new file mode 100644 index 000000000..0eac149a9 --- /dev/null +++ b/packages/desktop/src/daemon/node-entrypoint-runner.ts @@ -0,0 +1,21 @@ +import { pathToFileURL } from "node:url"; + +async function main(): Promise { + const [argvMode, entryPath, ...args] = process.argv.slice(2); + if (argvMode !== "bare" && argvMode !== "node-script") { + throw new Error(`Unsupported node entrypoint argv mode: ${argvMode ?? ""}`); + } + if (!entryPath) { + throw new Error("Missing node entrypoint path."); + } + + process.argv = + argvMode === "bare" ? [process.argv[0] ?? "node", ...args] : [process.argv[0] ?? "node", entryPath, ...args]; + await import(pathToFileURL(entryPath).href); +} + +void main().catch((error) => { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index 8d5db11d2..5f6a4ea5c 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -3,21 +3,23 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { createRequire } from "node:module"; import path from "node:path"; import { app } from "electron"; +import { + DESKTOP_CLI_ENV, + createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation, + parseCliPassthroughArgsFromArgv as parseCliPassthroughArgs, + type NodeEntrypointArgvMode, + type NodeEntrypointInvocation, + type NodeEntrypointSpec, +} from "./node-entrypoint-launcher.js"; const CLI_PACKAGE_NAME = "@getpaseo/cli"; const SERVER_PACKAGE_NAME = "@getpaseo/server"; const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`; -const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_"; type PackageInfo = { root: string; }; -export type NodeEntrypointSpec = { - entryPath: string; - execArgv: string[]; -}; - const esmRequire = createRequire(__filename); function findPackageRootFromResolvedPath(input: { @@ -73,6 +75,10 @@ function resolvePackagedAsarPath(): string { return path.join(process.resourcesPath, "app.asar"); } +function resolvePackagedNodeEntrypointRunnerPath(): string { + return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js"); +} + function assertPathExists(input: { label: string; filePath: string }): string { if (!existsSync(input.filePath)) { throw new Error(`${input.label} is missing at ${input.filePath}`); @@ -82,16 +88,11 @@ function assertPathExists(input: { label: string; filePath: string }): string { } export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null { - const startIndex = process.defaultApp ? 2 : 1; - const effective = argv - .slice(startIndex) - .filter( - (arg) => - !arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX) && - !arg.startsWith("--"), - ); - - return effective.length > 0 ? effective : null; + return parseCliPassthroughArgs({ + argv, + isDefaultApp: process.defaultApp, + forceCli: process.env[DESKTOP_CLI_ENV] === "1", + }); } export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec { @@ -167,18 +168,39 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec { }; } -export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return { - ...baseEnv, - ELECTRON_RUN_AS_NODE: "1", - }; +export function createNodeEntrypointInvocation(input: { + entrypoint: NodeEntrypointSpec; + argvMode: NodeEntrypointArgvMode; + args: string[]; + baseEnv: NodeJS.ProcessEnv; +}): NodeEntrypointInvocation { + return createSharedNodeEntrypointInvocation({ + execPath: process.execPath, + isPackaged: app.isPackaged, + packagedRunnerPath: app.isPackaged + ? assertPathExists({ + label: "Bundled node entrypoint runner", + filePath: resolvePackagedNodeEntrypointRunnerPath(), + }) + : null, + entrypoint: input.entrypoint, + argvMode: input.argvMode, + args: input.args, + baseEnv: input.baseEnv, + }); } function spawnCliProcess(args: string[]): SpawnSyncReturns { const cli = resolveCliEntrypoint(); + const invocation = createNodeEntrypointInvocation({ + entrypoint: cli, + argvMode: "bare", + args, + baseEnv: process.env, + }); - return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], { - env: createElectronNodeEnv(process.env), + return spawnSync(invocation.command, invocation.args, { + env: invocation.env, stdio: "inherit", }); } diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 3aff7ddda..00f26f062 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -5,8 +5,9 @@ "main": "build/index.js", "types": "build/index.d.ts", "scripts": { - "build": "expo-module build", + "build": "EXPO_NONINTERACTIVE=1 expo-module build", "clean": "expo-module clean", + "dev": "expo-module build", "lint": "expo-module lint", "test": "expo-module test", "format": "biome check .",