Add macOS packaged desktop smoke gate (#578)
* Fix packaged node entrypoint env preservation * Add packaged desktop smoke gate
This commit is contained in:
@@ -24,6 +24,19 @@ on:
|
|||||||
- macos
|
- macos
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- 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:
|
concurrency:
|
||||||
group: desktop-release-${{ github.ref }}
|
group: desktop-release-${{ github.ref }}
|
||||||
@@ -31,12 +44,14 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
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_WORKSPACE: "@getpaseo/desktop"
|
||||||
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -44,7 +59,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
sparse-checkout: scripts
|
sparse-checkout: scripts
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ env.CHECKOUT_REF }}
|
||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -91,7 +106,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ env.CHECKOUT_REF }}
|
||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -140,20 +155,22 @@ jobs:
|
|||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
publish_mode="never"
|
publish_mode="never"
|
||||||
publish_args=()
|
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
||||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
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
|
- 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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: mac-manifest-${{ matrix.electron_arch }}
|
name: mac-manifest-${{ matrix.electron_arch }}
|
||||||
@@ -162,7 +179,7 @@ jobs:
|
|||||||
|
|
||||||
finalize-mac-manifest:
|
finalize-mac-manifest:
|
||||||
needs: [publish-macos]
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -171,7 +188,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
sparse-checkout: scripts
|
sparse-checkout: scripts
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ env.CHECKOUT_REF }}
|
||||||
|
|
||||||
- name: Resolve release tag
|
- name: Resolve release tag
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -269,7 +286,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ env.CHECKOUT_REF }}
|
||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -308,22 +325,27 @@ jobs:
|
|||||||
- name: Build web app for desktop
|
- name: Build web app for desktop
|
||||||
run: npm run build:web --workspace=@getpaseo/app
|
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
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
EP_GH_IGNORE_TIME: true
|
EP_GH_IGNORE_TIME: true
|
||||||
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
publish_mode="never"
|
publish_mode="never"
|
||||||
publish_args=()
|
build_args=(-- --publish "$publish_mode" --linux --x64)
|
||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
build_args=(-- --publish "$publish_mode" --linux --x64)
|
||||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||||
|
|
||||||
publish-windows:
|
publish-windows:
|
||||||
needs: [create-release]
|
needs: [create-release]
|
||||||
@@ -337,7 +359,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
ref: ${{ env.CHECKOUT_REF }}
|
||||||
|
|
||||||
- name: Resolve release metadata
|
- name: Resolve release metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -389,14 +411,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
EP_GH_IGNORE_TIME: true
|
EP_GH_IGNORE_TIME: true
|
||||||
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
publish_mode="never"
|
publish_mode="never"
|
||||||
publish_args=()
|
build_args=(-- --publish "$publish_mode" --win --x64)
|
||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
build_args=(-- --publish "$publish_mode" --win --x64)
|
||||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ 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: 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
|
if [ -x "${RESOURCES_DIR}/../MacOS/Paseo" ]; then
|
||||||
APP_EXECUTABLE="${RESOURCES_DIR}/../MacOS/Paseo"
|
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
|
elif [ -x "${RESOURCES_DIR}/../Paseo" ]; then
|
||||||
APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo"
|
APP_EXECUTABLE="${RESOURCES_DIR}/../Paseo"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ appId: sh.paseo.desktop
|
|||||||
productName: Paseo
|
productName: Paseo
|
||||||
executableName: Paseo
|
executableName: Paseo
|
||||||
afterPack: ./scripts/after-pack.js
|
afterPack: ./scripts/after-pack.js
|
||||||
|
afterSign: ./scripts/after-sign.js
|
||||||
directories:
|
directories:
|
||||||
output: release
|
output: release
|
||||||
files:
|
files:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
|
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||||
|
|
||||||
const EXECUTABLE_NAME = "Paseo";
|
const EXECUTABLE_NAME = "Paseo";
|
||||||
const WRAPPER_MODE = 0o755;
|
const WRAPPER_MODE = 0o755;
|
||||||
const WRAPPER_SCRIPT = `#!/bin/bash
|
const WRAPPER_SCRIPT = `#!/bin/bash
|
||||||
@@ -129,16 +131,24 @@ exports.default = async function afterPack(context) {
|
|||||||
|
|
||||||
pruneNativeModules(context.appOutDir, platform, arch);
|
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)) {
|
if (fs.existsSync(chromeSandbox)) {
|
||||||
fs.unlinkSync(chromeSandbox);
|
fs.unlinkSync(chromeSandbox);
|
||||||
console.log("Removed chrome-sandbox from Linux build");
|
console.log("Removed chrome-sandbox from Linux build");
|
||||||
}
|
}
|
||||||
|
|
||||||
const executablePath = path.join(context.appOutDir, EXECUTABLE_NAME);
|
const executablePath = path.join(appOutDir, EXECUTABLE_NAME);
|
||||||
const wrappedBinaryPath = path.join(context.appOutDir, `${EXECUTABLE_NAME}.bin`);
|
const wrappedBinaryPath = path.join(appOutDir, `${EXECUTABLE_NAME}.bin`);
|
||||||
|
|
||||||
if (!fs.existsSync(wrappedBinaryPath)) {
|
if (!fs.existsSync(wrappedBinaryPath)) {
|
||||||
if (!fs.existsSync(executablePath)) {
|
if (!fs.existsSync(executablePath)) {
|
||||||
@@ -152,4 +162,14 @@ exports.default = async function afterPack(context) {
|
|||||||
fs.writeFileSync(executablePath, WRAPPER_SCRIPT, { mode: WRAPPER_MODE });
|
fs.writeFileSync(executablePath, WRAPPER_SCRIPT, { mode: WRAPPER_MODE });
|
||||||
fs.chmodSync(executablePath, WRAPPER_MODE);
|
fs.chmodSync(executablePath, WRAPPER_MODE);
|
||||||
console.log(`Created Linux wrapper for ${EXECUTABLE_NAME} with --no-sandbox`);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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() || "<empty>"}`,
|
||||||
|
`App stderr:\n${stderr.join("").trim() || "<empty>"}`,
|
||||||
|
`Desktop log:\n${desktopLog?.trim() || "<missing>"}`,
|
||||||
|
`Daemon log:\n${daemonLog?.trim() || "<missing>"}`,
|
||||||
|
].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() || "<empty>"}\n\nStderr:\n${
|
||||||
|
stderr.join("").trim() || "<empty>"
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, 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() || "<empty>"
|
||||||
|
}\n\nStderr:\n${stderr.join("").trim() || "<empty>"}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Paseo.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { pathToFileURL } from "node:url";
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, protocol } from "electron";
|
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 {
|
import {
|
||||||
parseCliPassthroughArgsFromArgv,
|
parseCliPassthroughArgsFromArgv,
|
||||||
runCliPassthroughCommand,
|
runCliPassthroughCommand,
|
||||||
@@ -47,6 +47,8 @@ import {
|
|||||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||||
const APP_SCHEME = "paseo";
|
const APP_SCHEME = "paseo";
|
||||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
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");
|
app.setName("Paseo");
|
||||||
|
|
||||||
// In dev mode, detect git worktrees and isolate each instance so multiple
|
// In dev mode, detect git worktrees and isolate each instance so multiple
|
||||||
@@ -288,6 +290,53 @@ async function runCliPassthroughIfRequested(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runDesktopSmokeIfRequested(): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) {
|
if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) {
|
||||||
return;
|
return;
|
||||||
@@ -329,6 +378,9 @@ async function bootstrap(): Promise<void> {
|
|||||||
applyAppIcon();
|
applyAppIcon();
|
||||||
setupApplicationMenu();
|
setupApplicationMenu();
|
||||||
ensureNotificationCenterRegistration();
|
ensureNotificationCenterRegistration();
|
||||||
|
if (await runDesktopSmokeIfRequested()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
registerDaemonManager();
|
registerDaemonManager();
|
||||||
registerWindowManager();
|
registerWindowManager();
|
||||||
registerDialogHandlers();
|
registerDialogHandlers();
|
||||||
|
|||||||
Reference in New Issue
Block a user