Fix macOS packaged CLI daemon Dock icons (#1759)

* Fix macOS CLI daemon relaunch path

* test(cli): mock helper existence in daemon launch test

* fix(desktop): launch packaged CLI through Helper

The packaged macOS CLI shim entered through the main app executable, so daemon supervision inherited app lifecycle behavior and surfaced Dock icons. Make Helper the required macOS CLI runtime, keep daemon relaunches on process.execPath, and cover cold bundled CLI daemon starts in release smoke.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
yz
2026-06-29 20:45:53 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent f94ec0e430
commit efd7ab3420
4 changed files with 274 additions and 7 deletions
@@ -97,6 +97,7 @@ describe("local daemon launch supervision", () => {
await Promise.all( await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
); );
vi.restoreAllMocks();
}); });
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {
+12 -6
View File
@@ -20,12 +20,18 @@ done
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
# macOS: MacOS/Paseo is a sibling of Resources/ MACOS_DIR="${RESOURCES_DIR}/../MacOS"
# Linux: the executable sits next to resources/. Prefer Paseo.bin when present MACOS_HELPER_EXECUTABLE="${RESOURCES_DIR}/../Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper"
# because Paseo is a GUI wrapper that adds Chromium flags not accepted by
# ELECTRON_RUN_AS_NODE. # macOS CLI invocations must enter Electron through the agent Helper. Launching
if [ -x "${RESOURCES_DIR}/../MacOS/Paseo" ]; then # the main APPL executable here makes later daemon supervision inherit app UI
APP_EXECUTABLE="${RESOURCES_DIR}/../MacOS/Paseo" # lifecycle behavior.
if [ -d "${MACOS_DIR}" ]; then
if [ ! -x "${MACOS_HELPER_EXECUTABLE}" ]; then
echo "Bundled Paseo Helper executable not found at ${MACOS_HELPER_EXECUTABLE}" >&2
exit 1
fi
APP_EXECUTABLE="${MACOS_HELPER_EXECUTABLE}"
elif [ -x "${RESOURCES_DIR}/../Paseo.bin" ]; then elif [ -x "${RESOURCES_DIR}/../Paseo.bin" ]; then
APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo.bin" APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo.bin"
elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then
@@ -1,5 +1,6 @@
const { spawn, spawnSync } = require("node:child_process"); const { spawn, spawnSync } = require("node:child_process");
const fs = require("node:fs"); const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises"); const { setTimeout: delay } = require("node:timers/promises");
@@ -47,6 +48,10 @@ function getCliShimPath(appPath) {
return path.join(appPath, "resources", "bin", "paseo"); return path.join(appPath, "resources", "bin", "paseo");
} }
function getMacMainExecutablePath(appPath) {
return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
}
function getLaunchCommand(executablePath) { function getLaunchCommand(executablePath) {
if (process.platform !== "linux") { if (process.platform !== "linux") {
return { return {
@@ -98,6 +103,108 @@ function createDefaultDaemonEnv(extraEnv) {
return env; return env;
} }
function reserveLocalTcpPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to reserve a TCP port for smoke test")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
async function waitForFile(filePath, label) {
const deadline = Date.now() + SMOKE_TIMEOUT_MS;
while (Date.now() < deadline) {
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath, "utf8");
}
await delay(250);
}
throw new Error(`Timed out waiting for ${label}: ${filePath}`);
}
async function waitForChildPids(parentPid) {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const childPids = listChildPids(parentPid);
if (childPids.length > 0) {
return childPids;
}
await delay(250);
}
throw new Error(`Timed out waiting for daemon worker child of supervisor PID ${parentPid}`);
}
function listChildPids(parentPid) {
const result = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`ps failed while listing child processes: ${result.stderr.trim()}`);
}
const children = [];
for (const line of result.stdout.split(/\r?\n/)) {
const [pidText, ppidText] = line.trim().split(/\s+/);
const pid = Number(pidText);
const ppid = Number(ppidText);
if (Number.isInteger(pid) && ppid === parentPid) {
children.push(pid);
}
}
return children;
}
function listDarwinTextExecutables(pid) {
const result = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], {
encoding: "utf8",
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`lsof failed while inspecting PID ${pid}: ${
result.stderr.trim() || result.stdout.trim() || "<empty>"
}`,
);
}
return result.stdout
.split(/\r?\n/)
.filter((line) => line.startsWith("n"))
.map((line) => line.slice(1));
}
function assertDarwinProcessDoesNotUseMainAppExecutable({ appPath, pid, label }) {
const mainExecutablePath = getMacMainExecutablePath(appPath);
assertExecutable(mainExecutablePath, "Packaged app executable");
const textExecutables = listDarwinTextExecutables(pid);
if (textExecutables.includes(mainExecutablePath)) {
throw new Error(
`${label} PID ${pid} launched through the main app executable ${mainExecutablePath}.\nText executables:\n${textExecutables.join(
"\n",
)}`,
);
}
}
function parseSmokeLine(line) { function parseSmokeLine(line) {
const prefix = "[paseo-smoke] "; const prefix = "[paseo-smoke] ";
if (!line.startsWith(prefix)) { if (!line.startsWith(prefix)) {
@@ -370,6 +477,67 @@ async function smokeCliShim({ appPath, env }) {
assertCleanDaemonStatusOutput(`${result.stdout}\n${result.stderr}`); assertCleanDaemonStatusOutput(`${result.stdout}\n${result.stderr}`);
} }
async function smokeColdCliDaemonStart({ appPath }) {
const home = createTempDir("paseo-smoke-cli-daemon-home-");
const pidPath = path.join(home, "paseo.pid");
const port = await reserveLocalTcpPort();
const listen = `127.0.0.1:${port}`;
const env = createDefaultDaemonEnv();
try {
console.log("Packaged desktop smoke: cold-starting daemon through bundled CLI shim");
await runCliShimCommand({
appPath,
env,
args: [
"daemon",
"start",
"--home",
home,
"--listen",
listen,
"--no-relay",
"--no-mcp",
"--no-inject-mcp",
],
label: "Bundled CLI shim cold daemon start",
});
const pidInfo = JSON.parse(await waitForFile(pidPath, "cold CLI daemon pid file"));
if (!pidInfo || typeof pidInfo.pid !== "number") {
throw new Error(`Cold CLI daemon wrote invalid pid file: ${JSON.stringify(pidInfo)}`);
}
if (process.platform === "darwin") {
assertDarwinProcessDoesNotUseMainAppExecutable({
appPath,
pid: pidInfo.pid,
label: "Cold CLI daemon supervisor",
});
const childPids = await waitForChildPids(pidInfo.pid);
for (const childPid of childPids) {
assertDarwinProcessDoesNotUseMainAppExecutable({
appPath,
pid: childPid,
label: "Cold CLI daemon worker",
});
}
}
} finally {
if (fs.existsSync(pidPath)) {
await runCliShimCommand({
appPath,
env,
args: ["daemon", "stop", "--home", home, "--force"],
label: "Bundled CLI shim cold daemon stop",
}).catch((error) => {
console.warn(`Packaged desktop smoke: failed to stop cold CLI daemon: ${error}`);
});
}
await removeTempDir(home);
}
}
function assertCleanDaemonStatusOutput(output) { function assertCleanDaemonStatusOutput(output) {
const failureNeedles = [ const failureNeedles = [
"Get-CimInstance", "Get-CimInstance",
@@ -466,6 +634,7 @@ async function stopCliDaemon({ appPath, env }) {
async function smokePackagedDesktopApp({ appPath }) { async function smokePackagedDesktopApp({ appPath }) {
const executablePath = getExecutablePath(appPath); const executablePath = getExecutablePath(appPath);
assertExecutable(executablePath, "Packaged app executable"); assertExecutable(executablePath, "Packaged app executable");
await smokeColdCliDaemonStart({ appPath });
const userData = createTempDir("paseo-smoke-user-data-"); const userData = createTempDir("paseo-smoke-user-data-");
const env = createDefaultDaemonEnv({ const env = createDefaultDaemonEnv({
@@ -1,10 +1,67 @@
import { readFileSync } from "node:fs"; import { spawnSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
function writeExecutable(filePath: string, contents: string): void {
writeFileSync(filePath, contents, "utf8");
chmodSync(filePath, 0o755);
}
function createFakeMacBundle(options: { includeHelper: boolean }): {
root: string;
shimPath: string;
} {
const root = mkdtempSync(join(tmpdir(), "paseo-cli-shim-test-"));
const appPath = join(root, "Paseo.app");
const contentsPath = join(appPath, "Contents");
const resourcesPath = join(contentsPath, "Resources");
const shimPath = join(resourcesPath, "bin", "paseo");
const mainPath = join(contentsPath, "MacOS", "Paseo");
const helperPath = join(
contentsPath,
"Frameworks",
"Paseo Helper.app",
"Contents",
"MacOS",
"Paseo Helper",
);
mkdirSync(dirname(shimPath), { recursive: true });
mkdirSync(dirname(mainPath), { recursive: true });
copyFileSync(join(packageRoot, "bin", "paseo"), shimPath);
chmodSync(shimPath, 0o755);
writeExecutable(mainPath, "#!/bin/sh\necho main-executable\n");
if (options.includeHelper) {
mkdirSync(dirname(helperPath), { recursive: true });
writeExecutable(
helperPath,
[
"#!/bin/sh",
'printf "helper env=%s/%s\\n" "$ELECTRON_RUN_AS_NODE" "$PASEO_NODE_ENV"',
'printf "args=%s\\n" "$*"',
"",
].join("\n"),
);
}
return { root, shimPath };
}
describe("desktop packaging", () => { describe("desktop packaging", () => {
it("unpacks server zsh shell integration files for external shells", () => { it("unpacks server zsh shell integration files for external shells", () => {
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
@@ -48,4 +105,38 @@ describe("desktop packaging", () => {
expect(deps[required], `${required} must be declared in dependencies`).toBe("*"); expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
} }
}); });
it("launches the packaged macOS CLI through Helper instead of the main app executable", () => {
if (process.platform === "win32") return;
const bundle = createFakeMacBundle({ includeHelper: true });
try {
const result = spawnSync(bundle.shimPath, ["--version"], { encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stdout).toContain("helper env=1/production");
expect(result.stdout).toContain("node-entrypoint-runner.js");
expect(result.stdout).toContain("node-script");
expect(result.stdout).toContain("@getpaseo/cli/dist/index.js");
expect(result.stdout).toContain("--version");
expect(result.stdout).not.toContain("main-executable");
} finally {
rmSync(bundle.root, { recursive: true, force: true });
}
});
it("fails packaged macOS CLI startup when Helper is missing", () => {
if (process.platform === "win32") return;
const bundle = createFakeMacBundle({ includeHelper: false });
try {
const result = spawnSync(bundle.shimPath, ["--version"], { encoding: "utf8" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Bundled Paseo Helper executable not found");
expect(result.stdout).not.toContain("main-executable");
} finally {
rmSync(bundle.root, { recursive: true, force: true });
}
});
}); });