open-design/tools/pack/src/linux.ts
lefarcen 43f7fc536a
Add Langfuse telemetry relay (#1296)
* Add Langfuse telemetry relay

* Configure telemetry worker custom domain

* Add telemetry relay health check

* Harden telemetry relay config
2026-05-12 13:59:19 +08:00

1381 lines
48 KiB
TypeScript

import { execFile, spawn } from "node:child_process";
import { access, chmod, cp, mkdir, open, 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<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function commandExists(bin: string): Promise<boolean> {
try {
await execFileAsync(bin, ["--version"]);
return true;
} catch {
return false;
}
}
type DockerUserMapping = {
uid: number;
gid: number;
};
function toDockerMountPath(value: string): string {
return value.replaceAll("\\", "/");
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
export function buildDockerArgs(
config: ToolPackConfig,
user: DockerUserMapping,
): string[] {
const workspaceRoot = toDockerMountPath(config.workspaceRoot);
const toolPackRoot = toDockerMountPath(config.roots.toolPackRoot);
const dockerHome = toDockerMountPath(join(config.roots.toolPackRoot, ".docker-home"));
const electronCache = toDockerMountPath(join(config.roots.toolPackRoot, ".docker-cache", "electron"));
const electronBuilderCache = toDockerMountPath(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 <workspaceRoot>/.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
// - config.appVersion is shell-quoted below because release versions can
// carry punctuation that is not part of the namespace / target enums.
//
// 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@<version>` 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");
}
if (config.appVersion != null) {
innerArgs.push(`--app-version ${shellQuote(config.appVersion)}`);
}
const innerCommand = `${pnpmCmd} install --frozen-lockfile && ` + innerArgs.join(" ");
const dockerArgs = [
"run",
"--rm",
"--user",
`${user.uid}:${user.gid}`,
"-v",
`${workspaceRoot}:/project`,
"-v",
`${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",
];
if (config.telemetryRelayUrl != null) {
dockerArgs.push("-e", `OPEN_DESIGN_TELEMETRY_RELAY_URL=${config.telemetryRelayUrl}`);
}
dockerArgs.push(
"-w",
"/project",
"electronuserland/builder:base",
"bash",
"-lc",
innerCommand,
);
return dockerArgs;
}
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<string, string>;
};
export function matchesAppImageProcess(
snapshot: AppImageProcessSnapshot,
installPath: string,
): boolean {
if (snapshot.executable === installPath) return true;
// Two AppImage launch modes leave different executable paths in /proc/<pid>/exe:
// FUSE-mounted: /tmp/.mount_<hex>/AppRun
// --appimage-extract-and-run: /tmp/appimage_extracted_<hex>/<binary>
// 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<void> {
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<void> {
await execFileAsync("npm", ["install", "--omit=dev", "--no-package-lock"], {
cwd: appRoot,
env: process.env,
});
}
async function readPackagedVersion(config: ToolPackConfig): Promise<string> {
if (config.appVersion != null) return config.appVersion;
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<void> {
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<PackedTarballInfo[]> {
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<void> {
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<void> {
await rm(paths.assembledAppRoot, { force: true, recursive: true });
await mkdir(paths.assembledAppRoot, { recursive: true });
const dependencies: Record<string, string> = {};
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.telemetryRelayUrl == null ? {} : { telemetryRelayUrl: config.telemetryRelayUrl }),
...(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<void> {
const target = config.to === "dir" ? ["dir"] : ["AppImage"];
const namespaceToken = sanitizeNamespace(config.namespace);
const packagedVersion = await readPackagedVersion(config);
const builderConfig: Record<string, unknown> = {
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<void> {
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<string | null> {
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<LinuxPackResult> {
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<void> {
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<void> {
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<LinuxInstallResult> {
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<DesktopRootIdentityMarker>;
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<string, unknown> {
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<Record<string, string>> {
try {
const raw = await readFile(`/proc/${pid}/environ`, "utf8");
const result: Record<string, string> = {};
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<string> {
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<boolean> {
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<DesktopStatusSnapshot | null> {
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<LinuxStartResult> {
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<void> {
const snapshots = await listProcessSnapshots();
const treePids = collectProcessTreePids(snapshots, [rootPid]);
await stopProcesses(treePids);
}
export async function stopPackedLinuxApp(config: ToolPackConfig): Promise<LinuxStopResult> {
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/lifecycle.ts.
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/lifecycle.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<string, { lines: string[]; logPath: string }>;
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<string, { lines: string[]; logPath: string }> = {};
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<LinuxUninstallResult> {
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;
};
// --- Headless lifecycle ---
// Paths resolved relative to the assembled app written during `tools-pack linux build`.
// The headless entry lives at:
// <assembledAppRoot>/node_modules/@open-design/packaged/dist/headless.mjs
// The bundled Node binary lives at:
// <namespaceRoot>/resources/open-design/bin/node (populated by copyResourceTree)
function resolveHeadlessEntryPath(paths: LinuxPaths): string {
return join(paths.assembledAppRoot, "node_modules", "@open-design", "packaged", "dist", "headless.mjs");
}
function resolveHeadlessBundledNodePath(paths: LinuxPaths): string {
return join(paths.resourceRoot, "bin", "node");
}
function headlessLauncherPath(config: ToolPackConfig): string {
return join(homedir(), ".local", "bin", `open-design-headless-${sanitizeNamespace(config.namespace)}`);
}
function headlessLogPath(config: ToolPackConfig): string {
return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log");
}
export type LinuxHeadlessInstallResult = {
launcherPath: string;
namespace: string;
};
export type LinuxHeadlessStartResult = {
launcherPath: string;
logPath: string;
namespace: string;
pid: number;
status: WebRootIdentity;
};
type WebRootIdentity = {
namespace: string;
pid: number;
url: string;
startedAt: string;
version: 1;
};
function webIdentityPath(config: ToolPackConfig): string {
return join(config.roots.runtime.namespaceRoot, "runtime", "web-root.json");
}
function isValidWebIdentity(
identity: unknown,
namespace: string,
pid: number,
): identity is WebRootIdentity {
if (typeof identity !== "object" || identity == null) return false;
const obj = identity as Record<string, unknown>;
return (
obj.version === 1 &&
obj.namespace === namespace &&
obj.pid === pid &&
typeof obj.url === "string" &&
obj.url.length > 0
);
}
async function waitForWebIdentity(config: ToolPackConfig, childPid: number, timeoutMs: number): Promise<WebRootIdentity | null> {
const path = webIdentityPath(config);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const content = await readFile(path, "utf8");
const identity = JSON.parse(content);
if (isValidWebIdentity(identity, config.namespace, childPid)) return identity;
} catch {
// File doesn't exist yet or invalid JSON
}
await new Promise((r) => setTimeout(r, 100));
}
return null;
}
export async function installPackedLinuxHeadless(config: ToolPackConfig): Promise<LinuxHeadlessInstallResult> {
const paths = resolveLinuxPaths(config);
const entryPath = resolveHeadlessEntryPath(paths);
const nodePath = resolveHeadlessBundledNodePath(paths);
if (!(await pathExists(entryPath))) {
throw new Error(
`headless entry not found at ${entryPath}; run \`tools-pack linux build\` first`,
);
}
if (!(await pathExists(nodePath))) {
throw new Error(
`bundled node binary not found at ${nodePath}; run \`tools-pack linux build\` first`,
);
}
const launcherPath = headlessLauncherPath(config);
await mkdir(dirname(launcherPath), { recursive: true });
// Write a self-contained launcher script. The namespace is baked in so the
// launcher name and the runtime namespace always agree. namespace is
// pre-sanitized by sidecar-proto to [A-Za-z0-9._-]. OD_DATA_DIR is baked
// so the headless process writes its runtime data under the same paths that
// tools-pack stop/logs expect.
const dataDir = dirname(config.roots.runtime.namespaceBaseRoot);
const script = [
"#!/bin/sh",
`# Open Design headless launcher — namespace: ${config.namespace}`,
`OD_NAMESPACE=${JSON.stringify(config.namespace)} OD_DATA_DIR=${JSON.stringify(dataDir)} OD_RESOURCE_ROOT=${JSON.stringify(paths.resourceRoot)} exec ${JSON.stringify(nodePath)} ${JSON.stringify(entryPath)} "$@"`,
].join("\n") + "\n";
await writeFile(launcherPath, script, { encoding: "utf8", mode: 0o755 });
return { launcherPath, namespace: config.namespace };
}
// Waits up to 35s for the desktop identity marker, then up to 60s for the
// web identity (95s total).
export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise<LinuxHeadlessStartResult> {
const paths = resolveLinuxPaths(config);
const entryPath = resolveHeadlessEntryPath(paths);
const nodePath = resolveHeadlessBundledNodePath(paths);
if (!(await pathExists(entryPath))) {
throw new Error(
`headless entry not found at ${entryPath}; run \`tools-pack linux build\` first`,
);
}
const nodeCommand = (await pathExists(nodePath)) ? nodePath : process.execPath;
const logPath = headlessLogPath(config);
await mkdir(dirname(logPath), { recursive: true });
await writeFile(logPath, "", "utf8");
// Remove stale identity markers from a previous run so waitForMarker and
// waitForWebIdentity below wait for the newly spawned process.
await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined);
await rm(webIdentityPath(config), { force: true }).catch(() => undefined);
// Open the log file so stdout/stderr from the headless process are captured.
const logHandle = await open(logPath, "a");
let child: { pid: number };
try {
child = await spawnBackgroundProcess({
args: [entryPath],
command: nodeCommand,
cwd: dirname(entryPath),
env: {
...process.env,
// Bake in the namespace so headless uses the same namespace as the
// tools-pack config regardless of the caller's environment.
OD_NAMESPACE: config.namespace,
// Point the headless data root at the tools-pack runtime directory so
// the identity marker is written to the path this function polls.
// headless.ts computes: join(OD_DATA_DIR, "namespaces") which must
// equal config.roots.runtime.namespaceBaseRoot.
OD_DATA_DIR: dirname(config.roots.runtime.namespaceBaseRoot),
OD_RESOURCE_ROOT: paths.resourceRoot,
},
logFd: logHandle.fd,
});
} finally {
// Close the parent-side handle; the child has already inherited the fd.
await logHandle.close().catch(() => undefined);
}
const markerPath = desktopIdentityPath(config);
const ready = await waitForMarker(markerPath, 35_000);
if (!ready) {
await teardownOrphanedStart(child.pid).catch(() => undefined);
throw new Error(`headless identity marker not written within 35s at ${markerPath}`);
}
const webIdentity = await waitForWebIdentity(config, child.pid, 60_000);
if (webIdentity == null) {
await teardownOrphanedStart(child.pid).catch(() => undefined);
throw new Error(`web-root.json not written within 60s at ${webIdentityPath(config)}`);
}
return {
launcherPath: headlessLauncherPath(config),
logPath,
namespace: config.namespace,
pid: child.pid,
status: webIdentity,
};
}
export async function stopPackedLinuxHeadless(config: ToolPackConfig): Promise<LinuxStopResult> {
const { fallback, marker } = await readDesktopRootIdentityMarker(config);
if (marker == null) {
return {
fallback,
gracefulRequested: false,
namespace: config.namespace,
remainingPids: [],
status: "not-running",
stoppedPids: [],
};
}
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 stamp. Headless writes source=PACKAGED; skip the AppImage
// process-command check used by stopPackedLinuxApp since the headless entry
// is a plain Node process, not an AppImage.
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.PACKAGED;
if (!stampOk || 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: [],
};
}
let gracefulRequested = false;
try {
await requestJsonIpc(marker.stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 });
gracefulRequested = true;
} catch {
gracefulRequested = false;
}
const treePids = collectProcessTreePids(snapshots, [marker.pid]);
const result = await stopProcesses(treePids);
if (result.remainingPids.length === 0) {
await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined);
await rm(webIdentityPath(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 cleanupPackedLinuxNamespace(config: ToolPackConfig): Promise<LinuxCleanupResult> {
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,
};
}