diff --git a/AGENTS.md b/AGENTS.md index 3201a97cf..6e3d135ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ Nexu is a desktop-first OpenClaw platform. Users create AI bots, connect them to - `apps/web` — React + Ant Design + Vite - `openclaw-runtime` — Repo-local packaged OpenClaw runtime for local dev and desktop packaging; replaces global `openclaw` CLI - `packages/shared` — Shared Zod schemas +- `packages/dev-utils` — TS-first reusable utilities for local script tooling ## Project overview @@ -23,13 +24,16 @@ All commands use pnpm. Target a single app with `pnpm --filter `. ```bash pnpm install # Install -pnpm dev # Local controller-first web stack (Controller + Web) -pnpm dev:controller # Controller only -pnpm start # Build and launch the desktop local runtime stack -pnpm stop # Stop the desktop local runtime stack -pnpm restart # Restart the desktop local runtime stack -pnpm reset-state # Stop desktop runtime and delete repo-local desktop state -pnpm status # Show desktop local runtime status +pnpm --filter @nexu/shared build # Build shared dist required by cold-start dev flows +pnpm dev start # Start the lightweight local stack: openclaw -> controller -> web -> desktop +pnpm dev start # Start one local-dev service: desktop|openclaw|controller|web +pnpm dev restart # Restart the lightweight local stack +pnpm dev stop # Stop the lightweight local stack in reverse order +pnpm dev stop # Stop one local-dev service +pnpm dev restart # Restart one local-dev service +pnpm dev status # Show status for one local-dev service +pnpm dev logs # Show active-session log tail (max 200 lines) for one local-dev service +pnpm dev:controller # Legacy controller-only direct dev entrypoint pnpm dist:mac # Build signed macOS desktop distributables pnpm dist:mac:arm64 # Build signed Apple Silicon macOS desktop distributables pnpm dist:mac:x64 # Build signed Intel macOS desktop distributables @@ -67,15 +71,16 @@ This repo is desktop-first. Prefer the controller-first path and remove or ignor ## Desktop local development -- Use `pnpm install` first, then `pnpm start` / `pnpm stop` / `pnpm restart` / `pnpm status` as the standard local desktop workflow. -- `pnpm start` is the canonical local desktop entrypoint and now applies safe startup optimizations by default: it reuses existing build artifacts when present and reuses the prepared OpenClaw sidecar cache when its inputs have not changed. -- Temporary escape hatches exist for debugging or suspicious cache behavior: `NEXU_DESKTOP_FORCE_FULL_START=1` disables the optimized start path, `NEXU_DESKTOP_DISABLE_BUILD_REUSE=1` disables build artifact reuse only, and `NEXU_DESKTOP_DISABLE_OPENCLAW_SIDECAR_CACHE=1` disables the OpenClaw sidecar cache only. -- `pnpm reset-state` is the reset button for the optimized path too: it stops the desktop runtime and clears repo-local runtime state plus cached sidecars under `.tmp/sidecars/`. +- Minimal cold-start setup on a fresh machine is: `pnpm install` -> `pnpm --filter @nexu/shared build` -> copy `scripts/dev/.env.example` to `scripts/dev/.env` only if you need dev-only overrides. +- Default daily flow is: `pnpm dev start` -> `pnpm dev status ` / `pnpm dev logs ` as needed -> `pnpm dev stop`. +- Use `pnpm dev restart` for a clean full-stack recycle; use `pnpm dev restart ` only when you are intentionally touching one service. +- Explicit single-service control remains available through `pnpm dev start `, `pnpm dev stop `, `pnpm dev restart `, `pnpm dev status `, and `pnpm dev logs `. +- `pnpm dev` intentionally does not support `all`; the full local stack order remains `openclaw` -> `controller` -> `web` -> `desktop`. +- `pnpm dev logs ` is session-scoped, prints a fixed header, and tails at most the last 200 lines from the active service session. +- `scripts/dev/.env.example` is the source-of-truth template for dev-only overrides. Copy it to `scripts/dev/.env` only when you need to override ports, URLs, state paths, or the shared OpenClaw gateway token for local development. - Keep the detailed startup optimization rules, cache invalidation behavior, and troubleshooting notes in `specs/guides/desktop-runtime-guide.md`; keep only the core workflow expectations here. - The repo also includes a local Slack reply smoke probe at `scripts/probe/slack-reply-probe.mjs` (`pnpm probe:slack prepare` / `pnpm probe:slack run`) for verifying the end-to-end Slack DM reply path after local runtime or OpenClaw changes. - The Slack smoke probe is not zero-setup: install Chrome Canary first, then manually log into Slack in the opened Canary window before running `pnpm probe:slack run`. -- The desktop dev launcher is `apps/desktop/scripts/dev-cli.mjs`; `apps/desktop/dev.sh` is only a thin compatibility wrapper. -- Treat `pnpm start` as the canonical cold-start entrypoint for the full local desktop runtime. - The active desktop runtime path is controller-first: desktop launches `controller + web + openclaw` and no longer starts local `api`, `gateway`, or `pglite` sidecars. - Desktop local runtime should not depend on PostgreSQL. In dev mode, all state (config, OpenClaw state, logs) lives under `.tmp/desktop/nexu-home/`, fully isolated from the packaged app. Launchd plists go to `.tmp/launchd/`, runtime-ports.json also lives there. - In packaged mode, data is split across two directories (see table below). Launchd plists go to `~/Library/LaunchAgents/`. @@ -90,14 +95,10 @@ This repo is desktop-first. Prefer the controller-first path and remove or ignor The split is intentional: `NEXU_HOME` holds lightweight user preferences that should persist across reinstalls; Electron `userData` holds heavy runtime state tied to the app lifecycle. `OPENCLAW_STATE_DIR` is explicitly set by the desktop launcher to point to the `userData` path — do not rely on the controller's default fallback. - `tmux` is no longer required for the desktop local-dev workflow; process state is tracked by the platform-aware launcher entrypoints. -- For startup troubleshooting, use `pnpm logs` and `node apps/desktop/scripts/dev-cli.mjs devlog` when you need the raw dev-cli timeline. -- `pnpm reset-state` is a dev-only cleanup shortcut; it stops the stack and removes repo-local desktop runtime state under `.tmp/desktop/`, but it does not delete packaged app state. - To fully reset local desktop + controller state, stop the stack, remove `.tmp/desktop/`, then remove `~/.nexu/` and `~/Library/Application Support/@nexu/desktop/`. -- If `pnpm start` exits immediately because `electron/cli.js` cannot be resolved from `apps/desktop`, validate `pnpm -C apps/desktop exec electron --version` and consult `specs/guides/desktop-runtime-guide.md` before changing the launcher flow. - Desktop already exposes an agent-friendly runtime observability surface; prefer subscribing/querying before adding temporary UI or ad hoc debug logging. - For deeper desktop runtime inspection, use the existing event/query path (`onRuntimeEvent(...)`, `runtime:query-events`, `queryRuntimeEvents(...)`) instead of rebuilding one-off diagnostics. - Use `actionId`, `reasonCode`, and `cursor` / `nextCursor` as the primary correlation and incremental-fetch primitives for desktop runtime debugging. -- To fully clear local desktop runtime state, use `node apps/desktop/scripts/dev-cli.mjs reset-state`. - Desktop runtime guide: `specs/guides/desktop-runtime-guide.md`. - The controller sidecar is packaged by `apps/desktop/scripts/prepare-controller-sidecar.mjs` which deep-copies all controller `dependencies` and their transitive deps into `.dist-runtime/controller/node_modules/`. Keep controller deps minimal to avoid bloating the desktop distributable. - SkillHub (catalog, install, uninstall) runs in the controller via HTTP — not in the Electron main process via IPC. The web app always uses HTTP SDK for skill operations. @@ -105,6 +106,7 @@ The split is intentional: `NEXU_HOME` holds lightweight user preferences that sh ## Hard rules +- **Debugging first principle: binary isolate, don't guess.** For UI/runtime regressions, start with overall bisection and add tiny reversible `quick return` / `quick fail` probes at key boundaries. Prefer changes that create obvious UI/log differences, narrow the fault domain quickly, and can be reverted immediately after verification. Do not start by rewriting route guards, state flows, or core logic based on intuition. - **Never use `any`.** Use `unknown` with narrowing or `z.infer`. - No foreign keys in Drizzle schema — application-level joins only. - Credentials (bot tokens, signing secrets) must never appear in logs or errors. @@ -168,6 +170,7 @@ See `ARCHITECTURE.md` for the full bird's-eye view. Key points: | Workspace templates | `specs/guides/workspace-templates.md` | | Local Slack testing | `specs/references/local-slack-testing.md` | | Local Slack smoke probe | `scripts/probe/README.md`, `scripts/probe/slack-reply-probe.mjs` | +| Local dev CLI guidance | `scripts/dev/AGENTS.md` | | Frontend conventions | `specs/FRONTEND.md` | | Desktop runtime guide | `specs/guides/desktop-runtime-guide.md` | | Security posture | `specs/SECURITY.md` | @@ -258,9 +261,12 @@ This note should track: ## Local quick reference - Controller env path: `apps/controller/.env` +- Fresh local-dev cold start: `pnpm install` -> `pnpm --filter @nexu/shared build` -> optional `copy scripts/dev/.env.example scripts/dev/.env` (Windows) or `cp scripts/dev/.env.example scripts/dev/.env` (POSIX) -> `pnpm dev start` +- Daily local-dev flow: `pnpm dev start` -> `pnpm dev logs ` / `pnpm dev status ` when needed -> `pnpm dev restart` for a clean recycle -> `pnpm dev stop` - OpenClaw managed skills dir (expected default): `~/.openclaw/skills/` - Slack smoke probe setup: install Chrome Canary, set `PROBE_SLACK_URL`, run `pnpm probe:slack prepare`, then manually log into Slack in Canary before `pnpm probe:slack run` - `openclaw-runtime` is installed implicitly by `pnpm install`; local development should normally not use a global `openclaw` CLI +- Full-stack startup order is `openclaw` -> `controller` -> `web` -> `desktop`; shutdown order is the reverse - Prefer `./openclaw-wrapper` over global `openclaw` in local development; it executes `openclaw-runtime/node_modules/openclaw/openclaw.mjs` - When OpenClaw is started manually, set `RUNTIME_MANAGE_OPENCLAW_PROCESS=false` for `@nexu/controller` to avoid launching a second OpenClaw process - If behavior differs, verify effective `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` used by the running controller process. diff --git a/TASK.md b/TASK.md new file mode 100644 index 000000000..8877f752e --- /dev/null +++ b/TASK.md @@ -0,0 +1,147 @@ +# Handoff Notes + +## Branch + +- Current branch: `feat/local-dev-workflow-optimization` +- Branch pushed to: `origin/feat/local-dev-workflow-optimization` +- Latest commit from before this session: `01bd29a` `refactor: clarify scripts dev module boundaries` +- Workspace status at handoff: code changes for desktop/controller/scripts-dev integration plus this `TASK.md` update + +## What Changed In This Session + +### Desktop runtime ownership was split into `external | internal` + +- `apps/desktop/shared/runtime-config.ts` now exposes `runtimeMode: "external" | "internal"` +- `apps/desktop/main/platforms/shared/runtime-common.ts` now has an external runtime adapter path +- `apps/desktop/main/platforms/index.ts` selects an external adapter when desktop is launched in external mode +- `apps/desktop/main/runtime/manifests.ts` marks `web`, `controller`, and `openclaw` runtime units as `external` when desktop is attaching instead of owning processes +- `apps/desktop/main/runtime/daemon-supervisor.ts` now probes external runtime units by port and reports external availability state instead of trying to manage them +- `apps/desktop/main/index.ts` logs the effective desktop runtime mode and the external runtime targets during cold start + +### Controller OpenClaw ownership was split into `external | internal` + +- `apps/controller/src/app/env.ts` now accepts: + - `NEXU_CONTROLLER_OPENCLAW_MODE=external|internal` + - `OPENCLAW_BASE_URL` + - `OPENCLAW_LOG_DIR` +- Legacy `RUNTIME_MANAGE_OPENCLAW_PROCESS` is now treated as a compatibility input; the effective owner is derived from the explicit mode when present +- `apps/controller/src/runtime/gateway-client.ts`, `apps/controller/src/runtime/runtime-health.ts`, and `apps/controller/src/runtime/openclaw-ws-client.ts` now connect through `OPENCLAW_BASE_URL` instead of hard-coded `127.0.0.1:${port}` +- `apps/controller/src/app/bootstrap.ts` now logs the runtime contract and only starts OpenClaw when controller is in `internal` mode +- `apps/controller/src/services/model-provider-service.ts` and `apps/controller/src/services/desktop-local-service.ts` now skip runtime restarts when controller is attached to an external OpenClaw instance +- `apps/controller/src/runtime/openclaw-process.ts` now exposes `managesProcess()` so ownership checks are explicit at call sites + +### `scripts/dev` now owns OpenClaw local-dev startup + +- Added `scripts/dev/src/shared/dev-runtime-config.ts` + - reads `scripts/dev/.env` when present + - defines the cross-service local-dev contract for ports, URLs, state dirs, config path, log dir, and gateway token +- Added `scripts/dev/.env.example` as the source-of-truth example for dev-only external injection +- Added `scripts/dev/src/services/openclaw.ts` +- Added `scripts/dev/src/supervisors/openclaw.ts` +- Updated `scripts/dev/src/index.ts` so `pnpm dev start|restart|stop|status|logs` now includes `openclaw` +- Updated existing `scripts/dev` controller/web assembly to consume injected values from `scripts/dev/.env` instead of assuming only hard-coded defaults + +### `scripts/dev` now also owns desktop local-dev attach + +- Added `scripts/dev/src/services/desktop.ts` +- Updated `scripts/dev/src/index.ts` so `pnpm dev start|restart|stop|status|logs` now includes `desktop` +- Added desktop-specific `scripts/dev` state/log plumbing: + - `.tmp/dev/desktop.pid` + - desktop log access wired to `.tmp/dev/logs//desktop.log` +- `scripts/dev` command routing no longer has implicit aggregate defaults for `start | status | stop | restart` + - these commands now require an explicit single-service target: `desktop | openclaw | controller | web` + - aggregate target `all` is rejected + - example: `pnpm dev start openclaw`, `pnpm dev status desktop`, `pnpm dev stop web` +- `pnpm dev logs ` is now session-scoped and tail-oriented: + - logs are resolved from the active service session only + - output is capped to the last 200 lines by default + - the CLI prints a fixed header with the tail policy, session line count, and actual log file path before log content +- Desktop local dev now starts Electron directly from `scripts/dev` + - the old `scripts/desktop-dev.mjs` -> `apps/desktop/scripts/dev-cli.mjs` launcher chain is deleted + - desktop session logs now live under `.tmp/dev/logs//desktop.log` like the other services + - desktop pid locks now store `launchId` directly inside the service lock +- The old desktop local-dev system is intentionally not backward compatible anymore + - `pnpm start` now fails because that script no longer exists + - `apps/desktop/dev.sh` and `apps/desktop/scripts/dev-cli.mjs` are deleted + - validation helpers now invoke explicit `pnpm dev start|stop ` commands only +- Two outdated root dev aliases were also removed: + - `dev:controller` + - `dev:desktop` +- Root `AGENTS.md` and `scripts/dev/AGENTS.md` were updated to describe the explicit per-service local-dev workflow, the lack of an `all` target, and the new session-scoped logging contract +- Cleaned obvious old script-managed dev wording in service errors so each service now points to the matching explicit stop command (`pnpm dev stop `) +- `apps/desktop/main/bootstrap.ts` now respects a pre-injected `NEXU_HOME` in local dev instead of always forcing the desktop-local fallback path +- Root `package.json` no longer exposes the old `pnpm start|stop|restart|status|logs|reset-state` desktop launcher scripts + +### Small controller-chain robustness fix + +- `apps/controller/src/runtime/openclaw-config-writer.ts` now derives a fallback state dir from `openclawConfigPath` when the full env shape is not present, which fixed the related config-writer regression in tests + +## Validation Already Done + +- `pnpm --filter @nexu/desktop typecheck` passed +- `pnpm --filter @nexu/desktop build` passed earlier in the session after the desktop external-runtime split +- `pnpm --filter @nexu/controller typecheck` passed +- `pnpm --filter @nexu/controller build` passed +- `pnpm --dir ./scripts/dev exec tsc --noEmit` passed +- Root-entrypoint local-dev acceptance for explicit per-service local-dev flow passed: + - `pnpm dev status all` rejects `all` as intended + - `pnpm dev start openclaw` + - `pnpm dev logs openclaw` + - `pnpm dev start controller` + - `pnpm dev logs controller` + - `pnpm dev start web` + - `pnpm dev logs web` + - `pnpm dev start desktop` + - `pnpm dev logs desktop` + - `pnpm dev stop desktop` + - `pnpm dev stop web` + - `pnpm dev stop controller` + - `pnpm dev stop openclaw` +- Follow-up doc / cleanup validation passed: + - `pnpm --dir ./scripts/dev exec tsc --noEmit` + - grep audit across `AGENTS.md`, `scripts/dev/AGENTS.md`, and `scripts/dev/src/` for stale implicit-aggregate command wording +- Direct desktop supervisor validation passed: + - `pnpm dev stop desktop` + - `pnpm dev start desktop` + - `pnpm dev logs desktop` + - `pnpm dev status desktop` +- Legacy launcher removal validation passed: + - `pnpm start` now fails with `ERR_PNPM_NO_SCRIPT_OR_SERVER` as intended + - `pnpm --filter @nexu/desktop typecheck` + - `pnpm dev restart desktop` + - `pnpm dev logs desktop` +- Root alias removal is committed and pushed on `feat/local-dev-workflow-optimization` + - latest commit at handoff: `5adcbe3` (`refactor: remove legacy desktop dev launchers`) +- Verified controller now boots in `external` OpenClaw mode and successfully reaches `openclaw_ws_connected` through the `scripts/dev`-managed OpenClaw process +- `pnpm lint` still fails, but only on pre-existing repo-wide Biome formatting drift unrelated to this branch +- `pnpm test` still fails, but the observed failures are pre-existing desktop cross-platform/path test issues unrelated to this branch + +## Important Current Behavior + +- OpenClaw local dev is now expected to be orchestrated by `scripts/dev`, not by its own dedicated `.env` +- `scripts/dev/.env` is intended to become the single dev-only source of truth for cross-service injected runtime values +- Controller local dev is already consuming OpenClaw through that external contract when launched via `scripts/dev` +- Desktop local dev is now started/stopped through `scripts/dev` in `external` mode and attaches to the `scripts/dev`-managed controller/web/openclaw stack +- `pnpm dev start|status|stop|restart` now require an explicit single-service target; `all` is intentionally unsupported +- `pnpm dev logs ` only works for the active session of that service and prints at most the last 200 lines, prefixed with a fixed metadata header +- Desktop launched via `scripts/dev` now goes straight through Electron + pid lock supervision instead of the extra `dev-cli` wrapper layer +- Desktop session logs launched via `scripts/dev` now use `.tmp/dev/logs//desktop.log` +- The old desktop launcher model is abolished; local desktop development is now only supported through explicit `pnpm dev ` flows +- Root `package.json` no longer exposes the old desktop launcher scripts or the old `dev:controller` / `dev:desktop` aliases +- Remaining old-command references are documentation debt only in historical design/plan docs, not active executable paths + +## Known Existing Issues + +- `pnpm lint` still fails due to pre-existing repo-wide Biome formatting issues unrelated to this branch +- `pnpm --filter @nexu/controller test` still has pre-existing failures not introduced by this session: + - `tests/nexu-config-store.test.ts` + - `tests/openclaw-sync.test.ts` + - `tests/openclaw-runtime-plugin-writer.test.ts` (Windows symlink permission issue) +- `pnpm test` still has additional pre-existing desktop failures on Windows path expectations / filesystem assumptions (launchd, plist, state migration, skill path, runtime manifest, skill DB migration) + +## Suggested Next Steps + +1. Decide whether any per-service dependency guardrails are needed when users start `controller` or `desktop` without their expected upstream services already running +2. Add the missing OpenClaw runtime-root/runtime-port contract that desktop still expects in external mode so the current `Missing external runtime port` warning disappears +3. Continue tightening the `scripts/dev/.env` contract so every external injection is documented, named consistently, and traced to a single owner +4. Optionally do a historical-doc cleanup pass for old `pnpm start` / `pnpm restart` references that no longer map to executable paths diff --git a/apps/controller/src/app/bootstrap.ts b/apps/controller/src/app/bootstrap.ts index 21beaafa4..9f2335a35 100644 --- a/apps/controller/src/app/bootstrap.ts +++ b/apps/controller/src/app/bootstrap.ts @@ -1,8 +1,20 @@ +import { logger } from "../lib/logger.js"; import type { ControllerContainer } from "./container.js"; export async function bootstrapController( container: ControllerContainer, ): Promise<() => void> { + logger.info( + { + openclawOwnershipMode: container.env.openclawOwnershipMode, + openclawBaseUrl: container.env.openclawBaseUrl, + openclawConfigPath: container.env.openclawConfigPath, + openclawStateDir: container.env.openclawStateDir, + openclawLogDir: container.env.openclawLogDir, + }, + "controller_bootstrap_runtime_contract", + ); + // Run independent prep tasks in parallel to shave off startup time. // All three are independent: process cleanup, plugin files, cloud model fetch. await Promise.all([ @@ -28,8 +40,12 @@ export async function bootstrapController( // restarts from async setup (cloud connect, model selection, bot creation). container.openclawSyncService.beginSettling(); - container.openclawProcess.enableAutoRestart(); - container.openclawProcess.start(); + if (container.openclawProcess.managesProcess()) { + container.openclawProcess.enableAutoRestart(); + container.openclawProcess.start(); + } else { + logger.info({}, "controller_bootstrap_attaching_external_openclaw"); + } container.channelFallbackService.start(); // Start WS client — connects to OpenClaw gateway diff --git a/apps/controller/src/app/container.ts b/apps/controller/src/app/container.ts index 1acc9eaf7..3baa914d1 100644 --- a/apps/controller/src/app/container.ts +++ b/apps/controller/src/app/container.ts @@ -69,12 +69,10 @@ export interface ControllerContainer { export async function createContainer(): Promise { const configStore = new NexuConfigStore(env); await configStore.reconcileConfiguredDesktopCloudState(); - if (env.manageOpenclawProcess) { - await configStore.syncManagedRuntimeGateway({ - port: env.openclawGatewayPort, - authMode: env.openclawGatewayToken ? "token" : "none", - }); - } + await configStore.syncManagedRuntimeGateway({ + port: env.openclawGatewayPort, + authMode: env.openclawGatewayToken ? "token" : "none", + }); const artifactsStore = new ArtifactsStore(env); const compiledStore = new CompiledOpenClawStore(env); const configWriter = new OpenClawConfigWriter(env); diff --git a/apps/controller/src/app/env.ts b/apps/controller/src/app/env.ts index e575263f8..eabd0fc44 100644 --- a/apps/controller/src/app/env.ts +++ b/apps/controller/src/app/env.ts @@ -36,6 +36,40 @@ const booleanSchema = z .enum(["true", "false", "1", "0"]) .transform((value) => value === "true" || value === "1"); +const openclawOwnershipModeSchema = z.enum(["external", "internal"]); + +function parseUrlPort(value: string): number | null { + try { + const url = new URL(value); + if (url.port.length > 0) { + return Number.parseInt(url.port, 10); + } + + if (url.protocol === "https:") { + return 443; + } + + if (url.protocol === "http:") { + return 80; + } + + return null; + } catch { + return null; + } +} + +function readOpenclawOwnershipMode(input: { + explicitMode?: "external" | "internal"; + legacyManageProcess: boolean; +}): "external" | "internal" { + if (input.explicitMode) { + return input.explicitMode; + } + + return input.legacyManageProcess ? "internal" : "external"; +} + const envSchema = z.object({ NODE_ENV: z .enum(["development", "test", "production"]) @@ -43,8 +77,11 @@ const envSchema = z.object({ PORT: z.coerce.number().int().positive().default(3010), HOST: z.string().default("127.0.0.1"), NEXU_HOME: z.string().default("~/.nexu"), + NEXU_CONTROLLER_OPENCLAW_MODE: openclawOwnershipModeSchema.optional(), + OPENCLAW_BASE_URL: z.string().url().optional(), OPENCLAW_STATE_DIR: z.string().optional(), OPENCLAW_CONFIG_PATH: z.string().optional(), + OPENCLAW_LOG_DIR: z.string().optional(), OPENCLAW_SKILLS_DIR: z.string().optional(), SKILLHUB_STATIC_SKILLS_DIR: z.string().optional(), PLATFORM_TEMPLATES_DIR: z.string().optional(), @@ -64,6 +101,15 @@ const envSchema = z.object({ }); const parsed = envSchema.parse(process.env); +const openclawOwnershipMode = readOpenclawOwnershipMode({ + explicitMode: parsed.NEXU_CONTROLLER_OPENCLAW_MODE, + legacyManageProcess: parsed.RUNTIME_MANAGE_OPENCLAW_PROCESS, +}); +const openclawBaseUrl = + parsed.OPENCLAW_BASE_URL ?? + `http://127.0.0.1:${String(parsed.OPENCLAW_GATEWAY_PORT)}`; +const openclawGatewayPort = + parseUrlPort(openclawBaseUrl) ?? parsed.OPENCLAW_GATEWAY_PORT; const nexuHomeDir = expandHomeDir(parsed.NEXU_HOME); const openclawStateDir = expandHomeDir( @@ -119,12 +165,17 @@ export const env = { openclawStateDir, "workspace-templates", ), + openclawOwnershipMode, + openclawBaseUrl, openclawBin: parsed.OPENCLAW_BIN, + openclawLogDir: expandHomeDir( + parsed.OPENCLAW_LOG_DIR ?? path.join(nexuHomeDir, "logs", "openclaw"), + ), litellmBaseUrl: parsed.LITELLM_BASE_URL ?? null, litellmApiKey: parsed.LITELLM_API_KEY ?? null, - openclawGatewayPort: parsed.OPENCLAW_GATEWAY_PORT, + openclawGatewayPort, openclawGatewayToken: parsed.OPENCLAW_GATEWAY_TOKEN, - manageOpenclawProcess: parsed.RUNTIME_MANAGE_OPENCLAW_PROCESS, + manageOpenclawProcess: openclawOwnershipMode === "internal", gatewayProbeEnabled: parsed.RUNTIME_GATEWAY_PROBE_ENABLED, runtimeSyncIntervalMs: parsed.RUNTIME_SYNC_INTERVAL_MS, runtimeHealthIntervalMs: parsed.RUNTIME_HEALTH_INTERVAL_MS, diff --git a/apps/controller/src/index.ts b/apps/controller/src/index.ts index c9ed521da..2c2a4889c 100644 --- a/apps/controller/src/index.ts +++ b/apps/controller/src/index.ts @@ -23,9 +23,28 @@ async function main(): Promise { }, ); + let shuttingDown = false; + + const closeServer = () => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + const shutdown = async () => { + if (shuttingDown) { + return; + } + + shuttingDown = true; stopBackgroundLoops(); - server.close(); + await closeServer(); await container.openclawProcess.stop(); process.exit(0); }; diff --git a/apps/controller/src/routes/desktop-compat-routes.ts b/apps/controller/src/routes/desktop-compat-routes.ts index 6c332ac29..6ebe24bf0 100644 --- a/apps/controller/src/routes/desktop-compat-routes.ts +++ b/apps/controller/src/routes/desktop-compat-routes.ts @@ -31,11 +31,55 @@ const defaultModelSetResponseSchema = z.object({ modelId: z.string(), configPushed: z.boolean(), }); +const desktopAuthSessionResponseSchema = z.object({ + session: z.object({ + id: z.string(), + expiresAt: z.string(), + }), + user: z.object({ + id: z.string(), + email: z.string(), + name: z.string(), + image: z.string().nullable(), + }), +}); export function registerDesktopCompatRoutes( app: OpenAPIHono, container: ControllerContainer, ): void { + app.openapi( + createRoute({ + method: "get", + path: "/api/auth/get-session", + tags: ["Desktop"], + responses: { + 200: { + content: { + "application/json": { schema: desktopAuthSessionResponseSchema }, + }, + description: "Desktop-local auth session", + }, + }, + }), + async (c) => + c.json( + { + session: { + id: "desktop-local-session", + expiresAt: "2099-01-01T00:00:00.000Z", + }, + user: { + id: "desktop-local-user", + email: "desktop@nexu.local", + name: "Desktop User", + image: null, + }, + }, + 200, + ), + ); + app.openapi( createRoute({ method: "get", diff --git a/apps/controller/src/runtime/gateway-client.ts b/apps/controller/src/runtime/gateway-client.ts index 992763363..0ee444f3f 100644 --- a/apps/controller/src/runtime/gateway-client.ts +++ b/apps/controller/src/runtime/gateway-client.ts @@ -4,10 +4,7 @@ export class GatewayClient { constructor(private readonly env: ControllerEnv) {} async fetchJson(pathname: string): Promise { - const url = new URL( - pathname, - `http://127.0.0.1:${this.env.openclawGatewayPort}`, - ); + const url = new URL(pathname, this.env.openclawBaseUrl); const response = await fetch(url, { headers: this.env.openclawGatewayToken ? { Authorization: `Bearer ${this.env.openclawGatewayToken}` } diff --git a/apps/controller/src/runtime/openclaw-config-writer.ts b/apps/controller/src/runtime/openclaw-config-writer.ts index 2fb468982..f72735c88 100644 --- a/apps/controller/src/runtime/openclaw-config-writer.ts +++ b/apps/controller/src/runtime/openclaw-config-writer.ts @@ -51,6 +51,10 @@ async function syncWeixinAccountIndex( ); } +function resolveOpenclawStateDir(env: ControllerEnv): string { + return env.openclawStateDir ?? path.dirname(env.openclawConfigPath); +} + export class OpenClawConfigWriter { /** Last successfully written content — used to skip redundant writes. */ private lastWrittenContent: string | null = null; @@ -100,7 +104,7 @@ export class OpenClawConfigWriter { this.lastWrittenContent = content; // Sync weixin account index for openclaw-weixin plugin compatibility - await syncWeixinAccountIndex(this.env.openclawStateDir, config); + await syncWeixinAccountIndex(resolveOpenclawStateDir(this.env), config); const configStat = await stat(this.env.openclawConfigPath); logger.info( diff --git a/apps/controller/src/runtime/openclaw-process.ts b/apps/controller/src/runtime/openclaw-process.ts index d3123bb96..02d3fc27a 100644 --- a/apps/controller/src/runtime/openclaw-process.ts +++ b/apps/controller/src/runtime/openclaw-process.ts @@ -36,6 +36,10 @@ export class OpenClawProcessManager { constructor(private readonly env: ControllerEnv) {} + managesProcess(): boolean { + return this.env.manageOpenclawProcess; + } + async prepare(): Promise { if (!this.env.manageOpenclawProcess) { return; diff --git a/apps/controller/src/runtime/openclaw-ws-client.ts b/apps/controller/src/runtime/openclaw-ws-client.ts index c27d2c9a6..b773806ee 100644 --- a/apps/controller/src/runtime/openclaw-ws-client.ts +++ b/apps/controller/src/runtime/openclaw-ws-client.ts @@ -141,6 +141,12 @@ function buildDeviceAuthPayloadV3(params: { ].join("|"); } +function toGatewayWsUrl(baseUrl: string): string { + const url = new URL(baseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString().replace(/\/$/, ""); +} + // --------------------------------------------------------------------------- // Protocol types (subset of openclaw/src/gateway/protocol) // --------------------------------------------------------------------------- @@ -204,7 +210,7 @@ export class OpenClawWsClient { private readonly deviceIdentity: DeviceIdentity; constructor(env: ControllerEnv) { - this.url = `ws://127.0.0.1:${env.openclawGatewayPort}`; + this.url = toGatewayWsUrl(env.openclawBaseUrl); this.token = env.openclawGatewayToken ?? ""; this.deviceIdentity = loadOrCreateDeviceIdentity( path.join(env.openclawStateDir, "identity", "device.json"), diff --git a/apps/controller/src/runtime/runtime-health.ts b/apps/controller/src/runtime/runtime-health.ts index a0c66d979..bb19da0e8 100644 --- a/apps/controller/src/runtime/runtime-health.ts +++ b/apps/controller/src/runtime/runtime-health.ts @@ -10,7 +10,7 @@ export class RuntimeHealth { try { const response = await fetch( - `http://127.0.0.1:${this.env.openclawGatewayPort}/health`, + new URL("/health", this.env.openclawBaseUrl), ); return { ok: response.ok, diff --git a/apps/controller/src/services/desktop-local-service.ts b/apps/controller/src/services/desktop-local-service.ts index 290625668..2778a62a9 100644 --- a/apps/controller/src/services/desktop-local-service.ts +++ b/apps/controller/src/services/desktop-local-service.ts @@ -1,3 +1,4 @@ +import { logger } from "../lib/logger.js"; import type { OpenClawProcessManager } from "../runtime/openclaw-process.js"; import type { NexuConfigStore } from "../store/nexu-config-store.js"; import type { ModelProviderService } from "./model-provider-service.js"; @@ -87,6 +88,14 @@ export class DesktopLocalService { } async restartRuntime(): Promise { + if (!this.openclawProcess.managesProcess()) { + logger.info( + {}, + "desktop_local_runtime_restart_skipped_external_openclaw", + ); + return; + } + await this.openclawProcess.stop(); this.openclawProcess.enableAutoRestart(); this.openclawProcess.start(); diff --git a/apps/controller/src/services/model-provider-service.ts b/apps/controller/src/services/model-provider-service.ts index bfce3fc98..c95d5e7ee 100644 --- a/apps/controller/src/services/model-provider-service.ts +++ b/apps/controller/src/services/model-provider-service.ts @@ -925,6 +925,14 @@ export class ModelProviderService { } private async restartRuntime(): Promise { + if (!this.openclawProcess.managesProcess()) { + logger.info( + {}, + "model_provider_runtime_restart_skipped_external_openclaw", + ); + return; + } + await this.openclawProcess.stop(); this.openclawProcess.enableAutoRestart(); this.openclawProcess.start(); diff --git a/apps/controller/static/runtime-plugins/openclaw-weixin/package-lock.json b/apps/controller/static/runtime-plugins/openclaw-weixin/package-lock.json index bce6cdae4..5f9b7457a 100644 --- a/apps/controller/static/runtime-plugins/openclaw-weixin/package-lock.json +++ b/apps/controller/static/runtime-plugins/openclaw-weixin/package-lock.json @@ -1777,7 +1777,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2180,7 +2179,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2279,7 +2277,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/apps/desktop/dev.sh b/apps/desktop/dev.sh deleted file mode 100755 index 017c54529..000000000 --- a/apps/desktop/dev.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -APP_DIR="$(cd "$(dirname "$0")" && pwd)" -exec node "$APP_DIR/scripts/dev-cli.mjs" "$@" diff --git a/apps/desktop/main/bootstrap.ts b/apps/desktop/main/bootstrap.ts index 45cd7f283..bb5ab4674 100644 --- a/apps/desktop/main/bootstrap.ts +++ b/apps/desktop/main/bootstrap.ts @@ -52,7 +52,9 @@ function configureLocalDevPaths(): void { const userDataPath = resolve(electronRoot, "user-data"); const sessionDataPath = resolve(electronRoot, "session-data"); const logsPath = resolve(userDataPath, "logs"); - const nexuHomePath = getDesktopNexuHomeDir(userDataPath); + const nexuHomePath = process.env.NEXU_HOME + ? resolve(process.env.NEXU_HOME) + : getDesktopNexuHomeDir(userDataPath); mkdirSync(userDataPath, { recursive: true }); mkdirSync(sessionDataPath, { recursive: true }); diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 706c7a772..8697d5571 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -31,11 +31,7 @@ import { rotateDesktopLogSession, writeDesktopMainLog, } from "./runtime/runtime-logger"; -import { - type LaunchdBootstrapResult, - getDefaultPlistDir, - installLaunchdQuitHandler, -} from "./services"; +import type { LaunchdBootstrapResult } from "./services"; import { SleepGuard, type SleepGuardLogEntry } from "./sleep-guard"; import { ComponentUpdater } from "./updater/component-updater"; import { StartupHealthCheck } from "./updater/rollback"; @@ -83,14 +79,16 @@ const baseRuntimeConfig = getDesktopRuntimeConfig(process.env, { useBuildConfig: app.isPackaged, }); logStartupStep("base runtime config created"); -const runtimePlatform = getDesktopRuntimePlatformAdapter(); +const runtimePlatform = getDesktopRuntimePlatformAdapter(baseRuntimeConfig); const { allocations: runtimePortAllocations, runtimeConfig } = await runtimePlatform.prepareRuntimeConfig({ baseRuntimeConfig, env: process.env, logStartupStep, }); -logStartupStep(`runtime config ready platform=${runtimePlatform.id}`); +logStartupStep( + `runtime config ready platform=${runtimePlatform.id} mode=${runtimeConfig.runtimeMode}`, +); const orchestrator = new RuntimeOrchestrator( await measureStartupStep("createRuntimeUnitManifests", () => createRuntimeUnitManifests( @@ -98,6 +96,7 @@ const orchestrator = new RuntimeOrchestrator( app.getPath("userData"), app.isPackaged, runtimeConfig, + runtimePlatform.capabilities, ), ), ); @@ -612,9 +611,19 @@ function createMainWindow(): BrowserWindow { } }); - void window.loadFile(resolve(__dirname, "../../dist/index.html")); - logLaunchTimeline("main window loadFile dispatched"); - logStartupStep("createMainWindow:loadFile-dispatched"); + const desktopDevServerUrl = process.env.NEXU_DESKTOP_DEV_SERVER_URL; + + if (desktopDevServerUrl) { + void window.loadURL(desktopDevServerUrl); + logLaunchTimeline( + `main window loadURL dispatched url=${desktopDevServerUrl}`, + ); + logStartupStep("createMainWindow:loadURL-dispatched"); + } else { + void window.loadFile(resolve(__dirname, "../../dist/index.html")); + logLaunchTimeline("main window loadFile dispatched"); + logStartupStep("createMainWindow:loadFile-dispatched"); + } mainWindow = window; return window; } @@ -759,6 +768,10 @@ app.whenReady().then(async () => { try { logColdStart(`bootstrap mode: ${runtimePlatform.mode}`); + logColdStart(`runtime mode: ${runtimeConfig.runtimeMode}`); + logColdStart( + `runtime targets controller=${runtimeConfig.urls.controllerBase} web=${runtimeConfig.urls.web} openclaw=${runtimeConfig.urls.openclawBase}`, + ); const coldStartResult = await runtimePlatform.runColdStart({ app, electronRoot, @@ -790,21 +803,15 @@ app.whenReady().then(async () => { resolveColdStartReady(); } - // Install launchd quit handler regardless of cold-start success/failure - // so services can always be stopped cleanly on quit. - if (launchdResult) { - installLaunchdQuitHandler({ - launchd: launchdResult.launchd, - labels: launchdResult.labels, - webServer: launchdResult.webServer, - plistDir: getDefaultPlistDir(!app.isPackaged), - onBeforeQuit: async () => { - sleepGuard?.dispose("launchd-quit"); - await diagnosticsReporter?.flushNow().catch(() => undefined); - flushRuntimeLoggers(); - }, - }); - } + runtimePlatform.capabilities.shutdownCoordinator.install({ + app, + mainWindow: win, + launchdResult, + orchestrator, + diagnosticsReporter, + sleepGuardDispose: (reason) => sleepGuard?.dispose(reason), + flushRuntimeLoggers, + }); if (app.isPackaged && runtimeConfig.updates.autoUpdateEnabled) { const updateMgr = new UpdateManager(win, orchestrator, { @@ -840,28 +847,3 @@ app.on("window-all-closed", () => { app.quit(); } }); - -app.on("before-quit", (event) => { - sleepGuard?.dispose("app-before-quit"); - void diagnosticsReporter?.flushNow().catch(() => undefined); - flushRuntimeLoggers(); - - // If using launchd mode, the quit handler is installed separately - // and shows a dialog for quit options - if (launchdResult) { - // Launchd quit handler is already installed via installLaunchdQuitHandler - // This handler just does cleanup; actual quit logic is in quit-handler.ts - return; - } - - // Legacy orchestrator mode: clean up child processes - event.preventDefault(); - orchestrator - .dispose() - .catch(() => undefined) - .finally(() => { - // Remove this handler so the next quit attempt goes through. - app.removeAllListeners("before-quit"); - app.quit(); - }); -}); diff --git a/apps/desktop/main/platforms/default/capabilities.ts b/apps/desktop/main/platforms/default/capabilities.ts new file mode 100644 index 000000000..9406fa715 --- /dev/null +++ b/apps/desktop/main/platforms/default/capabilities.ts @@ -0,0 +1,25 @@ +import { createManagedPortStrategy } from "../shared/port-strategy"; +import { createDefaultRuntimeExecutableResolver } from "../shared/runtime-executables"; +import { resolveManagedRuntimeRoots } from "../shared/runtime-roots"; +import { createManagedShutdownCoordinator } from "../shared/shutdown-coordinator"; +import { createSyncTarSidecarMaterializer } from "../shared/sidecar-materializer"; +import { createNoopStateMigrationPolicy } from "../shared/state-migration-policy"; +import type { DesktopPlatformCapabilities } from "../types"; + +export function createDefaultPlatformCapabilities(): DesktopPlatformCapabilities { + return { + platformId: "default", + runtimeResidency: "managed", + packagedArchive: { + format: "tar.gz", + extractionMode: "sync", + supportsAtomicSwap: false, + }, + resolveRuntimeRoots: resolveManagedRuntimeRoots, + sidecarMaterializer: createSyncTarSidecarMaterializer(), + runtimeExecutables: createDefaultRuntimeExecutableResolver(), + portStrategy: createManagedPortStrategy(), + stateMigrationPolicy: createNoopStateMigrationPolicy(), + shutdownCoordinator: createManagedShutdownCoordinator(), + }; +} diff --git a/apps/desktop/main/platforms/index.ts b/apps/desktop/main/platforms/index.ts index b24069957..ab08e0f5e 100644 --- a/apps/desktop/main/platforms/index.ts +++ b/apps/desktop/main/platforms/index.ts @@ -1,12 +1,44 @@ +import type { DesktopRuntimeConfig } from "../../shared/runtime-config"; +import { createDefaultPlatformCapabilities } from "./default/capabilities"; import { createFallbackMacRuntimePlatformAdapter, createMacRuntimePlatformAdapter, shouldUseMacLaunchdRuntime, } from "./mac/runtime"; -import { createManagedRuntimePlatformAdapter } from "./shared/runtime-common"; +import { + createExternalRuntimePlatformAdapter, + createManagedRuntimePlatformAdapter, +} from "./shared/runtime-common"; import { createWindowsRuntimePlatformAdapter } from "./win/runtime"; -export function getDesktopRuntimePlatformAdapter() { +function createExternalAdapter() { + if (process.platform === "darwin") { + return createExternalRuntimePlatformAdapter( + "mac", + createFallbackMacRuntimePlatformAdapter().capabilities, + ); + } + + if (process.platform === "win32") { + return createExternalRuntimePlatformAdapter( + "win", + createWindowsRuntimePlatformAdapter().capabilities, + ); + } + + return createExternalRuntimePlatformAdapter( + "default", + createDefaultPlatformCapabilities(), + ); +} + +export function getDesktopRuntimePlatformAdapter( + baseRuntimeConfig?: DesktopRuntimeConfig, +) { + if (baseRuntimeConfig?.runtimeMode === "external") { + return createExternalAdapter(); + } + if (shouldUseMacLaunchdRuntime()) { return createMacRuntimePlatformAdapter(); } @@ -19,5 +51,8 @@ export function getDesktopRuntimePlatformAdapter() { return createWindowsRuntimePlatformAdapter(); } - return createManagedRuntimePlatformAdapter("default"); + return createManagedRuntimePlatformAdapter( + "default", + createDefaultPlatformCapabilities(), + ); } diff --git a/apps/desktop/main/platforms/mac/capabilities.ts b/apps/desktop/main/platforms/mac/capabilities.ts new file mode 100644 index 000000000..52deded03 --- /dev/null +++ b/apps/desktop/main/platforms/mac/capabilities.ts @@ -0,0 +1,55 @@ +import { + createLaunchdPortStrategy, + createManagedPortStrategy, +} from "../shared/port-strategy"; +import { createDefaultRuntimeExecutableResolver } from "../shared/runtime-executables"; +import { + resolveLaunchdRuntimeRoots, + resolveManagedRuntimeRoots, +} from "../shared/runtime-roots"; +import { + createLaunchdShutdownCoordinator, + createManagedShutdownCoordinator, +} from "../shared/shutdown-coordinator"; +import { createSyncTarSidecarMaterializer } from "../shared/sidecar-materializer"; +import { + createMacPackagedStateMigrationPolicy, + createNoopStateMigrationPolicy, +} from "../shared/state-migration-policy"; +import type { DesktopPlatformCapabilities } from "../types"; + +export function createMacLaunchdCapabilities(): DesktopPlatformCapabilities { + return { + platformId: "mac", + runtimeResidency: "launchd", + packagedArchive: { + format: "tar.gz", + extractionMode: "sync", + supportsAtomicSwap: false, + }, + resolveRuntimeRoots: resolveLaunchdRuntimeRoots, + sidecarMaterializer: createSyncTarSidecarMaterializer(), + runtimeExecutables: createDefaultRuntimeExecutableResolver(), + portStrategy: createLaunchdPortStrategy(), + stateMigrationPolicy: createMacPackagedStateMigrationPolicy(), + shutdownCoordinator: createLaunchdShutdownCoordinator(), + }; +} + +export function createMacManagedCapabilities(): DesktopPlatformCapabilities { + return { + platformId: "mac", + runtimeResidency: "managed", + packagedArchive: { + format: "tar.gz", + extractionMode: "sync", + supportsAtomicSwap: false, + }, + resolveRuntimeRoots: resolveManagedRuntimeRoots, + sidecarMaterializer: createSyncTarSidecarMaterializer(), + runtimeExecutables: createDefaultRuntimeExecutableResolver(), + portStrategy: createManagedPortStrategy(), + stateMigrationPolicy: createNoopStateMigrationPolicy(), + shutdownCoordinator: createManagedShutdownCoordinator(), + }; +} diff --git a/apps/desktop/main/platforms/mac/runtime.ts b/apps/desktop/main/platforms/mac/runtime.ts index 8cda8bf0e..b58b0662b 100644 --- a/apps/desktop/main/platforms/mac/runtime.ts +++ b/apps/desktop/main/platforms/mac/runtime.ts @@ -4,23 +4,24 @@ import { getWorkspaceRoot } from "../../../shared/workspace-paths"; import { SERVICE_LABELS, bootstrapWithLaunchd, - getLogDir, getDefaultPlistDir, + getLogDir, isLaunchdBootstrapEnabled, resolveLaunchdPaths, } from "../../services"; -import { - getLegacyNexuHomeStateDir, - migrateOpenclawState, -} from "../../services/state-migration"; -import { buildSkillNodePath } from "../../runtime/manifests"; import { createManagedRuntimePlatformAdapter } from "../shared/runtime-common"; import type { DesktopRuntimePlatformAdapter } from "../types"; +import { + createMacLaunchdCapabilities, + createMacManagedCapabilities, +} from "./capabilities"; export function createMacRuntimePlatformAdapter(): DesktopRuntimePlatformAdapter { + const capabilities = createMacLaunchdCapabilities(); return { id: "mac", mode: "launchd", + capabilities, prepareRuntimeConfig: ({ baseRuntimeConfig, logStartupStep }) => { logStartupStep("mac:prepareRuntimeConfig:launchd"); return Promise.resolve({ @@ -42,38 +43,31 @@ export function createMacRuntimePlatformAdapter(): DesktopRuntimePlatformAdapter const isDev = !app.isPackaged; const paths = resolveLaunchdPaths(app.isPackaged, electronRoot); - const nexuHome = runtimeConfig.paths.nexuHome.replace( - /^~/, - process.env.HOME ?? "", - ); - const openclawRuntimeRoot = isDev - ? resolve(nexuHome, "runtime", "openclaw") - : resolve(app.getPath("userData"), "runtime", "openclaw"); - const openclawStateDir = resolve(openclawRuntimeRoot, "state"); - const openclawConfigPath = resolve(openclawStateDir, "openclaw.json"); + const runtimeRoots = capabilities.resolveRuntimeRoots({ + app, + electronRoot, + runtimeConfig, + }); + const nexuHome = runtimeRoots.nexuHome; + const openclawRuntimeRoot = runtimeRoots.openclawRuntimeRoot; + const openclawStateDir = runtimeRoots.openclawStateDir; + const openclawConfigPath = runtimeRoots.openclawConfigPath; - if (!isDev) { - const legacyStateDir = getLegacyNexuHomeStateDir( - runtimeConfig.paths.nexuHome, - ); - if (legacyStateDir !== openclawStateDir) { - migrateOpenclawState({ - targetStateDir: openclawStateDir, - sourceStateDir: legacyStateDir, - log: (message) => logColdStart(`state-migration: ${message}`), - }); - } - } + capabilities.stateMigrationPolicy.run({ + runtimeConfig, + runtimeRoots, + isPackaged: app.isPackaged, + log: logColdStart, + }); - const webRoot = isDev - ? resolve(getWorkspaceRoot(), "apps", "web", "dist") - : resolve(electronRoot, "runtime", "web", "dist"); + const webRoot = runtimeRoots.webRoot; const repoRoot = getWorkspaceRoot(); const userDataPath = app.getPath("userData"); const openclawSkillsDir = getOpenclawSkillsDir(userDataPath); - const openclawTmpDir = resolve(openclawRuntimeRoot, "tmp"); + const openclawTmpDir = runtimeRoots.openclawTmpDir; const openclawBinPath = - process.env.NEXU_OPENCLAW_BIN ?? resolve(paths.openclawCwd, "bin/openclaw"); + process.env.NEXU_OPENCLAW_BIN ?? + resolve(paths.openclawCwd, "bin/openclaw"); const openclawPackageRoot = resolve( paths.openclawCwd, "node_modules/openclaw", @@ -85,7 +79,12 @@ export function createMacRuntimePlatformAdapter(): DesktopRuntimePlatformAdapter const platformTemplatesDir = app.isPackaged ? resolve(electronRoot, "static/platform-templates") : resolve(repoRoot, "apps/controller/static/platform-templates"); - const skillNodePath = buildSkillNodePath(electronRoot, app.isPackaged); + const skillNodePath = + capabilities.runtimeExecutables.resolveSkillNodePath({ + electronRoot, + isPackaged: app.isPackaged, + openclawSidecarRoot: paths.openclawCwd, + }); const launchdResult = await bootstrapWithLaunchd({ isDev, @@ -133,7 +132,9 @@ export function createMacRuntimePlatformAdapter(): DesktopRuntimePlatformAdapter `attached to running services (controller=${controllerPort} openclaw=${openclawPort} web=${webPort})`, ); } else { - logColdStart("launchd services started, waiting for controller readiness"); + logColdStart( + "launchd services started, waiting for controller readiness", + ); diagnosticsReporter?.markColdStartRunning( "waiting for controller readiness", ); @@ -157,5 +158,8 @@ export function shouldUseMacLaunchdRuntime(): boolean { } export function createFallbackMacRuntimePlatformAdapter() { - return createManagedRuntimePlatformAdapter("mac"); + return createManagedRuntimePlatformAdapter( + "mac", + createMacManagedCapabilities(), + ); } diff --git a/apps/desktop/main/platforms/shared/archive-flow.ts b/apps/desktop/main/platforms/shared/archive-flow.ts new file mode 100644 index 000000000..c3add9bfc --- /dev/null +++ b/apps/desktop/main/platforms/shared/archive-flow.ts @@ -0,0 +1,16 @@ +import type { + DesktopPlatformCapabilities, + PackagedArchiveFormat, +} from "../types"; + +export function shouldUseAsyncArchiveExtraction( + capabilities: DesktopPlatformCapabilities, +): boolean { + return capabilities.packagedArchive.extractionMode === "async"; +} + +export function getPreferredPackagedArchiveFormat( + capabilities: DesktopPlatformCapabilities, +): PackagedArchiveFormat { + return capabilities.packagedArchive.format; +} diff --git a/apps/desktop/main/platforms/shared/port-strategy.ts b/apps/desktop/main/platforms/shared/port-strategy.ts new file mode 100644 index 000000000..57bea404a --- /dev/null +++ b/apps/desktop/main/platforms/shared/port-strategy.ts @@ -0,0 +1,43 @@ +import { + PortAllocationError, + allocateDesktopRuntimePorts, +} from "../../runtime/port-allocation"; +import type { + DesktopRuntimePortStrategy, + PrepareRuntimeConfigArgs, +} from "../types"; + +export function createManagedPortStrategy(): DesktopRuntimePortStrategy { + return { + async allocateRuntimePorts({ + baseRuntimeConfig, + env, + }: PrepareRuntimeConfigArgs) { + return allocateDesktopRuntimePorts(env, baseRuntimeConfig).catch( + (error: unknown) => { + if (error instanceof PortAllocationError) { + throw new Error( + `[desktop:ports] ${error.code} purpose=${error.purpose} ` + + `preferredPort=${error.preferredPort ?? "n/a"} ${error.message}`, + ); + } + + throw error; + }, + ); + }, + }; +} + +export function createLaunchdPortStrategy(): DesktopRuntimePortStrategy { + return { + async allocateRuntimePorts({ + baseRuntimeConfig, + }: PrepareRuntimeConfigArgs) { + return { + allocations: [], + runtimeConfig: baseRuntimeConfig, + }; + }, + }; +} diff --git a/apps/desktop/main/platforms/shared/runtime-common.ts b/apps/desktop/main/platforms/shared/runtime-common.ts index 31656109b..2759baeae 100644 --- a/apps/desktop/main/platforms/shared/runtime-common.ts +++ b/apps/desktop/main/platforms/shared/runtime-common.ts @@ -1,11 +1,11 @@ -import type { DesktopRuntimePlatformAdapter } from "../types"; -import { - PortAllocationError, - allocateDesktopRuntimePorts, -} from "../../runtime/port-allocation"; +import type { + DesktopPlatformCapabilities, + DesktopRuntimePlatformAdapter, +} from "../types"; export async function prepareManagedRuntimeConfig( adapterId: DesktopRuntimePlatformAdapter["id"], + capabilities: DesktopPlatformCapabilities, { baseRuntimeConfig, env, @@ -14,18 +14,11 @@ export async function prepareManagedRuntimeConfig( ) { logStartupStep(`${adapterId}:prepareRuntimeConfig:start`); try { - const result = await allocateDesktopRuntimePorts(env, baseRuntimeConfig).catch( - (error: unknown) => { - if (error instanceof PortAllocationError) { - throw new Error( - `[desktop:ports] ${error.code} purpose=${error.purpose} ` + - `preferredPort=${error.preferredPort ?? "n/a"} ${error.message}`, - ); - } - - throw error; - }, - ); + const result = await capabilities.portStrategy.allocateRuntimePorts({ + baseRuntimeConfig, + env, + logStartupStep, + }); logStartupStep(`${adapterId}:prepareRuntimeConfig:done`); return result; } catch (error) { @@ -68,13 +61,63 @@ export async function runManagedColdStart({ }; } +export async function runExternalColdStart({ + diagnosticsReporter, + logColdStart, + logStartupStep, + rotateDesktopLogSession, + waitForControllerReadiness, +}: Parameters[0]) { + logStartupStep("externalColdStart:start"); + diagnosticsReporter?.markColdStartRunning("attaching to external runtime"); + logColdStart("attaching to external runtime"); + + diagnosticsReporter?.markColdStartRunning( + "waiting for external controller readiness", + ); + logColdStart("waiting for external controller readiness"); + await waitForControllerReadiness(); + + const sessionId = rotateDesktopLogSession(); + logColdStart(`external runtime session ready sessionId=${sessionId}`); + logColdStart("external runtime attach complete"); + diagnosticsReporter?.markColdStartSucceeded(); + logStartupStep("externalColdStart:done"); + + return { + launchdResult: null, + }; +} + export function createManagedRuntimePlatformAdapter( id: DesktopRuntimePlatformAdapter["id"], + capabilities: DesktopPlatformCapabilities, ): DesktopRuntimePlatformAdapter { return { id, mode: "managed", - prepareRuntimeConfig: (args) => prepareManagedRuntimeConfig(id, args), + capabilities, + prepareRuntimeConfig: (args) => + prepareManagedRuntimeConfig(id, capabilities, args), runColdStart: (args) => runManagedColdStart(args), }; } + +export function createExternalRuntimePlatformAdapter( + id: DesktopRuntimePlatformAdapter["id"], + capabilities: DesktopPlatformCapabilities, +): DesktopRuntimePlatformAdapter { + return { + id, + mode: "external", + capabilities, + prepareRuntimeConfig: async ({ baseRuntimeConfig, logStartupStep }) => { + logStartupStep(`${id}:prepareRuntimeConfig:external`); + return { + allocations: [], + runtimeConfig: baseRuntimeConfig, + }; + }, + runColdStart: (args) => runExternalColdStart(args), + }; +} diff --git a/apps/desktop/main/platforms/shared/runtime-executables.ts b/apps/desktop/main/platforms/shared/runtime-executables.ts new file mode 100644 index 000000000..baa005f29 --- /dev/null +++ b/apps/desktop/main/platforms/shared/runtime-executables.ts @@ -0,0 +1,126 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import type { + DesktopRuntimeExecutableResolver, + ResolveRuntimeExecutablesArgs, +} from "../types"; + +function normalizeNodeCandidate( + candidate: string | undefined, +): string | undefined { + const trimmed = candidate?.trim(); + if (!trimmed || !existsSync(trimmed)) { + return undefined; + } + + return trimmed; +} + +function buildNode22Path(): string | undefined { + const nvmDir = process.env.NVM_DIR; + if (!nvmDir) return undefined; + try { + const versionsDir = path.resolve(nvmDir, "versions/node"); + const dirs = readdirSync(versionsDir) + .filter((d) => d.startsWith("v22.")) + .sort() + .reverse(); + for (const d of dirs) { + const binDir = path.resolve(versionsDir, d, "bin"); + if (existsSync(path.resolve(binDir, "node"))) { + return `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + } + } + } catch { + return undefined; + } + return undefined; +} + +function supportsOpenclawRuntime( + nodeBinaryPath: string, + openclawSidecarRoot: string, +): boolean { + try { + execFileSync( + nodeBinaryPath, + [ + "-e", + 'require(require("node:path").resolve(process.argv[1], "node_modules/@snazzah/davey"))', + openclawSidecarRoot, + ], + { + stdio: "ignore", + env: { + ...process.env, + NODE_PATH: "", + }, + }, + ); + return true; + } catch { + return false; + } +} + +function resolveOpenclawNodePath({ + openclawSidecarRoot, +}: ResolveRuntimeExecutablesArgs): string | undefined { + const currentPath = process.env.PATH ?? ""; + const candidates = [normalizeNodeCandidate(process.env.NODE)]; + + try { + candidates.push( + normalizeNodeCandidate( + execFileSync("which", ["node"], { encoding: "utf8" }), + ), + ); + } catch { + // ignore + } + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + if (!supportsOpenclawRuntime(candidate, openclawSidecarRoot)) { + continue; + } + + const candidateDir = path.dirname(candidate); + const currentFirstPath = currentPath.split(path.delimiter)[0] ?? ""; + if (candidateDir === currentFirstPath) { + return undefined; + } + + return `${candidateDir}${path.delimiter}${currentPath}`; + } + + return buildNode22Path(); +} + +function resolveSkillNodePath({ + electronRoot, + isPackaged, + inheritedNodePath = process.env.NODE_PATH, +}: ResolveRuntimeExecutablesArgs): string { + const bundledModulesPath = isPackaged + ? path.resolve(electronRoot, "bundled-node-modules") + : path.resolve(electronRoot, "node_modules"); + const inheritedEntries = (inheritedNodePath ?? "") + .split(path.delimiter) + .filter((entry) => entry.length > 0); + + return Array.from(new Set([bundledModulesPath, ...inheritedEntries])).join( + path.delimiter, + ); +} + +export function createDefaultRuntimeExecutableResolver(): DesktopRuntimeExecutableResolver { + return { + resolveSkillNodePath, + resolveOpenclawNodePath, + }; +} diff --git a/apps/desktop/main/platforms/shared/runtime-roots.ts b/apps/desktop/main/platforms/shared/runtime-roots.ts new file mode 100644 index 000000000..22f1d26c9 --- /dev/null +++ b/apps/desktop/main/platforms/shared/runtime-roots.ts @@ -0,0 +1,55 @@ +import { resolve } from "node:path"; +import type { DesktopRuntimeRoots, PlatformCapabilitiesArgs } from "../types"; + +function expandHomePath(input: string): string { + return input.replace(/^~/, process.env.HOME ?? ""); +} + +export function resolveManagedRuntimeRoots({ + app, + electronRoot, + runtimeConfig, +}: PlatformCapabilitiesArgs): DesktopRuntimeRoots { + const userDataPath = app.getPath("userData"); + const runtimeRoot = resolve(userDataPath, "runtime"); + const openclawRuntimeRoot = resolve(runtimeRoot, "openclaw"); + + return { + nexuHome: expandHomePath(runtimeConfig.paths.nexuHome), + runtimeRoot, + openclawRuntimeRoot, + openclawStateDir: resolve(openclawRuntimeRoot, "state"), + openclawConfigPath: resolve(openclawRuntimeRoot, "config", "openclaw.json"), + openclawTmpDir: resolve(openclawRuntimeRoot, "tmp"), + webRoot: app.isPackaged + ? resolve(electronRoot, "runtime", "web", "dist") + : resolve(electronRoot, "..", "web", "dist"), + logsRoot: resolve(userDataPath, "logs"), + }; +} + +export function resolveLaunchdRuntimeRoots({ + app, + electronRoot, + runtimeConfig, +}: PlatformCapabilitiesArgs): DesktopRuntimeRoots { + const nexuHome = expandHomePath(runtimeConfig.paths.nexuHome); + const openclawRuntimeRoot = app.isPackaged + ? resolve(app.getPath("userData"), "runtime", "openclaw") + : resolve(nexuHome, "runtime", "openclaw"); + + return { + nexuHome, + runtimeRoot: app.isPackaged + ? resolve(app.getPath("userData"), "runtime") + : resolve(nexuHome, "runtime"), + openclawRuntimeRoot, + openclawStateDir: resolve(openclawRuntimeRoot, "state"), + openclawConfigPath: resolve(openclawRuntimeRoot, "state", "openclaw.json"), + openclawTmpDir: resolve(openclawRuntimeRoot, "tmp"), + webRoot: app.isPackaged + ? resolve(electronRoot, "runtime", "web", "dist") + : resolve(electronRoot, "..", "web", "dist"), + logsRoot: resolve(nexuHome, "logs"), + }; +} diff --git a/apps/desktop/main/platforms/shared/shutdown-coordinator.ts b/apps/desktop/main/platforms/shared/shutdown-coordinator.ts new file mode 100644 index 000000000..b84e910d2 --- /dev/null +++ b/apps/desktop/main/platforms/shared/shutdown-coordinator.ts @@ -0,0 +1,77 @@ +import { app } from "electron"; +import { getDefaultPlistDir, installLaunchdQuitHandler } from "../../services"; +import type { + DesktopShutdownCoordinator, + InstallShutdownCoordinatorArgs, +} from "../types"; + +export function createManagedShutdownCoordinator(): DesktopShutdownCoordinator { + return { + install({ + diagnosticsReporter, + flushRuntimeLoggers, + launchdResult, + orchestrator, + sleepGuardDispose, + }: InstallShutdownCoordinatorArgs) { + app.on("before-quit", (event) => { + sleepGuardDispose("app-before-quit"); + void diagnosticsReporter?.flushNow().catch(() => undefined); + flushRuntimeLoggers(); + + if (launchdResult) { + return; + } + + event.preventDefault(); + orchestrator + .dispose() + .catch(() => undefined) + .finally(() => { + app.removeAllListeners("before-quit"); + app.quit(); + }); + }); + }, + }; +} + +export function createLaunchdShutdownCoordinator(): DesktopShutdownCoordinator { + return { + install({ + app: electronApp, + diagnosticsReporter, + flushRuntimeLoggers, + launchdResult, + mainWindow: _mainWindow, + orchestrator: _orchestrator, + sleepGuardDispose, + }: InstallShutdownCoordinatorArgs) { + if (launchdResult) { + installLaunchdQuitHandler({ + launchd: launchdResult.launchd, + labels: launchdResult.labels, + webServer: launchdResult.webServer, + plistDir: getDefaultPlistDir(!electronApp.isPackaged), + onBeforeQuit: async () => { + sleepGuardDispose("launchd-quit"); + await diagnosticsReporter?.flushNow().catch(() => undefined); + flushRuntimeLoggers(); + }, + }); + } + + app.on("before-quit", (event) => { + sleepGuardDispose("app-before-quit"); + void diagnosticsReporter?.flushNow().catch(() => undefined); + flushRuntimeLoggers(); + + if (launchdResult) { + return; + } + + event.preventDefault(); + }); + }, + }; +} diff --git a/apps/desktop/main/platforms/shared/sidecar-materializer.ts b/apps/desktop/main/platforms/shared/sidecar-materializer.ts new file mode 100644 index 000000000..38072ef18 --- /dev/null +++ b/apps/desktop/main/platforms/shared/sidecar-materializer.ts @@ -0,0 +1,311 @@ +import { execFileSync } from "node:child_process"; +import { + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { chmod, mkdir, rename, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import type { + DesktopSidecarMaterializer, + MaterializePackagedSidecarArgs, +} from "../types"; + +const require = createRequire(import.meta.url); +const yauzl = require("yauzl") as { + open: ( + path: string, + options: { lazyEntries: boolean }, + callback: (error: Error | null, zipFile?: YauzlZipFile) => void, + ) => void; +}; + +type YauzlEntry = { + fileName: string; + externalFileAttributes?: number; +}; + +type YauzlZipFile = { + readEntry: () => void; + on: (event: "entry", listener: (entry: YauzlEntry) => void) => void; + once: ( + event: "end" | "error", + listener: (() => void) | ((error: Error) => void), + ) => void; + openReadStream: ( + entry: YauzlEntry, + callback: (error: Error | null, stream?: NodeJS.ReadableStream) => void, + ) => void; + close: () => void; +}; + +type PackagedArchiveMetadata = { + format: string; + path: string; + version?: string; +}; + +function ensureDir(targetPath: string): string { + mkdirSync(targetPath, { recursive: true }); + return targetPath; +} + +function resolveArchiveStamp( + archivePath: string, + archiveMetadata: PackagedArchiveMetadata | null, +): string { + if (archiveMetadata?.version) { + return archiveMetadata.version; + } + + const archiveStat = statSync(archivePath); + return `${archiveStat.size}:${archiveStat.mtimeMs}`; +} + +function readPackagedArchiveMetadata( + packagedSidecarRoot: string, +): PackagedArchiveMetadata | null { + const archiveMetadataPath = path.resolve(packagedSidecarRoot, "archive.json"); + + if (!existsSync(archiveMetadataPath)) { + return null; + } + + return JSON.parse( + readFileSync(archiveMetadataPath, "utf8"), + ) as PackagedArchiveMetadata; +} + +async function extractZipArchive( + archivePath: string, + destinationRoot: string, +): Promise { + await new Promise((resolveExtract, rejectExtract) => { + yauzl.open(archivePath, { lazyEntries: true }, (openError, zipFile) => { + if (openError || !zipFile) { + rejectExtract( + openError ?? new Error(`Unable to open zip archive ${archivePath}`), + ); + return; + } + + const closeWithError = (error: Error) => { + zipFile.close(); + rejectExtract(error); + }; + + zipFile.once("error", closeWithError); + zipFile.once("end", () => { + zipFile.close(); + resolveExtract(); + }); + zipFile.on("entry", (entry) => { + void (async () => { + const normalizedPath = entry.fileName.replace(/\\/gu, "/"); + if (!normalizedPath || normalizedPath === ".") { + zipFile.readEntry(); + return; + } + + const destinationPath = path.resolve(destinationRoot, normalizedPath); + const relativePath = path.relative(destinationRoot, destinationPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error( + `Refusing to extract path outside destination: ${entry.fileName}`, + ); + } + + if (normalizedPath.endsWith("/")) { + await mkdir(destinationPath, { recursive: true }); + zipFile.readEntry(); + return; + } + + await mkdir(path.dirname(destinationPath), { recursive: true }); + zipFile.openReadStream(entry, async (streamError, readStream) => { + if (streamError || !readStream) { + closeWithError( + streamError ?? + new Error(`Unable to read zip entry ${entry.fileName}`), + ); + return; + } + + try { + await pipeline(readStream, createWriteStream(destinationPath)); + if (process.platform !== "win32") { + const entryMode = entry.externalFileAttributes + ? (entry.externalFileAttributes >>> 16) & 0o777 + : 0; + if (entryMode > 0) { + await chmod(destinationPath, entryMode); + } + } + zipFile.readEntry(); + } catch (error) { + closeWithError( + error instanceof Error ? error : new Error(String(error)), + ); + } + }); + })().catch((error) => { + closeWithError( + error instanceof Error ? error : new Error(String(error)), + ); + }); + }); + + zipFile.readEntry(); + }); + }); +} + +function resolveSidecarPaths({ + runtimeSidecarBaseRoot, + runtimeRoot, +}: MaterializePackagedSidecarArgs) { + const packagedSidecarRoot = path.resolve(runtimeSidecarBaseRoot, "openclaw"); + const archiveMetadata = readPackagedArchiveMetadata(packagedSidecarRoot); + const archivePath = archiveMetadata + ? path.resolve(packagedSidecarRoot, archiveMetadata.path) + : path.resolve(packagedSidecarRoot, "payload.tar.gz"); + const extractedSidecarRoot = ensureDir( + path.resolve(runtimeRoot, "openclaw-sidecar"), + ); + const stampPath = path.resolve(extractedSidecarRoot, ".archive-stamp"); + const archiveStamp = resolveArchiveStamp(archivePath, archiveMetadata); + const extractedOpenclawEntry = path.resolve( + extractedSidecarRoot, + "node_modules/openclaw/openclaw.mjs", + ); + + return { + packagedSidecarRoot, + archiveMetadata, + archivePath, + extractedSidecarRoot, + stampPath, + archiveStamp, + extractedOpenclawEntry, + }; +} + +export function createSyncTarSidecarMaterializer(): DesktopSidecarMaterializer { + const materializePackagedOpenclawSidecarSync = ( + args: MaterializePackagedSidecarArgs, + ): string => { + const resolved = resolveSidecarPaths(args); + if (!existsSync(resolved.archivePath)) { + return resolved.packagedSidecarRoot; + } + + if ( + existsSync(resolved.stampPath) && + existsSync(resolved.extractedOpenclawEntry) && + readFileSync(resolved.stampPath, "utf8") === resolved.archiveStamp + ) { + return resolved.extractedSidecarRoot; + } + + if (resolved.archiveMetadata?.format === "zip") { + throw new Error( + "Synchronous packaged OpenClaw extraction does not support zip archives.", + ); + } + + const maxRetries = 3; + for (let attempt = 0; attempt < maxRetries; attempt += 1) { + try { + if (existsSync(resolved.extractedSidecarRoot)) { + execFileSync("rm", ["-rf", resolved.extractedSidecarRoot]); + } + mkdirSync(resolved.extractedSidecarRoot, { recursive: true }); + execFileSync("tar", [ + "-xzf", + resolved.archivePath, + "-C", + resolved.extractedSidecarRoot, + ]); + writeFileSync(resolved.stampPath, resolved.archiveStamp); + break; + } catch (error) { + if (attempt === maxRetries - 1) { + throw error; + } + execFileSync("sleep", ["1"]); + } + } + + return resolved.extractedSidecarRoot; + }; + + return { + async materializePackagedOpenclawSidecar(args) { + return materializePackagedOpenclawSidecarSync(args); + }, + materializePackagedOpenclawSidecarSync, + }; +} + +export function createAsyncArchiveSidecarMaterializer(): DesktopSidecarMaterializer { + return { + async materializePackagedOpenclawSidecar(args) { + const resolved = resolveSidecarPaths(args); + if (!existsSync(resolved.archivePath)) { + return resolved.packagedSidecarRoot; + } + + if ( + existsSync(resolved.stampPath) && + existsSync(resolved.extractedOpenclawEntry) && + readFileSync(resolved.stampPath, "utf8") === resolved.archiveStamp + ) { + return resolved.extractedSidecarRoot; + } + + const tempExtractedSidecarRoot = path.resolve( + args.runtimeRoot, + "openclaw-sidecar.extracting", + ); + await rm(tempExtractedSidecarRoot, { recursive: true, force: true }); + await mkdir(tempExtractedSidecarRoot, { recursive: true }); + + if ( + !resolved.archiveMetadata || + resolved.archiveMetadata.format === "tar.gz" + ) { + execFileSync("tar", [ + "-xzf", + resolved.archivePath, + "-C", + tempExtractedSidecarRoot, + ]); + } else if (resolved.archiveMetadata.format === "zip") { + await extractZipArchive(resolved.archivePath, tempExtractedSidecarRoot); + } else { + throw new Error( + `Unsupported packaged archive format: ${resolved.archiveMetadata.format}`, + ); + } + + if (process.platform !== "win32") { + await chmod( + path.resolve(tempExtractedSidecarRoot, "bin/openclaw"), + 0o755, + ).catch(() => null); + } + + rmSync(resolved.extractedSidecarRoot, { recursive: true, force: true }); + await rename(tempExtractedSidecarRoot, resolved.extractedSidecarRoot); + writeFileSync(resolved.stampPath, resolved.archiveStamp); + + return resolved.extractedSidecarRoot; + }, + }; +} diff --git a/apps/desktop/main/platforms/shared/state-migration-policy.ts b/apps/desktop/main/platforms/shared/state-migration-policy.ts new file mode 100644 index 000000000..2115b970a --- /dev/null +++ b/apps/desktop/main/platforms/shared/state-migration-policy.ts @@ -0,0 +1,36 @@ +import { + getLegacyNexuHomeStateDir, + migrateOpenclawState, +} from "../../services/state-migration"; +import type { DesktopRuntimeStateMigrationPolicy } from "../types"; + +export function createNoopStateMigrationPolicy(): DesktopRuntimeStateMigrationPolicy { + return { + run() { + // no-op + }, + }; +} + +export function createMacPackagedStateMigrationPolicy(): DesktopRuntimeStateMigrationPolicy { + return { + run({ isPackaged, log, runtimeConfig, runtimeRoots }) { + if (!isPackaged) { + return; + } + + const legacyStateDir = getLegacyNexuHomeStateDir( + runtimeConfig.paths.nexuHome, + ); + if (legacyStateDir === runtimeRoots.openclawStateDir) { + return; + } + + migrateOpenclawState({ + targetStateDir: runtimeRoots.openclawStateDir, + sourceStateDir: legacyStateDir, + log: (message) => log(`state-migration: ${message}`), + }); + }, + }; +} diff --git a/apps/desktop/main/platforms/types.ts b/apps/desktop/main/platforms/types.ts index 764bd42e0..ccb4f2df8 100644 --- a/apps/desktop/main/platforms/types.ts +++ b/apps/desktop/main/platforms/types.ts @@ -1,8 +1,12 @@ import type { App } from "electron"; +import type { BrowserWindow } from "electron"; import type { DesktopRuntimeConfig } from "../../shared/runtime-config"; import type { DesktopDiagnosticsReporter } from "../desktop-diagnostics"; import type { RuntimeOrchestrator } from "../runtime/daemon-supervisor"; -import type { PortAllocation } from "../runtime/port-allocation"; +import type { + DesktopPortAllocationResult, + PortAllocation, +} from "../runtime/port-allocation"; import type { LaunchdBootstrapResult } from "../services"; export type RuntimeConfigPreparation = { @@ -10,6 +14,102 @@ export type RuntimeConfigPreparation = { runtimeConfig: DesktopRuntimeConfig; }; +export type RuntimeResidencyMode = "managed" | "launchd" | "external"; + +export type PackagedArchiveFormat = "tar.gz" | "zip"; + +export type DesktopRuntimeRoots = { + nexuHome: string; + runtimeRoot: string; + openclawRuntimeRoot: string; + openclawStateDir: string; + openclawConfigPath: string; + openclawTmpDir: string; + webRoot: string; + logsRoot: string; +}; + +export type MaterializePackagedSidecarArgs = { + runtimeSidecarBaseRoot: string; + runtimeRoot: string; +}; + +export type ResolveRuntimeExecutablesArgs = { + electronRoot: string; + isPackaged: boolean; + openclawSidecarRoot: string; + inheritedNodePath?: string; +}; + +export type DesktopSidecarMaterializer = { + materializePackagedOpenclawSidecar: ( + args: MaterializePackagedSidecarArgs, + ) => Promise; + materializePackagedOpenclawSidecarSync?: ( + args: MaterializePackagedSidecarArgs, + ) => string; +}; + +export type DesktopRuntimeExecutableResolver = { + resolveSkillNodePath: (args: ResolveRuntimeExecutablesArgs) => string; + resolveOpenclawNodePath: ( + args: ResolveRuntimeExecutablesArgs, + ) => string | undefined; +}; + +export type DesktopRuntimePortStrategy = { + allocateRuntimePorts: ( + args: PrepareRuntimeConfigArgs, + ) => Promise; +}; + +export type RunStateMigrationArgs = { + runtimeConfig: DesktopRuntimeConfig; + runtimeRoots: DesktopRuntimeRoots; + isPackaged: boolean; + log: (message: string) => void; +}; + +export type DesktopRuntimeStateMigrationPolicy = { + run: (args: RunStateMigrationArgs) => void; +}; + +export type InstallShutdownCoordinatorArgs = { + app: App; + mainWindow: BrowserWindow; + launchdResult: LaunchdBootstrapResult | null; + orchestrator: RuntimeOrchestrator; + diagnosticsReporter: DesktopDiagnosticsReporter | null; + sleepGuardDispose: (reason: string) => void; + flushRuntimeLoggers: () => void; +}; + +export type DesktopShutdownCoordinator = { + install: (args: InstallShutdownCoordinatorArgs) => void; +}; + +export type PlatformCapabilitiesArgs = { + app: App; + electronRoot: string; + runtimeConfig: DesktopRuntimeConfig; +}; + +export type DesktopPlatformCapabilities = { + platformId: "mac" | "win" | "default"; + runtimeResidency: RuntimeResidencyMode; + packagedArchive: { + format: PackagedArchiveFormat; + extractionMode: "sync" | "async"; + supportsAtomicSwap: boolean; + }; + resolveRuntimeRoots: (args: PlatformCapabilitiesArgs) => DesktopRuntimeRoots; + sidecarMaterializer: DesktopSidecarMaterializer; + runtimeExecutables: DesktopRuntimeExecutableResolver; + portStrategy: DesktopRuntimePortStrategy; + stateMigrationPolicy: DesktopRuntimeStateMigrationPolicy; + shutdownCoordinator: DesktopShutdownCoordinator; +}; + export type PlatformColdStartResult = { launchdResult: LaunchdBootstrapResult | null; }; @@ -34,7 +134,8 @@ export type RunPlatformColdStartArgs = { export type DesktopRuntimePlatformAdapter = { id: "mac" | "win" | "default"; - mode: "managed" | "launchd"; + mode: RuntimeResidencyMode; + capabilities: DesktopPlatformCapabilities; prepareRuntimeConfig: ( args: PrepareRuntimeConfigArgs, ) => Promise; diff --git a/apps/desktop/main/platforms/win/capabilities.ts b/apps/desktop/main/platforms/win/capabilities.ts new file mode 100644 index 000000000..5171584b4 --- /dev/null +++ b/apps/desktop/main/platforms/win/capabilities.ts @@ -0,0 +1,25 @@ +import { createManagedPortStrategy } from "../shared/port-strategy"; +import { createDefaultRuntimeExecutableResolver } from "../shared/runtime-executables"; +import { resolveManagedRuntimeRoots } from "../shared/runtime-roots"; +import { createManagedShutdownCoordinator } from "../shared/shutdown-coordinator"; +import { createAsyncArchiveSidecarMaterializer } from "../shared/sidecar-materializer"; +import { createNoopStateMigrationPolicy } from "../shared/state-migration-policy"; +import type { DesktopPlatformCapabilities } from "../types"; + +export function createWindowsPlatformCapabilities(): DesktopPlatformCapabilities { + return { + platformId: "win", + runtimeResidency: "managed", + packagedArchive: { + format: "zip", + extractionMode: "async", + supportsAtomicSwap: true, + }, + resolveRuntimeRoots: resolveManagedRuntimeRoots, + sidecarMaterializer: createAsyncArchiveSidecarMaterializer(), + runtimeExecutables: createDefaultRuntimeExecutableResolver(), + portStrategy: createManagedPortStrategy(), + stateMigrationPolicy: createNoopStateMigrationPolicy(), + shutdownCoordinator: createManagedShutdownCoordinator(), + }; +} diff --git a/apps/desktop/main/platforms/win/runtime.ts b/apps/desktop/main/platforms/win/runtime.ts index 3293019a3..33c09c176 100644 --- a/apps/desktop/main/platforms/win/runtime.ts +++ b/apps/desktop/main/platforms/win/runtime.ts @@ -1,5 +1,9 @@ import { createManagedRuntimePlatformAdapter } from "../shared/runtime-common"; +import { createWindowsPlatformCapabilities } from "./capabilities"; export function createWindowsRuntimePlatformAdapter() { - return createManagedRuntimePlatformAdapter("win"); + return createManagedRuntimePlatformAdapter( + "win", + createWindowsPlatformCapabilities(), + ); } diff --git a/apps/desktop/main/runtime/daemon-supervisor.ts b/apps/desktop/main/runtime/daemon-supervisor.ts index 6859cdfee..c9921f7bf 100644 --- a/apps/desktop/main/runtime/daemon-supervisor.ts +++ b/apps/desktop/main/runtime/daemon-supervisor.ts @@ -78,6 +78,7 @@ export class RuntimeOrchestrator { manifest.launchStrategy === "embedded" ? "running" : manifest.launchStrategy === "delegated" || + manifest.launchStrategy === "external" || manifest.launchStrategy === "launchd" ? "stopped" : "idle", @@ -121,6 +122,7 @@ export class RuntimeOrchestrator { } getRuntimeState(): RuntimeState { + this.refreshExternalUnits(); this.refreshDelegatedUnits(); this.refreshLaunchdUnits(); @@ -609,9 +611,11 @@ export class RuntimeOrchestrator { ? [record.manifest.command, ...record.manifest.args].join(" ") : record.manifest.launchStrategy === "launchd" ? `launchd service: ${record.manifest.launchdLabel ?? "unknown"}` - : record.manifest.launchStrategy === "delegated" - ? `delegated process match: ${record.manifest.delegatedProcessMatch ?? "unknown"}` - : null, + : record.manifest.launchStrategy === "external" + ? `external port: ${record.manifest.port ?? "unknown"}` + : record.manifest.launchStrategy === "delegated" + ? `delegated process match: ${record.manifest.delegatedProcessMatch ?? "unknown"}` + : null, binaryPath: record.manifest.binaryPath ?? null, logFilePath: record.logFilePath, logTail: record.logTail, @@ -917,6 +921,78 @@ export class RuntimeOrchestrator { } } + private refreshExternalUnits(): void { + for (const record of this.units.values()) { + if (record.manifest.launchStrategy !== "external") { + continue; + } + + this.refreshExternalUnit(record); + } + } + + private refreshExternalUnit(record: RuntimeUnitRecord): void { + const port = record.manifest.port; + const previousPhase = record.phase; + const previousPid = record.pid; + const previousError = record.lastError; + + if (port === null) { + setRecordPhase(record, "failed"); + record.lastError = "Missing external runtime port."; + markProbeFailure(record); + + if ( + previousPhase !== record.phase || + previousError !== record.lastError + ) { + const actionId = beginAction(record, "probe"); + this.logStateChange(record, { + kind: "probe", + actionId, + reasonCode: "external_unavailable", + message: `external runtime ${record.manifest.id} is misconfigured: ${record.lastError}`, + }); + } + return; + } + + const pid = getListeningPidByPort(port); + + if (pid !== null) { + setRecordPhase(record, "running"); + record.pid = pid; + record.startedAt ??= this.startedAt; + record.exitedAt = null; + record.exitCode = null; + record.lastError = null; + markProbeSuccess(record); + } else { + setRecordPhase(record, "stopped"); + record.pid = null; + record.lastError = null; + markProbeFailure(record); + } + + if ( + previousPhase !== record.phase || + previousPid !== record.pid || + previousError !== record.lastError + ) { + const actionId = beginAction(record, "probe"); + this.logStateChange(record, { + kind: "probe", + actionId, + reasonCode: + pid !== null ? "external_available" : "external_unavailable", + message: + pid !== null + ? `external runtime ${record.manifest.id} detected on port ${port} (pid=${pid})` + : `external runtime ${record.manifest.id} unavailable on port ${port}`, + }); + } + } + private refreshDelegatedUnit(record: RuntimeUnitRecord): void { const match = record.manifest.delegatedProcessMatch?.trim(); if (!match) { @@ -1253,6 +1329,56 @@ function ensureActionId(record: RuntimeUnitRecord, verb: string): string { return record.currentActionId ?? beginAction(record, verb); } +function getListeningPidByPort(port: number): number | null { + try { + if (process.platform === "win32") { + const output = execFileSync("netstat", ["-ano", "-p", "tcp"], { + encoding: "utf-8", + }); + + for (const rawLine of output.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line.startsWith("TCP")) { + continue; + } + + const columns = line.split(/\s+/u); + if (columns.length < 5 || columns[3] !== "LISTENING") { + continue; + } + + const localAddress = columns[1] ?? ""; + const localPort = Number.parseInt( + localAddress.split(":").at(-1) ?? "", + 10, + ); + if (localPort !== port) { + continue; + } + + const pid = Number.parseInt(columns[4] ?? "", 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } + + return null; + } + + const output = execFileSync( + "lsof", + [`-tiTCP:${String(port)}`, "-sTCP:LISTEN"], + { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + + const pid = Number.parseInt(output.split(/\r?\n/u).find(Boolean) ?? "", 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + function onManagedError( child: ManagedChildProcess, listener: (error: unknown) => void, diff --git a/apps/desktop/main/runtime/manifests.ts b/apps/desktop/main/runtime/manifests.ts index bb598c07f..68eeccbec 100644 --- a/apps/desktop/main/runtime/manifests.ts +++ b/apps/desktop/main/runtime/manifests.ts @@ -1,57 +1,11 @@ -import { execFileSync } from "node:child_process"; -import { - createWriteStream, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { chmod, mkdir, rename, rm } from "node:fs/promises"; -import { createRequire } from "node:module"; +import { mkdirSync } from "node:fs"; import * as path from "node:path"; -import { pipeline } from "node:stream/promises"; import { getOpenclawSkillsDir } from "../../shared/desktop-paths"; import type { DesktopRuntimeConfig } from "../../shared/runtime-config"; import { getWorkspaceRoot } from "../../shared/workspace-paths"; +import type { DesktopPlatformCapabilities } from "../platforms/types"; import type { RuntimeUnitManifest } from "./types"; -const require = createRequire(import.meta.url); -const yauzl = require("yauzl") as { - open: ( - path: string, - options: { lazyEntries: boolean }, - callback: (error: Error | null, zipFile?: YauzlZipFile) => void, - ) => void; -}; - -type YauzlEntry = { - fileName: string; - externalFileAttributes?: number; -}; - -type YauzlZipFile = { - readEntry: () => void; - on: (event: "entry", listener: (entry: YauzlEntry) => void) => void; - once: ( - event: "end" | "error", - listener: (() => void) | ((error: Error) => void), - ) => void; - openReadStream: ( - entry: YauzlEntry, - callback: (error: Error | null, stream?: NodeJS.ReadableStream) => void, - ) => void; - close: () => void; -}; - -type PackagedArchiveMetadata = { - format: string; - path: string; - version?: string; -}; - function ensureDir(path: string): string { mkdirSync(path, { recursive: true }); return path; @@ -71,362 +25,8 @@ function resolveElectronNodeRunner(): string { return process.execPath; } -function normalizeNodeCandidate( - candidate: string | undefined, -): string | undefined { - const trimmed = candidate?.trim(); - if (!trimmed || !existsSync(trimmed)) { - return undefined; - } - - return trimmed; -} - -/** - * Build a PATH prefix that puts a Node.js >= 22 binary first. - * OpenClaw requires Node 22.12+; in dev mode the system `node` may be - * older (e.g. nvm defaulting to v20). We scan NVM_DIR for a v22 install - * and, if found, prepend its bin directory to the inherited PATH. - */ -function buildNode22Path(): string | undefined { - const nvmDir = process.env.NVM_DIR; - if (!nvmDir) return undefined; - try { - const versionsDir = path.resolve(nvmDir, "versions/node"); - const dirs = readdirSync(versionsDir) - .filter((d) => d.startsWith("v22.")) - .sort() - .reverse(); - for (const d of dirs) { - const binDir = path.resolve(versionsDir, d, "bin"); - if (existsSync(path.resolve(binDir, "node"))) { - return `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; - } - } - } catch { - /* nvm dir not present or unreadable */ - } - return undefined; -} - -function supportsOpenclawRuntime( - nodeBinaryPath: string, - openclawSidecarRoot: string, -): boolean { - try { - execFileSync( - nodeBinaryPath, - [ - "-e", - 'require(require("node:path").resolve(process.argv[1], "node_modules/@snazzah/davey"))', - openclawSidecarRoot, - ], - { - stdio: "ignore", - env: { - ...process.env, - NODE_PATH: "", - }, - }, - ); - return true; - } catch { - return false; - } -} - -/** - * Prefer the current session's Node binary when it can boot OpenClaw. - * Fall back to the previous Node 22 heuristic for older dev shells. - * - * The desktop gateway used to force Node 22 because OpenClaw historically - * required 22.12+. Some local sidecars are instead bound to the current - * session's Node ABI (for example Node 24), so we should try that first. - */ -function buildOpenclawNodePath( - openclawSidecarRoot: string, -): string | undefined { - const currentPath = process.env.PATH ?? ""; - const candidates = [normalizeNodeCandidate(process.env.NODE)]; - - try { - candidates.push( - normalizeNodeCandidate( - execFileSync("which", ["node"], { encoding: "utf8" }), - ), - ); - } catch { - /* current PATH may not expose node */ - } - - for (const candidate of candidates) { - if (!candidate) { - continue; - } - - if (!supportsOpenclawRuntime(candidate, openclawSidecarRoot)) { - continue; - } - - const candidateDir = path.dirname(candidate); - const currentFirstPath = currentPath.split(path.delimiter)[0] ?? ""; - if (candidateDir === currentFirstPath) { - return undefined; - } - - return `${candidateDir}${path.delimiter}${currentPath}`; - } - - return buildNode22Path(); -} - -export function buildSkillNodePath( - electronRoot: string, - isPackaged: boolean, - inheritedNodePath = process.env.NODE_PATH, -): string { - const bundledModulesPath = isPackaged - ? path.resolve(electronRoot, "bundled-node-modules") - : path.resolve(electronRoot, "node_modules"); - const inheritedEntries = (inheritedNodePath ?? "") - .split(path.delimiter) - .filter((entry) => entry.length > 0); - - return Array.from(new Set([bundledModulesPath, ...inheritedEntries])).join( - path.delimiter, - ); -} - -function resolveArchiveStamp( - archivePath: string, - archiveMetadata: PackagedArchiveMetadata | null, -): string { - if (archiveMetadata?.version) { - return archiveMetadata.version; - } - - const archiveStat = statSync(archivePath); - return `${archiveStat.size}:${archiveStat.mtimeMs}`; -} - -function readPackagedArchiveMetadata( - packagedSidecarRoot: string, -): PackagedArchiveMetadata | null { - const archiveMetadataPath = path.resolve(packagedSidecarRoot, "archive.json"); - - if (!existsSync(archiveMetadataPath)) { - return null; - } - - return JSON.parse( - readFileSync(archiveMetadataPath, "utf8"), - ) as PackagedArchiveMetadata; -} - -async function extractZipArchive( - archivePath: string, - destinationRoot: string, -): Promise { - await new Promise((resolveExtract, rejectExtract) => { - yauzl.open(archivePath, { lazyEntries: true }, (openError, zipFile) => { - if (openError || !zipFile) { - rejectExtract( - openError ?? new Error(`Unable to open zip archive ${archivePath}`), - ); - return; - } - - const closeWithError = (error: Error) => { - zipFile.close(); - rejectExtract(error); - }; - - zipFile.once("error", closeWithError); - zipFile.once("end", () => { - zipFile.close(); - resolveExtract(); - }); - zipFile.on("entry", (entry) => { - void (async () => { - const normalizedPath = entry.fileName.replace(/\\/gu, "/"); - if (!normalizedPath || normalizedPath === ".") { - zipFile.readEntry(); - return; - } - - const destinationPath = path.resolve(destinationRoot, normalizedPath); - const relativePath = path.relative(destinationRoot, destinationPath); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - throw new Error( - `Refusing to extract path outside destination: ${entry.fileName}`, - ); - } - - if (normalizedPath.endsWith("/")) { - await mkdir(destinationPath, { recursive: true }); - zipFile.readEntry(); - return; - } - - await mkdir(path.dirname(destinationPath), { recursive: true }); - zipFile.openReadStream(entry, async (streamError, readStream) => { - if (streamError || !readStream) { - closeWithError( - streamError ?? - new Error(`Unable to read zip entry ${entry.fileName}`), - ); - return; - } - - try { - await pipeline(readStream, createWriteStream(destinationPath)); - if (process.platform !== "win32") { - const entryMode = entry.externalFileAttributes - ? (entry.externalFileAttributes >>> 16) & 0o777 - : 0; - if (entryMode > 0) { - await chmod(destinationPath, entryMode); - } - } - zipFile.readEntry(); - } catch (error) { - closeWithError( - error instanceof Error ? error : new Error(String(error)), - ); - } - }); - })().catch((error) => { - closeWithError( - error instanceof Error ? error : new Error(String(error)), - ); - }); - }); - - zipFile.readEntry(); - }); - }); -} - -export function ensurePackagedOpenclawSidecarSync( - runtimeSidecarBaseRoot: string, - runtimeRoot: string, -): string { - const packagedSidecarRoot = path.resolve(runtimeSidecarBaseRoot, "openclaw"); - const archiveMetadata = readPackagedArchiveMetadata(packagedSidecarRoot); - const archivePath = archiveMetadata - ? path.resolve(packagedSidecarRoot, archiveMetadata.path) - : path.resolve(packagedSidecarRoot, "payload.tar.gz"); - - if (!existsSync(archivePath)) { - return packagedSidecarRoot; - } - - const extractedSidecarRoot = ensureDir( - path.resolve(runtimeRoot, "openclaw-sidecar"), - ); - const stampPath = path.resolve(extractedSidecarRoot, ".archive-stamp"); - const archiveStamp = resolveArchiveStamp(archivePath, archiveMetadata); - const extractedOpenclawEntry = path.resolve( - extractedSidecarRoot, - "node_modules/openclaw/openclaw.mjs", - ); - - if ( - existsSync(stampPath) && - existsSync(extractedOpenclawEntry) && - readFileSync(stampPath, "utf8") === archiveStamp - ) { - return extractedSidecarRoot; - } - - if (archiveMetadata?.format === "zip") { - throw new Error( - "Synchronous packaged OpenClaw extraction does not support zip archives.", - ); - } - - const maxRetries = 3; - for (let attempt = 0; attempt < maxRetries; attempt += 1) { - try { - if (existsSync(extractedSidecarRoot)) { - execFileSync("rm", ["-rf", extractedSidecarRoot]); - } - mkdirSync(extractedSidecarRoot, { recursive: true }); - execFileSync("tar", ["-xzf", archivePath, "-C", extractedSidecarRoot]); - writeFileSync(stampPath, archiveStamp); - break; - } catch (error) { - if (attempt === maxRetries - 1) { - throw error; - } - execFileSync("sleep", ["1"]); - } - } - - return extractedSidecarRoot; -} - -async function ensurePackagedOpenclawSidecar( - runtimeSidecarBaseRoot: string, - runtimeRoot: string, -): Promise { - const packagedSidecarRoot = path.resolve(runtimeSidecarBaseRoot, "openclaw"); - const archiveMetadata = readPackagedArchiveMetadata(packagedSidecarRoot); - const archivePath = archiveMetadata - ? path.resolve(packagedSidecarRoot, archiveMetadata.path) - : path.resolve(packagedSidecarRoot, "payload.tar.gz"); - - if (!existsSync(archivePath)) { - return packagedSidecarRoot; - } - - const extractedSidecarRoot = ensureDir( - path.resolve(runtimeRoot, "openclaw-sidecar"), - ); - const stampPath = path.resolve(extractedSidecarRoot, ".archive-stamp"); - const archiveStamp = resolveArchiveStamp(archivePath, archiveMetadata); - const extractedOpenclawEntry = path.resolve( - extractedSidecarRoot, - "node_modules/openclaw/openclaw.mjs", - ); - - if ( - existsSync(stampPath) && - existsSync(extractedOpenclawEntry) && - readFileSync(stampPath, "utf8") === archiveStamp - ) { - return extractedSidecarRoot; - } - - const tempExtractedSidecarRoot = path.resolve( - runtimeRoot, - "openclaw-sidecar.extracting", - ); - await rm(tempExtractedSidecarRoot, { recursive: true, force: true }); - await mkdir(tempExtractedSidecarRoot, { recursive: true }); - - if (!archiveMetadata || archiveMetadata.format === "tar.gz") { - execFileSync("tar", ["-xzf", archivePath, "-C", tempExtractedSidecarRoot]); - } else if (archiveMetadata.format === "zip") { - await extractZipArchive(archivePath, tempExtractedSidecarRoot); - } else { - throw new Error( - `Unsupported packaged archive format: ${archiveMetadata.format}`, - ); - } - - if (process.platform !== "win32") { - await chmod( - path.resolve(tempExtractedSidecarRoot, "bin/openclaw"), - 0o755, - ).catch(() => null); - } - - rmSync(extractedSidecarRoot, { recursive: true, force: true }); - await rename(tempExtractedSidecarRoot, extractedSidecarRoot); - writeFileSync(stampPath, archiveStamp); - - return extractedSidecarRoot; +function isExternalRuntimeMode(runtimeConfig: DesktopRuntimeConfig): boolean { + return runtimeConfig.runtimeMode === "external"; } export async function createRuntimeUnitManifests( @@ -434,6 +34,7 @@ export async function createRuntimeUnitManifests( userDataPath: string, isPackaged: boolean, runtimeConfig: DesktopRuntimeConfig, + platformCapabilities: DesktopPlatformCapabilities, ): Promise { const repoRoot = getWorkspaceRoot(); const _nexuRoot = repoRoot; @@ -442,7 +43,12 @@ export async function createRuntimeUnitManifests( : path.resolve(repoRoot, ".tmp/sidecars"); const runtimeRoot = ensureDir(path.resolve(userDataPath, "runtime")); const openclawSidecarRoot = isPackaged - ? await ensurePackagedOpenclawSidecar(runtimeSidecarBaseRoot, runtimeRoot) + ? await platformCapabilities.sidecarMaterializer.materializePackagedOpenclawSidecar( + { + runtimeSidecarBaseRoot, + runtimeRoot, + }, + ) : path.resolve(runtimeSidecarBaseRoot, "openclaw"); const logsDir = ensureDir(path.resolve(userDataPath, "logs/runtime-units")); const openclawRuntimeRoot = ensureDir(path.resolve(runtimeRoot, "openclaw")); @@ -476,9 +82,20 @@ export async function createRuntimeUnitManifests( const controllerPort = runtimeConfig.ports.controller; const webPort = runtimeConfig.ports.web; const webUrl = runtimeConfig.urls.web; + const externalRuntimeMode = isExternalRuntimeMode(runtimeConfig); const electronNodeRunner = resolveElectronNodeRunner(); - const openclawNodePath = buildOpenclawNodePath(openclawSidecarRoot); - const skillNodePath = buildSkillNodePath(electronRoot, isPackaged); + const openclawNodePath = + platformCapabilities.runtimeExecutables.resolveOpenclawNodePath({ + electronRoot, + isPackaged, + openclawSidecarRoot, + }); + const skillNodePath = + platformCapabilities.runtimeExecutables.resolveSkillNodePath({ + electronRoot, + isPackaged, + openclawSidecarRoot, + }); // Keep all default ports and local URLs defined from this one manifest factory. Other desktop // entry points still mirror a few of these defaults directly, so changes here should be treated @@ -489,7 +106,7 @@ export async function createRuntimeUnitManifests( id: "web", label: "nexu Web Surface", kind: "surface", - launchStrategy: "managed", + launchStrategy: externalRuntimeMode ? "external" : "managed", runner: "spawn", command: electronNodeRunner, args: [webModulePath], @@ -518,7 +135,7 @@ export async function createRuntimeUnitManifests( id: "controller", label: "nexu Controller", kind: "service", - launchStrategy: "managed", + launchStrategy: externalRuntimeMode ? "external" : "managed", // Use spawn instead of utility-process due to Electron bugs: // - https://github.com/electron/electron/issues/43186 // Network requests fail with ECONNRESET after event loop blocking @@ -570,10 +187,12 @@ export async function createRuntimeUnitManifests( id: "openclaw", label: "OpenClaw Runtime", kind: "runtime", - launchStrategy: "delegated", + launchStrategy: externalRuntimeMode ? "external" : "delegated", delegatedProcessMatch: "openclaw-gateway", binaryPath: openclawBinPath, - port: null, + port: new URL(runtimeConfig.urls.openclawBase).port + ? Number.parseInt(new URL(runtimeConfig.urls.openclawBase).port, 10) + : 18_789, autoStart: true, logFilePath: path.resolve(logsDir, "openclaw.log"), }, diff --git a/apps/desktop/main/services/launchd-bootstrap.ts b/apps/desktop/main/services/launchd-bootstrap.ts index 1d26c08e3..7846eddbd 100644 --- a/apps/desktop/main/services/launchd-bootstrap.ts +++ b/apps/desktop/main/services/launchd-bootstrap.ts @@ -17,7 +17,7 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); import { getWorkspaceRoot } from "../../shared/workspace-paths"; -import { ensurePackagedOpenclawSidecarSync } from "../runtime/manifests"; +import { createMacLaunchdCapabilities } from "../platforms/mac/capabilities"; import { type EmbeddedWebServer, startEmbeddedWebServer, @@ -687,10 +687,13 @@ export function resolveLaunchdPaths( // then resolve paths to the extracted location. const runtimeDir = path.join(resourcesPath, "runtime"); const nexuHome = path.join(os.homedir(), ".nexu"); - const openclawSidecarRoot = ensurePackagedOpenclawSidecarSync( - runtimeDir, - nexuHome, - ); + const openclawSidecarRoot = + createMacLaunchdCapabilities().sidecarMaterializer.materializePackagedOpenclawSidecarSync?.( + { + runtimeSidecarBaseRoot: runtimeDir, + runtimeRoot: nexuHome, + }, + ) ?? path.join(runtimeDir, "openclaw"); return { nodePath: process.execPath, controllerEntryPath: path.join( diff --git a/apps/desktop/main/services/quit-handler.ts b/apps/desktop/main/services/quit-handler.ts index 40d48f4b8..e4d4a1fca 100644 --- a/apps/desktop/main/services/quit-handler.ts +++ b/apps/desktop/main/services/quit-handler.ts @@ -95,7 +95,7 @@ export function installLaunchdQuitHandler(opts: QuitHandlerOptions): void { if ((app as unknown as Record).__nexuForceQuit) return; // Dev mode: let the window close normally (no dialog, no hide). - // Services are stopped by `pnpm stop` / dev-launchd.sh. + // Service cleanup is handled by the explicit `pnpm dev stop ` flow. if (!app.isPackaged) { return; } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 61ba1bda3..147d65436 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,7 +10,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "start:electron": "node -e \"console.error('Use pnpm start from the nexu project root.'); process.exit(1)\"", + "start:electron": "node -e \"console.error('Use pnpm dev start desktop from the nexu project root after starting openclaw/controller/web as needed.'); process.exit(1)\"", "prepare:controller-sidecar": "node ./scripts/prepare-controller-sidecar.mjs", "prepare:openclaw-sidecar": "node ./scripts/prepare-openclaw-sidecar.mjs", "prepare:runtime-sidecars": "node ./scripts/prepare-runtime-sidecars.mjs", diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts index 544b61d46..2f371147f 100644 --- a/apps/desktop/preload/index.ts +++ b/apps/desktop/preload/index.ts @@ -20,12 +20,14 @@ const runtimeConfig = getDesktopRuntimeConfig(process.env, { resourcesPath: process.defaultApp ? undefined : process.resourcesPath, useBuildConfig: !process.defaultApp, }); +const webviewPreloadUrl = new URL("./webview-preload.js", import.meta.url).href; const hostBridge: HostBridge = { bootstrap: { buildInfo: runtimeConfig.buildInfo, sentryDsn: runtimeConfig.sentryDsn, isPackaged: !process.defaultApp, + webviewPreloadUrl, }, invoke( diff --git a/apps/desktop/preload/webview-preload.ts b/apps/desktop/preload/webview-preload.ts index 9f196d2af..8507ec08c 100644 --- a/apps/desktop/preload/webview-preload.ts +++ b/apps/desktop/preload/webview-preload.ts @@ -16,12 +16,14 @@ const runtimeConfig = getDesktopRuntimeConfig(process.env, { resourcesPath: process.defaultApp ? undefined : process.resourcesPath, useBuildConfig: !process.defaultApp, }); +const webviewPreloadUrl = new URL("./webview-preload.js", import.meta.url).href; const hostBridge: HostBridge = { bootstrap: { buildInfo: runtimeConfig.buildInfo, sentryDsn: runtimeConfig.sentryDsn, isPackaged: !process.defaultApp, + webviewPreloadUrl, }, invoke( diff --git a/apps/desktop/scripts/dev-cli.mjs b/apps/desktop/scripts/dev-cli.mjs deleted file mode 100644 index 785796714..000000000 --- a/apps/desktop/scripts/dev-cli.mjs +++ /dev/null @@ -1,820 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; -import { existsSync, openSync, readFileSync } from "node:fs"; -import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptPath = fileURLToPath(import.meta.url); -const scriptDir = resolve(scriptPath, ".."); -const appDir = resolve(scriptDir, ".."); -const rootDir = resolve(appDir, "../.."); -const tmpDir = resolve(rootDir, ".tmp"); -const runtimeRoot = resolve(tmpDir, "desktop"); -const logDir = resolve(tmpDir, "logs"); -const lockDir = resolve(tmpDir, "locks", "desktop-dev.lock"); -const lockInfoFile = resolve(lockDir, "owner.json"); -const logFile = resolve(logDir, "desktop-dev.log"); -const timelineFile = resolve(logDir, "desktop-startup-timeline.log"); -const managerDir = resolve(runtimeRoot, "manager"); -const stateFile = resolve(managerDir, "state.json"); -const defaultPorts = [18789, 50800, 50810]; -const cliFlags = new Set(process.argv.slice(3)); -const sidecarRoot = resolve(rootDir, ".tmp", "sidecars"); - -const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; -const gitCommand = process.platform === "win32" ? "git.exe" : "git"; -function createCommandSpec(command, args) { - if ( - process.platform === "win32" && - (command === "pnpm" || command === "pnpm.cmd") - ) { - return { - command: "cmd.exe", - args: ["/d", "/s", "/c", ["pnpm", ...args].join(" ")], - }; - } - - return { command, args }; -} - -function timestamp() { - return new Date().toISOString().replace("T", " ").replace("Z", ""); -} - -function stripTerminalControl(input) { - let result = ""; - - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - - if (code === 0x1b) { - const next = input[index + 1]; - - if (next === "[") { - index += 2; - while (index < input.length) { - const current = input.charCodeAt(index); - if (current >= 0x40 && current <= 0x7e) { - break; - } - index += 1; - } - continue; - } - - if (next === "]") { - index += 2; - while (index < input.length) { - const current = input.charCodeAt(index); - if (current === 0x07) { - break; - } - if (current === 0x1b && input[index + 1] === "\\") { - index += 1; - break; - } - index += 1; - } - continue; - } - - continue; - } - - if ( - code === 0x09 || - code === 0x0a || - code === 0x0d || - (code >= 0x20 && code !== 0x7f) - ) { - result += input[index]; - } - } - - return result; -} - -function isEnvFlagEnabled(name) { - const value = process.env[name]?.trim().toLowerCase(); - return value === "1" || value === "true" || value === "yes"; -} - -function shouldForceFullStart() { - return ( - cliFlags.has("--force-full-build") || - isEnvFlagEnabled("NEXU_DESKTOP_FORCE_FULL_START") - ); -} - -function shouldReuseBuildArtifacts() { - if (shouldForceFullStart()) { - return false; - } - - if (cliFlags.has("--no-reuse-build")) { - return false; - } - - return !isEnvFlagEnabled("NEXU_DESKTOP_DISABLE_BUILD_REUSE"); -} - -function createLauncherEnv() { - return { - ...process.env, - NO_COLOR: "1", - FORCE_COLOR: "0", - CLICOLOR: "0", - npm_config_color: "false", - ...(shouldForceFullStart() - ? { - NEXU_DESKTOP_FORCE_FULL_START: "1", - } - : {}), - }; -} - -function createWebBuildEnv() { - return { - ...createLauncherEnv(), - VITE_DESKTOP_PLATFORM: process.platform, - }; -} - -async function appendLine(filePath, message) { - await appendFile( - filePath, - `[${timestamp()}] ${stripTerminalControl(message)}\n`, - ); -} - -async function log(message) { - console.log(`[${timestamp()}] ${message}`); - await appendLine(logFile, message); -} - -async function runTimedPhase(label, action) { - const startedAt = Date.now(); - await log(`phase:start ${label}`); - try { - const result = await action(); - await log(`phase:done ${label} durationMs=${Date.now() - startedAt}`); - return result; - } catch (error) { - await log(`phase:fail ${label} durationMs=${Date.now() - startedAt}`); - throw error; - } -} - -async function logTimeline(message) { - await appendLine(timelineFile, message); -} - -async function ensureBaseDirs() { - await mkdir(logDir, { recursive: true }); - await mkdir(managerDir, { recursive: true }); - await mkdir(resolve(tmpDir, "locks"), { recursive: true }); -} - -function validateWorkspaceLayout() { - if ( - !existsSync(resolve(rootDir, "package.json")) || - !existsSync(resolve(appDir, "package.json")) - ) { - throw new Error( - [ - "[desktop-dev] invalid workspace layout detected", - "", - `NEXU_WORKSPACE_ROOT=${rootDir}`, - `NEXU_DESKTOP_APP_ROOT=${appDir}`, - ].join("\n"), - ); - } -} - -async function acquireLock() { - let announcedWait = false; - - while (true) { - try { - await mkdir(lockDir, { recursive: false }); - try { - await writeFile( - lockInfoFile, - `${JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }, null, 2)}\n`, - ); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" - ) { - continue; - } - throw error; - } - return; - } catch (error) { - if ( - !(error instanceof Error) || - !String(error.message).includes("EEXIST") - ) { - throw error; - } - - let ownerPid = null; - try { - const owner = JSON.parse(await readFile(lockInfoFile, "utf8")); - ownerPid = Number.isInteger(owner?.pid) ? owner.pid : null; - } catch {} - - if (!ownerPid || !isPidRunning(ownerPid)) { - await rm(lockDir, { recursive: true, force: true }); - continue; - } - - if (!announcedWait) { - await log(`waiting for desktop lock held by pid=${ownerPid}`); - announcedWait = true; - } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); - } - } -} - -async function removePathWithRetry(targetPath, retries = 5) { - for (let attempt = 0; attempt <= retries; attempt += 1) { - try { - await rm(targetPath, { recursive: true, force: true }); - return; - } catch (error) { - if (attempt === retries) { - throw error; - } - - await new Promise((resolvePromise) => setTimeout(resolvePromise, 150)); - } - } -} - -async function releaseLock() { - await rm(lockDir, { recursive: true, force: true }); -} - -async function withLock(action) { - await acquireLock(); - try { - return await action(); - } finally { - await releaseLock(); - } -} - -async function readState() { - try { - return JSON.parse(await readFile(stateFile, "utf8")); - } catch { - return null; - } -} - -async function writeState(state) { - await mkdir(managerDir, { recursive: true }); - await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`); -} - -async function removeState() { - await rm(stateFile, { force: true }); -} - -function hasReusableArtifacts() { - const requiredPaths = [ - resolve(rootDir, "packages/shared/dist/index.js"), - resolve(rootDir, "apps/controller/dist/index.js"), - resolve(rootDir, "apps/web/dist/index.html"), - resolve(appDir, "dist/index.html"), - resolve(appDir, "dist-electron/main/bootstrap.js"), - resolve(rootDir, ".tmp/sidecars/controller/dist/index.js"), - resolve( - rootDir, - ".tmp/sidecars/openclaw/node_modules/openclaw/openclaw.mjs", - ), - resolve(rootDir, ".tmp/sidecars/web/index.js"), - ]; - - return requiredPaths.every((filePath) => existsSync(filePath)); -} - -function runCapture(command, args, options = {}) { - const commandSpec = createCommandSpec(command, args); - const result = spawnSync(commandSpec.command, commandSpec.args, { - cwd: options.cwd ?? rootDir, - env: options.env ?? process.env, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (result.error) { - throw result.error; - } - - return { - status: result.status ?? 1, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - }; -} - -function isPidRunning(pid) { - if (!Number.isInteger(pid) || pid <= 0) { - return false; - } - - if (process.platform === "win32") { - const result = spawnSync( - "tasklist", - ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }, - ); - return result.status === 0 && result.stdout.includes(`"${pid}"`); - } - - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function killPid(pid) { - if (!Number.isInteger(pid) || pid <= 0) { - return; - } - - if (process.platform === "win32") { - spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { - stdio: "ignore", - }); - return; - } - - try { - process.kill(-pid, "SIGKILL"); - } catch { - try { - process.kill(pid, "SIGKILL"); - } catch {} - } -} - -function listListeningPids(ports) { - if (process.platform === "win32") { - const result = spawnSync("netstat", ["-ano", "-p", "tcp"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - if (result.status !== 0) { - return []; - } - - const targetPorts = new Set(ports.map(String)); - const pids = new Set(); - for (const rawLine of result.stdout.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line.startsWith("TCP")) { - continue; - } - - const columns = line.split(/\s+/u); - if (columns.length < 5 || columns[3] !== "LISTENING") { - continue; - } - - const localAddress = columns[1]; - const port = localAddress.split(":").at(-1); - if (!port || !targetPorts.has(port)) { - continue; - } - - const pid = Number.parseInt(columns[4], 10); - if (Number.isInteger(pid) && pid > 0) { - pids.add(pid); - } - } - - return [...pids]; - } - - const pids = new Set(); - for (const port of ports) { - const result = spawnSync( - "lsof", - [`-tiTCP:${String(port)}`, "-sTCP:LISTEN"], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }, - ); - if (result.status !== 0) { - continue; - } - for (const line of result.stdout.split(/\r?\n/u)) { - const pid = Number.parseInt(line.trim(), 10); - if (Number.isInteger(pid) && pid > 0) { - pids.add(pid); - } - } - } - return [...pids]; -} - -function readGitValue(args, fallback) { - try { - const result = runCapture(gitCommand, ["-C", rootDir, ...args]); - const value = result.stdout.trim(); - return result.status === 0 && value ? value : fallback; - } catch { - return fallback; - } -} - -async function runLogged(command, args, options = {}) { - const startedAt = Date.now(); - await log(`run:start ${command} ${args.join(" ")}`); - await new Promise((resolvePromise, rejectPromise) => { - const commandSpec = createCommandSpec(command, args); - const child = spawn(commandSpec.command, commandSpec.args, { - cwd: options.cwd ?? rootDir, - env: options.env ?? process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - - child.stdout.on("data", async (chunk) => { - const text = chunk.toString(); - process.stdout.write(text); - await appendFile(logFile, stripTerminalControl(text)); - }); - - child.stderr.on("data", async (chunk) => { - const text = chunk.toString(); - process.stderr.write(text); - await appendFile(logFile, stripTerminalControl(text)); - }); - - child.once("error", rejectPromise); - child.once("exit", (code) => { - if (code === 0) { - void log( - `run:done ${command} ${args.join(" ")} durationMs=${Date.now() - startedAt}`, - ); - resolvePromise(); - return; - } - void log( - `run:fail ${command} ${args.join(" ")} exit=${code ?? "null"} durationMs=${Date.now() - startedAt}`, - ); - rejectPromise( - new Error( - `Command failed: ${command} ${args.join(" ")} (exit ${code ?? "null"})`, - ), - ); - }); - }); -} - -function createDesktopEnv(launchId) { - return { - ...process.env, - NEXU_WORKSPACE_ROOT: rootDir, - NEXU_DESKTOP_APP_ROOT: appDir, - NEXU_DESKTOP_RUNTIME_ROOT: runtimeRoot, - NEXU_DESKTOP_BUILD_SOURCE: - process.env.NEXU_DESKTOP_BUILD_SOURCE ?? "local-dev", - NEXU_DESKTOP_BUILD_BRANCH: - process.env.NEXU_DESKTOP_BUILD_BRANCH ?? - readGitValue(["rev-parse", "--abbrev-ref", "HEAD"], "unknown"), - NEXU_DESKTOP_BUILD_COMMIT: - process.env.NEXU_DESKTOP_BUILD_COMMIT ?? - readGitValue(["rev-parse", "HEAD"], "unknown"), - NEXU_DESKTOP_BUILD_TIME: - process.env.NEXU_DESKTOP_BUILD_TIME ?? new Date().toISOString(), - NEXU_DESKTOP_LAUNCH_ID: launchId, - }; -} - -function ensureDarwinLsuiElement() { - if (process.platform !== "darwin") { - return; - } - - try { - const electronExec = runCapture( - pnpmCommand, - [ - "--dir", - rootDir, - "exec", - "node", - "-e", - 'const electron=require("electron"); process.stdout.write(electron)', - ], - { env: process.env }, - ).stdout.trim(); - if (!electronExec || !electronExec.endsWith("/Contents/MacOS/Electron")) { - return; - } - const electronApp = electronExec.slice( - 0, - -"/Contents/MacOS/Electron".length, - ); - const infoPlist = resolve(electronApp, "Contents/Info.plist"); - if (!existsSync(infoPlist)) { - return; - } - spawnSync( - "/usr/libexec/PlistBuddy", - ["-c", "Set :LSUIElement true", infoPlist], - { - stdio: "ignore", - }, - ); - spawnSync( - "/usr/libexec/PlistBuddy", - ["-c", "Add :LSUIElement bool true", infoPlist], - { - stdio: "ignore", - }, - ); - } catch {} -} - -async function killResidualProcesses() { - await runTimedPhase("kill_residual_processes", async () => { - await log("killing residual processes"); - - const state = await readState(); - if (state?.electronPid) { - await killPid(state.electronPid); - } - - const portPids = listListeningPids(defaultPorts); - for (const pid of portPids) { - await killPid(pid); - } - - await removeState(); - }); -} - -async function buildRuntime() { - const launcherEnv = createLauncherEnv(); - const webBuildEnv = createWebBuildEnv(); - - await runTimedPhase("build_runtime", async () => { - await log("building runtime artifacts"); - await logTimeline("build_runtime shared build start"); - await runLogged( - pnpmCommand, - ["--dir", rootDir, "--filter", "@nexu/shared", "build"], - { env: launcherEnv }, - ); - await logTimeline("build_runtime controller build start"); - await runLogged( - pnpmCommand, - ["--dir", rootDir, "--filter", "@nexu/controller", "build"], - { env: launcherEnv }, - ); - await logTimeline("build_runtime web build start"); - await runLogged( - pnpmCommand, - ["--dir", rootDir, "--filter", "@nexu/web", "build"], - { env: webBuildEnv }, - ); - await logTimeline("build_runtime controller sidecar start"); - await runLogged( - pnpmCommand, - ["--dir", appDir, "prepare:controller-sidecar"], - { env: launcherEnv }, - ); - await logTimeline("build_runtime openclaw sidecar start"); - await runLogged( - pnpmCommand, - ["--dir", appDir, "prepare:openclaw-sidecar"], - { env: launcherEnv }, - ); - await logTimeline("build_runtime web sidecar start"); - await runLogged(pnpmCommand, ["--dir", appDir, "prepare:web-sidecar"], { - env: launcherEnv, - }); - await logTimeline("build_runtime desktop build start"); - await runLogged(pnpmCommand, ["--dir", appDir, "build"], { - env: launcherEnv, - }); - - if (!process.env.SENTRY_AUTH_TOKEN?.trim()) { - await log( - "skipping desktop sourcemap upload because SENTRY_AUTH_TOKEN is unset", - ); - } else { - try { - await logTimeline("build_runtime upload sourcemaps start"); - await runLogged(pnpmCommand, ["--dir", appDir, "upload:sourcemaps"], { - env: { - ...launcherEnv, - ...createDesktopEnv("desktop-build-metadata"), - }, - }); - } catch { - await log( - "warning: desktop sourcemap upload failed; continuing startup", - ); - } - } - - await logTimeline("build_runtime complete"); - }); -} - -async function startSession() { - await runTimedPhase("start_session", async () => { - const launchId = `desktop-launch-${Date.now()}`; - const env = createDesktopEnv(launchId); - - await log(`start_session launchId=${launchId}`); - ensureDarwinLsuiElement(); - - await logTimeline(`launch electron requested launch_id=${launchId}`); - const stdoutFd = openSync(logFile, "a"); - const commandSpec = createCommandSpec(pnpmCommand, [ - "exec", - "electron", - "apps/desktop", - ]); - const child = spawn(commandSpec.command, commandSpec.args, { - cwd: rootDir, - env, - detached: true, - stdio: ["ignore", stdoutFd, stdoutFd], - }); - child.unref(); - await log(`start_session spawned pid=${child.pid ?? "unknown"}`); - - await writeState({ - launchId, - electronPid: child.pid ?? null, - startedAt: new Date().toISOString(), - runtimeRoot, - platform: process.platform, - }); - await logTimeline( - `background process started launch_id=${launchId} pid=${child.pid ?? "unknown"}`, - ); - await log(`started desktop process pid=${child.pid ?? "unknown"}`); - }); -} - -async function start() { - await runTimedPhase("start", async () => { - await withLock(async () => { - validateWorkspaceLayout(); - const state = await readState(); - if (state?.electronPid && isPidRunning(state.electronPid)) { - await log( - `desktop process is already running pid=${state.electronPid}`, - ); - return; - } - - await killResidualProcesses(); - if (shouldReuseBuildArtifacts() && hasReusableArtifacts()) { - await log("reusing existing build artifacts"); - } else { - if (shouldForceFullStart()) { - await log("full desktop rebuild forced by CLI/environment"); - } else if (shouldReuseBuildArtifacts()) { - await log( - "build reuse enabled but artifacts are incomplete; running full build", - ); - } else { - await log( - "build reuse disabled by CLI/environment; running full build", - ); - } - await buildRuntime(); - } - await startSession(); - }); - }); -} - -async function stop() { - await runTimedPhase("stop", async () => { - await withLock(async () => { - validateWorkspaceLayout(); - await killResidualProcesses(); - await log("stopped desktop process"); - }); - }); -} - -async function resetState() { - await runTimedPhase("reset_state", async () => { - await stop(); - await removePathWithRetry(runtimeRoot); - await removePathWithRetry(sidecarRoot); - await removePathWithRetry(lockDir); - await removeState(); - await log( - `reset desktop runtime state at '${runtimeRoot}' and cleared cached sidecars at '${sidecarRoot}'`, - ); - }); -} - -async function restart() { - await stop(); - await start(); -} - -async function status() { - validateWorkspaceLayout(); - const state = await readState(); - if (state?.electronPid && isPidRunning(state.electronPid)) { - console.log( - `[${timestamp()}] desktop process is running pid=${state.electronPid}`, - ); - } else { - console.log(`[${timestamp()}] desktop process is not running`); - } - - const portPids = listListeningPids(defaultPorts); - if (portPids.length > 0) { - console.log(`listening pids: ${portPids.join(", ")}`); - } -} - -function readLastLines(filePath, limit) { - if (!existsSync(filePath)) { - return ""; - } - - const content = readFileSync(filePath, "utf8"); - return content.split(/\r?\n/u).slice(-limit).join("\n"); -} - -async function logs() { - const output = readLastLines(logFile, 200); - if (output) { - process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); - } -} - -async function devlog() { - await logs(); -} - -async function control() { - const target = `file://${join(appDir, "dist", "index.html")}`; - if (process.platform === "win32") { - spawn("cmd", ["/c", "start", "", target], { - detached: true, - stdio: "ignore", - }).unref(); - return; - } - const opener = process.platform === "darwin" ? "open" : "xdg-open"; - spawn(opener, [target], { detached: true, stdio: "ignore" }).unref(); -} - -const command = process.argv[2] ?? "start"; -const commandMap = { - start, - stop, - restart, - "reset-state": resetState, - status, - logs, - devlog, - control, -}; - -try { - await ensureBaseDirs(); - const action = commandMap[command]; - if (!action) { - console.error( - "Usage: node apps/desktop/scripts/dev-cli.mjs ", - ); - process.exit(1); - } - await action(); -} catch (error) { - const message = - error instanceof Error ? (error.stack ?? error.message) : String(error); - await ensureBaseDirs(); - await appendLine(logFile, `fatal: ${message}`); - console.error(message); - process.exit(1); -} diff --git a/apps/desktop/scripts/dist-mac.mjs b/apps/desktop/scripts/dist-mac.mjs index 0a9fcd173..2984bd747 100644 --- a/apps/desktop/scripts/dist-mac.mjs +++ b/apps/desktop/scripts/dist-mac.mjs @@ -13,6 +13,11 @@ import { import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { createMacBuildCapabilities } from "./platforms/mac/build-capabilities.mjs"; +import { + createDesktopBuildContext, + getSharedBuildSteps, +} from "./platforms/shared/build-capabilities.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const electronRoot = resolve(scriptDir, ".."); @@ -498,51 +503,31 @@ async function main() { await ensureBuildConfig(); const desktopEnv = await loadDesktopEnv(); - const env = { - ...process.env, - ...desktopEnv, - NEXU_WORKSPACE_ROOT: repoRoot, - }; - const releaseRoot = env.NEXU_DESKTOP_RELEASE_DIR - ? resolve(env.NEXU_DESKTOP_RELEASE_DIR) - : resolve(electronRoot, "release"); - const { - APPLE_ID: appleId, - APPLE_APP_SPECIFIC_PASSWORD: appleAppSpecificPassword, - APPLE_TEAM_ID: appleTeamId, - ...notarizeEnv - } = env; - - if (appleId) { - notarizeEnv.NEXU_APPLE_ID = appleId; - } - - if (appleAppSpecificPassword) { - notarizeEnv.NEXU_APPLE_APP_SPECIFIC_PASSWORD = appleAppSpecificPassword; - } - - if (appleTeamId) { - notarizeEnv.NEXU_APPLE_TEAM_ID = appleTeamId; - } - - await rm(releaseRoot, rmWithRetriesOptions); - await rm(resolve(electronRoot, ".dist-runtime"), rmWithRetriesOptions); - - await run("pnpm", ["--dir", repoRoot, "--filter", "@nexu/shared", "build"], { - env, - }); - await run( - "pnpm", - ["--dir", repoRoot, "--filter", "@nexu/controller", "build"], - { - env, + const buildContext = createDesktopBuildContext({ + electronRoot, + repoRoot, + processEnv: { + ...process.env, + ...desktopEnv, }, - ); - await run("pnpm", ["--dir", repoRoot, "openclaw-runtime:install"], { + }); + const env = buildContext.env; + const releaseRoot = buildContext.resolveReleaseRoot(); + const buildCapabilities = createMacBuildCapabilities({ env, + releaseRoot, + targetMacArch, + isUnsigned, }); + + await rm(releaseRoot, rmWithRetriesOptions); + await rm(buildContext.resolveRuntimeDistRoot(), rmWithRetriesOptions); + + for (const [command, args] of getSharedBuildSteps({ repoRoot })) { + await run(command, args, { env }); + } await run("pnpm", ["--dir", repoRoot, "--filter", "@nexu/web", "build"], { - env, + env: buildCapabilities.webBuildEnv, }); await run("pnpm", ["run", "build"], { cwd: electronRoot, env }); await run("node", [resolve(scriptDir, "upload-sourcemaps.mjs")], { @@ -554,10 +539,7 @@ async function main() { [resolve(scriptDir, "prepare-runtime-sidecars.mjs"), "--release"], { cwd: electronRoot, - env: { - ...env, - ...(isUnsigned ? { NEXU_DESKTOP_MAC_UNSIGNED: "true" } : {}), - }, + env: buildCapabilities.sidecarReleaseEnv, }, ); env.CUSTOM_DMGBUILD_PATH = await ensureDmgbuildBundle(); @@ -578,27 +560,13 @@ async function main() { } await runElectronBuilder( - [ - "--mac", - `--${targetMacArch}`, - "--publish", - "never", - `--config.electronVersion=${electronVersion}`, - `--config.buildVersion=${buildVersion}`, - `--config.directories.output=${releaseRoot}`, - ...(isUnsigned - ? ["--config.mac.identity=null", "--config.mac.hardenedRuntime=false"] - : []), - ], + buildCapabilities.createElectronBuilderArgs({ + electronVersion, + buildVersion, + }), { cwd: electronRoot, - env: isUnsigned - ? { - ...notarizeEnv, - CSC_IDENTITY_AUTO_DISCOVERY: "false", - NEXU_DESKTOP_MAC_UNSIGNED: "true", - } - : notarizeEnv, + env: buildCapabilities.createElectronBuilderEnv(), }, ); await stapleNotarizedAppBundles(); diff --git a/apps/desktop/scripts/dist-win.mjs b/apps/desktop/scripts/dist-win.mjs index cc1e9e376..1cfb8f06a 100644 --- a/apps/desktop/scripts/dist-win.mjs +++ b/apps/desktop/scripts/dist-win.mjs @@ -3,6 +3,11 @@ import { cp, lstat, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { createWindowsBuildCapabilities } from "./platforms/win/build-capabilities.mjs"; +import { + createDesktopBuildContext, + getSharedBuildSteps, +} from "./platforms/shared/build-capabilities.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const electronRoot = resolve(scriptDir, ".."); @@ -12,13 +17,6 @@ const desktopPackageJsonPath = resolve(electronRoot, "package.json"); const require = createRequire(import.meta.url); const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; -function createWebBuildEnv(baseEnv) { - return { - ...baseEnv, - VITE_DESKTOP_PLATFORM: process.platform, - }; -} - function createCommandSpec(command, args) { if ( process.platform === "win32" && @@ -251,35 +249,29 @@ async function getWindowsBuildVersion() { async function main() { const rawArgs = new Set(process.argv.slice(2)); const dirOnly = rawArgs.has("--dir-only") || rawArgs.has("--target=dir"); - const env = { - ...process.env, - NEXU_WORKSPACE_ROOT: repoRoot, - }; - const webBuildEnv = createWebBuildEnv(env); - const releaseRoot = process.env.NEXU_DESKTOP_RELEASE_DIR - ? resolve(process.env.NEXU_DESKTOP_RELEASE_DIR) - : resolve(electronRoot, "release"); + const buildContext = createDesktopBuildContext({ + electronRoot, + repoRoot, + processEnv: process.env, + }); + const env = buildContext.env; + const releaseRoot = buildContext.resolveReleaseRoot(); + const buildCapabilities = createWindowsBuildCapabilities({ + env, + releaseRoot, + processPlatform: process.platform, + }); await rm(releaseRoot, rmWithRetriesOptions); - await rm(resolve(electronRoot, ".dist-runtime"), rmWithRetriesOptions); + await rm(buildContext.resolveRuntimeDistRoot(), rmWithRetriesOptions); - await run( - pnpmCommand, - ["--dir", repoRoot, "--filter", "@nexu/shared", "build"], - { env }, - ); - await run( - pnpmCommand, - ["--dir", repoRoot, "--filter", "@nexu/controller", "build"], - { env }, - ); - await run(pnpmCommand, ["--dir", repoRoot, "openclaw-runtime:install"], { - env, - }); + for (const [command, args] of getSharedBuildSteps({ repoRoot })) { + await run(command === "pnpm" ? pnpmCommand : command, args, { env }); + } await run( pnpmCommand, ["--dir", repoRoot, "--filter", "@nexu/web", "build"], - { env: webBuildEnv }, + { env: buildCapabilities.webBuildEnv }, ); await run(pnpmCommand, ["run", "build"], { cwd: electronRoot, env }); await run( @@ -287,7 +279,7 @@ async function main() { [resolve(scriptDir, "prepare-runtime-sidecars.mjs"), "--release"], { cwd: electronRoot, - env, + env: buildCapabilities.sidecarReleaseEnv, }, ); await ensureBuildConfig(); @@ -297,22 +289,14 @@ async function main() { const buildVersion = await getWindowsBuildVersion(); await runElectronBuilder( - [ - "--win", - ...(dirOnly ? ["dir"] : ["nsis", "dir"]), - "--publish", - "never", - `--config.electronVersion=${electronVersion}`, - `--config.buildVersion=${buildVersion}`, - `--config.directories.output=${releaseRoot}`, - ...(dirOnly ? ["--config.win.signAndEditExecutable=false"] : []), - ], + buildCapabilities.createElectronBuilderArgs({ + electronVersion, + buildVersion, + dirOnly, + }), { cwd: electronRoot, - env: { - ...env, - CSC_IDENTITY_AUTO_DISCOVERY: "false", - }, + env: buildCapabilities.createElectronBuilderEnv(), }, ); } diff --git a/apps/desktop/scripts/platforms/mac/build-capabilities.mjs b/apps/desktop/scripts/platforms/mac/build-capabilities.mjs new file mode 100644 index 000000000..5635af211 --- /dev/null +++ b/apps/desktop/scripts/platforms/mac/build-capabilities.mjs @@ -0,0 +1,63 @@ +export function createMacBuildCapabilities({ + env, + releaseRoot, + targetMacArch, + isUnsigned, +}) { + const { + APPLE_ID: appleId, + APPLE_APP_SPECIFIC_PASSWORD: appleAppSpecificPassword, + APPLE_TEAM_ID: appleTeamId, + ...notarizeEnv + } = env; + + if (appleId) { + notarizeEnv.NEXU_APPLE_ID = appleId; + } + + if (appleAppSpecificPassword) { + notarizeEnv.NEXU_APPLE_APP_SPECIFIC_PASSWORD = appleAppSpecificPassword; + } + + if (appleTeamId) { + notarizeEnv.NEXU_APPLE_TEAM_ID = appleTeamId; + } + + return { + platformId: "mac", + artifactLayout: { + primaryTargets: ["dmg", "zip"], + appBundleDirPrefix: "mac", + arch: targetMacArch, + }, + webBuildEnv: env, + sidecarReleaseEnv: { + ...env, + ...(isUnsigned ? { NEXU_DESKTOP_MAC_UNSIGNED: "true" } : {}), + }, + notarizeEnv, + createElectronBuilderArgs({ electronVersion, buildVersion }) { + return [ + "--mac", + `--${targetMacArch}`, + "--publish", + "never", + `--config.electronVersion=${electronVersion}`, + `--config.buildVersion=${buildVersion}`, + `--config.directories.output=${releaseRoot}`, + ...(isUnsigned + ? ["--config.mac.identity=null", "--config.mac.hardenedRuntime=false"] + : []), + ]; + }, + createElectronBuilderEnv() { + return isUnsigned + ? { + ...notarizeEnv, + CSC_IDENTITY_AUTO_DISCOVERY: "false", + NEXU_DESKTOP_MAC_UNSIGNED: "true", + } + : notarizeEnv; + }, + }; +} diff --git a/apps/desktop/scripts/platforms/shared/build-capabilities.mjs b/apps/desktop/scripts/platforms/shared/build-capabilities.mjs new file mode 100644 index 000000000..0d4deb311 --- /dev/null +++ b/apps/desktop/scripts/platforms/shared/build-capabilities.mjs @@ -0,0 +1,44 @@ +import { resolve } from "node:path"; + +export function createDesktopBuildContext({ + electronRoot, + repoRoot, + processEnv = process.env, +}) { + const env = { + ...processEnv, + NEXU_WORKSPACE_ROOT: repoRoot, + }; + + return { + electronRoot, + repoRoot, + env, + resolveReleaseRoot(customReleaseDir = env.NEXU_DESKTOP_RELEASE_DIR) { + return customReleaseDir + ? resolve(customReleaseDir) + : resolve(electronRoot, "release"); + }, + resolveRuntimeDistRoot() { + return resolve(electronRoot, ".dist-runtime"); + }, + }; +} + +export function createDesktopWebBuildEnv(baseEnv, platform) { + return { + ...baseEnv, + VITE_DESKTOP_PLATFORM: platform, + }; +} + +export function getSharedBuildSteps({ repoRoot }) { + return [ + ["pnpm", ["--dir", repoRoot, "--filter", "@nexu/shared", "build"]], + [ + "pnpm", + ["--dir", repoRoot, "--filter", "@nexu/controller", "build"], + ], + ["pnpm", ["--dir", repoRoot, "openclaw-runtime:install"]], + ]; +} diff --git a/apps/desktop/scripts/platforms/win/build-capabilities.mjs b/apps/desktop/scripts/platforms/win/build-capabilities.mjs new file mode 100644 index 000000000..b22a8cd93 --- /dev/null +++ b/apps/desktop/scripts/platforms/win/build-capabilities.mjs @@ -0,0 +1,31 @@ +import { createDesktopWebBuildEnv } from "../shared/build-capabilities.mjs"; + +export function createWindowsBuildCapabilities({ env, releaseRoot, processPlatform }) { + return { + platformId: "win", + artifactLayout: { + primaryTargets: ["nsis", "dir"], + unpackedDirName: "win-unpacked", + }, + webBuildEnv: createDesktopWebBuildEnv(env, processPlatform), + sidecarReleaseEnv: env, + createElectronBuilderArgs({ electronVersion, buildVersion, dirOnly }) { + return [ + "--win", + ...(dirOnly ? ["dir"] : this.artifactLayout.primaryTargets), + "--publish", + "never", + `--config.electronVersion=${electronVersion}`, + `--config.buildVersion=${buildVersion}`, + `--config.directories.output=${releaseRoot}`, + ...(dirOnly ? ["--config.win.signAndEditExecutable=false"] : []), + ]; + }, + createElectronBuilderEnv() { + return { + ...env, + CSC_IDENTITY_AUTO_DISCOVERY: "false", + }; + }, + }; +} diff --git a/apps/desktop/shared/host.ts b/apps/desktop/shared/host.ts index 95fd56c1c..7ffe81778 100644 --- a/apps/desktop/shared/host.ts +++ b/apps/desktop/shared/host.ts @@ -453,6 +453,7 @@ export type RuntimeUnitKind = "surface" | "service" | "runtime"; export type RuntimeUnitLaunchStrategy = | "embedded" | "managed" + | "external" | "delegated" | "launchd"; @@ -486,7 +487,9 @@ export type RuntimeReasonCode = | "launchd_stopped" | "launchd_start_requested" | "launchd_stop_requested" - | "launchd_log_line"; + | "launchd_log_line" + | "external_available" + | "external_unavailable"; export type RuntimeLogEntry = { id: string; @@ -541,6 +544,7 @@ export type HostBootstrap = { buildInfo: DesktopBuildInfo; sentryDsn: string | null; isPackaged: boolean; + webviewPreloadUrl: string; }; export type UpdateSource = "r2" | "github"; diff --git a/apps/desktop/shared/runtime-config.ts b/apps/desktop/shared/runtime-config.ts index 55f7dac67..2bfa347ad 100644 --- a/apps/desktop/shared/runtime-config.ts +++ b/apps/desktop/shared/runtime-config.ts @@ -174,6 +174,7 @@ function parseEnvBoolean(value: string | undefined): boolean | null { } export type DesktopRuntimeConfig = { + runtimeMode: "internal" | "external"; buildInfo: DesktopBuildInfo; updates: { autoUpdateEnabled: boolean; @@ -213,6 +214,12 @@ export function getDesktopRuntimeConfig( useBuildConfig?: boolean; }, ): DesktopRuntimeConfig { + const runtimeMode = + env.NEXU_DESKTOP_RUNTIME_MODE === "external" || + env.NEXU_DESKTOP_EXTERNAL_RUNTIME === "1" || + env.NEXU_DESKTOP_EXTERNAL_RUNTIME?.toLowerCase() === "true" + ? "external" + : "internal"; const buildConfig = defaults?.useBuildConfig === false ? {} @@ -256,6 +263,7 @@ export function getDesktopRuntimeConfig( }; return { + runtimeMode, buildInfo: { version: defaults?.appVersion ?? diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 0ab651cc8..a192b7b88 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -3,7 +3,7 @@ import { Identify } from "@amplitude/unified"; import * as Sentry from "@sentry/electron/renderer"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React, { useCallback, useEffect, useMemo, useState } from "react"; -import ReactDOM from "react-dom/client"; +import { type Root, createRoot } from "react-dom/client"; import { Toaster, toast } from "sonner"; import type { AppInfo, @@ -293,10 +293,7 @@ function SummaryCard({ } function getWebviewPreloadUrl(): string { - return new URL( - "../dist-electron/preload/webview-preload.js", - document.location.href, - ).href; + return window.nexuHost.bootstrap.webviewPreloadUrl; } // SurfaceFrame is imported from the shared component — see components/surface-frame.tsx @@ -1038,7 +1035,7 @@ function DesktopShell() {