mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(desktop): harden packaged launcher and desktop build
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}" "$@"
|
||||
|
||||
@@ -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%" %*
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
|
||||
131
packages/desktop/src/daemon/node-entrypoint-launcher.test.ts
Normal file
131
packages/desktop/src/daemon/node-entrypoint-launcher.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
78
packages/desktop/src/daemon/node-entrypoint-launcher.ts
Normal file
78
packages/desktop/src/daemon/node-entrypoint-launcher.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
packages/desktop/src/daemon/node-entrypoint-runner.ts
Normal file
21
packages/desktop/src/daemon/node-entrypoint-runner.ts
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
Reference in New Issue
Block a user