From 90032570ec83e97ad99376dbd6d2733489ae1ea1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Apr 2026 09:08:56 +0800 Subject: [PATCH] Add macOS packaged desktop smoke gate (#578) * Fix packaged node entrypoint env preservation * Add packaged desktop smoke gate --- .github/workflows/desktop-release.yml | 70 ++- packages/desktop/bin/paseo | 6 +- packages/desktop/electron-builder.yml | 1 + packages/desktop/scripts/after-pack.js | 30 +- packages/desktop/scripts/after-sign.js | 19 + .../scripts/smoke-packaged-desktop-app.js | 437 ++++++++++++++++++ packages/desktop/src/main.ts | 54 ++- 7 files changed, 587 insertions(+), 30 deletions(-) create mode 100644 packages/desktop/scripts/after-sign.js create mode 100644 packages/desktop/scripts/smoke-packaged-desktop-app.js diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f6379d2fc..a70f402f7 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -24,6 +24,19 @@ on: - macos - linux - windows + checkout_ref: + description: "Optional branch/ref to checkout while using tag for release metadata." + required: false + default: "" + type: string + publish: + description: "Publish built artifacts to GitHub Releases." + required: false + default: "true" + type: choice + options: + - "true" + - "false" concurrency: group: desktop-release-${{ github.ref }} @@ -31,12 +44,14 @@ concurrency: env: SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }} + SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }} DESKTOP_WORKSPACE: "@getpaseo/desktop" DESKTOP_PACKAGE_PATH: "packages/desktop" jobs: create-release: - if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }} + if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all' && github.event.inputs.publish != 'false') }} permissions: contents: write runs-on: ubuntu-latest @@ -44,7 +59,7 @@ jobs: - uses: actions/checkout@v4 with: sparse-checkout: scripts - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash @@ -91,7 +106,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash @@ -140,20 +155,22 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + PASEO_DESKTOP_SMOKE: "1" run: | set -euo pipefail publish_mode="never" - publish_args=() - if [[ "$IS_SMOKE_TAG" != "true" ]]; then + build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }}) + if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then publish_mode="always" - publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") - publish_args+=("-c.publish.channel=$RELEASE_CHANNEL") + build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }}) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") fi - npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}" + npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" - name: Upload manifest artifact - if: env.IS_SMOKE_TAG != 'true' + if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true' uses: actions/upload-artifact@v4 with: name: mac-manifest-${{ matrix.electron_arch }} @@ -162,7 +179,7 @@ jobs: finalize-mac-manifest: needs: [publish-macos] - if: ${{ needs.publish-macos.result == 'success' }} + if: ${{ needs.publish-macos.result == 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }} permissions: contents: write runs-on: ubuntu-latest @@ -171,7 +188,7 @@ jobs: - uses: actions/checkout@v4 with: sparse-checkout: scripts - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + ref: ${{ env.CHECKOUT_REF }} - name: Resolve release tag shell: bash @@ -269,7 +286,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash @@ -308,22 +325,27 @@ jobs: - name: Build web app for desktop run: npm run build:web --workspace=@getpaseo/app + - name: Install Linux smoke display + run: sudo apt-get update && sudo apt-get install -y xvfb + - name: Build desktop release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} EP_GH_IGNORE_TIME: true + PASEO_DESKTOP_SMOKE: "1" run: | set -euo pipefail publish_mode="never" - publish_args=() - if [[ "$IS_SMOKE_TAG" != "true" ]]; then + build_args=(-- --publish "$publish_mode" --linux --x64) + if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then publish_mode="always" - publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") - publish_args+=("-c.publish.channel=$RELEASE_CHANNEL") + build_args=(-- --publish "$publish_mode" --linux --x64) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") fi - npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}" + npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" publish-windows: needs: [create-release] @@ -337,7 +359,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + ref: ${{ env.CHECKOUT_REF }} - name: Resolve release metadata shell: bash @@ -389,14 +411,16 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} EP_GH_IGNORE_TIME: true + PASEO_DESKTOP_SMOKE: "1" run: | set -euo pipefail publish_mode="never" - publish_args=() - if [[ "$IS_SMOKE_TAG" != "true" ]]; then + build_args=(-- --publish "$publish_mode" --win --x64) + if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then publish_mode="always" - publish_args+=("-c.publish.releaseType=$RELEASE_TYPE") - publish_args+=("-c.publish.channel=$RELEASE_CHANNEL") + build_args=(-- --publish "$publish_mode" --win --x64) + build_args+=("-c.publish.releaseType=$RELEASE_TYPE") + build_args+=("-c.publish.channel=$RELEASE_CHANNEL") fi - npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}" + npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}" diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index fd0c134b6..9e3e497f4 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -21,9 +21,13 @@ 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/ +# 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" +elif [ -x "${RESOURCES_DIR}/../Paseo.bin" ]; then + APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo.bin" elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo" else diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index d6c957fb6..6263ea2cb 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -3,6 +3,7 @@ appId: sh.paseo.desktop productName: Paseo executableName: Paseo afterPack: ./scripts/after-pack.js +afterSign: ./scripts/after-sign.js directories: output: release files: diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js index 618ec2ae9..408aadae4 100644 --- a/packages/desktop/scripts/after-pack.js +++ b/packages/desktop/scripts/after-pack.js @@ -1,6 +1,8 @@ const fs = require("fs"); const path = require("path"); +const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js"); + const EXECUTABLE_NAME = "Paseo"; const WRAPPER_MODE = 0o755; const WRAPPER_SCRIPT = `#!/bin/bash @@ -129,16 +131,24 @@ exports.default = async function afterPack(context) { pruneNativeModules(context.appOutDir, platform, arch); - if (platform !== "linux") return; + if (platform === "linux") { + prepareLinuxApp(context.appOutDir); + } - const chromeSandbox = path.join(context.appOutDir, "chrome-sandbox"); + if (platform === "linux" || platform === "win32") { + await smokeUnpackedAppIfRequested(context.appOutDir); + } +}; + +function prepareLinuxApp(appOutDir) { + const chromeSandbox = path.join(appOutDir, "chrome-sandbox"); if (fs.existsSync(chromeSandbox)) { fs.unlinkSync(chromeSandbox); console.log("Removed chrome-sandbox from Linux build"); } - const executablePath = path.join(context.appOutDir, EXECUTABLE_NAME); - const wrappedBinaryPath = path.join(context.appOutDir, `${EXECUTABLE_NAME}.bin`); + const executablePath = path.join(appOutDir, EXECUTABLE_NAME); + const wrappedBinaryPath = path.join(appOutDir, `${EXECUTABLE_NAME}.bin`); if (!fs.existsSync(wrappedBinaryPath)) { if (!fs.existsSync(executablePath)) { @@ -152,4 +162,14 @@ exports.default = async function afterPack(context) { fs.writeFileSync(executablePath, WRAPPER_SCRIPT, { mode: WRAPPER_MODE }); fs.chmodSync(executablePath, WRAPPER_MODE); console.log(`Created Linux wrapper for ${EXECUTABLE_NAME} with --no-sandbox`); -}; +} + +async function smokeUnpackedAppIfRequested(appOutDir) { + if (process.env.PASEO_DESKTOP_SMOKE !== "1") { + return; + } + + await smokePackagedDesktopApp({ + appPath: appOutDir, + }); +} diff --git a/packages/desktop/scripts/after-sign.js b/packages/desktop/scripts/after-sign.js new file mode 100644 index 000000000..7d3aa4bbd --- /dev/null +++ b/packages/desktop/scripts/after-sign.js @@ -0,0 +1,19 @@ +const path = require("node:path"); + +const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js"); + +const EXECUTABLE_NAME = "Paseo"; + +exports.default = async function afterSign(context) { + if (process.env.PASEO_DESKTOP_SMOKE !== "1") { + return; + } + + if (context.electronPlatformName !== "darwin") { + return; + } + + await smokePackagedDesktopApp({ + appPath: path.join(context.appOutDir, `${EXECUTABLE_NAME}.app`), + }); +}; diff --git a/packages/desktop/scripts/smoke-packaged-desktop-app.js b/packages/desktop/scripts/smoke-packaged-desktop-app.js new file mode 100644 index 000000000..95a3ae821 --- /dev/null +++ b/packages/desktop/scripts/smoke-packaged-desktop-app.js @@ -0,0 +1,437 @@ +const { spawn, spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { setTimeout: delay } = require("node:timers/promises"); + +const EXECUTABLE_NAME = "Paseo"; +const SMOKE_TIMEOUT_MS = 60_000; +const EXIT_TIMEOUT_MS = 10_000; + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function assertExecutable(filePath, label) { + if (!fs.existsSync(filePath)) { + throw new Error(`${label} does not exist: ${filePath}`); + } + if (process.platform !== "win32") { + fs.accessSync(filePath, fs.constants.X_OK); + } +} + +function getExecutablePath(appPath) { + if (process.platform === "darwin") { + return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME); + } + + if (process.platform === "win32") { + return path.join(appPath, `${EXECUTABLE_NAME}.exe`); + } + + return path.join(appPath, EXECUTABLE_NAME); +} + +function getCliShimPath(appPath) { + if (process.platform === "darwin") { + return path.join(appPath, "Contents", "Resources", "bin", "paseo"); + } + + if (process.platform === "win32") { + return path.join(appPath, "resources", "bin", "paseo.cmd"); + } + + return path.join(appPath, "resources", "bin", "paseo"); +} + +function getLaunchCommand(executablePath) { + if (process.platform !== "linux") { + return { + command: executablePath, + args: [], + }; + } + + return { + command: "xvfb-run", + args: ["-a", executablePath], + }; +} + +function shellQuote(value) { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function getShellCommand(script) { + if (process.platform === "win32") { + return { + command: "cmd.exe", + args: ["/d", "/c", script], + }; + } + + return { + command: "/bin/sh", + args: ["-lc", script], + }; +} + +function createDefaultDaemonEnv(extraEnv) { + const env = { + ...process.env, + ...extraEnv, + }; + + delete env.PASEO_HOME; + delete env.PASEO_LISTEN; + return env; +} + +function parseSmokeLine(line) { + const prefix = "[paseo-smoke] "; + if (!line.startsWith(prefix)) { + return null; + } + return JSON.parse(line.slice(prefix.length)); +} + +function appendChunk(lines, chunk, onSmokeMessage) { + const text = chunk.toString(); + lines.push(text); + for (const line of text.split(/\r?\n/)) { + const parsed = parseSmokeLine(line.trim()); + if (parsed) { + onSmokeMessage(parsed); + } + } +} + +function readIfExists(filePath) { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null; +} + +function formatLogs({ stdout, stderr, userData }) { + const desktopLog = readIfExists(path.join(userData, "logs", "main.log")); + const daemonLog = readIfExists(path.join(os.homedir(), ".paseo", "daemon.log")); + return [ + `App stdout:\n${stdout.join("").trim() || ""}`, + `App stderr:\n${stderr.join("").trim() || ""}`, + `Desktop log:\n${desktopLog?.trim() || ""}`, + `Daemon log:\n${daemonLog?.trim() || ""}`, + ].join("\n\n"); +} + +function isRunning(child) { + return child.exitCode === null && child.signalCode === null; +} + +function terminateChild(child, signal = "SIGTERM") { + if (!isRunning(child)) { + return; + } + + if (process.platform === "win32" && child.pid) { + spawnSync("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" }); + return; + } + + if (child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch {} + } + + child.kill(signal); +} + +function waitForChildExit(child, timeoutMs = EXIT_TIMEOUT_MS) { + if (!isRunning(child)) { + return Promise.resolve(true); + } + + let onExit; + const exitPromise = new Promise((resolve) => { + onExit = () => resolve(true); + child.once("exit", onExit); + }); + + return Promise.race([exitPromise, delay(timeoutMs, false)]).finally(() => { + child.off("exit", onExit); + }); +} + +function releaseChildHandles(child) { + child.stdin?.destroy(); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); +} + +async function removeTempDir(tempDir) { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + return; + } catch (error) { + if (!["EBUSY", "ENOTEMPTY", "EPERM"].includes(error?.code) || attempt === 4) { + console.warn(`Packaged desktop smoke: failed to remove temp dir ${tempDir}: ${error}`); + return; + } + await delay(250); + } + } +} + +function waitForSmokeMessage({ child, stdout, stderr, userData, type, validate }) { + return new Promise((resolve, reject) => { + let settled = false; + + const finish = (callback, value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + callback(value); + }; + + const timer = setTimeout(() => { + terminateChild(child); + finish( + reject, + new Error( + `Timed out waiting for packaged desktop smoke result.\n${formatLogs({ + stdout, + stderr, + userData, + })}`, + ), + ); + }, SMOKE_TIMEOUT_MS); + + const onSmokeMessage = (message) => { + if (message.type !== type) { + return; + } + if (validate(message)) { + finish(resolve, message); + return; + } + finish(reject, new Error(`Unexpected desktop smoke message: ${JSON.stringify(message)}`)); + }; + + child.stdout.on("data", (chunk) => appendChunk(stdout, chunk, onSmokeMessage)); + child.stderr.on("data", (chunk) => appendChunk(stderr, chunk, onSmokeMessage)); + child.once("error", (error) => finish(reject, error)); + child.once("exit", (code, signal) => { + if (settled) { + return; + } + finish( + reject, + new Error( + `Packaged app exited before reporting smoke success (code ${code}, signal ${ + signal ?? "none" + }).\n${formatLogs({ stdout, stderr, userData })}`, + ), + ); + }); + }); +} + +function assertRunningDesktopManagedDaemon(message) { + return ( + message.status?.status === "running" && + message.status?.desktopManaged === true && + typeof message.status?.pid === "number" && + typeof message.status?.listen === "string" && + message.status.listen.length > 0 + ); +} + +function runShellCommand({ script, env, label }) { + return new Promise((resolve, reject) => { + let settled = false; + const shell = getShellCommand(script); + const child = spawn(shell.command, shell.args, { + detached: process.platform !== "win32", + env, + stdio: ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: process.platform === "win32", + }); + const stdout = []; + const stderr = []; + + const finish = (callback, value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + releaseChildHandles(child); + callback(value); + }; + + const timer = setTimeout(() => { + terminateChild(child); + finish( + reject, + new Error( + `${label} timed out.\nStdout:\n${stdout.join("").trim() || ""}\n\nStderr:\n${ + stderr.join("").trim() || "" + }`, + ), + ); + }, SMOKE_TIMEOUT_MS); + + child.stdout.on("data", (chunk) => stdout.push(chunk.toString())); + child.stderr.on("data", (chunk) => stderr.push(chunk.toString())); + child.once("error", (error) => finish(reject, error)); + child.once("exit", (code, signal) => { + if (code === 0) { + finish(resolve, { + stdout: stdout.join(""), + stderr: stderr.join(""), + }); + return; + } + + finish( + reject, + new Error( + `${label} failed (code ${code}, signal ${signal ?? "none"}).\nStdout:\n${ + stdout.join("").trim() || "" + }\n\nStderr:\n${stderr.join("").trim() || ""}`, + ), + ); + }); + }); +} + +function getCliShimScript(cliShimPath, args) { + const commandArgs = args.join(" "); + if (process.platform === "win32") { + return `call "${cliShimPath}" ${commandArgs}`; + } + + return `${shellQuote(cliShimPath)} ${commandArgs}`; +} + +async function runCliShimCommand({ appPath, env, args, label }) { + const cliShimPath = getCliShimPath(appPath); + assertExecutable(cliShimPath, "Bundled CLI shim"); + + await runShellCommand({ + script: getCliShimScript(cliShimPath, args), + env, + label, + }); +} + +async function smokeCliShim({ appPath, env }) { + console.log("Packaged desktop smoke: running bundled CLI shim daemon status"); + await runCliShimCommand({ + appPath, + env, + args: ["daemon", "status"], + label: "Bundled CLI shim daemon status", + }); +} + +async function stopCliDaemon({ appPath, env }) { + console.log("Packaged desktop smoke: stopping daemon through bundled CLI shim"); + await runCliShimCommand({ + appPath, + env, + args: ["daemon", "stop", "--force"], + label: "Bundled CLI shim daemon stop", + }); +} + +async function smokePackagedDesktopApp({ appPath }) { + const executablePath = getExecutablePath(appPath); + assertExecutable(executablePath, "Packaged app executable"); + + const userData = createTempDir("paseo-smoke-user-data-"); + const env = createDefaultDaemonEnv({ + PASEO_DESKTOP_SMOKE: "1", + PASEO_ELECTRON_USER_DATA_DIR: userData, + }); + + const stdout = []; + const stderr = []; + const launch = getLaunchCommand(executablePath); + console.log(`Packaged desktop smoke: launching ${launch.command} ${launch.args.join(" ")}`); + const child = spawn(launch.command, launch.args, { + detached: process.platform !== "win32", + env, + stdio: ["pipe", "pipe", "pipe"], + }); + let smokeStarted = false; + let daemonStopped = false; + + const stopDaemonForCleanup = async () => { + if (daemonStopped) { + return; + } + + await stopCliDaemon({ appPath, env: createDefaultDaemonEnv() }); + daemonStopped = true; + }; + + try { + const message = await waitForSmokeMessage({ + child, + stdout, + stderr, + userData, + type: "desktop-daemon-smoke-started", + validate: assertRunningDesktopManagedDaemon, + }); + smokeStarted = true; + console.log("Packaged desktop smoke: desktop-managed daemon reported running"); + await smokeCliShim({ appPath, env: createDefaultDaemonEnv() }); + await stopDaemonForCleanup(); + console.log( + `Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status succeeded`, + ); + } catch (error) { + if (smokeStarted && !daemonStopped) { + try { + await stopDaemonForCleanup(); + } catch {} + } + throw error; + } finally { + if (isRunning(child)) { + terminateChild(child); + if (!(await waitForChildExit(child))) { + terminateChild(child, "SIGKILL"); + await waitForChildExit(child); + } + } + releaseChildHandles(child); + await removeTempDir(userData); + } +} + +module.exports = { + smokePackagedDesktopApp, +}; + +if (require.main === module) { + const appIndex = process.argv.indexOf("--app"); + const appPath = appIndex >= 0 ? process.argv[appIndex + 1] : null; + if (!appPath) { + process.stderr.write("Usage: node smoke-packaged-desktop-app.js --app \n"); + process.exit(2); + } + + smokePackagedDesktopApp({ appPath }).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/main.ts b/packages/desktop/src/main.ts index fb72136be..c586e6211 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -10,7 +10,7 @@ import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, protocol } from "electron"; -import { registerDaemonManager } from "./daemon/daemon-manager.js"; +import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js"; import { parseCliPassthroughArgsFromArgv, runCliPassthroughCommand, @@ -47,6 +47,8 @@ import { const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; const OPEN_PROJECT_EVENT = "paseo:event:open-project"; +const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE"; +const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop"; app.setName("Paseo"); // In dev mode, detect git worktrees and isolate each instance so multiple @@ -288,6 +290,53 @@ async function runCliPassthroughIfRequested(): Promise { return true; } +async function runDesktopSmokeIfRequested(): Promise { + if (process.env[DESKTOP_SMOKE_ENV] !== "1") { + return false; + } + + const handlers = createDaemonCommandHandlers(); + const startStatus = await handlers.start_desktop_daemon(); + process.stdout.write( + `[paseo-smoke] ${JSON.stringify({ + type: "desktop-daemon-smoke-started", + status: startStatus, + })}\n`, + ); + + await waitForDesktopSmokeStopRequest(); + + const stopStatus = await handlers.stop_desktop_daemon(); + process.stdout.write( + `[paseo-smoke] ${JSON.stringify({ + type: "desktop-daemon-smoke-stopped", + stopStatus, + })}\n`, + ); + + app.exit(0); + return true; +} + +function waitForDesktopSmokeStopRequest(): Promise { + return new Promise((resolve) => { + let buffer = ""; + const stop = () => { + process.stdin.off("data", onData); + resolve(); + }; + const onData = (chunk: Buffer | string) => { + buffer += chunk.toString(); + if (buffer.includes(DESKTOP_SMOKE_STOP_REQUEST)) { + stop(); + } + }; + + process.stdin.on("data", onData); + process.stdin.resume(); + }); +} + async function bootstrap(): Promise { if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) { return; @@ -329,6 +378,9 @@ async function bootstrap(): Promise { applyAppIcon(); setupApplicationMenu(); ensureNotificationCenterRegistration(); + if (await runDesktopSmokeIfRequested()) { + return; + } registerDaemonManager(); registerWindowManager(); registerDialogHandlers();