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-30 02:45:53 +08:00
committed by GitHub
parent f94ec0e430
commit efd7ab3420
4 changed files with 274 additions and 7 deletions

View File

@@ -97,6 +97,7 @@ describe("local daemon launch supervision", () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
vi.restoreAllMocks();
});
test("foreground start spawns supervisor-entrypoint instead of server/index", async () => {

View File

@@ -20,12 +20,18 @@ done
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
# macOS: MacOS/Paseo is a sibling of Resources/
# Linux: the executable sits next to resources/. Prefer Paseo.bin when present
# because Paseo is a GUI wrapper that adds Chromium flags not accepted by
# ELECTRON_RUN_AS_NODE.
if [ -x "${RESOURCES_DIR}/../MacOS/Paseo" ]; then
APP_EXECUTABLE="${RESOURCES_DIR}/../MacOS/Paseo"
MACOS_DIR="${RESOURCES_DIR}/../MacOS"
MACOS_HELPER_EXECUTABLE="${RESOURCES_DIR}/../Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper"
# macOS CLI invocations must enter Electron through the agent Helper. Launching
# the main APPL executable here makes later daemon supervision inherit app UI
# 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
APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo.bin"
elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then

View File

@@ -1,5 +1,6 @@
const { spawn, spawnSync } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
@@ -47,6 +48,10 @@ function getCliShimPath(appPath) {
return path.join(appPath, "resources", "bin", "paseo");
}
function getMacMainExecutablePath(appPath) {
return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
}
function getLaunchCommand(executablePath) {
if (process.platform !== "linux") {
return {
@@ -98,6 +103,108 @@ function createDefaultDaemonEnv(extraEnv) {
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) {
const prefix = "[paseo-smoke] ";
if (!line.startsWith(prefix)) {
@@ -370,6 +477,67 @@ async function smokeCliShim({ appPath, env }) {
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) {
const failureNeedles = [
"Get-CimInstance",
@@ -466,6 +634,7 @@ async function stopCliDaemon({ appPath, env }) {
async function smokePackagedDesktopApp({ appPath }) {
const executablePath = getExecutablePath(appPath);
assertExecutable(executablePath, "Packaged app executable");
await smokeColdCliDaemonStart({ appPath });
const userData = createTempDir("paseo-smoke-user-data-");
const env = createDefaultDaemonEnv({

View File

@@ -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 { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
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", () => {
it("unpacks server zsh shell integration files for external shells", () => {
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("*");
}
});
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 });
}
});
});