fix(desktop): harden packaged launcher and desktop build

This commit is contained in:
Mohamed Boudra
2026-03-22 17:38:10 +07:00
parent d9bcf2a439
commit 83b681cebb
11 changed files with 314 additions and 42 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}" "$@"

View File

@@ -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%" %*

View File

@@ -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": {

View File

@@ -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<DesktopDaemonStatus> {
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"],
},
);

View File

@@ -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",
},
});
});
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,21 @@
import { pathToFileURL } from "node:url";
async function main(): Promise<void> {
const [argvMode, entryPath, ...args] = process.argv.slice(2);
if (argvMode !== "bare" && argvMode !== "node-script") {
throw new Error(`Unsupported node entrypoint argv mode: ${argvMode ?? "<missing>"}`);
}
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);
});

View File

@@ -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<Buffer> {
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",
});
}

View File

@@ -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 .",