first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# apps/packaged
|
||||
|
||||
Follow the root `AGENTS.md` and `apps/AGENTS.md` first. This app owns only the packaged Electron runtime assembly entry.
|
||||
|
||||
## Owns
|
||||
|
||||
- Packaged Electron entry glue.
|
||||
- Packaged config loading.
|
||||
- Runtime startup of daemon/web sidecars before desktop main.
|
||||
- `od://` packaged entry routing to the internal web runtime.
|
||||
|
||||
## Does not own
|
||||
|
||||
- Product/business logic.
|
||||
- Web, daemon, or desktop implementation details.
|
||||
- Sidecar protocol definitions or process stamp semantics.
|
||||
|
||||
## Rules
|
||||
|
||||
- Consume `@open-design/sidecar-proto`, `@open-design/sidecar`, and `@open-design/platform` primitives; do not hand-build stamp flags or process matching logic.
|
||||
- Keep data/log/runtime/cache paths namespace-scoped and independent from daemon/web ports.
|
||||
- Keep Next.js packaged runtime as SSR/web-sidecar-owned; do not put Next output under `OD_RESOURCE_ROOT`.
|
||||
- `OD_RESOURCE_ROOT` is only for daemon non-Next read-only resources: `skills/`, `design-systems/`, and `frames/`.
|
||||
@@ -0,0 +1,7 @@
|
||||
# apps/packaged
|
||||
|
||||
Thin packaged Electron runtime entry for Open Design.
|
||||
|
||||
This package starts the packaged daemon and web sidecars, registers the `od://`
|
||||
entry protocol, and then delegates to `@open-design/desktop/main` for the host
|
||||
window. Product logic stays in `apps/daemon`, `apps/web`, and `apps/desktop`.
|
||||
@@ -0,0 +1,11 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
bundle: true,
|
||||
entryPoints: ["./src/index.ts"],
|
||||
format: "esm",
|
||||
outfile: "./dist/index.mjs",
|
||||
packages: "external",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@open-design/packaged",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-design/daemon": "workspace:0.3.0",
|
||||
"@open-design/desktop": "workspace:0.3.0",
|
||||
"@open-design/platform": "workspace:0.3.0",
|
||||
"@open-design/sidecar": "workspace:0.3.0",
|
||||
"@open-design/sidecar-proto": "workspace:0.3.0",
|
||||
"@open-design/web": "workspace:0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.2",
|
||||
"electron": "41.3.0",
|
||||
"esbuild": "0.27.7",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { app } from "electron";
|
||||
|
||||
import { SIDECAR_DEFAULTS, normalizeNamespace } from "@open-design/sidecar-proto";
|
||||
|
||||
export const PACKAGED_CONFIG_PATH_ENV = "OD_PACKAGED_CONFIG_PATH";
|
||||
export const PACKAGED_NAMESPACE_ENV = "OD_PACKAGED_NAMESPACE";
|
||||
|
||||
export type RawPackagedConfig = {
|
||||
namespace?: string;
|
||||
namespaceBaseRoot?: string;
|
||||
nodeCommandRelative?: string;
|
||||
resourceRoot?: string;
|
||||
};
|
||||
|
||||
export type PackagedConfig = {
|
||||
namespace: string;
|
||||
namespaceBaseRoot: string;
|
||||
nodeCommand: string | null;
|
||||
resourceRoot: string;
|
||||
};
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonIfExists(filePath: string): Promise<RawPackagedConfig | null> {
|
||||
if (!(await pathExists(filePath))) return null;
|
||||
return JSON.parse(await readFile(filePath, "utf8")) as RawPackagedConfig;
|
||||
}
|
||||
|
||||
function resolveDefaultConfigPath(): string {
|
||||
return join(process.resourcesPath, "open-design-config.json");
|
||||
}
|
||||
|
||||
async function readRawPackagedConfig(): Promise<RawPackagedConfig> {
|
||||
const explicit = process.env[PACKAGED_CONFIG_PATH_ENV];
|
||||
if (explicit != null && explicit.length > 0) {
|
||||
const config = await readJsonIfExists(resolve(explicit));
|
||||
if (config == null) throw new Error(`packaged config not found at ${explicit}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
return (
|
||||
(await readJsonIfExists(resolveDefaultConfigPath())) ??
|
||||
(await readJsonIfExists(join(app.getAppPath(), "open-design-config.json"))) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOptionalPath(value: string | undefined): string | undefined {
|
||||
return value == null || value.length === 0 ? undefined : resolve(value);
|
||||
}
|
||||
|
||||
export async function readPackagedConfig(): Promise<PackagedConfig> {
|
||||
const raw = await readRawPackagedConfig();
|
||||
const namespace = normalizeNamespace(
|
||||
process.env[PACKAGED_NAMESPACE_ENV] ?? raw.namespace ?? SIDECAR_DEFAULTS.namespace,
|
||||
);
|
||||
const namespaceBaseRoot =
|
||||
resolveOptionalPath(raw.namespaceBaseRoot) ?? join(app.getPath("userData"), "namespaces");
|
||||
const resourceRoot = resolveOptionalPath(raw.resourceRoot) ?? join(process.resourcesPath, "open-design");
|
||||
const relativeNodeCommand =
|
||||
raw.nodeCommandRelative == null || raw.nodeCommandRelative.length === 0
|
||||
? join("open-design", "bin", "node")
|
||||
: raw.nodeCommandRelative;
|
||||
const nodeCommandCandidate = join(process.resourcesPath, relativeNodeCommand);
|
||||
const nodeCommand = (await pathExists(nodeCommandCandidate)) ? nodeCommandCandidate : null;
|
||||
|
||||
return {
|
||||
namespace,
|
||||
namespaceBaseRoot,
|
||||
nodeCommand,
|
||||
resourceRoot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { removeFile, writeJsonFile } from "@open-design/sidecar";
|
||||
import type { SidecarStamp } from "@open-design/sidecar-proto";
|
||||
|
||||
import type { PackagedNamespacePaths } from "./paths.js";
|
||||
|
||||
export type PackagedDesktopRootIdentity = {
|
||||
appPath: string;
|
||||
executablePath: string;
|
||||
logPath: string;
|
||||
namespaceRoot: string;
|
||||
pid: number;
|
||||
ppid: number;
|
||||
stamp: SidecarStamp;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
version: 1;
|
||||
};
|
||||
|
||||
export type PackagedDesktopIdentityHandle = {
|
||||
close(): Promise<void>;
|
||||
identity: PackagedDesktopRootIdentity;
|
||||
};
|
||||
|
||||
function resolveCurrentMacAppPath(executablePath: string): string {
|
||||
return dirname(dirname(dirname(executablePath)));
|
||||
}
|
||||
|
||||
function createPackagedDesktopRootIdentity(options: {
|
||||
paths: PackagedNamespacePaths;
|
||||
stamp: SidecarStamp;
|
||||
}): PackagedDesktopRootIdentity {
|
||||
const now = new Date().toISOString();
|
||||
const executablePath = process.execPath;
|
||||
|
||||
return {
|
||||
appPath: resolveCurrentMacAppPath(executablePath),
|
||||
executablePath,
|
||||
logPath: options.paths.desktopLogPath,
|
||||
namespaceRoot: options.paths.namespaceRoot,
|
||||
pid: process.pid,
|
||||
ppid: process.ppid,
|
||||
stamp: options.stamp,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writePackagedDesktopIdentity(options: {
|
||||
paths: PackagedNamespacePaths;
|
||||
stamp: SidecarStamp;
|
||||
}): Promise<PackagedDesktopIdentityHandle> {
|
||||
const identity = createPackagedDesktopRootIdentity(options);
|
||||
|
||||
const writeIdentity = async () => {
|
||||
identity.updatedAt = new Date().toISOString();
|
||||
await writeJsonFile(options.paths.desktopIdentityPath, identity);
|
||||
};
|
||||
|
||||
await writeIdentity();
|
||||
const heartbeat = setInterval(() => {
|
||||
void writeIdentity().catch(() => undefined);
|
||||
}, 5000);
|
||||
heartbeat.unref();
|
||||
|
||||
return {
|
||||
async close() {
|
||||
clearInterval(heartbeat);
|
||||
await removeFile(options.paths.desktopIdentityPath).catch(() => undefined);
|
||||
},
|
||||
identity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_MODES,
|
||||
SIDECAR_SOURCES,
|
||||
type SidecarStamp,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import {
|
||||
bootstrapSidecarRuntime,
|
||||
createSidecarLaunchEnv,
|
||||
resolveAppIpcPath,
|
||||
} from "@open-design/sidecar";
|
||||
import { readProcessStamp } from "@open-design/platform";
|
||||
import { app } from "electron";
|
||||
|
||||
import { readPackagedConfig } from "./config.js";
|
||||
import { writePackagedDesktopIdentity } from "./identity.js";
|
||||
import {
|
||||
applyPackagedElectronPathOverrides,
|
||||
ensurePackagedNamespacePaths,
|
||||
} from "./launch.js";
|
||||
import {
|
||||
attachPackagedDesktopProcessLogging,
|
||||
createPackagedDesktopLogger,
|
||||
type PackagedDesktopLogger,
|
||||
} from "./logging.js";
|
||||
import { resolvePackagedNamespacePaths } from "./paths.js";
|
||||
import { packagedEntryUrl, registerOdProtocol } from "./protocol.js";
|
||||
import { startPackagedSidecars } from "./sidecars.js";
|
||||
|
||||
let packagedLogger: PackagedDesktopLogger | null = null;
|
||||
|
||||
function createPackagedDesktopStamp(namespace: string): SidecarStamp {
|
||||
return {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
ipc: resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace,
|
||||
}),
|
||||
mode: SIDECAR_MODES.RUNTIME,
|
||||
namespace,
|
||||
source: SIDECAR_SOURCES.PACKAGED,
|
||||
};
|
||||
}
|
||||
|
||||
function applyLaunchEnv(base: string, stamp: SidecarStamp): void {
|
||||
const env = createSidecarLaunchEnv({
|
||||
base,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
stamp,
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value != null) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = await readPackagedConfig();
|
||||
const argvStamp = readProcessStamp(process.argv.slice(1), OPEN_DESIGN_SIDECAR_CONTRACT);
|
||||
const namespace = argvStamp?.namespace ?? config.namespace;
|
||||
const paths = resolvePackagedNamespacePaths(config, namespace);
|
||||
const stamp = argvStamp ?? createPackagedDesktopStamp(namespace);
|
||||
|
||||
await ensurePackagedNamespacePaths(paths);
|
||||
packagedLogger = createPackagedDesktopLogger(paths);
|
||||
attachPackagedDesktopProcessLogging({ logger: packagedLogger, paths, stamp });
|
||||
applyPackagedElectronPathOverrides(paths);
|
||||
const identity = await writePackagedDesktopIdentity({ paths, stamp });
|
||||
await app.whenReady();
|
||||
|
||||
applyLaunchEnv(paths.runtimeRoot, stamp);
|
||||
|
||||
const runtime = bootstrapSidecarRuntime(stamp, process.env, {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
base: paths.runtimeRoot,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
});
|
||||
|
||||
const sidecars = await startPackagedSidecars(runtime, paths, {
|
||||
nodeCommand: config.nodeCommand,
|
||||
});
|
||||
registerOdProtocol(sidecars.web.url ?? "http://127.0.0.1:0");
|
||||
|
||||
const { runDesktopMain } = await import("@open-design/desktop/main");
|
||||
await runDesktopMain(runtime, {
|
||||
async beforeShutdown() {
|
||||
try {
|
||||
await sidecars.close();
|
||||
} finally {
|
||||
await identity.close();
|
||||
}
|
||||
},
|
||||
async discoverWebUrl() {
|
||||
return packagedEntryUrl();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
packagedLogger?.error("packaged runtime failed", { error });
|
||||
console.error("packaged runtime failed", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { app } from "electron";
|
||||
|
||||
import type { PackagedNamespacePaths } from "./paths.js";
|
||||
|
||||
export async function ensurePackagedNamespacePaths(
|
||||
paths: PackagedNamespacePaths,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
mkdir(paths.namespaceRoot, { recursive: true }),
|
||||
mkdir(paths.cacheRoot, { recursive: true }),
|
||||
mkdir(paths.dataRoot, { recursive: true }),
|
||||
mkdir(paths.logsRoot, { recursive: true }),
|
||||
mkdir(paths.desktopLogsRoot, { recursive: true }),
|
||||
mkdir(paths.runtimeRoot, { recursive: true }),
|
||||
mkdir(paths.electronUserDataRoot, { recursive: true }),
|
||||
mkdir(paths.electronSessionDataRoot, { recursive: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
export function applyPackagedElectronPathOverrides(
|
||||
paths: PackagedNamespacePaths,
|
||||
): void {
|
||||
app.setPath("userData", paths.electronUserDataRoot);
|
||||
app.setPath("sessionData", paths.electronSessionDataRoot);
|
||||
app.setPath("logs", paths.desktopLogsRoot);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
import type { SidecarStamp } from "@open-design/sidecar-proto";
|
||||
|
||||
import type { PackagedNamespacePaths } from "./paths.js";
|
||||
|
||||
const DESKTOP_LOG_ECHO_ENV = "OD_DESKTOP_LOG_ECHO";
|
||||
|
||||
type LogLevel = "error" | "info" | "warn";
|
||||
|
||||
export type PackagedDesktopLogger = {
|
||||
error(message: string, meta?: Record<string, unknown>): void;
|
||||
info(message: string, meta?: Record<string, unknown>): void;
|
||||
warn(message: string, meta?: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
function normalizeError(error: unknown): unknown {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeMeta(meta: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
||||
if (meta == null) return undefined;
|
||||
return Object.fromEntries(
|
||||
Object.entries(meta).map(([key, value]) => [key, key === "error" || key === "reason" ? normalizeError(value) : value]),
|
||||
);
|
||||
}
|
||||
|
||||
function serializeMessage(level: LogLevel, message: string, meta?: Record<string, unknown>): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
try {
|
||||
return `${JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
timestamp,
|
||||
...(meta == null ? {} : { meta: normalizeMeta(meta) }),
|
||||
})}\n`;
|
||||
} catch (error) {
|
||||
return `${JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
timestamp,
|
||||
meta: {
|
||||
serializationError: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPackagedDesktopLogger(paths: PackagedNamespacePaths): PackagedDesktopLogger {
|
||||
const echo = process.env[DESKTOP_LOG_ECHO_ENV] !== "0";
|
||||
|
||||
const write = (level: LogLevel, message: string, meta?: Record<string, unknown>) => {
|
||||
appendFileSync(paths.desktopLogPath, serializeMessage(level, message, meta), "utf8");
|
||||
};
|
||||
|
||||
const logger: PackagedDesktopLogger = {
|
||||
error(message, meta) {
|
||||
write("error", message, meta);
|
||||
},
|
||||
info(message, meta) {
|
||||
write("info", message, meta);
|
||||
},
|
||||
warn(message, meta) {
|
||||
write("warn", message, meta);
|
||||
},
|
||||
};
|
||||
|
||||
const originalConsole = {
|
||||
error: console.error.bind(console),
|
||||
info: console.info.bind(console),
|
||||
log: console.log.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
};
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
logger.info("console.log", { args });
|
||||
if (echo) originalConsole.log(...args);
|
||||
};
|
||||
console.info = (...args: unknown[]) => {
|
||||
logger.info("console.info", { args });
|
||||
if (echo) originalConsole.info(...args);
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
logger.warn("console.warn", { args });
|
||||
if (echo) originalConsole.warn(...args);
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
logger.error("console.error", { args });
|
||||
if (echo) originalConsole.error(...args);
|
||||
};
|
||||
|
||||
return logger;
|
||||
}
|
||||
|
||||
export function attachPackagedDesktopProcessLogging(options: {
|
||||
logger: PackagedDesktopLogger;
|
||||
paths: PackagedNamespacePaths;
|
||||
stamp: SidecarStamp;
|
||||
}): void {
|
||||
const { logger, paths, stamp } = options;
|
||||
|
||||
logger.info("packaged desktop starting", {
|
||||
daemonDataRoot: paths.dataRoot,
|
||||
electronUserDataRoot: paths.electronUserDataRoot,
|
||||
executablePath: process.execPath,
|
||||
logPath: paths.desktopLogPath,
|
||||
namespace: stamp.namespace,
|
||||
pid: process.pid,
|
||||
ppid: process.ppid,
|
||||
resourceRoot: paths.resourceRoot,
|
||||
runtimeRoot: paths.runtimeRoot,
|
||||
source: stamp.source,
|
||||
});
|
||||
|
||||
process.on("uncaughtExceptionMonitor", (error) => {
|
||||
logger.error("packaged desktop uncaught exception", { error });
|
||||
});
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error("packaged desktop unhandled rejection", { reason });
|
||||
});
|
||||
process.on("beforeExit", (code) => {
|
||||
logger.warn("packaged desktop beforeExit", { code });
|
||||
});
|
||||
process.on("exit", (code) => {
|
||||
logger.warn("packaged desktop exit", { code });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { APP_KEYS } from "@open-design/sidecar-proto";
|
||||
|
||||
import type { PackagedConfig } from "./config.js";
|
||||
|
||||
export type PackagedNamespacePaths = {
|
||||
cacheRoot: string;
|
||||
desktopIdentityPath: string;
|
||||
desktopLogPath: string;
|
||||
dataRoot: string;
|
||||
desktopLogsRoot: string;
|
||||
electronSessionDataRoot: string;
|
||||
electronUserDataRoot: string;
|
||||
logsRoot: string;
|
||||
namespaceRoot: string;
|
||||
resourceRoot: string;
|
||||
runtimeRoot: string;
|
||||
};
|
||||
|
||||
export function resolvePackagedNamespacePaths(
|
||||
config: PackagedConfig,
|
||||
namespace = config.namespace,
|
||||
): PackagedNamespacePaths {
|
||||
const namespaceRoot = join(config.namespaceBaseRoot, namespace);
|
||||
|
||||
return {
|
||||
cacheRoot: join(namespaceRoot, "cache"),
|
||||
desktopIdentityPath: join(namespaceRoot, "runtime", "desktop-root.json"),
|
||||
desktopLogPath: join(namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"),
|
||||
dataRoot: join(namespaceRoot, "data"),
|
||||
desktopLogsRoot: join(namespaceRoot, "logs", APP_KEYS.DESKTOP),
|
||||
electronSessionDataRoot: join(namespaceRoot, "user-data", "session"),
|
||||
electronUserDataRoot: join(namespaceRoot, "user-data"),
|
||||
logsRoot: join(namespaceRoot, "logs"),
|
||||
namespaceRoot,
|
||||
resourceRoot: config.resourceRoot,
|
||||
runtimeRoot: join(namespaceRoot, "runtime"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { protocol } from "electron";
|
||||
|
||||
const OD_SCHEME = "od";
|
||||
const OD_ENTRY_URL = `${OD_SCHEME}://app/`;
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
privileges: {
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
scheme: OD_SCHEME,
|
||||
},
|
||||
]);
|
||||
|
||||
function toWebRuntimeUrl(webRuntimeUrl: string, requestUrl: string): string {
|
||||
const incoming = new URL(requestUrl);
|
||||
const target = new URL(webRuntimeUrl);
|
||||
target.pathname = incoming.pathname;
|
||||
target.search = incoming.search;
|
||||
target.hash = incoming.hash;
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
export function packagedEntryUrl(): string {
|
||||
return OD_ENTRY_URL;
|
||||
}
|
||||
|
||||
export function registerOdProtocol(webRuntimeUrl: string): void {
|
||||
protocol.handle(OD_SCHEME, async (request) => {
|
||||
const target = toWebRuntimeUrl(webRuntimeUrl, request.url);
|
||||
return await fetch(new Request(target, request));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { mkdir, open, type FileHandle } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_ENV,
|
||||
SIDECAR_MESSAGES,
|
||||
SIDECAR_MODES,
|
||||
type AppKey,
|
||||
type DaemonStatusSnapshot,
|
||||
type SidecarStamp,
|
||||
type WebStatusSnapshot,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import {
|
||||
createSidecarLaunchEnv,
|
||||
requestJsonIpc,
|
||||
resolveAppIpcPath,
|
||||
type SidecarRuntimeContext,
|
||||
} from "@open-design/sidecar";
|
||||
import { createProcessStampArgs, stopProcesses, waitForProcessExit } from "@open-design/platform";
|
||||
|
||||
import type { PackagedNamespacePaths } from "./paths.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const PACKAGED_CHILD_ENV_ALLOWLIST = ["HOME", "LANG", "LC_ALL", "LOGNAME", "TMPDIR", "USER"] as const;
|
||||
|
||||
export type PackagedSidecarHandle = {
|
||||
close(): Promise<void>;
|
||||
daemon: DaemonStatusSnapshot;
|
||||
web: WebStatusSnapshot;
|
||||
};
|
||||
|
||||
type ManagedSidecarChild = {
|
||||
app: AppKey;
|
||||
child: ChildProcess;
|
||||
ipcPath: string;
|
||||
logHandle: FileHandle;
|
||||
};
|
||||
|
||||
type PackagedDaemonManagedPathEnv = {
|
||||
OD_DATA_DIR: string;
|
||||
OD_RESOURCE_ROOT: string;
|
||||
};
|
||||
|
||||
function resolveSidecarEntry(packageName: string, exportName: string): string {
|
||||
return require.resolve(`${packageName}/${exportName}`);
|
||||
}
|
||||
|
||||
function logPathFor(paths: PackagedNamespacePaths, app: AppKey): string {
|
||||
return join(paths.logsRoot, app, "latest.log");
|
||||
}
|
||||
|
||||
async function openLog(path: string): Promise<FileHandle> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
return await open(path, "w");
|
||||
}
|
||||
|
||||
async function waitForStatus<T>(
|
||||
ipcPath: string,
|
||||
isReady: (status: T) => boolean,
|
||||
timeoutMs = 35_000,
|
||||
): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
let lastError: unknown;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const status = await requestJsonIpc<T>(
|
||||
ipcPath,
|
||||
{ type: SIDECAR_MESSAGES.STATUS },
|
||||
{ timeoutMs: 800 },
|
||||
);
|
||||
if (isReady(status)) return status;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`timed out waiting for sidecar status at ${ipcPath}${
|
||||
lastError instanceof Error ? ` (${lastError.message})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function extractPort(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
return parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
||||
}
|
||||
|
||||
function existingDirsUnder(root: string, segments: string[] = []): string[] {
|
||||
const dirs: string[] = [];
|
||||
try {
|
||||
const entries = readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const full = join(root, entry.name, ...segments);
|
||||
if (existsSync(full)) dirs.push(full);
|
||||
}
|
||||
} catch {
|
||||
// best-effort: directory may not exist or be unreadable
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function collectNvmFnmBins(home: string): string[] {
|
||||
return [
|
||||
...existingDirsUnder(join(home, ".nvm", "versions", "node"), ["bin"]),
|
||||
...existingDirsUnder(join(home, ".local", "share", "fnm", "node-versions"), ["installation", "bin"]),
|
||||
...existingDirsUnder(join(home, ".local", "share", "mise", "installs", "node"), ["bin"]),
|
||||
];
|
||||
}
|
||||
|
||||
function resolvePackagedPathEnv(basePath = process.env.PATH ?? ""): string {
|
||||
const home = homedir();
|
||||
const candidates = [
|
||||
...basePath.split(delimiter),
|
||||
join(home, ".local", "bin"),
|
||||
join(home, ".opencode", "bin"),
|
||||
join(home, ".cargo", "bin"),
|
||||
join(home, ".bun", "bin"),
|
||||
join(home, ".volta", "bin"),
|
||||
...collectNvmFnmBins(home),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
];
|
||||
return [...new Set(candidates.filter((entry) => entry.length > 0))].join(delimiter);
|
||||
}
|
||||
|
||||
function resolvePackagedChildBaseEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const baseEnv: NodeJS.ProcessEnv = {};
|
||||
for (const key of PACKAGED_CHILD_ENV_ALLOWLIST) {
|
||||
const value = env[key];
|
||||
if (value != null && value.length > 0) baseEnv[key] = value;
|
||||
}
|
||||
return baseEnv;
|
||||
}
|
||||
|
||||
function createPackagedDaemonManagedPathEnv(
|
||||
paths: PackagedNamespacePaths,
|
||||
): PackagedDaemonManagedPathEnv {
|
||||
return {
|
||||
OD_DATA_DIR: paths.dataRoot,
|
||||
OD_RESOURCE_ROOT: paths.resourceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
async function spawnSidecarChild(options: {
|
||||
app: AppKey;
|
||||
entryPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
nodeCommand: string | null;
|
||||
paths: PackagedNamespacePaths;
|
||||
runtime: SidecarRuntimeContext<SidecarStamp>;
|
||||
}): Promise<ManagedSidecarChild> {
|
||||
const ipcPath = resolveAppIpcPath({
|
||||
app: options.app,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: options.runtime.namespace,
|
||||
});
|
||||
const stamp = {
|
||||
app: options.app,
|
||||
ipc: ipcPath,
|
||||
mode: SIDECAR_MODES.RUNTIME,
|
||||
namespace: options.runtime.namespace,
|
||||
source: options.runtime.source,
|
||||
} satisfies SidecarStamp;
|
||||
const logHandle = await openLog(logPathFor(options.paths, options.app));
|
||||
const childEnv = createSidecarLaunchEnv({
|
||||
base: options.paths.runtimeRoot,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
extraEnv: {
|
||||
...resolvePackagedChildBaseEnv(),
|
||||
...options.env,
|
||||
NODE_ENV: "production",
|
||||
PATH: resolvePackagedPathEnv(),
|
||||
...(options.nodeCommand == null ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
|
||||
},
|
||||
stamp,
|
||||
});
|
||||
const command = options.nodeCommand ?? process.execPath;
|
||||
const child = spawn(
|
||||
command,
|
||||
[options.entryPath, ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: childEnv,
|
||||
stdio: ["ignore", logHandle.fd, logHandle.fd],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise<void>((resolveSpawn, rejectSpawn) => {
|
||||
child.once("error", rejectSpawn);
|
||||
child.once("spawn", resolveSpawn);
|
||||
});
|
||||
|
||||
return { app: options.app, child, ipcPath, logHandle };
|
||||
}
|
||||
|
||||
async function closeManagedChild(child: ManagedSidecarChild): Promise<void> {
|
||||
try {
|
||||
await requestJsonIpc(child.ipcPath, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1200 });
|
||||
} catch {
|
||||
// Fall through to process cleanup.
|
||||
}
|
||||
|
||||
if (!(await waitForProcessExit(child.child.pid, 5000))) {
|
||||
await stopProcesses([child.child.pid]);
|
||||
}
|
||||
|
||||
await child.logHandle.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function startPackagedSidecars(
|
||||
runtime: SidecarRuntimeContext<SidecarStamp>,
|
||||
paths: PackagedNamespacePaths,
|
||||
options: { nodeCommand: string | null },
|
||||
): Promise<PackagedSidecarHandle> {
|
||||
await mkdir(paths.namespaceRoot, { recursive: true });
|
||||
await mkdir(paths.cacheRoot, { recursive: true });
|
||||
await mkdir(paths.dataRoot, { recursive: true });
|
||||
await mkdir(paths.logsRoot, { recursive: true });
|
||||
await mkdir(paths.desktopLogsRoot, { recursive: true });
|
||||
await mkdir(paths.runtimeRoot, { recursive: true });
|
||||
await mkdir(paths.electronUserDataRoot, { recursive: true });
|
||||
await mkdir(paths.electronSessionDataRoot, { recursive: true });
|
||||
|
||||
const children: ManagedSidecarChild[] = [];
|
||||
|
||||
try {
|
||||
const daemon = await spawnSidecarChild({
|
||||
app: APP_KEYS.DAEMON,
|
||||
entryPath: resolveSidecarEntry("@open-design/daemon", "sidecar"),
|
||||
env: {
|
||||
[SIDECAR_ENV.DAEMON_PORT]: "0",
|
||||
// Packaged daemon managed paths are deliberately delivered through
|
||||
// the sidecar launch environment. The daemon may keep its own default
|
||||
// fallback, but packaged runtime must not rely on path inference from
|
||||
// Electron userData, bundle names, or ports.
|
||||
...createPackagedDaemonManagedPathEnv(paths),
|
||||
},
|
||||
nodeCommand: options.nodeCommand,
|
||||
paths,
|
||||
runtime,
|
||||
});
|
||||
children.push(daemon);
|
||||
const daemonStatus = await waitForStatus<DaemonStatusSnapshot>(
|
||||
daemon.ipcPath,
|
||||
(status) => status.url != null,
|
||||
);
|
||||
if (daemonStatus.url == null) throw new Error("daemon did not report a URL");
|
||||
|
||||
const web = await spawnSidecarChild({
|
||||
app: APP_KEYS.WEB,
|
||||
entryPath: resolveSidecarEntry("@open-design/web", "sidecar"),
|
||||
env: {
|
||||
[SIDECAR_ENV.DAEMON_PORT]: extractPort(daemonStatus.url),
|
||||
[SIDECAR_ENV.WEB_PORT]: "0",
|
||||
OD_WEB_OUTPUT_MODE: "server",
|
||||
PORT: "0",
|
||||
},
|
||||
nodeCommand: options.nodeCommand,
|
||||
paths,
|
||||
runtime,
|
||||
});
|
||||
children.push(web);
|
||||
const webStatus = await waitForStatus<WebStatusSnapshot>(
|
||||
web.ipcPath,
|
||||
(status) => status.url != null,
|
||||
);
|
||||
if (webStatus.url == null) throw new Error("web did not report a URL");
|
||||
|
||||
return {
|
||||
daemon: daemonStatus,
|
||||
web: webStatus,
|
||||
async close() {
|
||||
for (const child of [...children].reverse()) {
|
||||
await closeManagedChild(child).catch((error: unknown) => {
|
||||
console.error(`failed to close packaged ${child.app} sidecar`, error);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
for (const child of [...children].reverse()) {
|
||||
await closeManagedChild(child).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2024", "DOM"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2024",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user