import { execFile, spawn } from "node:child_process"; import { access, chmod, cp, mkdir, readFile, readdir, readlink, rename, rm, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; import { APP_KEYS, OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_MESSAGES, SIDECAR_MODES, SIDECAR_SOURCES, type DesktopStatusSnapshot, type SidecarStamp, } from "@open-design/sidecar-proto"; import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; import { collectProcessTreePids, createPackageManagerInvocation, createProcessStampArgs, listProcessSnapshots, readLogTail, spawnBackgroundProcess, stopProcesses, } from "@open-design/platform"; import type { ToolPackConfig } from "./config.js"; import { copyBundledResourceTrees, linuxResources } from "./resources.js"; const execFileAsync = promisify(execFile); const PRODUCT_NAME = "Open Design"; const APP_IMAGE_PRODUCT_NAME = "Open-Design"; const DESKTOP_LOG_ECHO_ENV = "OD_DESKTOP_LOG_ECHO"; const INTERNAL_PACKAGES = [ { directory: "packages/contracts", name: "@open-design/contracts" }, { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, { directory: "packages/sidecar", name: "@open-design/sidecar" }, { directory: "packages/platform", name: "@open-design/platform" }, { directory: "apps/daemon", name: "@open-design/daemon" }, { directory: "apps/web", name: "@open-design/web" }, { directory: "apps/desktop", name: "@open-design/desktop" }, { directory: "apps/packaged", name: "@open-design/packaged" }, ] as const; export function sanitizeNamespace(value: string): string { return value.replace(/[^A-Za-z0-9._-]+/g, "-"); } async function pathExists(path: string): Promise { try { await access(path); return true; } catch { return false; } } async function commandExists(bin: string): Promise { try { await execFileAsync(bin, ["--version"]); return true; } catch { return false; } } type DockerUserMapping = { uid: number; gid: number; }; export function buildDockerArgs( config: ToolPackConfig, user: DockerUserMapping, ): string[] { const dockerHome = join(config.roots.toolPackRoot, ".docker-home"); const electronCache = join(config.roots.toolPackRoot, ".docker-cache", "electron"); const electronBuilderCache = join(config.roots.toolPackRoot, ".docker-cache", "electron-builder"); // The tool-pack root is mounted at a fixed container path so the inner build // can be told where to write output via `--dir /tools-pack`. Without this // mount + flag, the inner build would default to /.tmp/tools-pack // and silently ignore the caller's `--dir`, breaking any orchestration (CI, // multi-namespace local builds) that pins tools-pack output outside the workspace. // The .docker-home and .docker-cache/* mounts below shadow this parent mount at // their specific paths under /home/builder, which is the supported overlap pattern. // // Shell-interpolation safety for the inner `bash -lc` command: // - config.namespace is sanitized at config-time by resolveNamespace() in // @open-design/sidecar-proto (restricted to namespace charset) // - config.to is enum-validated by resolveToolPackBuildOutput() in config.ts // to one of "all" | "appimage" | "dir" // - config.portable is a boolean // None of these values can contain shell metacharacters, so direct // interpolation into the inner command string is safe. // // We can't rely on `corepack pnpm` here: although Node 16.10+ ships corepack, // the `electronuserland/builder:base` image strips the corepack binary, so // the inner `bash -lc` fails with `corepack: command not found`. We also // can't `corepack enable` ourselves — the container runs as the host's // non-root uid (--user above) and corepack would try to write shims next // to the system Node binary, which is owned by root in this image. // // Use `npx --yes pnpm@` instead: `npx` ships with npm (always // present in the image), `--yes` skips the install confirmation, and the // package gets cached under `$HOME/.npm/_npx`, which is writable by the // unprivileged user. The pinned version matches the `packageManager` // field in the root package.json so reproducibility is preserved. const PNPM_VERSION = "10.33.2"; const pnpmCmd = `npx --yes pnpm@${PNPM_VERSION}`; const innerArgs = [ `${pnpmCmd} tools-pack linux build`, `--to ${config.to}`, `--namespace ${config.namespace}`, "--dir /tools-pack", ]; if (config.portable) { innerArgs.push("--portable"); } const innerCommand = `${pnpmCmd} install --frozen-lockfile && ` + innerArgs.join(" "); return [ "run", "--rm", "--user", `${user.uid}:${user.gid}`, "-v", `${config.workspaceRoot}:/project`, "-v", `${config.roots.toolPackRoot}:/tools-pack`, "-v", `${dockerHome}:/home/builder`, "-v", `${electronCache}:/home/builder/.cache/electron`, "-v", `${electronBuilderCache}:/home/builder/.cache/electron-builder`, "-e", "HOME=/home/builder", "-e", "ELECTRON_CACHE=/home/builder/.cache/electron", "-e", "ELECTRON_BUILDER_CACHE=/home/builder/.cache/electron-builder", "-w", "/project", "electronuserland/builder:base", "bash", "-lc", innerCommand, ]; } export type DesktopTemplateValues = { namespace: string; execPath: string; iconName: string; }; export function renderDesktopTemplate(template: string, values: DesktopTemplateValues): string { return template .replace(/@@NAMESPACE@@/g, values.namespace) .replace(/@@EXEC_PATH@@/g, values.execPath) .replace(/@@ICON_PATH@@/g, values.iconName); } export type AppImageProcessSnapshot = { pid: number; executable: string; env: Record; }; export function matchesAppImageProcess( snapshot: AppImageProcessSnapshot, installPath: string, ): boolean { if (snapshot.executable === installPath) return true; // Two AppImage launch modes leave different executable paths in /proc//exe: // FUSE-mounted: /tmp/.mount_/AppRun // --appimage-extract-and-run: /tmp/appimage_extracted_/ // In both cases the AppImage runtime sets $APPIMAGE to the original install path. const isMountedRunner = /^\/tmp\/\.mount_[^/]+\/AppRun$/.test(snapshot.executable); const isExtractedRunner = /^\/tmp\/appimage_extracted_[^/]+\/[^/]+$/.test(snapshot.executable); if (!isMountedRunner && !isExtractedRunner) return false; return snapshot.env.APPIMAGE === installPath; } // --- Step 1: LinuxPaths type and resolveLinuxPaths --- type LinuxPaths = { appBuilderConfigPath: string; appBuilderOutputRoot: string; appImagePath: string; assembledAppRoot: string; assembledMainEntryPath: string; assembledPackageJsonPath: string; installAppImagePath: string; installDesktopFilePath: string; installIconPath: string; packagedConfigPath: string; resourceRoot: string; tarballsRoot: string; }; function appImageInstallName(namespace: string): string { return `${APP_IMAGE_PRODUCT_NAME}.${sanitizeNamespace(namespace)}.AppImage`; } function desktopFileName(namespace: string): string { return `open-design-${sanitizeNamespace(namespace)}.desktop`; } function iconFileName(namespace: string): string { return `open-design-${sanitizeNamespace(namespace)}.png`; } function resolveLinuxPaths(config: ToolPackConfig): LinuxPaths { const namespaceRoot = config.roots.output.namespaceRoot; const appBuilderOutputRoot = config.roots.output.appBuilderRoot; const home = homedir(); return { appBuilderConfigPath: join(namespaceRoot, "builder-config.json"), appBuilderOutputRoot, appImagePath: "", assembledAppRoot: join(namespaceRoot, "assembled", "app"), assembledMainEntryPath: join(namespaceRoot, "assembled", "app", "main.cjs"), assembledPackageJsonPath: join(namespaceRoot, "assembled", "app", "package.json"), installAppImagePath: join(home, ".local", "bin", appImageInstallName(config.namespace)), installDesktopFilePath: join(home, ".local", "share", "applications", desktopFileName(config.namespace)), installIconPath: join( home, ".local", "share", "icons", "hicolor", "512x512", "apps", iconFileName(config.namespace), ), packagedConfigPath: join(namespaceRoot, "open-design-config.json"), resourceRoot: join(namespaceRoot, "resources", "open-design"), tarballsRoot: join(namespaceRoot, "tarballs"), }; } // --- Step 2: Runtime helpers --- async function runPnpm( config: ToolPackConfig, args: string[], extraEnv: NodeJS.ProcessEnv = {}, ): Promise { const invocation = createPackageManagerInvocation(args, process.env); await execFileAsync(invocation.command, invocation.args, { cwd: config.workspaceRoot, env: { ...process.env, ...extraEnv }, }); } async function runNpmInstall(appRoot: string): Promise { await execFileAsync("npm", ["install", "--omit=dev", "--no-package-lock"], { cwd: appRoot, env: process.env, }); } async function readPackagedVersion(config: ToolPackConfig): Promise { const packageJsonPath = join(config.workspaceRoot, "apps", "packaged", "package.json"); const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown }; if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { throw new Error(`missing apps/packaged package version in ${packageJsonPath}`); } return packageJson.version; } async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts"); const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null); await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]); await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); try { await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: "server" }); await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]); } finally { if (previousWebNextEnv == null) { await rm(webNextEnvPath, { force: true }); } else { await writeFile(webNextEnvPath, previousWebNextEnv, "utf8"); } } await runPnpm(config, ["--filter", "@open-design/desktop", "build"]); await runPnpm(config, ["--filter", "@open-design/packaged", "build"]); } // --- Step 3: Tarball + resource helpers --- type PackedTarballInfo = { fileName: string; packageName: (typeof INTERNAL_PACKAGES)[number]["name"]; }; async function collectWorkspaceTarballs( config: ToolPackConfig, paths: LinuxPaths, ): Promise { await rm(paths.tarballsRoot, { force: true, recursive: true }); await mkdir(paths.tarballsRoot, { recursive: true }); const packed: PackedTarballInfo[] = []; for (const pkg of INTERNAL_PACKAGES) { const before = new Set(await readdir(paths.tarballsRoot)); await runPnpm(config, ["-C", pkg.directory, "pack", "--pack-destination", paths.tarballsRoot]); const after = await readdir(paths.tarballsRoot); const novel = after.filter((e) => !before.has(e)); if (novel.length !== 1 || novel[0] == null) { throw new Error(`expected one tarball for ${pkg.name}, got ${novel.length}`); } packed.push({ fileName: novel[0], packageName: pkg.name }); } return packed; } async function copyResourceTree(config: ToolPackConfig, paths: LinuxPaths): Promise { await rm(paths.resourceRoot, { force: true, recursive: true }); await mkdir(paths.resourceRoot, { recursive: true }); await copyBundledResourceTrees({ workspaceRoot: config.workspaceRoot, resourceRoot: paths.resourceRoot, }); await mkdir(join(paths.resourceRoot, "bin"), { recursive: true }); await cp(process.execPath, join(paths.resourceRoot, "bin", "node")); await chmod(join(paths.resourceRoot, "bin", "node"), 0o755); } // --- Step 4: writeAssembledApp helper --- async function writeAssembledApp( config: ToolPackConfig, paths: LinuxPaths, packed: PackedTarballInfo[], ): Promise { await rm(paths.assembledAppRoot, { force: true, recursive: true }); await mkdir(paths.assembledAppRoot, { recursive: true }); const dependencies: Record = {}; for (const tarball of packed) { dependencies[tarball.packageName] = `file:${join(paths.tarballsRoot, tarball.fileName)}`; } const version = await readPackagedVersion(config); const packageJson = { name: "open-design-packaged", version, private: true, main: "main.cjs", dependencies, }; await writeFile(paths.assembledPackageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); const mainStub = `"use strict";\nrequire("@open-design/packaged");\n`; await writeFile(paths.assembledMainEntryPath, mainStub, "utf8"); await writeFile( paths.packagedConfigPath, `${JSON.stringify( { appVersion: version, namespace: config.namespace, nodeCommandRelative: "open-design/bin/node", ...(config.portable ? {} : { namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }), }, null, 2, )}\n`, "utf8", ); await runNpmInstall(paths.assembledAppRoot); } // --- Step 5: writeLinuxBuilderConfig helper --- async function writeLinuxBuilderConfig(config: ToolPackConfig, paths: LinuxPaths): Promise { const target = config.to === "dir" ? ["dir"] : ["AppImage"]; const namespaceToken = sanitizeNamespace(config.namespace); const packagedVersion = await readPackagedVersion(config); const builderConfig: Record = { appId: "io.open-design.desktop", artifactName: `${PRODUCT_NAME}-${namespaceToken}.\${ext}`, asar: false, buildDependenciesFromSource: false, compression: "maximum", directories: { app: paths.assembledAppRoot, output: paths.appBuilderOutputRoot, buildResources: dirname(linuxResources.icon), }, electronVersion: config.electronVersion.replace(/^[^\d]*/, ""), electronDist: config.electronDistPath, executableName: PRODUCT_NAME, extraMetadata: { main: "./main.cjs", name: "open-design-packaged-app", productName: PRODUCT_NAME, version: packagedVersion, ...(config.portable ? {} : { odToolsPackRuntimeRoot: config.roots.runtime.namespaceBaseRoot }), }, extraResources: [ { from: paths.resourceRoot, to: "open-design" }, { from: paths.packagedConfigPath, to: "open-design-config.json" }, ], files: ["**/*", "!**/node_modules/.bin", "!**/node_modules/electron{,/**/*}"], icon: linuxResources.icon, linux: { target, icon: linuxResources.icon, category: "Development", synopsis: "Open Design", maintainer: "Open Design Contributors", }, nodeGypRebuild: false, npmRebuild: false, productName: PRODUCT_NAME, }; await mkdir(dirname(paths.appBuilderConfigPath), { recursive: true }); await writeFile(paths.appBuilderConfigPath, `${JSON.stringify(builderConfig, null, 2)}\n`, "utf8"); } // --- Step 6: runElectronBuilderLinux + findBuiltAppImage helpers --- async function runElectronBuilderLinux(config: ToolPackConfig, paths: LinuxPaths): Promise { await rm(paths.appBuilderOutputRoot, { force: true, recursive: true }); const args = [ config.electronBuilderCliPath, "--linux", "--config", paths.appBuilderConfigPath, "--projectDir", paths.assembledAppRoot, "--publish", "never", ]; await execFileAsync(process.execPath, args, { cwd: config.workspaceRoot, env: process.env, }); } async function findBuiltAppImage(paths: LinuxPaths): Promise { if (!(await pathExists(paths.appBuilderOutputRoot))) return null; const entries = await readdir(paths.appBuilderOutputRoot); const appImage = entries.find((entry) => entry.endsWith(".AppImage")); return appImage ? join(paths.appBuilderOutputRoot, appImage) : null; } // --- Step 7: packLinux orchestrator + result type + stub for runBuildInContainer --- export type LinuxPackResult = { appImagePath: string | null; outputRoot: string; resourceRoot: string; runtimeNamespaceRoot: string; to: ToolPackConfig["to"]; containerized: boolean; }; export async function packLinux(config: ToolPackConfig): Promise { if (config.containerized) { await runBuildInContainer(config); const paths = resolveLinuxPaths(config); const appImagePath = config.to === "dir" ? null : await findBuiltAppImage(paths); return { appImagePath, outputRoot: paths.appBuilderOutputRoot, resourceRoot: paths.resourceRoot, runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, to: config.to, containerized: true, }; } const paths = resolveLinuxPaths(config); await mkdir(config.roots.output.namespaceRoot, { recursive: true }); await buildWorkspaceArtifacts(config); await copyResourceTree(config, paths); const tarballs = await collectWorkspaceTarballs(config, paths); await writeAssembledApp(config, paths, tarballs); await writeLinuxBuilderConfig(config, paths); await runElectronBuilderLinux(config, paths); const appImagePath = config.to === "dir" ? null : await findBuiltAppImage(paths); return { appImagePath, outputRoot: paths.appBuilderOutputRoot, resourceRoot: paths.resourceRoot, runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, to: config.to, containerized: false, }; } async function assertDockerAvailable(): Promise { if (!(await commandExists("docker"))) { throw new Error( "tools-pack linux build --containerized requires Docker. Install Docker or omit --containerized for a native build.", ); } } async function runBuildInContainer(config: ToolPackConfig): Promise { await assertDockerAvailable(); await mkdir(join(config.roots.toolPackRoot, ".docker-home"), { recursive: true }); await mkdir(join(config.roots.toolPackRoot, ".docker-cache", "electron"), { recursive: true }); await mkdir(join(config.roots.toolPackRoot, ".docker-cache", "electron-builder"), { recursive: true }); const uid = typeof process.getuid === "function" ? process.getuid() : 0; const gid = typeof process.getgid === "function" ? process.getgid() : 0; const args = buildDockerArgs(config, { uid, gid }); return new Promise((resolve, reject) => { const child = spawn("docker", args, { stdio: "inherit", env: process.env }); // In Node's child-process `exit` event, code === null means the child was // terminated by a signal (SIGTERM, SIGKILL, etc.). A signal-terminated // build is NOT a successful build — the AppImage may be missing or partial, // so we surface it as a failure instead of resolving silently. child.on("exit", (code, signal) => { if (code === 0 && signal == null) { resolve(); return; } if (signal != null) { reject(new Error(`docker build was terminated by signal ${signal}`)); return; } reject(new Error(`docker build exited with code ${code}`)); }); child.on("error", (error: Error) => { reject(error); }); }); } export type LinuxInstallResult = { appImagePath: string; desktopFilePath: string; iconPath: string; namespace: string; postInstall: { desktopDatabase: "ok" | "missing" | "failed"; iconCache: "ok" | "missing" | "failed"; }; }; async function bestEffortRun(bin: string, args: string[]): Promise<"ok" | "missing" | "failed"> { if (!(await commandExists(bin))) return "missing"; try { await execFileAsync(bin, args); return "ok"; } catch { return "failed"; } } export async function installPackedLinuxApp(config: ToolPackConfig): Promise { const paths = resolveLinuxPaths(config); const builtAppImage = await findBuiltAppImage(paths); if (builtAppImage == null) { throw new Error("no AppImage found in builder output; run `tools-pack linux build` first"); } await mkdir(dirname(paths.installAppImagePath), { recursive: true }); await mkdir(dirname(paths.installDesktopFilePath), { recursive: true }); await mkdir(dirname(paths.installIconPath), { recursive: true }); // Copy AppImage with executable bit. await cp(builtAppImage, paths.installAppImagePath); await chmod(paths.installAppImagePath, 0o755); // Copy icon. await cp(linuxResources.icon, paths.installIconPath); // Render and atomic-write the .desktop file. const template = await readFile(linuxResources.desktopTemplate, "utf8"); const rendered = renderDesktopTemplate(template, { namespace: sanitizeNamespace(config.namespace), execPath: paths.installAppImagePath, iconName: `open-design-${sanitizeNamespace(config.namespace)}`, }); const tmpDesktopPath = `${paths.installDesktopFilePath}.tmp`; await writeFile(tmpDesktopPath, rendered, "utf8"); await rename(tmpDesktopPath, paths.installDesktopFilePath); // Best-effort post-install hooks. const desktopDatabase = await bestEffortRun("update-desktop-database", [ join(homedir(), ".local", "share", "applications"), ]); const iconCache = await bestEffortRun("gtk-update-icon-cache", [ join(homedir(), ".local", "share", "icons", "hicolor"), ]); return { appImagePath: paths.installAppImagePath, desktopFilePath: paths.installDesktopFilePath, iconPath: paths.installIconPath, namespace: config.namespace, postInstall: { desktopDatabase, iconCache }, }; } type LinuxStartSource = "built" | "installed"; export type LinuxStartResult = { appImagePath: string; executablePath: string; logPath: string; namespace: string; pid: number; source: LinuxStartSource; status: DesktopStatusSnapshot | null; }; type DesktopRootIdentityMarker = { appPath: string; executablePath: string; logPath: string; namespaceRoot: string; pid: number; ppid: number; stamp: SidecarStamp; startedAt: string; updatedAt: string; version: 1; }; type DesktopRootIdentityFallback = { marker?: Partial; markerPath: string; processCommand?: string; reason: string; }; export type LinuxStopResult = { fallback?: DesktopRootIdentityFallback; gracefulRequested: boolean; namespace: string; remainingPids: number[]; status: "not-running" | "partial" | "stopped" | "unmanaged"; stoppedPids: number[]; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); } function isDesktopRootIdentityMarker(value: unknown): value is DesktopRootIdentityMarker { if (!isRecord(value)) return false; return ( value.version === 1 && typeof value.pid === "number" && typeof value.ppid === "number" && typeof value.appPath === "string" && typeof value.executablePath === "string" && typeof value.logPath === "string" && typeof value.namespaceRoot === "string" && typeof value.startedAt === "string" && typeof value.updatedAt === "string" && isRecord(value.stamp) ); } async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{ fallback: DesktopRootIdentityFallback; marker: DesktopRootIdentityMarker | null; }> { const markerPath = desktopIdentityPath(config); let payload: unknown; try { payload = JSON.parse(await readFile(markerPath, "utf8")); } catch (error) { const code = isRecord(error) && "code" in error ? String(error.code) : null; return { fallback: { markerPath, reason: code === "ENOENT" ? "marker-not-found" : "marker-read-failed" }, marker: null, }; } if (!isDesktopRootIdentityMarker(payload)) { return { fallback: { markerPath, reason: "marker-invalid-shape" }, marker: null }; } return { fallback: { marker: payload, markerPath, reason: "marker-present" }, marker: payload, }; } async function readProcessEnv(pid: number): Promise> { try { const raw = await readFile(`/proc/${pid}/environ`, "utf8"); const result: Record = {}; for (const entry of raw.split("\0")) { const eq = entry.indexOf("="); if (eq <= 0) continue; result[entry.slice(0, eq)] = entry.slice(eq + 1); } return result; } catch { return {}; } } async function readProcessExe(pid: number): Promise { try { return await readlink(`/proc/${pid}/exe`); } catch { return ""; } } function desktopLogPath(config: ToolPackConfig): string { return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"); } function desktopIdentityPath(config: ToolPackConfig): string { return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json"); } function linuxDesktopStamp(config: ToolPackConfig): SidecarStamp { return { app: APP_KEYS.DESKTOP, ipc: resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: config.namespace, }), mode: SIDECAR_MODES.RUNTIME, namespace: config.namespace, source: SIDECAR_SOURCES.TOOLS_PACK, }; } async function waitForMarker(markerPath: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await pathExists(markerPath)) return true; await new Promise((r) => setTimeout(r, 200)); } return false; } async function fetchDesktopStatus(config: ToolPackConfig): Promise { try { const ipc = resolveAppIpcPath({ contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: config.namespace, app: APP_KEYS.DESKTOP, }); const reply = await requestJsonIpc(ipc, { type: SIDECAR_MESSAGES.STATUS }); if (reply == null || typeof reply !== "object") return null; return reply as DesktopStatusSnapshot; } catch { return null; } } export async function startPackedLinuxApp(config: ToolPackConfig): Promise { const paths = resolveLinuxPaths(config); const installed = await pathExists(paths.installAppImagePath); const built = !installed ? await findBuiltAppImage(paths) : null; const appImagePath = installed ? paths.installAppImagePath : built; const source: LinuxStartSource = installed ? "installed" : "built"; if (appImagePath == null) { throw new Error("no AppImage found; run `tools-pack linux build` and/or `linux install` first"); } const logPath = desktopLogPath(config); await mkdir(dirname(logPath), { recursive: true }); await writeFile(logPath, "", "utf8"); // Remove any stale desktop-root.json from a previous run that didn't stop // cleanly (SIGKILL, OOM, crash). Otherwise waitForMarker below would return // instantly on the stale file instead of waiting for the new spawn's marker. await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); const stamp = linuxDesktopStamp(config); // --appimage-extract-and-run bypasses FUSE-mounted SquashFS, which is too slow // for daemon startup on first launch (smoke testing showed startup exceeded the // packaged sidecar's 35-second timeout when running from FUSE). const args = ["--appimage-extract-and-run", ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT)]; const child = await spawnBackgroundProcess({ args, command: appImagePath, cwd: dirname(appImagePath), env: createSidecarLaunchEnv({ base: join(config.roots.runtime.namespaceRoot, "runtime"), contract: OPEN_DESIGN_SIDECAR_CONTRACT, extraEnv: { ...process.env, [DESKTOP_LOG_ECHO_ENV]: "0" }, stamp, }), logFd: null, }); // 60s ceiling: AppImage --appimage-extract-and-run unpacks ~200MB to /tmp on // first launch before exec'ing the inner electron, which adds substantial // overhead vs mac's direct .app launch. // // If the readiness wait or the post-ready status fetch throws, the detached // child we just spawned is still running but unidentifiable to a future // `linux stop` (the marker is the only persistent identity source). Tear it // down via the same process-tree path stopPackedLinuxApp uses, then rethrow // so the failure surfaces to the caller. Any cleanup error is suppressed -- // we want the original failure preserved in the rejection. const markerPath = desktopIdentityPath(config); let status: DesktopStatusSnapshot | null; try { const ready = await waitForMarker(markerPath, 60_000); if (!ready) { throw new Error(`desktop-root.json not written within 60s at ${markerPath}`); } status = await fetchDesktopStatus(config); } catch (error) { await teardownOrphanedStart(child.pid).catch(() => undefined); throw error; } return { appImagePath, executablePath: appImagePath, logPath, namespace: config.namespace, pid: child.pid, source, status, }; } async function teardownOrphanedStart(rootPid: number): Promise { const snapshots = await listProcessSnapshots(); const treePids = collectProcessTreePids(snapshots, [rootPid]); await stopProcesses(treePids); } export async function stopPackedLinuxApp(config: ToolPackConfig): Promise { const paths = resolveLinuxPaths(config); const { fallback, marker } = await readDesktopRootIdentityMarker(config); if (marker == null) { return { fallback, gracefulRequested: false, namespace: config.namespace, remainingPids: [], status: "not-running", stoppedPids: [], }; } // Validate the marker still represents a live, owned process. const snapshots = await listProcessSnapshots(); const candidate = snapshots.find((s) => s.pid === marker.pid); if (candidate == null) { return { fallback: { ...fallback, reason: "marker-pid-not-running" }, gracefulRequested: false, namespace: config.namespace, remainingPids: [], status: "not-running", stoppedPids: [], }; } // Validate the marker stamp (file content written by apps/packaged itself) // rather than the process command line. Menu launches via the .desktop // entry don't pass createProcessStampArgs to the AppImage -- they only set // OD_PACKAGED_NAMESPACE -- so apps/packaged falls back to a SIDECAR_SOURCES.PACKAGED // stamp. Validating the process command would reject those legitimate // launches as `unmanaged`, which on uninstall would also remove the // AppImage/desktop/icon files out from under the still-running app. // Accept either TOOLS_PACK (CLI start) or PACKAGED (menu launch). Mirrors // the dual-source acceptance pattern in mac.ts:709-714. const expectedIpc = resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: config.namespace, }); const stampOk = marker.stamp.app === APP_KEYS.DESKTOP && marker.stamp.mode === SIDECAR_MODES.RUNTIME && marker.stamp.namespace === config.namespace && marker.stamp.ipc === expectedIpc && (marker.stamp.source === SIDECAR_SOURCES.TOOLS_PACK || marker.stamp.source === SIDECAR_SOURCES.PACKAGED); const exePath = await readProcessExe(marker.pid); const env = await readProcessEnv(marker.pid); // marker.appPath is unreliable on Linux (apps/packaged writes "/"). Use the // canonical install path we know about, falling back to the built AppImage // for not-yet-installed builds. const candidateAppImagePath = (await pathExists(paths.installAppImagePath)) ? paths.installAppImagePath : await findBuiltAppImage(paths); const cmdOk = candidateAppImagePath != null && matchesAppImageProcess( { pid: marker.pid, executable: exePath, env }, candidateAppImagePath, ); if (!stampOk || !cmdOk || marker.namespaceRoot !== config.roots.runtime.namespaceRoot) { return { fallback: { ...fallback, marker: { pid: marker.pid, stamp: marker.stamp }, processCommand: candidate.command, reason: "marker-validation-failed", }, gracefulRequested: false, namespace: config.namespace, remainingPids: [marker.pid], status: "unmanaged", stoppedPids: [], }; } // Try graceful shutdown via IPC first. mac.ts's pattern: best-effort SHUTDOWN // request with a short timeout so Electron renderers + sidecars get a chance // to flush state (SQLite WAL, logs) before SIGTERM. let gracefulRequested = false; try { await requestJsonIpc(marker.stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); gracefulRequested = true; } catch { gracefulRequested = false; } // Gather process tree, then SIGTERM -> SIGKILL via stopProcesses. const treePids = collectProcessTreePids(snapshots, [marker.pid]); const result = await stopProcesses(treePids); // Remove the marker on a clean stop so the next start has a fresh slate. if (result.remainingPids.length === 0) { await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); } return { gracefulRequested, namespace: config.namespace, remainingPids: result.remainingPids, status: result.remainingPids.length === 0 ? "stopped" : "partial", stoppedPids: result.stoppedPids, }; } export async function readPackedLinuxLogs(config: ToolPackConfig): Promise<{ logs: Record; namespace: string; }> { const logsRoot = join(config.roots.runtime.namespaceRoot, "logs"); const apps = [APP_KEYS.DESKTOP, APP_KEYS.WEB, APP_KEYS.DAEMON] as const; const logs: Record = {}; for (const app of apps) { const logPath = join(logsRoot, app, "latest.log"); const lines = (await pathExists(logPath)) ? await readLogTail(logPath, 200) : []; logs[app] = { lines, logPath }; } return { logs, namespace: config.namespace }; } export type LinuxUninstallResult = { namespace: string; removed: { appImage: "ok" | "already-removed" | "skipped-process-running"; desktop: "ok" | "already-removed" | "skipped-process-running"; icon: "ok" | "already-removed" | "skipped-process-running"; }; stop: LinuxStopResult; postUninstall: { desktopDatabase: "ok" | "missing" | "failed" | "skipped"; iconCache: "ok" | "missing" | "failed" | "skipped"; }; }; async function tryRemove(path: string): Promise<"ok" | "already-removed"> { if (!(await pathExists(path))) return "already-removed"; await rm(path, { force: true }); return "ok"; } // "stopped" means we just brought the process tree down cleanly. // "not-running" means there was nothing to stop in the first place. // Either state makes it safe to delete install files. "partial" means // remainingPids is non-empty (SIGTERM->SIGKILL didn't take everyone), and // "unmanaged" means the marker pointed at a process we couldn't validate as // ours -- in both cases something is still using the AppImage's mounted or // extracted contents, so destructive removal would leave broken file handles // and an orphan with stale state. function isSafeToRemoveInstallFiles(stop: LinuxStopResult): boolean { return stop.status === "stopped" || stop.status === "not-running"; } export async function uninstallPackedLinuxApp(config: ToolPackConfig): Promise { const paths = resolveLinuxPaths(config); const stop = await stopPackedLinuxApp(config); if (!isSafeToRemoveInstallFiles(stop)) { return { namespace: config.namespace, removed: { appImage: "skipped-process-running", desktop: "skipped-process-running", icon: "skipped-process-running", }, stop, postUninstall: { desktopDatabase: "skipped", iconCache: "skipped" }, }; } const removedAppImage = await tryRemove(paths.installAppImagePath); const removedDesktop = await tryRemove(paths.installDesktopFilePath); const removedIcon = await tryRemove(paths.installIconPath); const desktopDatabase = await bestEffortRun("update-desktop-database", [ join(homedir(), ".local", "share", "applications"), ]); const iconCache = await bestEffortRun("gtk-update-icon-cache", [ join(homedir(), ".local", "share", "icons", "hicolor"), ]); return { namespace: config.namespace, removed: { appImage: removedAppImage, desktop: removedDesktop, icon: removedIcon }, stop, postUninstall: { desktopDatabase, iconCache }, }; } export type LinuxCleanupResult = { namespace: string; outputRoot: string; removedOutputRoot: boolean; removedRuntimeNamespaceRoot: boolean; runtimeNamespaceRoot: string; // True when stopPackedLinuxApp returned "partial" or "unmanaged" -- the // output and runtime namespace roots may contain files held open by a // surviving process tree, so we leave them in place rather than yanking // SQLite WAL files / log handles / IPC sockets out from under it. // Both removed* flags will be false in this case. skipped: boolean; stop: LinuxStopResult; }; export async function cleanupPackedLinuxNamespace(config: ToolPackConfig): Promise { const stop = await stopPackedLinuxApp(config); const outputRoot = config.roots.output.namespaceRoot; const runtimeNamespaceRoot = config.roots.runtime.namespaceRoot; if (!isSafeToRemoveInstallFiles(stop)) { return { namespace: config.namespace, outputRoot, removedOutputRoot: false, removedRuntimeNamespaceRoot: false, runtimeNamespaceRoot, skipped: true, stop, }; } const hadOutput = await pathExists(outputRoot); if (hadOutput) await rm(outputRoot, { force: true, recursive: true }); const hadRuntime = await pathExists(runtimeNamespaceRoot); if (hadRuntime) await rm(runtimeNamespaceRoot, { force: true, recursive: true }); return { namespace: config.namespace, outputRoot, removedOutputRoot: hadOutput, removedRuntimeNamespaceRoot: hadRuntime, runtimeNamespaceRoot, skipped: false, stop, }; }