Commit f798e1f7 authored by edy's avatar edy

feat: prepare macOS DMG runtime packaging

parent f67771e8
Pipeline #18444 failed
...@@ -109,3 +109,13 @@ You MUST follow these rules: ...@@ -109,3 +109,13 @@ You MUST follow these rules:
- Long explanations - Long explanations
- Extra background info - Extra background info
- Covering multiple directions at once - Covering multiple directions at once
## OpenAI Responses Tool-Call Safety
Avoid the API error `No tool call found for function call output with call_id ...` by treating tool calls as a strict request/response state machine.
- Only send a `function_call_output` for a `call_id` that appeared in the immediately preceding model response.
- Send each tool result exactly once. Never replay old `call_id` values after retrying, resuming, or branching a conversation.
- Do not mix `call_id` values across different Responses, sessions, tabs, workers, or parallel agent branches.
- When continuing a Responses API chain, keep the correct `previous_response_id` and append only the pending tool outputs for that response.
- On retry after a transport or 400 error, rebuild the request from the last confirmed model response instead of reusing buffered tool outputs blindly.
appId: com.qianjiangclaw.desktop appId: com.qianjiangclaw.desktop
productName: 千匠问天 productName: 千匠问天
icon: build/icon.png
compression: store compression: store
asar: true asar: true
asarUnpack: asarUnpack:
...@@ -7,8 +8,7 @@ asarUnpack: ...@@ -7,8 +8,7 @@ asarUnpack:
- dist/main/project-workspace-agent-runner.js - dist/main/project-workspace-agent-runner.js
directories: directories:
output: ../../dist/installer output: ../../dist/installer
artifactName: ${productName}-Setup-${version}.${ext} artifactName: ${productName}-${version}-mac-arm64.${ext}
afterPack: build/hooks/after-pack-branding.cjs
files: files:
- dist/**/* - dist/**/*
- package.json - package.json
...@@ -19,15 +19,21 @@ extraResources: ...@@ -19,15 +19,21 @@ extraResources:
to: bootstrap to: bootstrap
- from: assets/expert-prompts - from: assets/expert-prompts
to: expert-prompts to: expert-prompts
- from: build/icons/brand-icon.ico mac:
to: brand-icon.ico category: public.app-category.productivity
win: icon: build/icon.png
executableName: 千匠问天 identity: null
icon: build/icons/brand-icon.ico
signAndEditExecutable: false
target: target:
- nsis - target: dmg
nsis: arch:
oneClick: false - arm64
allowToChangeInstallationDirectory: true dmg:
include: build/installer.nsh title: ${productName} ${version}
contents:
- x: 130
y: 220
type: file
- x: 410
y: 220
type: link
path: /Applications
...@@ -12,7 +12,8 @@ ...@@ -12,7 +12,8 @@
"dev:build": "tsup --config tsup.config.ts --watch", "dev:build": "tsup --config tsup.config.ts --watch",
"dev:start": "wait-on tcp:5173 file:dist/main/index.js && electronmon .", "dev:start": "wait-on tcp:5173 file:dist/main/index.js && electronmon .",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"package": "corepack pnpm --dir ../.. run materialize:runtime && corepack pnpm run build && electron-builder --config electron-builder.yml", "package": "corepack pnpm run package:mac",
"package:mac": "corepack pnpm --dir ../.. run materialize:runtime:mac && corepack pnpm run build && electron-builder --config electron-builder.yml --mac dmg --arm64",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
......
...@@ -10,16 +10,37 @@ export function resolveRendererEntry(): string { ...@@ -10,16 +10,37 @@ export function resolveRendererEntry(): string {
return path.join(app.getAppPath(), "dist", "renderer", "index.html"); return path.join(app.getAppPath(), "dist", "renderer", "index.html");
} }
function resolveWindowIcon(): string | undefined { function buildApplicationMenu(): Menu {
return process.platform === "win32" const appName = app.getName();
? (app.isPackaged return Menu.buildFromTemplate([
? path.join(process.resourcesPath, "brand-icon.ico") {
: path.join(app.getAppPath(), "build", "icons", "brand-icon.ico")) label: appName,
: undefined; submenu: [
{ role: "about", label: `About ${appName}` },
{ type: "separator" },
{ role: "quit", label: `Quit ${appName}`, accelerator: "Cmd+Q" }
]
},
{
label: "Edit",
submenu: [
{ role: "copy", accelerator: "Cmd+C" },
{ role: "paste", accelerator: "Cmd+V" },
{ role: "selectAll", accelerator: "Cmd+A" }
]
},
{
label: "Window",
submenu: [
{ role: "minimize", accelerator: "Cmd+M" },
{ role: "close", accelerator: "Cmd+W" }
]
}
]);
} }
export function createMainWindow(smokeEnabled = false): BrowserWindow { export function createMainWindow(smokeEnabled = false): BrowserWindow {
Menu.setApplicationMenu(null); Menu.setApplicationMenu(buildApplicationMenu());
const preloadPath = path.join(__dirname, "..", "preload", "index.js"); const preloadPath = path.join(__dirname, "..", "preload", "index.js");
return new BrowserWindow({ return new BrowserWindow({
width: 1400, width: 1400,
...@@ -28,7 +49,6 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow { ...@@ -28,7 +49,6 @@ export function createMainWindow(smokeEnabled = false): BrowserWindow {
minHeight: 640, minHeight: 640,
backgroundColor: "#f4f8ff", backgroundColor: "#f4f8ff",
autoHideMenuBar: true, autoHideMenuBar: true,
icon: resolveWindowIcon(),
webPreferences: { webPreferences: {
additionalArguments: smokeEnabled ? ["--qjc-smoke"] : [], additionalArguments: smokeEnabled ? ["--qjc-smoke"] : [],
contextIsolation: true, contextIsolation: true,
......
...@@ -69,19 +69,6 @@ interface ResolvedProjectAutomationCommand { ...@@ -69,19 +69,6 @@ interface ResolvedProjectAutomationCommand {
const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t"; const EVENT_PREFIX = "QJC_WORKSPACE_EVENT\t";
function escapePowerShellSingleQuoted(value: string): string {
return value.replace(/'/g, "''");
}
function getWindowsPowerShellPath(): string {
if (process.platform !== "win32") {
return "powershell.exe";
}
const systemRoot = process.env.SYSTEMROOT ?? process.env.WINDIR ?? "C:\\Windows";
return path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
}
function toErrorMessage(error: unknown): string { function toErrorMessage(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
return error.message; return error.message;
...@@ -102,12 +89,6 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde ...@@ -102,12 +89,6 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde
.split(path.delimiter) .split(path.delimiter)
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter(Boolean); .filter(Boolean);
const windowsExtensions = process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
: [""];
for (const commandName of commandNames) { for (const commandName of commandNames) {
const trimmed = commandName.trim(); const trimmed = commandName.trim();
...@@ -119,16 +100,10 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde ...@@ -119,16 +100,10 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde
return trimmed; return trimmed;
} }
const extensionCandidates = path.extname(trimmed)
? [""]
: windowsExtensions;
for (const directory of pathEntries) { for (const directory of pathEntries) {
for (const extension of extensionCandidates) { const candidate = path.join(directory, trimmed);
const candidate = path.join(directory, `${trimmed}${extension}`); if (existsSync(candidate)) {
if (existsSync(candidate)) { return candidate;
return candidate;
}
} }
} }
} }
...@@ -339,8 +314,8 @@ export class ProjectWorkspaceExecutorService { ...@@ -339,8 +314,8 @@ export class ProjectWorkspaceExecutorService {
let stderr = ""; let stderr = "";
let stdoutBuffer = ""; let stdoutBuffer = "";
let activeRunId = runId; let activeRunId = runId;
const resolvedFfmpeg = resolveInjectedBinaryPath("FFMPEG_BIN", paths.ffmpegExecutable, ["ffmpeg.exe", "ffmpeg"]); const resolvedFfmpeg = resolveInjectedBinaryPath("FFMPEG_BIN", paths.ffmpegExecutable, ["ffmpeg"]);
const resolvedFfprobe = resolveInjectedBinaryPath("FFPROBE_BIN", paths.ffprobeExecutable, ["ffprobe.exe", "ffprobe"]); const resolvedFfprobe = resolveInjectedBinaryPath("FFPROBE_BIN", paths.ffprobeExecutable, ["ffprobe"]);
const childEnv = { const childEnv = {
...process.env, ...process.env,
...@@ -356,10 +331,9 @@ export class ProjectWorkspaceExecutorService { ...@@ -356,10 +331,9 @@ export class ProjectWorkspaceExecutorService {
PYTHONIOENCODING: "utf-8", PYTHONIOENCODING: "utf-8",
PATH: [ PATH: [
paths.nodeExecutable ? path.dirname(paths.nodeExecutable) : null, paths.nodeExecutable ? path.dirname(paths.nodeExecutable) : null,
path.dirname(paths.pythonExecutable),
paths.ffmpegExecutable ? path.dirname(paths.ffmpegExecutable) : null, paths.ffmpegExecutable ? path.dirname(paths.ffmpegExecutable) : null,
paths.ffprobeExecutable ? path.dirname(paths.ffprobeExecutable) : null, paths.ffprobeExecutable ? path.dirname(paths.ffprobeExecutable) : null,
path.join(paths.runtimeDir, "python", "Scripts"),
path.dirname(paths.pythonExecutable),
process.env.PATH ?? "" process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter), ].filter(Boolean).join(path.delimiter),
QJC_PROJECT_ATTACHMENTS_JSON: JSON.stringify(input.attachments ?? []), QJC_PROJECT_ATTACHMENTS_JSON: JSON.stringify(input.attachments ?? []),
...@@ -371,45 +345,13 @@ export class ProjectWorkspaceExecutorService { ...@@ -371,45 +345,13 @@ export class ProjectWorkspaceExecutorService {
}; };
const spawnOptions = { const spawnOptions = {
cwd: input.projectRoot, cwd: input.projectRoot,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
env: childEnv env: childEnv
}; };
let child; const child = automationCommand
try { ? spawn(automationCommand.executablePath, [automationCommand.scriptPath, ...automationCommand.args], spawnOptions)
child = automationCommand : spawn(paths.nodeExecutable, [runnerScriptPath!], spawnOptions);
? spawn(automationCommand.executablePath, [automationCommand.scriptPath, ...automationCommand.args], spawnOptions)
: spawn(paths.nodeExecutable, [runnerScriptPath!], spawnOptions);
} catch (error) {
const errorCode = error instanceof Error
? String((error as Error & { code?: number | string }).code ?? "")
: "";
if (process.platform !== "win32" || errorCode !== "EPERM") {
throw error;
}
const wrapperScript = [
`Set-Location -LiteralPath '${escapePowerShellSingleQuoted(input.projectRoot)}'`,
...Object.entries(childEnv)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `$env:${key}='${escapePowerShellSingleQuoted(value)}'`),
automationCommand
? `& '${escapePowerShellSingleQuoted(automationCommand.executablePath)}' '${escapePowerShellSingleQuoted(automationCommand.scriptPath)}' ${automationCommand.args.map((value) => `'${escapePowerShellSingleQuoted(value)}'`).join(" ")}`
: `& '${escapePowerShellSingleQuoted(paths.nodeExecutable)}' '${escapePowerShellSingleQuoted(runnerScriptPath!)}'`,
"$exitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 }",
"exit $exitCode"
].join("; ");
child = spawn(getWindowsPowerShellPath(), [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
wrapperScript
], spawnOptions);
}
const finishWithError = (message: string, errorCategory?: string) => { const finishWithError = (message: string, errorCategory?: string) => {
if (settled) { if (settled) {
......
...@@ -26,6 +26,9 @@ export function isTransientLocalGatewayError(message?: string): boolean { ...@@ -26,6 +26,9 @@ export function isTransientLocalGatewayError(message?: string): boolean {
|| normalized.includes("gateway became ready") || normalized.includes("gateway became ready")
|| normalized.includes("gateway closed during readiness probe") || normalized.includes("gateway closed during readiness probe")
|| normalized.includes("gateway closed before readiness probe completed") || normalized.includes("gateway closed before readiness probe completed")
|| normalized.includes("gateway closed before health probe completed")
|| normalized.includes("timed out while probing reusable gateway")
|| normalized.includes("timed out while waiting for reusable gateway")
|| normalized.includes("bundled runtime exited before gateway became ready"); || normalized.includes("bundled runtime exited before gateway became ready");
} }
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 331 331" role="img" aria-label="千匠问天">
<defs>
<linearGradient id="qjc-ring" x1="46" y1="258" x2="258" y2="35" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1faeea"/>
<stop offset=".46" stop-color="#2f8ff7"/>
<stop offset="1" stop-color="#6848ff"/>
</linearGradient>
<linearGradient id="qjc-core" x1="113" y1="195" x2="232" y2="87" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7b26fb"/>
<stop offset=".58" stop-color="#5c55f7"/>
<stop offset="1" stop-color="#5362f8"/>
</linearGradient>
<linearGradient id="qjc-blade" x1="201" y1="72" x2="303" y2="279" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7150ff"/>
<stop offset=".46" stop-color="#7b42f8"/>
<stop offset="1" stop-color="#8d2df4"/>
</linearGradient>
<linearGradient id="qjc-needle" x1="0" y1="88" x2="137" y2="129" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#22a5f5"/>
<stop offset="1" stop-color="#2e82f3"/>
</linearGradient>
<linearGradient id="qjc-accent" x1="78" y1="72" x2="249" y2="50" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8e51ff"/>
<stop offset=".52" stop-color="#f0f7ff"/>
<stop offset="1" stop-color="#ffffff"/>
</linearGradient>
</defs>
<path d="M165.5 0C74.1 0 0 74.1 0 165.5c0 49.3 21.6 93.6 55.8 123.9l40.7-56.7a95.5 95.5 0 0 1 69-161.7 95 95 0 0 1 73.3 34.3l56.7-40.8A165 165 0 0 0 165.5 0Z" fill="url(#qjc-ring)"/>
<path d="M165.5 90.9a74.6 74.6 0 1 0 0 149.2 74.6 74.6 0 0 0 0-149.2Zm0 128.7a54.1 54.1 0 1 1 0-108.2 54.1 54.1 0 0 1 0 108.2Z" fill="url(#qjc-core)"/>
<path d="M0 89.4 134.4 116l-2.7 18.7L0 106.8V89.4Z" fill="url(#qjc-needle)"/>
<path d="M79.7 94.9c20.2-30.2 52.5-48.3 89.2-48.3 29.3 0 55.8 11.6 75.1 30.5" fill="none" stroke="url(#qjc-accent)" stroke-width="4.6" stroke-linecap="round"/>
<path d="M88.7 93.2c11.7-14.6 27.7-25.4 46.1-30.6" fill="none" stroke="#8b54ff" stroke-width="4" stroke-linecap="round"/>
<path d="M331 40 179.7 151l21.2 78.7L293.8 323c2.1 2.2 5.7.7 5.7-2.4V230.7L331 194.5V40Z" fill="url(#qjc-blade)"/>
<path d="M331 40 179.7 151l105.7-33.1L331 40Z" fill="#7554ff" opacity=".84"/>
<path d="M331 194.5 270.7 194.8 299.5 230.7 331 194.5Z" fill="#6f3dec" opacity=".7"/>
<path d="M179.7 151 201 229.7l40.7 53.2-18.7-86.8L179.7 151Z" fill="#6a39ed" opacity=".86"/>
<circle cx="165.5" cy="151" r="21.7" fill="#f4f7ff"/>
<path d="M179.7 151 191.1 174.1 165.5 172.7a21.7 21.7 0 0 0 14.2-21.7Z" fill="#e7ecff"/>
</svg>
# Build Notes # Build Notes
- `apps/ui` emits its production bundle into `apps/desktop/dist/renderer` - `apps/ui` emits its production bundle into `apps/desktop/dist/renderer`
- `apps/desktop` packages the final EXE - `apps/desktop` packages the final macOS Apple Silicon DMG on this branch
- `vendor/openclaw-runtime` is reserved for the pinned runtime payload - `vendor/openclaw-runtime` is reserved for the pinned macOS arm64 runtime payload
- macOS formal entry points are `materialize-runtime-payload.mjs`, `pnpm package:mac`, and `pnpm smoke:mac:*`; the PowerShell/NSIS scripts below are legacy Windows validation references and are not the primary packaging path on this branch
- `installer-smoke.ps1` now splits NSIS validation into installer materialization first and installed-app smoke second; it records preflight old-install evidence, classifies installer failures (`empty-exit-zero-files`, `missing-uninstaller-only`, `partial-materialization`, `app-smoke-failure`), retries the empty-exit case once by default, and writes a combined JSON summary instead of only the raw renderer smoke payload; the summary now also records installed runtime payload size/file-count breakdown; `pnpm smoke:installer` - `installer-smoke.ps1` now splits NSIS validation into installer materialization first and installed-app smoke second; it records preflight old-install evidence, classifies installer failures (`empty-exit-zero-files`, `missing-uninstaller-only`, `partial-materialization`, `app-smoke-failure`), retries the empty-exit case once by default, and writes a combined JSON summary instead of only the raw renderer smoke payload; the summary now also records installed runtime payload size/file-count breakdown; `pnpm smoke:installer`
- `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions - `electron-smoke.ps1` launches the desktop app directly under Electron with isolated `userData` and `logs` paths, then validates execution-policy smoke output; it now also supports preparing a workspace-entry fixture, preserving `userData`, and remote bundle-specific assertions
- `materialize-runtime-payload.ps1` generates a local bundled runtime payload under `vendor/openclaw-runtime/` by copying the local `node.exe`, the installed OpenClaw package, a local OpenClaw config snapshot, and a self-contained Python runtime with the locked dependency set installed into it; before the payload is finalized it now strips low-risk non-runtime artifacts such as non-template OpenClaw docs/README files, Python build helpers, `__pycache__`, `.pyc` / `.pyo`, and source maps, while preserving `openclaw/package/docs/reference/templates` required by OpenClaw workspace bootstrap, and writes payload size/file-count telemetry into `runtime-manifest.json`; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation - `materialize-runtime-payload.mjs` generates the macOS arm64 bundled runtime payload under `vendor/openclaw-runtime/` by copying `node/bin/node`, the installed OpenClaw package, a local OpenClaw config snapshot, a self-contained `python/bin/python3` runtime, `ffmpeg/bin/ffmpeg`, `ffmpeg/bin/ffprobe`, and Playwright browsers; it writes `platform: "darwin"` / `arch: "arm64"` plus size/file-count telemetry into `runtime-manifest.json`; when the existing payload manifest's `materializationKey` still matches the current inputs, it short-circuits and reuses the payload without rerunning `pip` upgrade or dependency installation
- `mac-runtime-smoke.mjs`, `mac-package-smoke.mjs`, `mac-workspace-entry-smoke.mjs`, and `mac-workspace-service-smoke.mjs` validate the macOS runtime payload, packaged app resources, direct bundled-Python workspace entry, and `ProjectWorkspaceExecutorService` automation path respectively; `pnpm smoke:mac:runtime`, `pnpm smoke:mac:package`, `pnpm smoke:mac:workspace-entry`, `pnpm smoke:mac:workspace-service`
- `materialize-runtime-payload.ps1` is the legacy Windows materializer for the historical NSIS/EXE flow
- `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache` - `materialize-runtime-cache-smoke.ps1` materializes an isolated runtime directory twice and asserts the first run is a cache miss while the second run is a cache hit that skips `pip` upgrade and locked dependency installation; `pnpm smoke:materialize-cache`
- `ffmpeg-runtime-smoke.ps1` compiles the targeted `ffmpeg-runtime-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies bundled `ffmpeg` / `ffprobe` path resolution plus `FFMPEG_BIN` / `FFPROBE_BIN` injection into workspace automation subprocesses; `pnpm smoke:ffmpeg-runtime` - `ffmpeg-runtime-smoke.ps1` compiles the targeted `ffmpeg-runtime-smoke.ts` service-level smoke with the local desktop TypeScript toolchain, runs it under Node, and verifies bundled `ffmpeg` / `ffprobe` path resolution plus `FFMPEG_BIN` / `FFPROBE_BIN` injection into workspace automation subprocesses; `pnpm smoke:ffmpeg-runtime`
- `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end - `bundled-runtime-smoke.ps1` materializes the local runtime payload, forces bundled-runtime mode, and validates that Electron can launch and use the managed runtime end to end
...@@ -20,7 +23,7 @@ ...@@ -20,7 +23,7 @@
- `douyin-expert-live-run.ps1` packages `workspace/douyin` into a zip-backed expert bundle, sends an experts-page prompt with a test image attachment, and validates that Douyin writes fresh preview artifacts such as `_latest_workflow_summary.json`, `video_request.json`, `storyboard.json`, and `omnihuman_prompt.txt` inside the synced project; `powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-live-run.ps1` - `douyin-expert-live-run.ps1` packages `workspace/douyin` into a zip-backed expert bundle, sends an experts-page prompt with a test image attachment, and validates that Douyin writes fresh preview artifacts such as `_latest_workflow_summary.json`, `video_request.json`, `storyboard.json`, and `omnihuman_prompt.txt` inside the synced project; `powershell -ExecutionPolicy Bypass -File build/scripts/douyin-expert-live-run.ps1`
- Remote project zip delivery and workspace-entry packaging rules are documented in `docs/remote-project-bundle-spec.zh-CN.md` - Remote project zip delivery and workspace-entry packaging rules are documented in `docs/remote-project-bundle-spec.zh-CN.md`
- `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat` - `default-chat-smoke.ps1` compiles the targeted `default-chat-context-smoke.ts` service-level smoke with the local desktop TypeScript toolchain and verifies `chat-fallback` routing, project context injection into the prepared prompt, post-execution snapshot refresh/rebind, and reuse of the refreshed snapshot on the next request; `pnpm smoke:default-chat`
- `installer-smoke.ps1` validates the packaged Python runtime by importing the preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe` - Legacy Windows `installer-smoke.ps1` validates the packaged Python runtime by importing preinstalled table/document/web dependencies from `resources/vendor/openclaw-runtime/python/python.exe`; on this macOS branch use `mac-runtime-smoke.mjs` against `python/bin/python3`
- `installer-smoke.ps1` also validates that the packaged runtime still contains the OpenClaw workspace template fallback file `resources/vendor/openclaw-runtime/openclaw/package/docs/reference/templates/AGENTS.md` before it launches the installed app smoke - `installer-smoke.ps1` also validates that the packaged runtime still contains the OpenClaw workspace template fallback file `resources/vendor/openclaw-runtime/openclaw/package/docs/reference/templates/AGENTS.md` before it launches the installed app smoke
- `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall 千匠问天.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change` - `installer-path-change-smoke.ps1` installs once to an initial path, reinstalls the same package to a second path, and asserts the relocated run still materializes `Uninstall 千匠问天.exe` while reporting prior-install evidence; `pnpm smoke:installer:path-change`
- `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall 千匠问天.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue` - `installer-target-residue-smoke.ps1` preseeds the target directory with a stale `Uninstall 千匠问天.exe`, runs the real NSIS installer silently, and verifies the packaged install can overwrite removable residue instead of failing at the uninstaller write step; `pnpm smoke:installer:target-residue`
......
#!/usr/bin/env node
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { join, resolve } from "node:path"
const repoRoot = resolve(import.meta.dirname, "..", "..")
const installerDir = join(repoRoot, "dist", "installer")
let failures = 0
function check(label, condition, detail) {
if (condition) {
console.log(` PASS: ${label}`)
} else {
console.error(` FAIL: ${label}${detail ? ` — ${detail}` : ""}`)
failures++
}
}
function findAppDir(dir) {
if (!existsSync(dir)) return null
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.endsWith(".app")) {
return join(dir, entry.name)
}
}
return null
}
console.log("mac-package-smoke: validating electron-builder output")
// 1. Check for .app directory
const macArm64Dir = join(installerDir, "mac-arm64")
const appDir = findAppDir(macArm64Dir) || findAppDir(installerDir)
check(".app directory exists", Boolean(appDir), `searched in: ${macArm64Dir} and ${installerDir}`)
if (appDir) {
// 2. Check for vendor/openclaw-runtime in Resources
const resourcesDir = join(appDir, "Contents", "Resources")
const runtimeDir = join(resourcesDir, "vendor", "openclaw-runtime")
check("vendor/openclaw-runtime in app Resources", existsSync(runtimeDir), `not found: ${runtimeDir}`)
if (existsSync(runtimeDir)) {
const manifestPath = join(runtimeDir, "runtime-manifest.json")
check("runtime-manifest.json in Resources", existsSync(manifestPath))
check("node/bin/node in Resources", existsSync(join(runtimeDir, "node", "bin", "node")))
check("python/bin/python3 in Resources", existsSync(join(runtimeDir, "python", "bin", "python3")))
if (existsSync(manifestPath)) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"))
check("packaged manifest platform is darwin", manifest.platform === "darwin", `got: ${manifest.platform}`)
check("packaged manifest arch is arm64", manifest.arch === "arm64", `got: ${manifest.arch}`)
} catch (e) {
check("packaged runtime-manifest.json is valid JSON", false, e.message)
}
}
}
// 3. Check for app.asar
const asarPath = join(resourcesDir, "app.asar")
check("app.asar exists", existsSync(asarPath), `not found: ${asarPath}`)
// 4. Check Info.plist
const plistPath = join(appDir, "Contents", "Info.plist")
check("Info.plist exists", existsSync(plistPath))
}
// 5. Check for DMG file (optional, --dir mode doesn't produce one)
const dmgFiles = existsSync(installerDir)
? readdirSync(installerDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.endsWith(".dmg"))
.map(e => e.name)
: []
if (dmgFiles.length > 0) {
console.log(` INFO: Found DMG file(s): ${dmgFiles.join(", ")}`)
}
// Summary
console.log("")
if (failures === 0) {
console.log("mac-package-smoke: ALL PASSED")
} else {
console.error(`mac-package-smoke: ${failures} FAILURE(S)`)
process.exit(1)
}
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs"
import { execFileSync } from "node:child_process"
import { join, resolve } from "node:path"
const repoRoot = resolve(import.meta.dirname, "..", "..")
const runtimeDir = process.env.QJCLAW_SMOKE_RUNTIME_DIR || join(repoRoot, "vendor", "openclaw-runtime")
let failures = 0
function check(label, condition, detail) {
if (condition) {
console.log(` PASS: ${label}`)
} else {
console.error(` FAIL: ${label}${detail ? ` — ${detail}` : ""}`)
failures++
}
}
function checkFile(label, filePath) {
check(label, existsSync(filePath), `not found: ${filePath}`)
}
function checkExec(label, cmd, args) {
try {
const stdout = execFileSync(cmd, args, { encoding: "utf8" }).trim()
check(label, true, stdout)
} catch (e) {
check(label, false, e.message)
}
}
console.log(`mac-runtime-smoke: validating ${runtimeDir}`)
// 1. Manifest
const manifestPath = join(runtimeDir, "runtime-manifest.json")
checkFile("runtime-manifest.json exists", manifestPath)
let manifest = null
if (existsSync(manifestPath)) {
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"))
check("manifest platform is darwin", manifest.platform === "darwin", `got: ${manifest.platform}`)
check("manifest arch is arm64", manifest.arch === "arm64", `got: ${manifest.arch}`)
check("manifest bundledNodeExecutable is node/bin/node", manifest.bundledNodeExecutable === "node/bin/node", `got: ${manifest.bundledNodeExecutable}`)
check("manifest pythonExecutable is python/bin/python3", manifest.pythonExecutable === "python/bin/python3", `got: ${manifest.pythonExecutable}`)
check("manifest ffmpegExecutable is ffmpeg/bin/ffmpeg", manifest.ffmpegExecutable === "ffmpeg/bin/ffmpeg", `got: ${manifest.ffmpegExecutable}`)
check("manifest ffprobeExecutable is ffmpeg/bin/ffprobe", manifest.ffprobeExecutable === "ffmpeg/bin/ffprobe", `got: ${manifest.ffprobeExecutable}`)
} catch (e) {
check("manifest is valid JSON", false, e.message)
}
}
// 2. Required files
checkFile("node/bin/node", join(runtimeDir, "node", "bin", "node"))
checkFile("openclaw/index.js", join(runtimeDir, "openclaw", "index.js"))
checkFile("openclaw/package/openclaw.mjs", join(runtimeDir, "openclaw", "package", "openclaw.mjs"))
checkFile("openclaw/package/package.json", join(runtimeDir, "openclaw", "package", "package.json"))
checkFile("config/openclaw.json", join(runtimeDir, "config", "openclaw.json"))
checkFile("python/bin/python3", join(runtimeDir, "python", "bin", "python3"))
checkFile("python/python-manifest.json", join(runtimeDir, "python", "python-manifest.json"))
checkFile("python/runtime-requirements.lock.txt", join(runtimeDir, "python", "runtime-requirements.lock.txt"))
checkFile("ffmpeg/bin/ffmpeg", join(runtimeDir, "ffmpeg", "bin", "ffmpeg"))
checkFile("ffmpeg/bin/ffprobe", join(runtimeDir, "ffmpeg", "bin", "ffprobe"))
checkFile("playwright-browsers", join(runtimeDir, "playwright-browsers"))
checkFile("README.md", join(runtimeDir, "README.md"))
// 3. Executable probes
const nodeExe = join(runtimeDir, "node", "bin", "node")
const pythonExe = join(runtimeDir, "python", "bin", "python3")
if (existsSync(nodeExe)) {
checkExec("node --version", nodeExe, ["--version"])
}
if (existsSync(pythonExe)) {
checkExec("python3 --version", pythonExe, ["--version"])
try {
const stdout = execFileSync(pythonExe, ["-c", "import openpyxl, pandas, requests, fastapi, imageio_ffmpeg, qiniu; print('ok')"], {
encoding: "utf8",
timeout: 15000,
}).trim()
check("python key imports", stdout === "ok", `got: ${stdout}`)
} catch (e) {
check("python key imports", false, e.message?.split("\n")[0])
}
}
// 4. Config probe
const configPath = join(runtimeDir, "config", "openclaw.json")
if (existsSync(configPath)) {
try {
const config = JSON.parse(readFileSync(configPath, "utf8"))
check("config gateway.mode is local", config.gateway?.mode === "local", `got: ${config.gateway?.mode}`)
check("config gateway.bind is loopback", config.gateway?.bind === "loopback", `got: ${config.gateway?.bind}`)
} catch (e) {
check("config is valid JSON", false, e.message)
}
}
// Summary
console.log("")
if (failures === 0) {
console.log("mac-runtime-smoke: ALL PASSED")
} else {
console.error(`mac-runtime-smoke: ${failures} FAILURE(S)`)
process.exit(1)
}
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"
import { execFileSync } from "node:child_process"
import { join, resolve } from "node:path"
const repoRoot = resolve(import.meta.dirname, "..", "..")
const runtimeDir = process.env.QJCLAW_SMOKE_RUNTIME_DIR || join(repoRoot, "vendor", "openclaw-runtime")
let failures = 0
function check(label, condition, detail) {
if (condition) {
console.log(` PASS: ${label}`)
} else {
console.error(` FAIL: ${label}${detail ? ` — ${detail}` : ""}`)
failures++
}
}
console.log("mac-workspace-entry-smoke: validating workspace automation via bundled Python")
const pythonExe = join(runtimeDir, "python", "bin", "python3")
const nodeExe = join(runtimeDir, "node", "bin", "node")
if (!existsSync(pythonExe)) {
console.error(` FATAL: python3 not found at ${pythonExe}`)
process.exit(1)
}
// 1. Create a minimal test project
const tmpDir = join(repoRoot, ".workspace-smoke-tmp")
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
mkdirSync(tmpDir, { recursive: true })
// Write project.json with Python automation
const projectJson = {
workspaceAutomation: {
runtime: "python",
script: "run.py",
args: []
}
}
writeFileSync(join(tmpDir, "project.json"), JSON.stringify(projectJson, null, 2), "utf8")
// Write a minimal run.py that prints a known marker
const runPy = `
import sys
import json
print("QJC_WORKSPACE_EVENT\\t" + json.dumps({"type": "started", "runId": "smoke-test"}))
print("QJC_WORKSPACE_EVENT\\t" + json.dumps({"type": "completed", "content": "workspace smoke ok"}))
sys.exit(0)
`
writeFileSync(join(tmpDir, "run.py"), runPy, "utf8")
// 2. Execute Python automation script
try {
const stdout = execFileSync(pythonExe, [join(tmpDir, "run.py")], {
encoding: "utf8",
timeout: 15000,
cwd: tmpDir,
env: {
...process.env,
PYTHONUTF8: "1",
PYTHONIOENCODING: "utf-8",
}
})
const lines = stdout.trim().split("\n")
const startedLine = lines.find(l => l.includes('"type": "started"'))
const completedLine = lines.find(l => l.includes('"type": "completed"'))
check("Python automation produced started event", Boolean(startedLine))
check("Python automation produced completed event", Boolean(completedLine))
if (completedLine) {
const eventData = JSON.parse(completedLine.split("\t")[1])
check("completed event content matches", eventData.content === "workspace smoke ok", `got: ${eventData.content}`)
}
} catch (e) {
check("Python automation execution", false, e.message?.split("\n")[0])
}
// 3. Test Node executable can run
if (existsSync(nodeExe)) {
try {
const stdout = execFileSync(nodeExe, ["-e", "console.log('node-ok')"], {
encoding: "utf8",
timeout: 5000,
}).trim()
check("Node bundled executable works", stdout === "node-ok", `got: ${stdout}`)
} catch (e) {
check("Node bundled executable works", false, e.message?.split("\n")[0])
}
}
// Cleanup
rmSync(tmpDir, { recursive: true, force: true })
// Summary
console.log("")
if (failures === 0) {
console.log("mac-workspace-entry-smoke: ALL PASSED")
} else {
console.error(`mac-workspace-entry-smoke: ${failures} FAILURE(S)`)
process.exit(1)
}
#!/usr/bin/env node
import { execFileSync } from "node:child_process"
import { createRequire } from "node:module"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join, resolve } from "node:path"
const repoRoot = resolve(import.meta.dirname, "..", "..")
const runtimeDir = process.env.QJCLAW_SMOKE_RUNTIME_DIR || join(repoRoot, "vendor", "openclaw-runtime")
const tmpRoot = join(repoRoot, ".tmp", "mac-workspace-service-smoke")
const compileDir = join(tmpRoot, "compiled")
const projectRoot = join(tmpRoot, "project")
const runtimeDataDir = join(tmpRoot, "runtime-data")
let failures = 0
function check(label, condition, detail) {
if (condition) {
console.log(` PASS: ${label}`)
} else {
console.error(` FAIL: ${label}${detail ? ` — ${detail}` : ""}`)
failures++
}
}
function checkFile(label, filePath) {
check(label, existsSync(filePath), `not found: ${filePath}`)
}
function emitSummary() {
console.log("")
if (failures === 0) {
console.log("mac-workspace-service-smoke: ALL PASSED")
} else {
console.error(`mac-workspace-service-smoke: ${failures} FAILURE(S)`)
process.exit(1)
}
}
async function main() {
console.log("mac-workspace-service-smoke: validating ProjectWorkspaceExecutorService with bundled Python")
const pythonExe = join(runtimeDir, "python", "bin", "python3")
const nodeExe = join(runtimeDir, "node", "bin", "node")
const ffmpegExe = join(runtimeDir, "ffmpeg", "bin", "ffmpeg")
const ffprobeExe = join(runtimeDir, "ffmpeg", "bin", "ffprobe")
const playwrightBrowsersPath = join(runtimeDir, "playwright-browsers")
checkFile("runtime-manifest.json", join(runtimeDir, "runtime-manifest.json"))
checkFile("bundled python3", pythonExe)
checkFile("bundled node", nodeExe)
if (failures > 0) {
emitSummary()
return
}
rmSync(tmpRoot, { recursive: true, force: true })
mkdirSync(compileDir, { recursive: true })
mkdirSync(projectRoot, { recursive: true })
mkdirSync(join(runtimeDataDir, "state"), { recursive: true })
mkdirSync(join(runtimeDataDir, "logs"), { recursive: true })
execFileSync("corepack", [
"pnpm",
"--dir",
join(repoRoot, "apps", "desktop"),
"exec",
"tsup",
"src/main/services/project-workspace-executor.ts",
"--no-config",
"--format",
"cjs",
"--platform",
"node",
"--target",
"node20",
"--out-dir",
compileDir,
"--external",
"electron",
], {
cwd: repoRoot,
stdio: "inherit",
})
const compiledService = join(compileDir, "project-workspace-executor.js")
checkFile("compiled ProjectWorkspaceExecutorService", compiledService)
if (failures > 0) {
emitSummary()
return
}
const projectJson = {
workspaceAutomation: {
runtime: "python",
script: "run.py",
args: ["--prompt", "{prompt}", "--session", "{sessionId}"],
env: {
QJC_SMOKE_PROJECT_ROOT: "{projectRoot}",
QJC_SMOKE_ATTACHMENT_PATHS: "{attachmentPathsJson}",
},
},
}
writeFileSync(join(projectRoot, "project.json"), JSON.stringify(projectJson, null, 2), "utf8")
const runPy = `
import argparse
import json
import os
import sys
parser = argparse.ArgumentParser()
parser.add_argument("--prompt", required=True)
parser.add_argument("--session", required=True)
args = parser.parse_args()
checks = {
"prompt": args.prompt,
"session": args.session,
"project_root": os.environ.get("QJC_SMOKE_PROJECT_ROOT", ""),
"runtime_dir": os.environ.get("QJCLAW_BUNDLED_RUNTIME_DIR", ""),
"playwright": os.environ.get("PLAYWRIGHT_BROWSERS_PATH", ""),
"ffmpeg": os.environ.get("FFMPEG_BIN", ""),
"ffprobe": os.environ.get("FFPROBE_BIN", ""),
"python": sys.executable,
}
missing = [
name for name in ["project_root", "runtime_dir", "playwright", "python"]
if not checks[name]
]
if missing:
print("QJC_WORKSPACE_EVENT\\t" + json.dumps({
"type": "error",
"message": "missing env: " + ",".join(missing),
}))
sys.exit(0)
print("QJC_WORKSPACE_EVENT\\t" + json.dumps({
"type": "started",
"runId": "mac-workspace-service-smoke-run",
}))
print("QJC_WORKSPACE_EVENT\\t" + json.dumps({
"type": "status",
"stage": "python-automation",
"label": "Bundled Python automation started",
}))
print("QJC_WORKSPACE_EVENT\\t" + json.dumps({
"type": "completed",
"content": "service smoke ok: " + json.dumps(checks, sort_keys=True),
}))
`.trimStart()
writeFileSync(join(projectRoot, "run.py"), runPy, "utf8")
const require = createRequire(import.meta.url)
const { ProjectWorkspaceExecutorService } = require(compiledService)
const statuses = []
const started = []
let syncedAction = null
const runtimeManager = {
status: async () => ({ payloadState: "ready" }),
syncManagedConfig: async (action) => {
syncedAction = action
},
resolveBundledPaths: () => ({
runtimeDir,
nodeExecutable: nodeExe,
openClawEntry: join(runtimeDir, "openclaw", "index.js"),
packagedOpenClawEntry: join(runtimeDir, "openclaw", "package", "openclaw.mjs"),
runtimeManifestPath: join(runtimeDir, "runtime-manifest.json"),
defaultConfigPath: join(runtimeDir, "config", "openclaw.json"),
pythonExecutable: pythonExe,
pythonManifestPath: join(runtimeDir, "python", "python-manifest.json"),
ffmpegExecutable: existsSync(ffmpegExe) ? ffmpegExe : undefined,
ffprobeExecutable: existsSync(ffprobeExe) ? ffprobeExe : undefined,
playwrightBrowsersPath,
managedConfigPath: join(runtimeDataDir, "state", "openclaw.runtime.json"),
readmePath: join(runtimeDir, "README.md"),
runtimeDataDir,
runtimeStateDir: join(runtimeDataDir, "state"),
runtimeLogsDir: join(runtimeDataDir, "logs"),
logFilePath: join(runtimeDataDir, "logs", "runtime.log"),
}),
}
try {
const service = new ProjectWorkspaceExecutorService(runtimeManager)
const result = await service.execute({
sessionId: "mac-workspace-service-smoke-session",
projectRoot,
prompt: "prepared smoke prompt",
userPrompt: "human smoke prompt",
runId: "outer-smoke-run",
attachments: [],
}, {
onStarted: (runId) => started.push(runId),
onStatus: (stage, label) => statuses.push({ stage, label }),
})
check("syncManagedConfig called with sync", syncedAction === "sync", `got: ${syncedAction}`)
check("service observed started event", started.includes("mac-workspace-service-smoke-run"), `got: ${started.join(", ")}`)
check("service observed status event", statuses.some((entry) => entry.stage === "python-automation"), JSON.stringify(statuses))
check("service returned assistant reply", Boolean(result.reply), JSON.stringify(result))
check("service reply content matches", result.reply?.content?.includes("service smoke ok"), result.reply?.content)
check("service used bundled python", result.reply?.content?.includes(pythonExe), result.reply?.content)
check("service injected runtime dir", result.reply?.content?.includes(runtimeDir), result.reply?.content)
} catch (error) {
check("ProjectWorkspaceExecutorService execution", false, error instanceof Error ? error.message : String(error))
} finally {
rmSync(tmpRoot, { recursive: true, force: true })
}
emitSummary()
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error))
process.exit(1)
})
#!/usr/bin/env node
import { createHash } from "node:crypto"
import { execFileSync, execFile as execFileCb } from "node:child_process"
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, rmSync, statSync, readdirSync, chmodSync, cpSync } from "node:fs"
import { cp, readFile, writeFile, mkdir, rm, stat, readdir, rename } from "node:fs/promises"
import { createRequire } from "node:module"
import { basename, dirname, join, relative, resolve, delimiter } from "node:path"
import { homedir, tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { promisify } from "node:util"
const execFileAsync = promisify(execFileCb)
// --- Platform guard ---
if (process.platform !== "darwin") {
console.error("This script only runs on macOS.")
process.exit(1)
}
if (process.arch !== "arm64") {
console.error("This script only runs on Apple Silicon (arm64).")
process.exit(1)
}
// --- Argument parsing ---
function parseArgs(argv) {
const args = {}
for (let i = 2; i < argv.length; i++) {
if (argv[i].startsWith("--") && i + 1 < argv.length) {
const key = argv[i].slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())
args[key] = argv[++i]
}
}
return args
}
const args = parseArgs(process.argv)
const repoRoot = resolve(import.meta.dirname, "..", "..")
const runtimeDir = args.runtimeDir || join(repoRoot, "vendor", "openclaw-runtime")
const sourceConfigPath = args.sourceConfigPath || process.env.QJCLAW_MAC_OPENCLAW_CONFIG_PATH || join(homedir(), ".openclaw", "openclaw.json")
const sourceNode = args.sourceNode || process.env.QJCLAW_MAC_NODE || resolveCommand("node")
const sourcePythonRoot = args.sourcePythonRoot || process.env.QJCLAW_MAC_PYTHON_ROOT || null
const sourceOpenClawEntry = args.sourceOpenclawEntry || process.env.QJCLAW_MAC_OPENCLAW_ENTRY || resolveOpenClawEntry()
const sourceFfprobe = args.sourceFfprobe || process.env.QJCLAW_MAC_FFPROBE || null
const requirementsPath = args.requirementsPath || join(repoRoot, "build", "runtime", "python", "runtime-requirements.lock.txt")
const gatewayPort = args.gatewayPort ? parseInt(args.gatewayPort, 10) : 18889
const gatewayToken = args.gatewayToken || "qjc-bundled-runtime-token"
// --- Helpers ---
function resolveCommand(name) {
try {
const result = execFileSync("which", [name], { encoding: "utf8" }).trim()
return result || null
} catch {
return null
}
}
function resolveOpenClawEntry() {
const cmd = resolveCommand("openclaw")
if (!cmd) return null
const cliDir = dirname(cmd)
const candidate = join(cliDir, "node_modules", "openclaw", "openclaw.mjs")
if (existsSync(candidate)) return candidate
return null
}
function fileFingerprint(filePath, includeHash = false) {
const st = statSync(filePath)
const fp = { path: resolve(filePath), length: st.size, lastWriteTimeUtc: st.mtime.toISOString() }
if (includeHash) {
fp.hashSha256 = hashFileSha256(filePath)
}
return fp
}
function hashFileSha256(filePath) {
const hash = createHash("sha256")
const data = readFileSync(filePath)
hash.update(data)
return hash.digest("hex")
}
function computeMaterializationKey(inputs) {
const serialized = JSON.stringify(inputs)
return createHash("sha256").update(serialized).digest("hex")
}
function chmodPlusX(filePath) {
const mode = statSync(filePath).mode
chmodSync(filePath, mode | 0o111)
}
function validateExists(path, label) {
if (!existsSync(path)) {
throw new Error(`${label} not found at ${path}`)
}
}
function resolveOptionalBinarySource(explicitPath, candidatePaths = [], commandNames = []) {
if (explicitPath) {
const resolved = resolve(explicitPath)
if (!existsSync(resolved)) {
throw new Error(`Optional binary source was not found at ${resolved}`)
}
return resolved
}
for (const candidate of candidatePaths) {
if (!candidate) continue
const resolved = resolve(candidate)
if (existsSync(resolved)) return resolved
}
for (const name of commandNames) {
const found = resolveCommand(name)
if (found && existsSync(found)) return resolve(found)
}
return null
}
function getImageioFfmpegBinaryPath(pythonDir) {
const sitePackagesDir = join(pythonDir, "lib", "python*/site-packages/imageio_ffmpeg/binaries")
// Use glob-style search
const { globSync } = requireGlob()
if (!globSync) {
// Fallback: manual search
return findImageioFfmpegManually(pythonDir)
}
const matches = globSync(join(pythonDir, "lib", "*", "site-packages", "imageio_ffmpeg", "binaries", "ffmpeg-*"))
if (matches.length === 0) {
throw new Error(`imageio-ffmpeg binaries directory not found under ${pythonDir}`)
}
// Filter for macOS binaries (not .exe)
const macBinaries = matches.filter(f => !f.endsWith(".exe") && !f.endsWith(".zip"))
if (macBinaries.length === 0) {
throw new Error(`imageio-ffmpeg did not materialize a bundled macOS ffmpeg under ${pythonDir}`)
}
macBinaries.sort()
return macBinaries[0]
}
function findImageioFfmpegManually(pythonDir) {
const libDir = join(pythonDir, "lib")
if (!existsSync(libDir)) {
throw new Error(`Python lib directory not found at ${libDir}`)
}
for (const entry of readdirSync(libDir)) {
const binariesDir = join(libDir, entry, "site-packages", "imageio_ffmpeg", "binaries")
if (!existsSync(binariesDir)) continue
const candidates = readdirSync(binariesDir)
.filter(f => f.startsWith("ffmpeg-") && !f.endsWith(".exe") && !f.endsWith(".zip"))
.sort()
if (candidates.length > 0) {
return join(binariesDir, candidates[0])
}
}
throw new Error(`imageio-ffmpeg binaries not found under ${pythonDir}`)
}
function requireGlob() {
try {
return { globSync: require("glob").globSync }
} catch {
return { globSync: null }
}
}
const require = createRequire(import.meta.url)
async function directoryMetrics(dirPath) {
if (!existsSync(dirPath)) return { fileCount: 0, sizeBytes: 0 }
const st = statSync(dirPath)
if (!st.isDirectory()) return { fileCount: 1, sizeBytes: st.size }
let fileCount = 0
let sizeBytes = 0
async function walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
await walk(fullPath)
} else if (entry.isFile()) {
fileCount++
const s = await stat(fullPath)
sizeBytes += s.size
}
}
}
await walk(dirPath)
return { fileCount, sizeBytes }
}
async function runtimePayloadStats(runtimeDirPath) {
const rootMetrics = await directoryMetrics(runtimeDirPath)
const topLevelBreakdown = []
if (existsSync(runtimeDirPath)) {
const entries = await readdir(runtimeDirPath, { withFileTypes: true })
entries.sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
const metrics = await directoryMetrics(join(runtimeDirPath, entry.name))
topLevelBreakdown.push({
path: entry.name,
fileCount: metrics.fileCount,
sizeBytes: metrics.sizeBytes,
})
}
}
return { fileCount: rootMetrics.fileCount, sizeBytes: rootMetrics.sizeBytes, topLevelBreakdown }
}
function relativePath(root, target) {
return relative(root, target).replace(/\\/g, "/")
}
function removeOptionalLiteralPath(runtimeRoot, targetPath, removedPaths) {
if (!existsSync(targetPath)) return false
try { rmSync(targetPath, { recursive: true, force: true }) } catch { return false }
if (existsSync(targetPath)) return false
if (removedPaths) removedPaths.push(relativePath(runtimeRoot, targetPath))
return true
}
function removeOptionalNamedDirectories(runtimeRoot, root, names) {
const examples = []
let removedCount = 0
function walk(dir) {
if (!existsSync(dir)) return
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const fullPath = join(dir, entry.name)
walk(fullPath)
if (names.includes(entry.name)) {
try { rmSync(fullPath, { recursive: true, force: true }) } catch { continue }
if (!existsSync(fullPath)) {
removedCount++
if (examples.length < 12) examples.push(relativePath(runtimeRoot, fullPath))
}
}
}
}
walk(root)
return { removedCount, examples }
}
function removeOptionalFilesByPattern(runtimeRoot, root, patterns) {
const examples = []
let removedCount = 0
function walk(dir) {
if (!existsSync(dir)) return
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
walk(fullPath)
} else if (entry.isFile()) {
const name = entry.name
if (patterns.some(p => matchSimplePattern(name, p))) {
try { rmSync(fullPath, { force: true }) } catch { continue }
if (!existsSync(fullPath)) {
removedCount++
if (examples.length < 12) examples.push(relativePath(runtimeRoot, fullPath))
}
}
}
}
}
walk(root)
return { removedCount, examples }
}
function matchSimplePattern(name, pattern) {
// Simple glob: only supports *.ext
if (pattern.startsWith("*.")) {
return name.endsWith(pattern.slice(1))
}
return name === pattern
}
function pruneOpenClawDocsPreservingTemplates(runtimeRoot, docsDir) {
const preservedDirs = []
if (!existsSync(docsDir)) return { pruned: false, preservedDirs: [] }
const preserveRoot = join(dirname(docsDir), `.docs-preserve-${randomUUID()}`)
const templateDirs = [
join(docsDir, "reference", "templates"),
join(docsDir, "zh-CN", "reference", "templates"),
]
try {
for (const templateDir of templateDirs) {
if (!existsSync(templateDir)) continue
const relDir = relativePath(docsDir, templateDir)
const preservedPath = join(preserveRoot, relDir)
mkdirSync(dirname(preservedPath), { recursive: true })
cpSync(templateDir, preservedPath, { recursive: true })
}
rmSync(docsDir, { recursive: true, force: true })
mkdirSync(docsDir, { recursive: true })
for (const templateDir of templateDirs) {
const relDir = relativePath(docsDir, templateDir)
const preservedPath = join(preserveRoot, relDir)
if (!existsSync(preservedPath)) continue
mkdirSync(dirname(templateDir), { recursive: true })
cpSync(preservedPath, templateDir, { recursive: true })
preservedDirs.push(relativePath(runtimeRoot, templateDir))
}
} finally {
if (existsSync(preserveRoot)) {
rmSync(preserveRoot, { recursive: true, force: true })
}
}
return { pruned: true, preservedDirs }
}
function getRequiredWorkspaceTemplatePaths(openClawPackageDir) {
return [
join(openClawPackageDir, "docs", "reference", "templates", "AGENTS.md"),
join(openClawPackageDir, "docs", "reference", "templates", "BOOTSTRAP.md"),
join(openClawPackageDir, "docs", "reference", "templates", "HEARTBEAT.md"),
join(openClawPackageDir, "docs", "reference", "templates", "IDENTITY.md"),
join(openClawPackageDir, "docs", "reference", "templates", "SOUL.md"),
join(openClawPackageDir, "docs", "reference", "templates", "TOOLS.md"),
join(openClawPackageDir, "docs", "reference", "templates", "USER.md"),
]
}
function invokeRuntimePayloadCleanup(runtimeRoot, openClawPackageDir, pythonDir) {
const removedLiteralPaths = []
const docsCleanup = pruneOpenClawDocsPreservingTemplates(runtimeRoot, join(openClawPackageDir, "docs"))
const literalTargets = [
join(openClawPackageDir, "README.md"),
join(openClawPackageDir, "README-header.png"),
join(openClawPackageDir, "CHANGELOG.md"),
join(pythonDir, "Doc"),
join(pythonDir, "Tools"),
join(pythonDir, "lib", "test"),
join(pythonDir, "lib", "idlelib"),
join(pythonDir, "lib", "tkinter"),
join(pythonDir, "lib", "turtledemo"),
join(pythonDir, "lib", "ensurepip"),
join(pythonDir, "lib", "site-packages", "setuptools"),
join(pythonDir, "lib", "site-packages", "wheel"),
]
for (const target of literalTargets) {
removeOptionalLiteralPath(runtimeRoot, target, removedLiteralPaths)
}
const pythonCacheDirs = removeOptionalNamedDirectories(runtimeRoot, pythonDir, ["__pycache__"])
const sourceMapFiles = removeOptionalFilesByPattern(runtimeRoot, openClawPackageDir, ["*.map"])
const pythonCompiledFiles = removeOptionalFilesByPattern(runtimeRoot, pythonDir, ["*.pyc", "*.pyo"])
return {
cleanupRulesVersion: 3,
prunedOpenClawDocs: docsCleanup.pruned,
preservedWorkspaceTemplateDirs: docsCleanup.preservedDirs,
removedLiteralPaths,
removedLiteralPathCount: removedLiteralPaths.length,
removedPycacheDirectories: pythonCacheDirs,
removedSourceMapFiles: sourceMapFiles,
removedCompiledPythonFiles: pythonCompiledFiles,
}
}
function testRuntimePayloadReady(runtimeDirPath) {
const openClawPackageDir = join(runtimeDirPath, "openclaw", "package")
const requiredPaths = [
join(runtimeDirPath, "node", "bin", "node"),
join(runtimeDirPath, "openclaw", "index.js"),
join(openClawPackageDir, "openclaw.mjs"),
join(openClawPackageDir, "package.json"),
join(runtimeDirPath, "config", "openclaw.json"),
join(runtimeDirPath, "python", "bin", "python3"),
join(runtimeDirPath, "python", "python-manifest.json"),
join(runtimeDirPath, "python", "runtime-requirements.lock.txt"),
join(runtimeDirPath, "ffmpeg", "bin", "ffmpeg"),
join(runtimeDirPath, "ffmpeg", "bin", "ffprobe"),
join(runtimeDirPath, "playwright-browsers"),
join(runtimeDirPath, "runtime-manifest.json"),
join(runtimeDirPath, "README.md"),
...getRequiredWorkspaceTemplatePaths(openClawPackageDir),
]
return requiredPaths.every(p => existsSync(p))
}
// --- Main ---
async function main() {
// Resolve source Python root
let resolvedSourcePythonRoot = sourcePythonRoot
if (!resolvedSourcePythonRoot) {
const pythonCmd = resolveCommand("python3")
if (pythonCmd) {
// Walk up to find the Python root (the parent of bin/)
resolvedSourcePythonRoot = dirname(dirname(pythonCmd))
}
}
// Resolve source ffprobe
const resolvedSourceFfprobe = resolveOptionalBinarySource(
sourceFfprobe,
[
join(homedir(), "ffmpeg", "current", "bin", "ffprobe"),
"/opt/homebrew/bin/ffprobe",
"/usr/local/bin/ffprobe",
],
["ffprobe"]
)
if (!resolvedSourceFfprobe) {
throw new Error(
"ffprobe is required for bundled video analysis workflows. " +
"Install FFmpeg with ffprobe (e.g. brew install ffmpeg), or pass --source-ffprobe /path/to/ffprobe."
)
}
// Validate all sources
validateExists(sourceConfigPath, "OpenClaw config")
validateExists(sourceNode, "Node executable")
validateExists(sourceOpenClawEntry, "OpenClaw entry")
validateExists(requirementsPath, "Runtime requirements lock file")
validateExists(resolvedSourcePythonRoot, "Python root directory")
validateExists(join(resolvedSourcePythonRoot, "bin", "python3"), "Python executable (bin/python3)")
const sourceOpenClawDir = dirname(sourceOpenClawEntry)
const sourceOpenClawPackageJsonPath = join(sourceOpenClawDir, "package.json")
validateExists(sourceOpenClawDir, "OpenClaw package directory")
validateExists(sourceOpenClawPackageJsonPath, "OpenClaw package.json")
// Compute materialization key for caching
const materializationInputs = {
schemaVersion: 6,
gatewayPort,
gatewayToken,
sourceConfig: fileFingerprint(sourceConfigPath, true),
sourceNodeExe: fileFingerprint(sourceNode),
sourcePythonExe: fileFingerprint(join(resolvedSourcePythonRoot, "bin", "python3")),
sourceOpenClawEntry: fileFingerprint(sourceOpenClawEntry),
sourceOpenClawPackageJson: fileFingerprint(sourceOpenClawPackageJsonPath, true),
requirements: fileFingerprint(requirementsPath, true),
sourceFfprobeExe: resolvedSourceFfprobe ? fileFingerprint(resolvedSourceFfprobe) : null,
}
const materializationKey = computeMaterializationKey(materializationInputs)
// Check cache
const existingManifestPath = join(runtimeDir, "runtime-manifest.json")
if (existsSync(existingManifestPath) && testRuntimePayloadReady(runtimeDir)) {
try {
const existingManifest = JSON.parse(readFileSync(existingManifestPath, "utf8"))
if (existingManifest.materializationKey === materializationKey) {
console.log(`Reusing bundled runtime payload at ${runtimeDir} (materialization key match)`)
console.log(JSON.stringify(buildRuntimeSummary(runtimeDir, existingManifest, true), null, 2))
return
}
} catch (e) {
console.warn(`Existing runtime manifest could not be reused: ${e.message}`)
}
}
// Create staging directory
const stagingDir = join(dirname(runtimeDir), `openclaw-runtime.staging.${randomUUID()}`)
const nodeBinDir = join(stagingDir, "node", "bin")
const openclawDir = join(stagingDir, "openclaw")
const openclawPackageDir = join(openclawDir, "package")
const configDir = join(stagingDir, "config")
const pythonDir = join(stagingDir, "python")
const ffmpegBinDir = join(stagingDir, "ffmpeg", "bin")
const playwrightBrowsersDir = join(stagingDir, "playwright-browsers")
const payloadNodeExe = join(nodeBinDir, "node")
const payloadFfmpegExe = join(ffmpegBinDir, "ffmpeg")
const payloadFfprobeExe = join(ffmpegBinDir, "ffprobe")
const payloadPythonExe = join(pythonDir, "bin", "python3")
const wrapperPath = join(openclawDir, "index.js")
const configPath = join(configDir, "openclaw.json")
const pythonManifestPath = join(pythonDir, "python-manifest.json")
const pythonRequirementsCopy = join(pythonDir, "runtime-requirements.lock.txt")
const manifestPath = join(stagingDir, "runtime-manifest.json")
const payloadReadmePath = join(stagingDir, "README.md")
if (existsSync(stagingDir)) {
rmSync(stagingDir, { recursive: true, force: true })
}
try {
mkdirSync(nodeBinDir, { recursive: true })
mkdirSync(openclawDir, { recursive: true })
mkdirSync(configDir, { recursive: true })
mkdirSync(ffmpegBinDir, { recursive: true })
mkdirSync(playwrightBrowsersDir, { recursive: true })
// --- Copy Node ---
console.log(`Copying Node from ${sourceNode}`)
copyFileSync(sourceNode, payloadNodeExe)
chmodPlusX(payloadNodeExe)
// --- Copy OpenClaw package ---
console.log(`Copying OpenClaw package from ${sourceOpenClawDir}`)
cpSync(sourceOpenClawDir, openclawPackageDir, { recursive: true })
// --- Copy and rewrite config ---
const config = JSON.parse(readFileSync(sourceConfigPath, "utf8"))
if (!config.gateway) config.gateway = {}
if (!config.gateway.auth) config.gateway.auth = {}
config.gateway.mode = "local"
config.gateway.bind = "loopback"
config.gateway.port = gatewayPort
config.gateway.auth.mode = "token"
config.gateway.auth.token = gatewayToken
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8")
// --- Copy Python ---
console.log(`Copying bundled Python runtime from ${resolvedSourcePythonRoot}`)
cpSync(resolvedSourcePythonRoot, pythonDir, { recursive: true })
if (!existsSync(payloadPythonExe)) {
throw new Error(`Bundled Python executable not found at ${payloadPythonExe}`)
}
chmodPlusX(payloadPythonExe)
// --- Upgrade pip ---
console.log("Upgrading pip in bundled Python runtime")
execFileSync(payloadPythonExe, ["-m", "pip", "install", "--disable-pip-version-check", "--upgrade", "pip"], {
stdio: "inherit",
})
// --- Install requirements ---
console.log(`Installing locked runtime dependencies from ${requirementsPath}`)
execFileSync(payloadPythonExe, ["-m", "pip", "install", "--disable-pip-version-check", "-r", requirementsPath], {
stdio: "inherit",
})
// --- Install Playwright Chromium ---
console.log(`Installing bundled Playwright Chromium into ${playwrightBrowsersDir}`)
execFileSync(payloadPythonExe, ["-m", "playwright", "install", "chromium"], {
stdio: "inherit",
env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: playwrightBrowsersDir },
})
// --- Copy requirements lock file ---
copyFileSync(requirementsPath, pythonRequirementsCopy)
// --- Generate Python manifest ---
const manifestScript = `
import importlib.metadata as metadata
import json
import pathlib
import sys
requirements_path = pathlib.Path(sys.argv[1])
requested = []
for line in requirements_path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
package_name = line.split('==', 1)[0].strip()
requested.append({
'name': package_name,
'version': metadata.version(package_name)
})
payload = {
'pythonVersion': sys.version.split()[0],
'requestedPackages': requested,
'resolvedPackages': sorted(
[
{'name': dist.metadata['Name'], 'version': dist.version}
for dist in metadata.distributions()
if dist.metadata['Name']
],
key=lambda item: item['name'].lower(),
),
}
print(json.dumps(payload))
`.trim()
const pythonManifestJson = execFileSync(payloadPythonExe, ["-c", manifestScript, pythonRequirementsCopy], {
encoding: "utf8",
})
const pythonManifestProbe = JSON.parse(pythonManifestJson)
const materializedAt = new Date().toISOString()
const pythonManifest = {
pythonVersion: pythonManifestProbe.pythonVersion,
executable: "bin/python3",
requirementsPath: "runtime-requirements.lock.txt",
requestedPackages: pythonManifestProbe.requestedPackages,
resolvedPackages: pythonManifestProbe.resolvedPackages,
materializedAt,
}
writeFileSync(pythonManifestPath, JSON.stringify(pythonManifest, null, 2), "utf8")
// --- Copy ffmpeg from imageio-ffmpeg ---
const imageioFfmpegSource = getImageioFfmpegBinaryPath(pythonDir)
console.log(`Copying ffmpeg from ${imageioFfmpegSource}`)
copyFileSync(imageioFfmpegSource, payloadFfmpegExe)
chmodPlusX(payloadFfmpegExe)
if (!existsSync(payloadFfmpegExe)) {
throw new Error(`Bundled ffmpeg executable was not materialized at ${payloadFfmpegExe}`)
}
// --- Copy ffprobe ---
console.log(`Copying ffprobe from ${resolvedSourceFfprobe}`)
copyFileSync(resolvedSourceFfprobe, payloadFfprobeExe)
chmodPlusX(payloadFfprobeExe)
// --- Read OpenClaw package info ---
const openClawPackageJson = existsSync(sourceOpenClawPackageJsonPath)
? JSON.parse(readFileSync(sourceOpenClawPackageJsonPath, "utf8"))
: null
// --- Write runtime-manifest.json ---
const manifest = {
platform: "darwin",
arch: "arm64",
bundledNodeExecutable: "node/bin/node",
packagedOpenClawEntry: "openclaw/package/openclaw.mjs",
packagedSkillsRoot: "openclaw/package/skills",
defaultConfigPath: "config/openclaw.json",
pythonExecutable: "python/bin/python3",
pythonManifestPath: "python/python-manifest.json",
requirementsPath: "python/runtime-requirements.lock.txt",
ffmpegExecutable: "ffmpeg/bin/ffmpeg",
ffprobeExecutable: "ffmpeg/bin/ffprobe",
playwrightBrowsersPath: "playwright-browsers",
gatewayPort,
gatewayToken,
materializedAt,
materializationKey,
materializationInputs,
pythonVersion: pythonManifest.pythonVersion,
openClawVersion: openClawPackageJson?.version ?? null,
installedPythonPackages: pythonManifest.requestedPackages.map(p => `${p.name}==${p.version}`),
}
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8")
// --- Write openclaw/index.js wrapper ---
const wrapper = `const { readFileSync } = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
async function main() {
const runtimeDir = path.join(__dirname, '..');
const manifestPath = path.join(runtimeDir, 'runtime-manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const entryRelative = typeof manifest.packagedOpenClawEntry === 'string' ? manifest.packagedOpenClawEntry.trim() : '';
if (!entryRelative) {
throw new Error('runtime-manifest.json is missing packagedOpenClawEntry');
}
const entryPath = path.resolve(runtimeDir, entryRelative);
process.argv[1] = entryPath;
await import(pathToFileURL(entryPath).href);
}
main().catch((error) => {
const message = error instanceof Error ? error.stack || error.message : String(error);
console.error(message);
process.exit(1);
});
`
writeFileSync(wrapperPath, wrapper, "utf8")
// --- Write README ---
const readme = `# Bundled Runtime Payload
Immutable packaged payload under \`vendor/openclaw-runtime/\` includes:
- \`node/bin/node\`
- \`openclaw/index.js\`
- \`openclaw/package/openclaw.mjs\`
- \`config/openclaw.json\`
- \`python/bin/python3\`
- \`python/python-manifest.json\`
- \`python/runtime-requirements.lock.txt\`
- \`ffmpeg/bin/ffmpeg\`
- \`ffmpeg/bin/ffprobe\`
- \`playwright-browsers/\`
Mutable runtime data lives outside the installer payload and should be created under Electron \`userData/runtime/\`.
The payload is considered ready only when the Node entry, OpenClaw package, Python executable, Python manifest, FFmpeg/FFprobe tools, and locked Python imports all validate successfully on the target machine.
`
writeFileSync(payloadReadmePath, readme.trimStart(), "utf8")
// --- Cleanup ---
const payloadStatsBeforeCleanup = await runtimePayloadStats(stagingDir)
const cleanupSummary = invokeRuntimePayloadCleanup(stagingDir, openclawPackageDir, pythonDir)
const payloadStatsAfterCleanup = await runtimePayloadStats(stagingDir)
const cleanupBytesRemoved = payloadStatsBeforeCleanup.sizeBytes - payloadStatsAfterCleanup.sizeBytes
const cleanupFilesRemoved = payloadStatsBeforeCleanup.fileCount - payloadStatsAfterCleanup.fileCount
console.log(`Runtime payload cleanup removed ${cleanupFilesRemoved} files and ${cleanupBytesRemoved} bytes`)
// --- Update manifest with stats ---
manifest.sizeBytes = payloadStatsAfterCleanup.sizeBytes
manifest.fileCount = payloadStatsAfterCleanup.fileCount
manifest.topLevelBreakdown = payloadStatsAfterCleanup.topLevelBreakdown
manifest.payloadStats = {
beforeCleanup: payloadStatsBeforeCleanup,
afterCleanup: payloadStatsAfterCleanup,
bytesRemoved: cleanupBytesRemoved,
filesRemoved: cleanupFilesRemoved,
}
manifest.cleanupSummary = cleanupSummary
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8")
// --- Validate required files ---
const requiredPaths = [
payloadNodeExe,
wrapperPath,
join(openclawPackageDir, "openclaw.mjs"),
join(openclawPackageDir, "package.json"),
configPath,
payloadPythonExe,
pythonManifestPath,
payloadFfmpegExe,
payloadFfprobeExe,
manifestPath,
payloadReadmePath,
...getRequiredWorkspaceTemplatePaths(openclawPackageDir),
]
for (const rp of requiredPaths) {
if (!existsSync(rp)) {
throw new Error(`Bundled runtime materialization failed; missing ${rp}`)
}
}
// --- Atomic swap ---
if (existsSync(runtimeDir)) {
rmSync(runtimeDir, { recursive: true, force: true })
}
renameSync(stagingDir, runtimeDir)
console.log(JSON.stringify(buildRuntimeSummary(runtimeDir, manifest, false), null, 2))
} finally {
if (stagingDir && existsSync(stagingDir)) {
rmSync(stagingDir, { recursive: true, force: true })
}
}
}
function buildRuntimeSummary(runtimeDirPath, manifest, cacheHit) {
return {
ok: true,
cacheHit,
platform: manifest.platform ?? null,
arch: manifest.arch ?? null,
materializationKey: manifest.materializationKey,
runtimeDir: runtimeDirPath,
nodeExecutable: join(runtimeDirPath, "node", "bin", "node"),
openClawEntry: join(runtimeDirPath, "openclaw", "index.js"),
openClawPackageDir: join(runtimeDirPath, "openclaw", "package"),
configPath: join(runtimeDirPath, "config", "openclaw.json"),
manifestPath: join(runtimeDirPath, "runtime-manifest.json"),
pythonExecutable: join(runtimeDirPath, "python", "bin", "python3"),
pythonManifestPath: join(runtimeDirPath, "python", "python-manifest.json"),
requirementsPath: join(runtimeDirPath, "python", "runtime-requirements.lock.txt"),
ffmpegExecutable: join(runtimeDirPath, "ffmpeg", "bin", "ffmpeg"),
ffprobeExecutable: join(runtimeDirPath, "ffmpeg", "bin", "ffprobe"),
playwrightBrowsersPath: join(runtimeDirPath, "playwright-browsers"),
gatewayPort: manifest.gatewayPort,
gatewayToken: manifest.gatewayToken,
installedPythonPackages: manifest.installedPythonPackages,
sizeBytes: manifest.sizeBytes ?? null,
fileCount: manifest.fileCount ?? null,
topLevelBreakdown: manifest.topLevelBreakdown ?? [],
payloadStats: manifest.payloadStats ?? null,
cleanupSummary: manifest.cleanupSummary ?? null,
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error))
process.exit(1)
})
...@@ -51,8 +51,45 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig { ...@@ -51,8 +51,45 @@ function createConfig(overrides: Partial<AppConfig> = {}): AppConfig {
copywriting: { copywriting: {
baseUrl: "", baseUrl: "",
apiKeyConfigured: false apiKeyConfigured: false
},
digitalHuman: {
volcRegion: "cn-north-1",
volcService: "cv",
volcHost: "visual.volcengineapi.com",
volcScheme: "https",
ttsVoice: "zh-CN-YunxiNeural",
qiniuBucket: "alketas",
qiniuDomain: "http://tcwwu6wg4.hd-bkt.clouddn.com",
qiniuKeyPrefix: "omnihuman",
volcAccessKeyConfigured: false,
volcSecretKeyConfigured: false,
qiniuAccessKeyConfigured: false,
qiniuSecretKeyConfigured: false
}
},
douyinRuntimeConfig: {
videoAnalyzer: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
replicationBrief: {
baseUrl: "",
apiKeyConfigured: false,
modelId: ""
},
vectcut: {
baseUrl: "",
fileBaseUrl: "",
apiKeyConfigured: false
} }
}, },
xhsFeishuConfig: {
appIdConfigured: false,
appSecretConfigured: false,
appTokenConfigured: false,
tableIdConfigured: false
},
...overrides ...overrides
}; };
} }
...@@ -137,6 +174,9 @@ async function main(): Promise<void> { ...@@ -137,6 +174,9 @@ async function main(): Promise<void> {
"Gateway closed during connect (1006).", "Gateway closed during connect (1006).",
"Gateway closed during connect (1005).", "Gateway closed during connect (1005).",
"Gateway closed during connect (1000).", "Gateway closed during connect (1000).",
"Gateway closed before health probe completed (1000).",
"Timed out while probing reusable Gateway status and health at ws://127.0.0.1:18889.",
"Timed out while waiting for reusable Gateway at ws://127.0.0.1:18889.",
"connect ECONNREFUSED 127.0.0.1:18889" "connect ECONNREFUSED 127.0.0.1:18889"
]; ];
for (const message of transientCodes) { for (const message of transientCodes) {
......
# macOS .dmg 独立分支改造方案(不做双平台共存) # macOS Apple Silicon DMG 改造详细计划
## Summary ## 1. 背景与目标
目标改为:基于当前仓库事实,复制出一个新的独立分支,只为 macOS arm64 开发 .dmg 包与对应运行时,不考虑和现有 Windows 打包共存,也不要求保留 Windows 打包链路 本计划基于当前 `dev` 分支代码事实制定,目标是把现有 Windows OpenClaw 客户端安装包链路改造成 **只面向 macOS Apple Silicon 的内部测试 DMG 包**
这会把方案明显简化: 本次改造不做双平台共存,不保留 Windows NSIS 安装器作为目标链路,也不纳入 Apple Developer ID 签名、公证、staple 等正式外部分发能力。阶段性目标是先让 M 芯片 Mac 上可以构建、安装、启动并跑通 bundled runtime 与 workspace execution。
- 不做双平台 builder 配置兼容 ### 当前代码事实
- 不保留 NSIS / .exe / Windows branding / installer smoke
- 不做跨平台 materialize 主入口
- 直接把当前仓库里的 Windows 假设替换为 mac 假设
- 目标产物就是单一 macOS arm64 .dmg
当前需要改的核心仍是 4 块: > **注意**:以下为改造前的初始状态,用于记录基线。实际改造进度见第 9 节。
- electron-builder 只支持 Windows,见 /D:/qjclaw/apps/desktop/electron-builder.yml:24 - `apps/desktop/electron-builder.yml` 目前只配置了 `win``nsis`,产物命名为 `${productName}-Setup-${version}.${ext}`
- runtime 路径写死 node.exe / python.exe,见 /D:/qjclaw/packages/runtime-manager/src/index.ts:653 - `apps/desktop/electron-builder.yml` 引用了 `build/icons/brand-icon.ico`,但当前仓库未发现实际图标文件。
- runtime materialize 是 PowerShell 且产出 Windows 物料,见 /D:/qjclaw/build/scripts/materialize-runtime-payload.ps1:52 -`package.json``materialize:runtime` 指向 `build/scripts/materialize-runtime-payload.ps1`,是 PowerShell/Windows-only 链路。
- workspace 执行器含 PowerShell fallback 和 python\Scripts 假设,见 /D:/qjclaw/apps/desktop/src/main/services/project-workspace-executor.ts:271 -`package.json` 的大部分 smoke 命令都依赖 `.ps1``.exe`、NSIS installer 或 Windows Electron 路径。
- `packages/runtime-manager/src/index.ts` 写死了 `node/node.exe``python/python.exe``ffmpeg.exe``ffprobe.exe``python/Scripts`、PowerShell EPERM fallback 和 `taskkill`
- `apps/desktop/src/main/services/project-workspace-executor.ts` 同样包含 PowerShell wrapper fallback、Windows PATH 扩展名逻辑和 `python/Scripts` 注入。
- `vendor/openclaw-runtime` 目前只有 README 占位,README 描述的是 Windows runtime 布局。
- packaged runtime 路径解析已经适合 macOS 包内资源:`apps/desktop/src/main/index.ts` 会在 packaged 模式使用 `process.resourcesPath/vendor/openclaw-runtime`
## Goal ### 总体成功标准
### 目标 - 在 Apple Silicon macOS 上运行 `corepack pnpm run package:mac` 能产出 `.dmg`
- DMG 内 `.app` 包含 `vendor/openclaw-runtime` mac arm64 runtime。
- `.app` 可启动并进入主界面。
- RuntimeManager 能把 bundled runtime 判定为 `ready`
- managed OpenClaw Gateway 能启动并通过 readiness probe。
- workspace execution 能用 bundled Node/Python 跑通主路径。
- Playwright Chromium 能从包内 `playwright-browsers` 被 Python runtime 使用。
- 关闭应用后 managed runtime 能退出,不遗留 Gateway 占用端口。
在新的 mac 专用分支上,做出一个只面向 darwin-arm64 的桌面包架构。 ## 2. 改造边界与默认决策
### 成功标准 ### 平台边界
- 唯一目标平台:`darwin-arm64`
- 不支持 Windows。
- 不支持 Intel Mac。
- 不做 universal app。
- 不做 macOS x64 fallback。
- pnpm package:mac 能产出 .dmg ### 发布边界
- .app 内包含可工作的 mac bundled runtime
- 应用能启动并进入主界面 - 目标产物:内部测试 `.dmg`
- RuntimeManager 在 mac 下可识别 runtime ready - 不配置正式签名。
- workspace execution 主链路可跑通 - 不配置 notarization。
- 不保留任何 Windows 打包要求 - 不配置 auto update。
- 允许首次启动时由测试人员手动绕过 Gatekeeper。
## Phase 1: 建立 mac 专用分支的边界 ### 代码边界
- 保留 Electron 主进程、preload、UI、runtime-manager、workspace execution、OpenClaw package、Python skills。
- 废弃 Windows 打包目标、NSIS、installer.nsh、Windows installer smoke、PowerShell runtime materialize 主入口。
- Windows 相关历史脚本可暂时保留在仓库中,但 mac 分支的正式 scripts 不再引用它们。
### 目标 ### Runtime 目录标准
先明确这个分支里哪些内容可以直接删掉或废弃,避免继续为 Windows 兼容付出成本。 mac-only 分支统一采用以下布局:
### 步骤 ```text
vendor/openclaw-runtime/
node/bin/node
openclaw/index.js
openclaw/package/openclaw.mjs
openclaw/package/package.json
config/openclaw.json
python/bin/python3
python/python-manifest.json
python/runtime-requirements.lock.txt
ffmpeg/bin/ffmpeg
ffmpeg/bin/ffprobe
playwright-browsers/
runtime-manifest.json
README.md
```
1. 明确此分支唯一目标平台 ## 3. 分阶段实施计划
- 只支持 darwin-arm64 ### Phase 0: 建立实施基线
- 不支持 Windows
- 不支持 Intel mac
- 不做 universal
2. 明确此分支保留的能力 目标:在开始改代码前固定当前事实和验收边界,避免后续实施时重新决策。
- Electron 主程序 实施内容:
- UI 渲染层
- bundled runtime - 确认当前分支为 `dev`,并从这里切出 mac-only 工作分支。
- workspace execution - 记录本计划中的平台边界:只做 `darwin-arm64`
- mac 打包和 smoke - 确认本次不处理正式签名、公证、自动更新。
- 确认 runtime 物料来源要求:
- Node 使用本机 mac arm64 Node。
- Python 使用可复制的 mac arm64 Python 根目录。
- ffprobe 必须来自 PATH 或显式参数。
- OpenClaw package 必须能从本机安装位置解析。
阶段性目标:
- 有一个明确的 mac-only 工作分支。
- 所有人对“不会保留 Windows 打包兼容”有一致预期。
- 后续实现不再为 Windows/NSIS/PowerShell 设计抽象层。
验收标准:
- `git status --short --branch` 显示工作分支清晰。
- 文档中已记录 mac-only、内部测试 DMG 两个关键决策。
### Phase 1: 改造 Electron Builder 打包配置
目标:把当前 Windows installer 配置改成只生成 macOS arm64 DMG。
涉及文件:
- `apps/desktop/electron-builder.yml`
- `apps/desktop/package.json`
-`package.json`
实施内容:
- 重写 `apps/desktop/electron-builder.yml`
- 保留 `appId``productName``compression``asar``asarUnpack``directories.output``files``extraResources`
- 删除 `win` 配置。
- 删除 `nsis` 配置。
- 删除 `afterPack: build/hooks/after-pack-branding.cjs`
- 删除 `build/icons/brand-icon.ico` extraResource。
-`artifactName` 改成 `${productName}-${version}-mac-arm64.${ext}`
- 新增 `mac.target: dmg`
- 新增 `mac.arch: arm64`
- 新增 `mac.category: public.app-category.productivity`
- 内部测试阶段设置 `mac.identity: null` 或通过 electron-builder CLI 禁用 signing。
- 暂不强制配置 `mac.icon`,避免当前缺少 `.icns` 阻塞。
- DMG 配置采用最小可用布局:
- `dmg.title: ${productName} ${version}`
- `dmg.contents` 包含 app 和 Applications link。
-`package.json` 新增:
- `materialize:runtime:mac`
- `package:mac`
- `apps/desktop/package.json` 新增或调整:
- `package:mac`
推荐命令语义:
```json
{
"materialize:runtime:mac": "node build/scripts/materialize-runtime-payload.mjs",
"package:mac": "corepack pnpm run materialize:runtime:mac && corepack pnpm build && corepack pnpm --filter @qjclaw/desktop exec electron-builder --config electron-builder.yml --mac dmg --arm64"
}
```
阶段性目标:
- electron-builder 不再尝试生成 `.exe` 或 NSIS installer。
- macOS 上可以进入 electron-builder 的 mac target 构建流程。
- 打包命令名称与产物目标清晰对应。
验收标准:
- `rg -n "nsis|win:|Setup|brand-icon.ico|after-pack-branding" apps/desktop/electron-builder.yml` 没有命中。
- `corepack pnpm --filter @qjclaw/desktop exec electron-builder --config electron-builder.yml --mac dmg --arm64 --dir` 至少能进入 mac packaging 阶段。
### Phase 2: 新建 mac runtime materialize 脚本
目标:替代 PowerShell runtime 物料生成链路,生成 mac arm64 可运行的 `vendor/openclaw-runtime`
涉及文件:
- `build/scripts/materialize-runtime-payload.mjs`
- `build/runtime/python/runtime-requirements.lock.txt`
- `vendor/openclaw-runtime/README.md`
-`package.json`
实施内容:
- 新建 Node 脚本 `build/scripts/materialize-runtime-payload.mjs`
- 脚本启动时校验:
- `process.platform === "darwin"`
- `process.arch === "arm64"`
- 支持参数和环境变量:
- `--runtime-dir`
- `--source-config-path`
- `--source-node`
- `--source-python-root`
- `--source-openclaw-entry`
- `--source-ffprobe`
- `--gateway-port`
- `--gateway-token`
- `QJCLAW_MAC_PYTHON_ROOT`
- 默认值:
- runtime dir: `vendor/openclaw-runtime`
- config: `~/.openclaw/openclaw.json`
- requirements: `build/runtime/python/runtime-requirements.lock.txt`
- gateway port: `18889`
- gateway token: `qjc-bundled-runtime-token`
- 生成 staging 目录,全部成功后再原子替换 `vendor/openclaw-runtime`
- 复制 Node:
- 解析 `node` 命令。
- 放入 `node/bin/node`
- `chmod 755`
- 复制 OpenClaw package:
- 优先从 `--source-openclaw-entry` 解析。
- 否则从 PATH 中 `openclaw` 解析。
- 拷贝 package 到 `openclaw/package`
- 生成 `openclaw/index.js` wrapper,按 manifest 载入 `openclaw/package/openclaw.mjs`
- 复制并改写 config:
- 读取源 `openclaw.json`
- 强制设置 `gateway.mode = "local"`
- 强制设置 `gateway.bind = "loopback"`
- 设置 `gateway.port` 和 token。
- 写入 `config/openclaw.json`
- 复制 Python:
-`--source-python-root``QJCLAW_MAC_PYTHON_ROOT` 获取完整 Python 根目录。
- 要求存在 `bin/python3`
- 拷贝到 `python/`
- `chmod 755 python/bin/python3`
- 执行 `python/bin/python3 -m pip install --disable-pip-version-check --upgrade pip`
- 执行 `python/bin/python3 -m pip install --disable-pip-version-check -r build/runtime/python/runtime-requirements.lock.txt`
- 设置 `PLAYWRIGHT_BROWSERS_PATH` 后执行 `python/bin/python3 -m playwright install chromium`
- 复制 ffmpeg/ffprobe:
- `ffmpeg` 优先从 `imageio_ffmpeg` Python package 的 binaries 目录解析。
- `ffprobe``--source-ffprobe` 或 PATH 解析。
- 放入 `ffmpeg/bin/ffmpeg``ffmpeg/bin/ffprobe`
- 两者都 `chmod 755`
- 生成 `python/python-manifest.json`
- Python 版本。
- executable: `bin/python3`
- requirementsPath: `runtime-requirements.lock.txt`
- requestedPackages。
- resolvedPackages。
- 生成 `runtime-manifest.json`
- platform: `darwin`
- arch: `arm64`
- bundledNodeExecutable: `node/bin/node`
- packagedOpenClawEntry: `openclaw/package/openclaw.mjs`
- defaultConfigPath: `config/openclaw.json`
- pythonExecutable: `python/bin/python3`
- pythonManifestPath: `python/python-manifest.json`
- requirementsPath: `python/runtime-requirements.lock.txt`
- ffmpegExecutable: `ffmpeg/bin/ffmpeg`
- ffprobeExecutable: `ffmpeg/bin/ffprobe`
- playwrightBrowsersPath: `playwright-browsers`
- gatewayPort
- gatewayToken
- materializedAt
- materializationKey
- sizeBytes
- fileCount
- 清理低风险冗余文件:
- `__pycache__`
- `*.pyc`
- `*.pyo`
- OpenClaw sourcemap
- 非模板 docs,保留 workspace bootstrap 需要的 templates。
阶段性目标:
- macOS 上可以通过一个 Node 脚本生成完整 runtime payload。
- 生成的 runtime 布局与后续 runtime-manager 路径完全一致。
- PowerShell 不再是 mac runtime 物料生成入口。
验收标准:
- `corepack pnpm run materialize:runtime:mac` 成功退出。
- `vendor/openclaw-runtime/node/bin/node --version` 成功。
- `vendor/openclaw-runtime/python/bin/python3 --version` 成功。
- `vendor/openclaw-runtime/python/bin/python3 -m pip --version` 成功。
- `vendor/openclaw-runtime/runtime-manifest.json``platform``darwin``arch``arm64`
- `vendor/openclaw-runtime/playwright-browsers` 存在且包含 Chromium。
### Phase 3: 改造 RuntimeManager 为 mac-only
目标:让 Electron 主进程能识别、启动、停止 mac bundled runtime。
涉及文件:
- `packages/runtime-manager/src/index.ts`
实施内容:
- `resolveBundledPaths()` 改为 mac 路径:
- `node/bin/node`
- `python/bin/python3`
- `ffmpeg/bin/ffmpeg`
- `ffmpeg/bin/ffprobe`
- 删除 Windows 专用工具:
- `escapePowerShellSingleQuoted`
- `resolveWindowsSystemExecutable`
- `WINDOWS_POWERSHELL_PATH`
- `WINDOWS_TASKKILL_PATH`
- `buildWindowsChildWrapperScript`
- 删除 `execPythonInlineScript()` 中的 Windows EPERM PowerShell fallback。
- 删除 `detectRuntime()` 中 Python manifest EPERM fallback。
- `resolveExecutableOnPath()` 不再处理 PATHEXT,直接按 POSIX PATH 查找。
- `resolveBundledFfmpegExecutable()` 支持:
- `ffmpeg/bin/ffmpeg`
- `python/lib/python*/site-packages/imageio_ffmpeg/binaries/ffmpeg-*`
- `playwright-browsers/ffmpeg-*` 下 mac ffmpeg 文件。
- `resolveBundledFfprobeExecutable()` 支持:
- `ffmpeg/bin/ffprobe`
- 与 ffmpeg 同目录的 `ffprobe`
- `buildManagedChildEnv()` 改为 mac 环境:
- 继承 `HOME``USER``TMPDIR``SHELL``PATH``LANG``LC_ALL` 等必要变量。
- 设置 `HOME = runtimeDataDir`
- 设置 `OPENCLAW_HOME``OPENCLAW_STATE_DIR``OPENCLAW_CONFIG_PATH`
- 设置 `PLAYWRIGHT_BROWSERS_PATH`
- PATH 顺序为 `node/bin``python/bin``ffmpeg/bin`、系统 PATH。
- `performStart()` 直接 `spawn(paths.nodeExecutable, childArgs, spawnOptions)`
- `stop()` 改为:
- 优先对 tracked child 发 `SIGTERM`
- 等待短延迟。
- 如仍未退出,发 `SIGKILL`
- 不调用 `taskkill`
- Python probe 保持现有核心导入检查,并补充当前 lock 文件中已有但 probe 未覆盖的关键包:
- `imageio-ffmpeg` 对应 `imageio_ffmpeg`
- `qiniu`
阶段性目标:
- RuntimeManager 不再有 Windows runtime 路径假设。
- RuntimeManager 能读取 mac runtime manifest 并判定 payload ready。
- RuntimeManager 能启动 Gateway 并在停止时清理进程。
验收标准:
- `rg -n "powershell|taskkill|node.exe|python.exe|ffmpeg.exe|ffprobe.exe|python.*Scripts|win32" packages/runtime-manager/src/index.ts` 不再命中有效运行逻辑。
- `corepack pnpm --filter @qjclaw/runtime-manager typecheck` 通过。
- 在 dev 环境指定 `QJCLAW_SMOKE_RUNTIME_DIR=vendor/openclaw-runtime` 后,RuntimeManager status 中:
- `payloadState``ready`
- `nodeExecutable` 指向 `node/bin/node`
- `pythonExecutable` 指向 `python/bin/python3`
### Phase 4: 改造 workspace execution 为 mac-only
目标:保证项目自动化和 workspace agent runner 都能通过 mac bundled runtime 执行。
涉及文件:
- `apps/desktop/src/main/services/project-workspace-executor.ts`
实施内容:
- 删除 PowerShell helper:
- `escapePowerShellSingleQuoted`
- `getWindowsPowerShellPath`
- 删除 spawn EPERM 后 PowerShell wrapper retry。
- `resolveExecutableOnPath()` 简化为 POSIX PATH 查询。
- `resolveInjectedBinaryPath()` command candidates 改为:
- ffmpeg: `["ffmpeg"]`
- ffprobe: `["ffprobe"]`
- child env PATH 改为:
- `path.dirname(paths.nodeExecutable)`
- `path.dirname(paths.pythonExecutable)`
- `path.dirname(paths.ffmpegExecutable)`
- `path.dirname(paths.ffprobeExecutable)`
- `process.env.PATH`
- 移除 `path.join(paths.runtimeDir, "python", "Scripts")`
- 保留现有协议不变:
- `workspaceAutomation.runtime = "python"`
- `workspaceAutomation.script` 模板解析。
- `project-workspace-agent-runner.js` stdin payload。
- stdout `QJC_WORKSPACE_EVENT` event stream。
阶段性目标:
- workspace automation 使用 `python/bin/python3`
- Node runner 使用 `node/bin/node`
- 所有执行路径都不依赖 shell wrapper。
验收标准:
- `rg -n "powershell|python.*Scripts|ffmpeg.exe|ffprobe.exe|win32" apps/desktop/src/main/services/project-workspace-executor.ts` 不再命中有效运行逻辑。
- `corepack pnpm --filter @qjclaw/desktop typecheck` 通过。
- 用一个最小 `project.json``workspaceAutomation.runtime = "python"` 项目验证 Python 脚本可启动。
### Phase 5: 调整 Electron mac 桌面行为
目标:让 `.app` 在 macOS 上以稳定、符合平台预期的方式启动和关闭。
涉及文件:
- `apps/desktop/src/main/create-window.ts`
- `apps/desktop/src/main/index.ts`
实施内容:
- `create-window.ts`
- 删除 Windows `.ico` 路径解析。
- mac 内部测试阶段可让 Electron 使用默认 app icon。
- 如果后续补充 `.icns`,只在 builder 配置中接入,不在 BrowserWindow 强行设置 `.ico`
- 菜单:
- mac 上不要完全 `Menu.setApplicationMenu(null)`
- 提供最小应用菜单:
- app name
- Quit
- Edit: Copy/Paste/Select All
- Window: Minimize/Close
- Windows 菜单隐藏策略不再作为目标。
- 生命周期:
- 保留 `window-all-closed` 中 mac 不直接 quit 的逻辑。
- 保留 `activate` 恢复或创建主窗口。
- 验证 Dock 点击能恢复窗口。
阶段性目标:
- mac `.app` 首次启动不会因窗口/菜单策略异常退出。
- 关闭窗口后 Dock 生命周期符合 mac 习惯。
3. 明确此分支不再保留的能力 验收标准:
- NSIS 安装器 - 启动应用出现主窗口。
- installer.nsh - `Cmd+Q` 可退出。
- Windows afterPack branding patch - 关闭最后一个窗口后应用仍可通过 Dock 重新打开窗口。
- .ico 图标链路
- node.exe / python.exe 路径假设
- taskkill
- PowerShell installer smoke
- 与 Windows 相关的打包命令和验证命令
4. 明确目录策略 ### Phase 6: 清理 package scripts 和 Windows installer 入口
- 继续沿用现有 repo 结构 目标:让 mac-only 分支的正式命令不再引用 Windows installer 链路。
- 但 vendor/openclaw-runtime 直接改成 mac 专用目录内容,不再做多平台子目录
- 因为这个分支只做 mac,没必要保留 win32-x64 物料布局 涉及文件:
-`package.json`
- `apps/desktop/package.json`
- `build/scripts/README.md`
### 输出 实施内容:
- 一个明确的“mac-only 分支边界” -`package.json`
- 后续实现过程中默认允许直接删除 Windows 打包逻辑 -`package` 指向 mac 打包,或保留 `package` 但新增明确的 `package:mac`
- 新增 `materialize:runtime:mac`
- 新增 `smoke:mac:runtime``smoke:mac:package``smoke:mac:workspace-entry`
- 将 Windows installer smoke 从正式验收命令中移除。
- `apps/desktop/package.json`
- 新增 `package:mac`
- `package` 若保留,应避免调用 PowerShell。
- `build/scripts/README.md`
- 更新 runtime materialize 说明为 mac 脚本。
- 标注旧 `.ps1` installer smoke 不属于 mac 分支验收入口。
阶段性目标:
- 新开发者只看 package scripts 就能知道 mac 打包入口。
- `corepack pnpm run package:mac` 是唯一正式 DMG 构建命令。
验收标准:
- `corepack pnpm run package:mac` 不调用 `powershell`
- `rg -n "materialize-runtime-payload.ps1|installer-smoke.ps1|Setup-.*exe" package.json apps/desktop/package.json` 不命中正式 mac scripts。
### Phase 7: 建立 mac smoke 验证脚本
目标:替代 Windows installer smoke,提供发布前最小可信回归。
涉及文件:
## Phase 2: Electron 打包链路改成 mac-only - `build/scripts/mac-runtime-smoke.mjs`
- `build/scripts/mac-package-smoke.mjs`
- `build/scripts/mac-workspace-entry-smoke.mjs`
-`package.json`
### 目标 实施内容:
把现在的 Windows electron-builder 配置,直接改造成只生成 mac .dmg 的配置。 - `mac-runtime-smoke.mjs`
- 读取 `vendor/openclaw-runtime/runtime-manifest.json`
- 校验 manifest 中 platform/arch。
- 校验 Node、Python、OpenClaw wrapper、config、ffmpeg、ffprobe、playwright-browsers 存在。
- 执行 Node/Python version probe。
- 执行 Python imports probe。
- `mac-package-smoke.mjs`
- 查找 `dist/installer/*.dmg`
- 查找 `dist/installer/mac-arm64/*.app` 或 electron-builder 实际输出目录。
- 校验 `.app/Contents/Resources/vendor/openclaw-runtime` 存在。
- 校验 `.app/Contents/Resources/app.asar` 存在。
- `mac-workspace-entry-smoke.mjs`
- 准备临时 project root。
- 写入最小 `project.json`,包含 Python automation。
- 通过 Electron smoke 或直接调用 executor 需要的主链路进行验证。
- 第一版可先做 runtime Python automation 级别验证,第二版再补 Electron UI 驱动。
- package scripts:
- `smoke:mac:runtime`
- `smoke:mac:package`
- `smoke:mac:workspace-entry`
### 步骤 阶段性目标:
1. 重写 /D:/qjclaw/apps/desktop/electron-builder.yml:1 - 能在不安装 DMG 的情况下验证 runtime payload。
- 能验证 electron-builder 产物资源布局。
- 能验证最小 workspace automation。
- 保留: 验收标准:
- appId
- productName
- compression
- asar
- asarUnpack
- files
- 删除:
- artifactName: ${productName}-Setup-${version}.${ext}
- afterPack: build/hooks/after-pack-branding.cjs
- extraResources 里的 .ico
- win
- nsis
2. 新增 mac-only 配置 - `corepack pnpm run smoke:mac:runtime` 通过。
- `corepack pnpm run smoke:mac:package` 通过。
- `corepack pnpm run smoke:mac:workspace-entry` 通过。
- 增加: ### Phase 8: 端到端 DMG 验证
- mac.target: dmg
- mac.arch: arm64
- mac.icon: build/icons/brand-icon.icns
- mac.category: public.app-category.productivity
- artifactName 改为适合 mac 的命名
- 增加 dmg 布局配置
- 输出目录可改为 dist/dmg 或 dist/release
3. 清理 Windows afterPack 目标:验证最终测试包真实可用。
- 不再使用 /D:/qjclaw/apps/desktop/build/hooks/after-pack-branding.cjs:100 实施内容:
- 该文件在 mac-only 分支可直接废弃
- 若后续需要 mac 的资源整理,再新写独立 hook
4. 图标资产替换 - 执行完整构建:
- 删除 .ico 在打包链路中的依赖 ```bash
- 补齐 .icns corepack pnpm run package:mac
- 主程序窗口图标解析改为 mac 逻辑 ```
5. 调整 package scripts - 打开 DMG。
-`.app` 拖入 `/Applications` 或测试目录。
- 启动 `.app`
- 验证:
- 主窗口出现。
- 设置页/聊天页不报主进程错误。
- Runtime status 显示 bundled runtime ready/running。
- managed Gateway readiness 成功。
- 默认聊天可以发起。
- 有 workspace automation 的项目可以调用 bundled Python。
- Playwright Chromium 能启动。
- 退出应用后 runtime 进程停止。
- 根 package.json 中: 阶段性目标:
- package 可直接改成 mac 打包命令,或新增 package:mac
- apps/desktop/package.json 中:
- package 也改成 mac-only 打包命令
### 输出 - 内部测试人员拿到 DMG 后可以安装并启动。
- 主要功能链路能完成一轮冒烟验证。
- 一个只生成 .dmg 的打包链路 验收标准:
- 不再有任何 NSIS / Setup / .exe 语义
## Phase 3: runtime-manager 全量切到 mac 路径 - `dist/installer/千匠问天-0.1.0-mac-arm64.dmg` 或对应版本文件存在。
- DMG 可挂载。
- `.app` 可启动。
- app 内 bundled runtime 可运行。
- 退出后无残留 managed Gateway 进程。
### 目标 ## 4. 推荐实施顺序
把运行时管理从 Windows 文件结构直接改成 mac 文件结构。 1.`dev` 基础上创建 mac-only 工作分支。
2.`electron-builder.yml`,先让 mac DMG target 配置成立。
3. 新建 `materialize-runtime-payload.mjs`,生成 mac runtime payload。
4. 更新 `vendor/openclaw-runtime/README.md` 为 mac 布局。
5.`runtime-manager` 的路径、PATH、ffmpeg/ffprobe、stop/start 逻辑。
6.`project-workspace-executor`,移除 PowerShell fallback 和 Windows PATH 假设。
7. 改 Electron 主窗口图标、菜单和 mac 生命周期细节。
8. 调整根 `package.json``apps/desktop/package.json` 的 mac 打包命令。
9. 增加 mac runtime/package/workspace smoke。
10. 跑 typecheck/build。
11. 跑 materialize runtime。
12. 跑 package:mac。
13. 挂载 DMG 做端到端人工验收。
### 步骤 ## 5. 风险与处理策略
1. 重写 resolveBundledPaths() ### Python runtime 可分发性
- 当前 /D:/qjclaw/packages/runtime-manager/src/index.ts:653 写死: 风险:直接复制系统 Python 或 Homebrew Python 可能存在动态库路径、venv、site-packages 可移植性问题。
- node/node.exe
- python/python.exe
- mac-only 分支直接改成:
- node/bin/node
- python/bin/python3
- 或你最终选定的 mac runtime 目录结构
2. 简化平台判断 处理策略:
- 删除大部分 process.platform === "win32" 特判 - 第一阶段使用显式 `QJCLAW_MAC_PYTHON_ROOT`,要求输入是已验证可复制的 Python 根目录。
- 代码默认认为当前平台是 mac - materialize 后立即在 `vendor/openclaw-runtime/python/bin/python3` 上执行 import probe。
- 保留极少量 darwin 生命周期逻辑即可 - 若复制式 Python 不稳定,下一阶段改为固定使用 standalone Python distribution。
3. 改 readiness 逻辑 ### ffprobe 来源
- readiness 检查目标文件全部切为 mac 文件 风险:`imageio-ffmpeg` 通常提供 ffmpeg,不保证提供 ffprobe。
- 关键存在性检查改成:
- Node 可执行文件
- Python 可执行文件
- OpenClaw 入口
- Config
- Playwright browsers
- README
- runtime manifest
4. 改 PATH 注入 处理策略:
- 当前把 python\Scripts 写入 PATH - materialize 阶段强制要求 `ffprobe` 可解析。
- 改成 mac 对应的 python/bin - 支持 `--source-ffprobe`
- 所有 child process 都依赖这个新路径 - 缺失时直接失败,不生成半可用 runtime。
5. 改 stop/restart ### Playwright 浏览器体积
- 删除 taskkill.exe 路径逻辑 风险:`playwright-browsers` 会显著增大 DMG。
- stop() 统一使用:
- SIGTERM
- 超时后 SIGKILL
- 不再维护 Windows 停止分支
6. 删除 PowerShell EPERM fallback 处理策略:
- execPythonInlineScript 中 PowerShell 回退可删 - v1 保留 Chromium,优先保证功能完整。
- runtime 启动的 PowerShell wrapper 可删 - 后续再做瘦身,不在首版 DMG 改造里压缩能力。
- 失败就直接报 mac 实际错误,不做 Windows 兼容补丁
### 输出 ### Gatekeeper
- 一个纯 mac 的 runtime-manager 风险:内部测试未签名 app 首次打开可能被 macOS 阻止。
- 路径、进程控制、PATH 注入都不再有 Windows 残留
## Phase 4: bundled runtime 物料生成改成 mac-only 处理策略:
### 目标 - 文档明确当前包是内部测试包。
- 验收时允许通过系统设置或 `xattr -dr com.apple.quarantine` 处理。
- 正式分发另开签名/公证阶段。
用 mac 物料重建 vendor/openclaw-runtime,不再兼容 Windows payload。 ### 图标资源缺失
### 步骤 风险:当前仓库未发现 `brand-icon.ico/.icns/.png`,强行接入图标会阻塞。
1. 废弃现有 PowerShell materialize 主入口 处理策略:
- /D:/qjclaw/build/scripts/materialize-runtime-payload.ps1:52 是 Windows-only 设计 - v1 不强制 `.icns`
- 在 mac-only 分支中,不建议继续修它 - electron-builder 使用默认 icon。
- 直接新建 mac materialize 脚本作为唯一正式入口 - 拿到品牌图后再补 `build/icons/brand-icon.icns` 和 DMG background。
2. 重新定义 runtime 目录结构 ## 6. 阶段性交付清单
- vendor/openclaw-runtime/ ### Milestone A: DMG 配置可构建
- node/
- python/
- openclaw/
- config/
- playwright-browsers/
- runtime-manifest.json
- README.md
3. 生成 Node runtime 交付物:
- 从 mac 环境收集 Node 可执行文件 - mac-only `apps/desktop/electron-builder.yml`
- 放入 node/bin/node - `package:mac` 命令
- manifest 记录真实相对路径 - Windows NSIS 配置从正式打包链路移除
4. 生成 Python runtime 验收:
- 收集或封装 mac 可分发 Python runtime - electron-builder 能进入 mac arm64 packaging。
- 放入 python/bin/python3
- 安装锁定依赖
- 生成 python-manifest.json
- 验证可执行性
5. 复制 OpenClaw package ### Milestone B: mac runtime 可生成
- 保持现有 OpenClaw 包内容复制逻辑 交付物:
- 保留模板文档依赖,例如 AGENTS.md 所在模板路径
6. 复制默认 config - `build/scripts/materialize-runtime-payload.mjs`
- mac 版 `vendor/openclaw-runtime`
- mac 版 runtime README
- 继续产出 config/openclaw.json 验收:
7. 复制 Playwright browsers - `materialize:runtime:mac` 成功。
- Node/Python/Playwright/ffmpeg/ffprobe probe 成功。
- 用 mac 平台的 browsers 目录 ### Milestone C: app 可启动 bundled runtime
- 不能复用 Windows 浏览器缓存
8. 生成 runtime manifest 交付物:
- 记录: - mac-only RuntimeManager
- platform: darwin - mac-only workspace executor
- arch: arm64 - mac child env 和 PATH 注入
- nodeExecutable
- pythonExecutable
- pythonBinDir
- openClawEntry
- playwrightBrowsersPath
- materializationKey
9. 建立 materialize:runtime:mac 验收:
- 根 package.json 增加 mac 专用命令 - RuntimeManager `payloadState = ready`
- 所有打包与 smoke 都依赖这个命令 - Gateway readiness 成功。
- runtime stop 能清理进程。
### 输出 ### Milestone D: DMG 可安装可冒烟
- vendor/openclaw-runtime 变成 mac 可运行 payload 交付物:
- materialize 链路不再依赖 PowerShell
## Phase 5: workspace execution 改成 mac-only - `.dmg`
- mac smoke scripts
- 更新后的打包验证文档
### 目标 验收:
保证 Python automation 和 Node runner 都能在 mac bundled runtime 下运行。 - DMG 可挂载。
- `.app` 可启动。
- 主界面、runtime、workspace automation、Playwright 均通过冒烟。
### 步骤 ## 7. 最终验收命令
1. 简化 /D:/qjclaw/apps/desktop/src/main/services/project-workspace-executor.ts:271 ```bash
corepack pnpm --filter @qjclaw/runtime-manager typecheck
corepack pnpm --filter @qjclaw/desktop typecheck
corepack pnpm build
corepack pnpm run materialize:runtime:mac
corepack pnpm run smoke:mac:runtime
corepack pnpm run package:mac
corepack pnpm run smoke:mac:package
corepack pnpm run smoke:mac:workspace-entry
```
- 删除 getWindowsPowerShellPath() ## 8. 后续增强项
- 删除 escapePowerShellSingleQuoted() 若仅用于 PowerShell
- 删除 EPERM -> PowerShell wrapper fallback
2. 保留主执行路径 这些不进入首版 mac-only 内部测试 DMG 范围:
- 统一直接 spawn(executable, args, spawnOptions) - Developer ID 签名。
- executable 来自 runtime-manager 的 mac 路径 - Notarization。
- 不通过 shell 包裹 - Staple。
- Auto update feed。
- Universal app。
- Intel Mac 支持。
- 品牌 `.icns`、DMG background、Applications 拖拽窗口美化。
- runtime payload 瘦身。
- 双平台共存抽象。
3. 调整 PATH 注入 ## 9. 实施进度跟踪
- 把 python\Scripts 改成 python/bin 工作分支:`feature/mac-arm64-dmg`
- 保留:
- OPENCLAW_HOME
- OPENCLAW_STATE_DIR
- OPENCLAW_CONFIG_PATH
- PLAYWRIGHT_BROWSERS_PATH
- 其余运行时环境变量
4. 保留 Python automation 协议 ### Phase 0: 建立实施基线 — ✅ 完成
- workspaceAutomation.runtime = "python" 不变 -`dev` 切出 `feature/mac-arm64-dmg` 分支
- scriptPath 模板解析逻辑不变 - 平台边界、发布边界、代码边界已记录在本计划中
- 只是 pythonExecutable 改用 mac 路径
5. 保留 Node runner 协议 ### Phase 1: 改造 Electron Builder 打包配置 — ✅ 代码完成,真实产物待验收
- project-workspace-agent-runner.js 继续保留 已修改文件:
- 运行入口改为 mac Node 可执行文件
### 输出 - `apps/desktop/electron-builder.yml` — 删除 `win`/`nsis`/`ico`/`afterPack`,新增 `mac.target: dmg`/`mac.arch: arm64`/`mac.category`/`mac.identity: null`/`dmg.title`/`dmg.contents``artifactName` 改为 `${productName}-${version}-mac-arm64.${ext}`
- `apps/desktop/package.json` — 新增 `package:mac` 脚本
- `package.json` — 新增 `package:mac``materialize:runtime:mac` 脚本
- `apps/ui/src/assets/brand-icon.svg` — 已接入品牌图资源,当前不再依赖缺失的 Windows `.ico`
- workspace execution 链路变成纯 mac 执行模型 验收状态:
- 不再有 PowerShell / Windows shell 假设
## Phase 6: Electron 主进程适配 mac 行为 - `rg -n "nsis|win:|Setup|brand-icon.ico|after-pack-branding" apps/desktop/electron-builder.yml` 无命中 ✅
- `corepack pnpm build` 已通过 ✅
- `electron-builder --mac dmg --arm64 --dir` 已通过 ✅
- 直连 GitHub 下载 Electron darwin arm64 zip 会长时间卡住;通过本地代理 `127.0.0.1:7897` 并提升权限后下载成功
- 已产出 `dist/installer/mac-arm64/千匠问天.app`
- 已产出 `dist/installer/千匠问天-0.1.0-mac-arm64.dmg`
- `hdiutil verify dist/installer/千匠问天-0.1.0-mac-arm64.dmg` 通过 ✅
- 当前产物内 `Resources/vendor/openclaw-runtime` 仅包含占位 README,尚不是最终可验收 runtime 包
### 目标 ### Phase 2: 新建 mac runtime materialize 脚本 — ✅ 代码完成,真实物料化待验收
让应用在 mac 上以合理方式启动和交互。 新建文件:
### 步骤 - `build/scripts/materialize-runtime-payload.mjs` — 完整的 mac arm64 runtime 物料生成脚本
- 平台校验 `darwin` + `arm64`
- 参数解析:`--runtime-dir``--source-config-path``--source-node``--source-python-root``--source-openclaw-entry``--source-ffprobe``--requirements-path``--gateway-port``--gateway-token`
- 环境变量 `QJCLAW_MAC_PYTHON_ROOT`
- Node / OpenClaw / Python / ffmpeg / ffprobe 复制与 chmod
- config 重写(gateway.mode=local, bind=loopback, port, token)
- pip upgrade + requirements install + Playwright install
- python-manifest.json 生成
- runtime-manifest.json 生成(`platform: "darwin"``arch: "arm64"`;mac 路径:`node/bin/node``python/bin/python3``ffmpeg/bin/ffmpeg``ffmpeg/bin/ffprobe`
- openclaw/index.js wrapper 生成
- README.md 生成(mac 布局)
- cleanup(`__pycache__``.pyc``.pyo``.map`、Python Doc/Tools/test/idlelib/tkinter、OpenClaw docs 保留 templates)
- 原子替换 vendor/openclaw-runtime
1. 改造窗口图标 - `package.json``materialize:runtime:mac` 已从 placeholder 改为 `node build/scripts/materialize-runtime-payload.mjs`
- /D:/qjclaw/apps/desktop/src/main/create-window.ts:23 当前只处理 .ico 验收状态:
- 改成只处理 mac 资源,或直接依赖应用 bundle 图标
- 删除 .ico 路径引用
2. 调整窗口框架 - `node --check build/scripts/materialize-runtime-payload.mjs` 通过 ✅
- `corepack pnpm run materialize:runtime:mac` 已执行,当前失败在第一处硬前置:`ffprobe is required...`
- 本机前置探测结果:
- Node 是 `darwin arm64`,路径为 `/Users/edy/.nvm/versions/node/v24.15.0/bin/node`
- `QJCLAW_MAC_PYTHON_ROOT` 未设置 ❌
- `command -v openclaw` 无结果 ❌
- `command -v ffprobe` 无结果 ❌
- `~/.openclaw/openclaw.json` 不存在 ❌
- 仓库和 `node_modules` 中未找到可直接复用的 `openclaw.mjs``openclaw.json``ffprobe`
- 后续仍需要:`QJCLAW_MAC_PYTHON_ROOT` 指向包含 `bin/python3` 的可复制 Python 根目录;用 `--source-openclaw-entry` 显式指定 OpenClaw entry;安装 ffmpeg 或用 `--source-ffprobe` 显式指定;准备 `~/.openclaw/openclaw.json` 或用 `--source-config-path` 指定
- 当前: ### Phase 3: 改造 RuntimeManager 为 mac-only — ✅ 代码完成,真实启动待验收
- frame: false
- titleBarStyle: "hidden"
- mac 下先以“可用优先”
- 推荐默认方案:
- 恢复较标准的 mac 窗口框架
- 不把 Windows 的无边框方案强搬过去
- 如果后续再做 mac 自定义标题栏,再单独优化
3. 菜单策略 已修改文件:`packages/runtime-manager/src/index.ts`
- 当前 Menu.setApplicationMenu(null) 在 mac 上不理想 具体改动:
- mac-only 分支应至少提供基础应用菜单
- 不必一开始做复杂菜单,但不能完全无菜单 - 路径改为 mac:`node/bin/node``python/bin/python3``ffmpeg/bin/ffmpeg``ffmpeg/bin/ffprobe`
- Runtime payload readiness probe 已补 `imageio_ffmpeg``qiniu`
- Playwright ffmpeg fallback 已补充到 `python/lib/*/site-packages/imageio_ffmpeg/binaries/ffmpeg-*`
- 删除 `escapePowerShellSingleQuoted``resolveWindowsSystemExecutable``WINDOWS_POWERSHELL_PATH``WINDOWS_TASKKILL_PATH`
- `execPythonInlineScript` 简化为直接执行(删除 EPERM PowerShell fallback)
- `detectRuntime` 中 Python probe fallback 改为通用 manifest fallback(不再限于 win32 EPERM)
- `resolveExecutableOnPath` 简化为 POSIX PATH 查询(删除 PATHEXT 逻辑)
- `resolveBundledFfmpegExecutable` 改为 mac 路径:`ffmpeg/bin/ffmpeg``python/lib/*/site-packages/imageio_ffmpeg/binaries/ffmpeg-*`
- `resolveBundledFfprobeExecutable` 改为 mac 路径:`ffmpeg/bin/ffprobe`
- `stop()` 改为 SIGTERM → 500ms → SIGKILL(删除 `taskkill`/`process.platform === "win32"` 分支)
- `performStart` 删除 EPERM PowerShell wrapper fallback(直接 spawn,失败即报错)
- `buildManagedChildEnv` 环境变量改为 macOS:HOME/USER/TMPDIR/SHELL/LANG/LC_ALL/PATH/XDG_* 等,删除 Windows 变量(ALLUSERSPROFILE/APPDATA/COMSPEC/HOMEDRIVE/HOMEPATH/PATHEXT/WINDIR 等);PATH 删除 `python/Scripts`,加入 `node/bin`;ffmpeg/ffprobe 命令名改为 `["ffmpeg"]`/`["ffprobe"]`;删除 `USERPROFILE`
- 删除 `buildWindowsChildWrapperScript` 方法
- 删除 `windowsHide: false` spawn 选项
4. 生命周期 验收状态:
- window-all-closed 与 activate 的 mac 逻辑保留 - `rg -n "powershell|taskkill|node.exe|python.exe|ffmpeg.exe|ffprobe.exe|python.*Scripts|win32" packages/runtime-manager/src/index.ts` 无命中 ✅
- 验证 dock 点击恢复窗口行为 - `corepack pnpm --filter @qjclaw/runtime-manager typecheck` 通过 ✅
- bundled runtime 启动和 Gateway readiness 仍需在真实 runtime payload 生成后验收
- `corepack pnpm run smoke:mac:runtime` 已执行,当前因 `vendor/openclaw-runtime` 只有 README、缺 `runtime-manifest.json`/Node/Python/ffmpeg/ffprobe/Playwright 而失败 ❌
### 输出 ### Phase 4: 改造 workspace execution 为 mac-only — ✅ 代码完成,真实执行待验收
- mac 启动和窗口行为稳定 已修改文件:`apps/desktop/src/main/services/project-workspace-executor.ts`
- 不受 Windows UI 壳策略影响
## Phase 7: 清理 Windows 打包与 smoke 残留 具体改动:
### 目标 - 删除 `escapePowerShellSingleQuoted``getWindowsPowerShellPath`
- `resolveExecutableOnPath` 简化为 POSIX PATH 查询(删除 PATHEXT / windowsExtensions)
- `resolveInjectedBinaryPath` 中 ffmpeg/ffprobe 命令名改为 `["ffmpeg"]`/`["ffprobe"]`(删除 `.exe`
- child env PATH 删除 `path.join(paths.runtimeDir, "python", "Scripts")`,保留 `path.dirname(paths.pythonExecutable)`
- spawn 删除 EPERM PowerShell wrapper fallback(删除 `let child` + try/catch PowerShell retry,改为 `const child = spawn(...)`)
- 删除 `windowsHide: true` spawn 选项
把这个分支里不再需要的 Windows 打包逻辑彻底下线,避免维护噪音。 验收状态:
- `rg -n "powershell|python.*Scripts|ffmpeg.exe|ffprobe.exe|win32" apps/desktop/src/main/services/project-workspace-executor.ts` 无命中 ✅
- `corepack pnpm --filter @qjclaw/desktop typecheck` 通过 ✅
- `smoke:mac:workspace-service` 已新增,用于通过 `ProjectWorkspaceExecutorService` 验证 bundled Python 调用;需在真实 runtime payload 生成后执行
- `corepack pnpm run smoke:mac:workspace-entry` 已执行,当前因缺 `vendor/openclaw-runtime/python/bin/python3` 失败 ❌
- `corepack pnpm run smoke:mac:workspace-service` 已执行,当前因缺 runtime manifest、bundled Python、bundled Node 失败 ❌
### Phase 5: 调整 Electron mac 桌面行为 — ✅ 代码完成,真实启动待验收
已修改文件:`apps/desktop/src/main/create-window.ts`
具体改动:
- 删除 `resolveWindowIcon()` 函数(win32 `.ico` 路径解析)
- 删除 `icon: resolveWindowIcon()` BrowserWindow 选项
- `Menu.setApplicationMenu(null)` 改为 `Menu.setApplicationMenu(buildApplicationMenu())`
- 新增 `buildApplicationMenu()` 函数,提供最小 mac 应用菜单:
- App 菜单:About、Quit (Cmd+Q)
- Edit 菜单:Copy (Cmd+C)、Paste (Cmd+V)、Select All (Cmd+A)
- Window 菜单:Minimize (Cmd+M)、Close (Cmd+W)
`apps/desktop/src/main/index.ts` 无需修改(已有正确的 mac 生命周期逻辑:`window-all-closed` 中对 darwin 不 quit、`activate` 恢复窗口)
验收状态:
- `corepack pnpm --filter @qjclaw/desktop typecheck` 通过 ✅
- `.app` 手工启动、主窗口、设置页、聊天页仍需在 unpacked app/DMG 产出后验收
### Phase 6: 清理 package scripts — ✅ 代码完成
已修改文件:`package.json`
- 新增 `smoke:mac:runtime``node build/scripts/mac-runtime-smoke.mjs`
- 新增 `smoke:mac:package``node build/scripts/mac-package-smoke.mjs`
- 新增 `smoke:mac:workspace-entry``node build/scripts/mac-workspace-entry-smoke.mjs`
- 新增 `smoke:mac:workspace-service``node build/scripts/mac-workspace-service-smoke.mjs`
- Windows PowerShell smoke 脚本暂时保留在 package.json 中,但 mac 分支的正式验收命令不再引用它们
### Phase 7: 建立 mac smoke 验证脚本 — ✅ 代码完成,真实 smoke 待跑
### 步骤 新建文件:
1. package scripts 清理 - `build/scripts/mac-runtime-smoke.mjs` — 校验 runtime-manifest.json(platform/arch)、所有必需文件存在、Node/Python 执行 probe、Python 关键 import probe、config gateway 校验
- `build/scripts/mac-package-smoke.mjs` — 查找 `.app` 目录、校验 `vendor/openclaw-runtime` 在 Resources 中、校验 `app.asar`、校验 Info.plist
- `build/scripts/mac-workspace-entry-smoke.mjs` — 创建临时 project(含 Python automation)、执行 Python 脚本、校验 QJC_WORKSPACE_EVENT 事件流、校验 Node 可执行
- `build/scripts/mac-workspace-service-smoke.mjs` — 编译并调用 `ProjectWorkspaceExecutorService`,验证 workspace service 使用 bundled Python 和 runtime env
### Phase 8: 端到端 DMG 验证 — ❌ 未开始
待执行:
```bash
corepack pnpm build
corepack pnpm --filter @qjclaw/desktop exec electron-builder --config electron-builder.yml --mac dmg --arm64 --dir
corepack pnpm run materialize:runtime:mac -- \
--source-python-root "$QJCLAW_MAC_PYTHON_ROOT" \
--source-openclaw-entry "/path/to/openclaw.mjs" \
--source-ffprobe "/path/to/ffprobe"
corepack pnpm run smoke:mac:runtime
corepack pnpm run smoke:mac:workspace-entry
corepack pnpm run smoke:mac:workspace-service
corepack pnpm run package:mac
corepack pnpm run smoke:mac:package
```
当前阻塞:
- `QJCLAW_MAC_PYTHON_ROOT` 未设置,且真实 `materialize:runtime:mac` 需要包含 `bin/python3` 的可复制 Python 根目录。
- `openclaw` 不在 PATH 时需通过 `--source-openclaw-entry /path/to/openclaw.mjs` 指定。
- `ffprobe` 不在 PATH 时需安装 ffmpeg 或通过 `--source-ffprobe /path/to/ffprobe` 指定。
- 默认 `~/.openclaw/openclaw.json` 不存在时需通过 `--source-config-path /path/to/openclaw.json` 指定。
- Electron darwin arm64 zip 直连 GitHub 会卡住;用 `HTTPS_PROXY=http://127.0.0.1:7897 HTTP_PROXY=http://127.0.0.1:7897 ALL_PROXY=http://127.0.0.1:7897 ELECTRON_GET_USE_PROXY=true` 可下载成功。
- `corepack pnpm run smoke:mac:package` 当前失败在 packaged runtime payload 不完整:`.app``app.asar`、DMG 均存在,但 `Resources/vendor/openclaw-runtime` 只有 README,缺 `runtime-manifest.json``node/bin/node``python/bin/python3`
- 删除或废弃: ### Milestone 达成状态
- smoke:installer
- smoke:installer:path-change
- smoke:installer:target-residue
- 其他明确依赖 .exe/NSIS 的命令
2. build scripts 清理 | Milestone | 描述 | 状态 | 备注 |
|-----------|------|------|------|
| A | DMG 配置可构建 | ✅ 通过 | 使用本地代理后 `--dir` 产出 `.app` 和 DMG;DMG checksum 验证通过 |
| B | mac runtime 可生成 | ✅ 代码完成,待真实 materialize | 需要 Python/OpenClaw/ffprobe 前置 |
| C | app 可启动 bundled runtime | ✅ 代码完成,待 bundled runtime 启动验证 | typecheck 通过;需在有 runtime payload 时验证 RuntimeManager/Gateway |
| D | DMG 可安装可冒烟 | ❌ 未完成 | 已有无 runtime payload 的 DMG;最终 package smoke 依赖 B 生成完整 runtime 后重跑 |
- 标记或移除: ### 改动文件汇总
- installer-smoke.ps1
- installer-path-change-smoke.ps1
- installer-target-residue-smoke.ps1
- installer.nsh
- 如果暂时不删,也应明确不再被任何命令引用
3. README/文档清理 已修改(7 个):
- /D:/qjclaw/build/scripts/README.md:1 中与 installer、NSIS、.exe 相关说明改为 mac 版本说明 - `apps/desktop/electron-builder.yml`
- 移除误导性的 Windows 打包说明 - `apps/desktop/package.json`
- `apps/desktop/src/main/create-window.ts`
- `apps/desktop/src/main/services/project-workspace-executor.ts`
- `package.json`
- `packages/runtime-manager/src/index.ts`
- `docs/dmg包改造方案.md`
4. 资源清理 新建(5 个):
- 移除 builder 中的 .ico - `build/scripts/materialize-runtime-payload.mjs`
- 不再把 Windows runtime 物料带入包中 - `build/scripts/mac-runtime-smoke.mjs`
- `build/scripts/mac-package-smoke.mjs`
### 输出 - `build/scripts/mac-workspace-entry-smoke.mjs`
- `build/scripts/mac-workspace-service-smoke.mjs`
- 分支内只保留 mac 需要的打包与验证路径
- 不再混杂 Windows 发布概念
## Phase 8: 建立 mac smoke 验证链路
### 目标
替代现有 Windows installer smoke,建立 mac 发布前回归链路。
### 步骤
1. smoke:mac:package
- 校验 .dmg 与 .app 是否产出
- 校验 .app/Contents/Resources 关键资源存在
2. smoke:mac:runtime
- 校验 bundled runtime 是否 ready
- 校验 manifest 路径可解析
- 校验 Node / Python 可执行
3. smoke:mac:python-imports
- 校验关键 Python 依赖导入:
- openpyxl
- pandas
- requests
- bs4
- lxml
- pypdf
- docx
- yaml
- PIL
- dotenv
- greenlet
- playwright
- edge_tts
4. smoke:mac:workspace-entry
- 启动应用
- 走 bundled runtime
- 执行 workspace entry
- 验证 event stream / reply 回传
5. smoke:mac:playwright
- 验证 bundled browsers 可被运行时访问
6. smoke:mac:launch
- 做一次最小人工/自动启动验证
- 确保应用首次进入主窗口而不是闪退
- 一组完整的 mac smoke 命令
## Phase 9: 交付顺序
### 推荐一步步实施顺序
1. 建立 mac-only 分支并确认“不保留 Windows 打包”
2. 重写 electron-builder.yml 为 mac-only
3. 新增 .icns 资源
4. 修改 create-window.ts 的图标、frame、menu 策略
5. 修改 runtime-manager,把 .exe / taskkill / PowerShell 全部切掉
6. 修改 project-workspace-executor.ts,移除 PowerShell fallback
7. 新建 mac runtime materialize 脚本
8. 重建 vendor/openclaw-runtime 为 mac payload
9. 接入 package:mac
10. 编写 smoke:mac:package
11. 编写 smoke:mac:runtime
12. 编写 smoke:mac:workspace-entry
13. 更新文档,清理 Windows installer 说明
## Deliverables
- mac-only 的 /D:/qjclaw/apps/desktop/electron-builder.yml:1
- mac 专用 .icns 图标资源
- mac-only 的 runtime-manager
- mac-only 的 project-workspace-executor
- 新的 mac runtime materialize 脚本
- 重建后的 vendor/openclaw-runtime
- package:mac
- smoke:mac:package
- smoke:mac:runtime
- smoke:mac:workspace-entry
- 一份 mac 打包与验证文档
## Test Plan
- pnpm typecheck
- pnpm materialize:runtime:mac
- pnpm package:mac
- pnpm smoke:mac:package
- pnpm smoke:mac:runtime
- pnpm smoke:mac:workspace-entry
- 人工确认:
- .dmg 可打开
- .app 可启动
- 主窗口正常
- bundled runtime 正常
- workspace automation 正常
## Assumptions
- 这是一个新的 mac 专用开发分支,不需要为 Windows 打包兼容负责。
- vendor/openclaw-runtime 在该分支里可以直接变成 mac-only 内容。
- 签名、公证、staple 当前不纳入实施。
- 若 mac 无边框窗口适配成本高,优先恢复标准 mac 窗口行为。
\ No newline at end of file
...@@ -8,11 +8,17 @@ ...@@ -8,11 +8,17 @@
"clean": "corepack pnpm -r run clean", "clean": "corepack pnpm -r run clean",
"dev": "corepack pnpm --parallel --filter @qjclaw/ui --filter @qjclaw/desktop dev", "dev": "corepack pnpm --parallel --filter @qjclaw/ui --filter @qjclaw/desktop dev",
"lint": "corepack pnpm -r run lint", "lint": "corepack pnpm -r run lint",
"package": "corepack pnpm run materialize:runtime && corepack pnpm build && corepack pnpm --filter @qjclaw/desktop exec electron-builder --config electron-builder.yml", "package": "corepack pnpm run package:mac",
"package:mac": "corepack pnpm run materialize:runtime:mac && corepack pnpm build && corepack pnpm --filter @qjclaw/desktop exec electron-builder --config electron-builder.yml --mac dmg --arm64",
"typecheck": "corepack pnpm -r run typecheck", "typecheck": "corepack pnpm -r run typecheck",
"smoke:installer": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1", "smoke:installer": "powershell -ExecutionPolicy Bypass -File build/scripts/installer-smoke.ps1",
"smoke:execution-policy": "powershell -ExecutionPolicy Bypass -File build/scripts/electron-smoke.ps1", "smoke:execution-policy": "powershell -ExecutionPolicy Bypass -File build/scripts/electron-smoke.ps1",
"materialize:runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/materialize-runtime-payload.ps1", "materialize:runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/materialize-runtime-payload.ps1",
"materialize:runtime:mac": "node build/scripts/materialize-runtime-payload.mjs",
"smoke:mac:runtime": "node build/scripts/mac-runtime-smoke.mjs",
"smoke:mac:package": "node build/scripts/mac-package-smoke.mjs",
"smoke:mac:workspace-entry": "node build/scripts/mac-workspace-entry-smoke.mjs",
"smoke:mac:workspace-service": "node build/scripts/mac-workspace-service-smoke.mjs",
"smoke:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/bundled-runtime-smoke.ps1", "smoke:bundled-runtime": "powershell -ExecutionPolicy Bypass -File build/scripts/bundled-runtime-smoke.ps1",
"smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1", "smoke:workspace-entry": "powershell -ExecutionPolicy Bypass -File build/scripts/workspace-entry-smoke.ps1",
"smoke:cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/cloud-bundle-smoke.ps1", "smoke:cloud-bundle": "powershell -ExecutionPolicy Bypass -File build/scripts/cloud-bundle-smoke.ps1",
...@@ -54,5 +60,3 @@ ...@@ -54,5 +60,3 @@
] ]
} }
} }
...@@ -19,7 +19,9 @@ import type { ...@@ -19,7 +19,9 @@ import type {
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const GATEWAY_CONNECT_REQUEST_ID = "runtime-manager-connect"; const GATEWAY_CONNECT_REQUEST_ID = "runtime-manager-connect";
const GATEWAY_STATUS_REQUEST_ID = "runtime-manager-status"; const GATEWAY_STATUS_REQUEST_ID = "runtime-manager-status";
const GATEWAY_HEALTH_REQUEST_ID = "runtime-manager-health";
const GATEWAY_PROBE_TIMEOUT_MS = 4_000; const GATEWAY_PROBE_TIMEOUT_MS = 4_000;
const GATEWAY_REUSE_READY_TIMEOUT_MS = 12_000;
const GATEWAY_READY_TIMEOUT_MS = 90_000; const GATEWAY_READY_TIMEOUT_MS = 90_000;
const GATEWAY_READY_POLL_INTERVAL_MS = 500; const GATEWAY_READY_POLL_INTERVAL_MS = 500;
const MANAGED_CHILD_PID_PREFIX = "__QJC_MANAGED_CHILD_PID__="; const MANAGED_CHILD_PID_PREFIX = "__QJC_MANAGED_CHILD_PID__=";
...@@ -106,6 +108,8 @@ interface GatewayProbeResult { ...@@ -106,6 +108,8 @@ interface GatewayProbeResult {
ready: boolean; ready: boolean;
checkedAt: string; checkedAt: string;
lastError?: string; lastError?: string;
version?: string;
availableMethods?: string[];
} }
interface GatewayProbeErrorShape { interface GatewayProbeErrorShape {
...@@ -170,7 +174,9 @@ const PYTHON_RUNTIME_IMPORTS = [ ...@@ -170,7 +174,9 @@ const PYTHON_RUNTIME_IMPORTS = [
["pdfplumber", "pdfplumber"], ["pdfplumber", "pdfplumber"],
["greenlet", "greenlet"], ["greenlet", "greenlet"],
["playwright", "playwright"], ["playwright", "playwright"],
["edge-tts", "edge_tts"] ["edge-tts", "edge_tts"],
["imageio-ffmpeg", "imageio_ffmpeg"],
["qiniu", "qiniu"]
] as const; ] as const;
const PYTHON_RUNTIME_DIRECT_IMPORT_PROBES = [ const PYTHON_RUNTIME_DIRECT_IMPORT_PROBES = [
...@@ -208,54 +214,9 @@ function trimTrailingPunctuation(value: string): string { ...@@ -208,54 +214,9 @@ function trimTrailingPunctuation(value: string): string {
return value.trim().replace(/[.\s]+$/u, ""); return value.trim().replace(/[.\s]+$/u, "");
} }
function escapePowerShellSingleQuoted(value: string): string {
return value.replace(/'/g, "''");
}
function resolveWindowsSystemExecutable(relativePath: string, fallback: string): string {
if (process.platform !== "win32") {
return fallback;
}
const systemRoot = process.env.SYSTEMROOT ?? process.env.WINDIR ?? "C:\\Windows";
return path.join(systemRoot, "System32", relativePath);
}
const WINDOWS_POWERSHELL_PATH = resolveWindowsSystemExecutable(
path.join("WindowsPowerShell", "v1.0", "powershell.exe"),
"powershell.exe"
);
const WINDOWS_TASKKILL_PATH = resolveWindowsSystemExecutable("taskkill.exe", "taskkill");
async function execPythonInlineScript(pythonExecutable: string, inlineScript: string): Promise<string> { async function execPythonInlineScript(pythonExecutable: string, inlineScript: string): Promise<string> {
try { const { stdout } = await execFileAsync(pythonExecutable, ["-c", inlineScript]);
const { stdout } = await execFileAsync(pythonExecutable, ["-c", inlineScript]); return stdout;
return stdout;
} catch (error) {
const errorCode = error instanceof Error
? String((error as Error & { code?: number | string }).code ?? "")
: "";
if (process.platform !== "win32" || errorCode !== "EPERM") {
throw error;
}
const command = [
"$script = @'",
inlineScript,
"'@",
"& '" + escapePowerShellSingleQuoted(pythonExecutable) + "' -c $script"
].join("\n");
const { stdout } = await execFileAsync(WINDOWS_POWERSHELL_PATH, [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
command
]);
return stdout;
}
} }
function formatGatewayProbeError(error: GatewayProbeErrorShape | undefined): string { function formatGatewayProbeError(error: GatewayProbeErrorShape | undefined): string {
...@@ -272,12 +233,16 @@ function formatGatewayProbeError(error: GatewayProbeErrorShape | undefined): str ...@@ -272,12 +233,16 @@ function formatGatewayProbeError(error: GatewayProbeErrorShape | undefined): str
async function probeGatewayReadiness( async function probeGatewayReadiness(
url: string, url: string,
token?: string, token?: string,
timeoutMs = GATEWAY_PROBE_TIMEOUT_MS timeoutMs = GATEWAY_PROBE_TIMEOUT_MS,
options: { requireStatusHealth?: boolean } = {}
): Promise<GatewayProbeResult> { ): Promise<GatewayProbeResult> {
return new Promise<GatewayProbeResult>((resolve) => { return new Promise<GatewayProbeResult>((resolve) => {
let settled = false; let settled = false;
let handshakeSent = false; let handshakeSent = false;
let connectAccepted = false; let connectAccepted = false;
let statusAccepted = false;
let gatewayVersion: string | undefined;
let availableMethods: string[] | undefined;
const finish = (result: Omit<GatewayProbeResult, "checkedAt">) => { const finish = (result: Omit<GatewayProbeResult, "checkedAt">) => {
if (settled) { if (settled) {
...@@ -303,7 +268,9 @@ async function probeGatewayReadiness( ...@@ -303,7 +268,9 @@ async function probeGatewayReadiness(
const timer = setTimeout(() => { const timer = setTimeout(() => {
finish({ finish({
ready: false, ready: false,
lastError: `Timed out while probing bundled Gateway readiness at ${url}.` lastError: options.requireStatusHealth
? `Timed out while probing reusable Gateway status and health at ${url}.`
: `Timed out while probing bundled Gateway readiness at ${url}.`
}); });
}, timeoutMs); }, timeoutMs);
...@@ -348,7 +315,69 @@ async function probeGatewayReadiness( ...@@ -348,7 +315,69 @@ async function probeGatewayReadiness(
} }
connectAccepted = true; connectAccepted = true;
finish({ ready: true }); const payload = (frame.payload ?? frame.result ?? {}) as {
server?: { version?: unknown };
features?: { methods?: unknown };
};
gatewayVersion = typeof payload.server?.version === "string" ? payload.server.version : undefined;
availableMethods = Array.isArray(payload.features?.methods)
? payload.features.methods.filter((method): method is string => typeof method === "string")
: undefined;
if (!options.requireStatusHealth) {
finish({
ready: true,
version: gatewayVersion,
availableMethods
});
return;
}
ws.send(JSON.stringify({
type: "req",
id: GATEWAY_STATUS_REQUEST_ID,
method: "status",
params: {}
}));
return;
}
if (frame.id === GATEWAY_STATUS_REQUEST_ID) {
if (frame.ok === false) {
finish({
ready: false,
lastError: formatGatewayProbeError(frame.error as GatewayProbeErrorShape | undefined)
});
return;
}
statusAccepted = true;
const payload = (frame.payload ?? frame.result ?? {}) as { runtimeVersion?: unknown };
if (typeof payload.runtimeVersion === "string" && payload.runtimeVersion.trim()) {
gatewayVersion = payload.runtimeVersion;
}
ws.send(JSON.stringify({
type: "req",
id: GATEWAY_HEALTH_REQUEST_ID,
method: "health",
params: {}
}));
return;
}
if (frame.id === GATEWAY_HEALTH_REQUEST_ID) {
if (frame.ok === false) {
finish({
ready: false,
lastError: formatGatewayProbeError(frame.error as GatewayProbeErrorShape | undefined)
});
return;
}
finish({
ready: true,
version: gatewayVersion,
availableMethods
});
} }
} catch (error) { } catch (error) {
finish({ finish({
...@@ -373,13 +402,40 @@ async function probeGatewayReadiness( ...@@ -373,13 +402,40 @@ async function probeGatewayReadiness(
finish({ finish({
ready: false, ready: false,
lastError: connectAccepted lastError: connectAccepted
? `Gateway closed before readiness probe completed (${code}).` ? statusAccepted
? `Gateway closed before health probe completed (${code}).`
: `Gateway closed before readiness probe completed (${code}).`
: `Gateway closed during readiness probe (${code}).` : `Gateway closed during readiness probe (${code}).`
}); });
}); });
}); });
} }
function isGatewayProbeNoListener(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("econnrefused")
|| normalized.includes("enotfound")
|| normalized.includes("ehostunreach")
|| normalized.includes("enetunreach");
}
function isGatewayProbeStartupTransient(message?: string): boolean {
if (!message) {
return false;
}
const normalized = message.toLowerCase();
return normalized.includes("gateway closed during readiness probe")
|| normalized.includes("gateway closed before readiness probe completed")
|| normalized.includes("gateway closed before health probe completed")
|| normalized.includes("timed out while probing reusable gateway")
|| normalized.includes("timed out while probing bundled gateway");
}
function formatPayloadIssue( function formatPayloadIssue(
missingFiles: string[], missingFiles: string[],
pythonProbe: PythonPayloadProbeResult, pythonProbe: PythonPayloadProbeResult,
...@@ -466,12 +522,6 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde ...@@ -466,12 +522,6 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde
.split(path.delimiter) .split(path.delimiter)
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter(Boolean); .filter(Boolean);
const windowsExtensions = process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
: [""];
for (const commandName of commandNames) { for (const commandName of commandNames) {
const trimmed = commandName.trim(); const trimmed = commandName.trim();
...@@ -483,16 +533,10 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde ...@@ -483,16 +533,10 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde
return trimmed; return trimmed;
} }
const extensionCandidates = path.extname(trimmed)
? [""]
: windowsExtensions;
for (const directory of pathEntries) { for (const directory of pathEntries) {
for (const extension of extensionCandidates) { const candidate = path.join(directory, trimmed);
const candidate = path.join(directory, `${trimmed}${extension}`); if (existsSync(candidate)) {
if (existsSync(candidate)) { return candidate;
return candidate;
}
} }
} }
} }
...@@ -501,20 +545,25 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde ...@@ -501,20 +545,25 @@ function resolveExecutableOnPath(commandNames: readonly string[]): string | unde
} }
function resolveBundledFfmpegExecutable(runtimeDir: string): string | undefined { function resolveBundledFfmpegExecutable(runtimeDir: string): string | undefined {
const stablePath = path.join(runtimeDir, "ffmpeg", "bin", "ffmpeg.exe"); const stablePath = path.join(runtimeDir, "ffmpeg", "bin", "ffmpeg");
if (existsSync(stablePath)) { if (existsSync(stablePath)) {
return stablePath; return stablePath;
} }
const imageioBinariesDir = path.join(runtimeDir, "python", "Lib", "site-packages", "imageio_ffmpeg", "binaries"); const imageioBinariesDir = path.join(runtimeDir, "python", "lib");
if (existsSync(imageioBinariesDir)) { if (existsSync(imageioBinariesDir)) {
try { try {
const imageioCandidate = readdirSync(imageioBinariesDir, { withFileTypes: true }) for (const pythonEntry of readdirSync(imageioBinariesDir, { withFileTypes: true })) {
.filter((entry) => entry.isFile() && /^ffmpeg-.*\.exe$/iu.test(entry.name)) if (!pythonEntry.isDirectory()) continue
.map((entry) => path.join(imageioBinariesDir, entry.name)) const binariesDir = path.join(imageioBinariesDir, pythonEntry.name, "site-packages", "imageio_ffmpeg", "binaries")
.sort((left, right) => left.localeCompare(right, "en"))[0]; if (!existsSync(binariesDir)) continue
if (imageioCandidate && existsSync(imageioCandidate)) { const imageioCandidate = readdirSync(binariesDir, { withFileTypes: true })
return imageioCandidate; .filter((entry) => entry.isFile() && entry.name.startsWith("ffmpeg-") && !entry.name.endsWith(".exe") && !entry.name.endsWith(".zip"))
.map((entry) => path.join(binariesDir, entry.name))
.sort((left, right) => left.localeCompare(right, "en"))[0];
if (imageioCandidate && existsSync(imageioCandidate)) {
return imageioCandidate;
}
} }
} catch { } catch {
// Ignore bundled imageio lookup failures and continue to other fallback locations. // Ignore bundled imageio lookup failures and continue to other fallback locations.
...@@ -524,17 +573,29 @@ function resolveBundledFfmpegExecutable(runtimeDir: string): string | undefined ...@@ -524,17 +573,29 @@ function resolveBundledFfmpegExecutable(runtimeDir: string): string | undefined
const playwrightBrowsersDir = path.join(runtimeDir, "playwright-browsers"); const playwrightBrowsersDir = path.join(runtimeDir, "playwright-browsers");
if (existsSync(playwrightBrowsersDir)) { if (existsSync(playwrightBrowsersDir)) {
try { try {
for (const entry of readdirSync(playwrightBrowsersDir, { withFileTypes: true })) { const candidates: string[] = [];
if (!entry.isDirectory() || !/^ffmpeg-/iu.test(entry.name)) { for (const browserEntry of readdirSync(playwrightBrowsersDir, { withFileTypes: true })) {
if (!browserEntry.isDirectory() || !browserEntry.name.startsWith("ffmpeg-")) {
continue; continue;
} }
const candidate = path.join(playwrightBrowsersDir, entry.name, "ffmpeg-win64.exe");
if (existsSync(candidate)) { const browserDir = path.join(playwrightBrowsersDir, browserEntry.name);
return candidate; for (const executableEntry of readdirSync(browserDir, { withFileTypes: true })) {
if (!executableEntry.isFile()) {
continue;
}
if (executableEntry.name === "ffmpeg" || executableEntry.name.startsWith("ffmpeg-")) {
candidates.push(path.join(browserDir, executableEntry.name));
}
} }
} }
const playwrightCandidate = candidates.sort((left, right) => left.localeCompare(right, "en"))[0];
if (playwrightCandidate && existsSync(playwrightCandidate)) {
return playwrightCandidate;
}
} catch { } catch {
// Ignore Playwright fallback lookup failures and continue with PATH fallback at injection time. // Ignore bundled Playwright lookup failures and continue without an ffmpeg fallback.
} }
} }
...@@ -542,19 +603,15 @@ function resolveBundledFfmpegExecutable(runtimeDir: string): string | undefined ...@@ -542,19 +603,15 @@ function resolveBundledFfmpegExecutable(runtimeDir: string): string | undefined
} }
function resolveBundledFfprobeExecutable(runtimeDir: string, ffmpegExecutable?: string): string | undefined { function resolveBundledFfprobeExecutable(runtimeDir: string, ffmpegExecutable?: string): string | undefined {
const stablePath = path.join(runtimeDir, "ffmpeg", "bin", "ffprobe.exe"); const stablePath = path.join(runtimeDir, "ffmpeg", "bin", "ffprobe");
if (existsSync(stablePath)) { if (existsSync(stablePath)) {
return stablePath; return stablePath;
} }
if (ffmpegExecutable) { if (ffmpegExecutable) {
for (const candidate of [ const candidate = path.join(path.dirname(ffmpegExecutable), "ffprobe");
path.join(path.dirname(ffmpegExecutable), "ffprobe.exe"), if (existsSync(candidate)) {
path.join(path.dirname(ffmpegExecutable), "ffprobe") return candidate;
]) {
if (existsSync(candidate)) {
return candidate;
}
} }
} }
...@@ -719,6 +776,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -719,6 +776,7 @@ export class RuntimeManager extends EventEmitter {
private lastError?: string; private lastError?: string;
private lastStderrLines: string[] = []; private lastStderrLines: string[] = [];
private startPromise?: Promise<RuntimeStatus>; private startPromise?: Promise<RuntimeStatus>;
private reusedExistingGateway = false;
constructor(options: RuntimeManagerOptions) { constructor(options: RuntimeManagerOptions) {
super(); super();
this.vendorRuntimeDir = options.vendorRuntimeDir; this.vendorRuntimeDir = options.vendorRuntimeDir;
...@@ -782,12 +840,12 @@ export class RuntimeManager extends EventEmitter { ...@@ -782,12 +840,12 @@ export class RuntimeManager extends EventEmitter {
const ffprobeExecutable = resolveBundledFfprobeExecutable(this.vendorRuntimeDir, ffmpegExecutable); const ffprobeExecutable = resolveBundledFfprobeExecutable(this.vendorRuntimeDir, ffmpegExecutable);
return { return {
runtimeDir: this.vendorRuntimeDir, runtimeDir: this.vendorRuntimeDir,
nodeExecutable: path.join(this.vendorRuntimeDir, "node", "node.exe"), nodeExecutable: path.join(this.vendorRuntimeDir, "node", "bin", "node"),
openClawEntry: path.join(this.vendorRuntimeDir, "openclaw", "index.js"), openClawEntry: path.join(this.vendorRuntimeDir, "openclaw", "index.js"),
packagedOpenClawEntry: path.join(this.vendorRuntimeDir, "openclaw", "package", "openclaw.mjs"), packagedOpenClawEntry: path.join(this.vendorRuntimeDir, "openclaw", "package", "openclaw.mjs"),
runtimeManifestPath: path.join(this.vendorRuntimeDir, "runtime-manifest.json"), runtimeManifestPath: path.join(this.vendorRuntimeDir, "runtime-manifest.json"),
defaultConfigPath: path.join(this.vendorRuntimeDir, "config", "openclaw.json"), defaultConfigPath: path.join(this.vendorRuntimeDir, "config", "openclaw.json"),
pythonExecutable: path.join(this.vendorRuntimeDir, "python", "python.exe"), pythonExecutable: path.join(this.vendorRuntimeDir, "python", "bin", "python3"),
pythonManifestPath: path.join(this.vendorRuntimeDir, "python", "python-manifest.json"), pythonManifestPath: path.join(this.vendorRuntimeDir, "python", "python-manifest.json"),
ffmpegExecutable, ffmpegExecutable,
ffprobeExecutable, ffprobeExecutable,
...@@ -837,14 +895,13 @@ export class RuntimeManager extends EventEmitter { ...@@ -837,14 +895,13 @@ export class RuntimeManager extends EventEmitter {
error: undefined error: undefined
}; };
const pythonProbeErrorCode = pythonProbe.error?.match(/(?:^|[;\s])code=([^;\s]+)/u)?.[1]?.toUpperCase(); if (!pythonProbe.ready && pythonManifestExists) {
if (!pythonProbe.ready && process.platform === "win32" && pythonManifestExists && pythonProbeErrorCode === "EPERM") {
const manifestProbe = await probePythonPayloadFromManifest(paths.pythonManifestPath); const manifestProbe = await probePythonPayloadFromManifest(paths.pythonManifestPath);
if (manifestProbe?.ready) { if (manifestProbe?.ready) {
pythonProbe = manifestProbe; pythonProbe = manifestProbe;
this.appendLog( this.appendLog(
"warn", "warn",
`Bundled Python direct probe was blocked with code ${pythonProbeErrorCode}; using python-manifest fallback for payload validation.` "Bundled Python direct probe failed; using python-manifest fallback for payload validation."
); );
} }
} }
...@@ -996,6 +1053,35 @@ export class RuntimeManager extends EventEmitter { ...@@ -996,6 +1053,35 @@ export class RuntimeManager extends EventEmitter {
return this.status(); return this.status();
} }
const reusableGateway = await this.waitForReusableExistingGateway();
if (reusableGateway.ready) {
this.reusedExistingGateway = true;
this.child = undefined;
this.managedChildPid = undefined;
this.lastError = undefined;
this.lastExitCode = undefined;
this.lastStartedAt = this.lastStartedAt ?? reusableGateway.checkedAt;
this.appendLog(
"info",
`Reusing existing OpenClaw Gateway at ${this.gatewayConnection.url}${reusableGateway.version ? ` (version ${reusableGateway.version})` : ""}; status and health probes succeeded.`
);
this.refreshStatus("running");
return this.status();
}
if (reusableGateway.lastError && !isGatewayProbeNoListener(reusableGateway.lastError)) {
this.reusedExistingGateway = false;
this.lastError = `Port already has a Gateway or service at ${this.gatewayConnection.url ?? "unknown"}, but it is not reusable: ${reusableGateway.lastError}`;
this.appendLog("error", this.lastError);
this.refreshStatus("error");
return this.status();
}
this.reusedExistingGateway = false;
if (reusableGateway.lastError) {
this.appendLog("info", `No reusable Gateway is active at ${this.gatewayConnection.url ?? "unknown"}: ${reusableGateway.lastError}`);
}
this.lastError = undefined; this.lastError = undefined;
this.lastExitCode = undefined; this.lastExitCode = undefined;
this.lastStartedAt = new Date().toISOString(); this.lastStartedAt = new Date().toISOString();
...@@ -1023,8 +1109,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -1023,8 +1109,7 @@ export class RuntimeManager extends EventEmitter {
const spawnOptions = { const spawnOptions = {
cwd: paths.runtimeDir, cwd: paths.runtimeDir,
env: childEnv, env: childEnv,
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"]
windowsHide: false
}; };
this.managedChildPid = undefined; this.managedChildPid = undefined;
...@@ -1033,34 +1118,10 @@ export class RuntimeManager extends EventEmitter { ...@@ -1033,34 +1118,10 @@ export class RuntimeManager extends EventEmitter {
try { try {
child = spawn(paths.nodeExecutable, childArgs, spawnOptions); child = spawn(paths.nodeExecutable, childArgs, spawnOptions);
} catch (error) { } catch (error) {
const errorCode = error instanceof Error this.lastError = `Bundled runtime failed to spawn: ${error instanceof Error ? error.message : String(error)}`;
? String((error as Error & { code?: number | string }).code ?? "") this.appendLog("error", this.lastError);
: ""; this.refreshStatus("error");
if (process.platform === "win32" && errorCode === "EPERM") { return this.status();
this.appendLog("warn", "Bundled runtime direct spawn was blocked with EPERM; retrying via PowerShell wrapper.");
const wrapperScript = this.buildWindowsChildWrapperScript(paths, childArgs, childStdoutLogPath, childStderrLogPath, childEnv);
try {
child = spawn(WINDOWS_POWERSHELL_PATH, [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
wrapperScript
], spawnOptions);
} catch (wrapperError) {
this.lastError = `Bundled runtime failed to spawn: ${wrapperError instanceof Error ? wrapperError.message : String(wrapperError)}`;
this.appendLog("error", this.lastError);
this.refreshStatus("error");
return this.status();
}
} else {
this.lastError = `Bundled runtime failed to spawn: ${error instanceof Error ? error.message : String(error)}`;
this.appendLog("error", this.lastError);
this.refreshStatus("error");
return this.status();
}
} }
this.child = child; this.child = child;
...@@ -1081,6 +1142,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -1081,6 +1142,7 @@ export class RuntimeManager extends EventEmitter {
this.lastStoppedAt = new Date().toISOString(); this.lastStoppedAt = new Date().toISOString();
this.child = undefined; this.child = undefined;
this.managedChildPid = undefined; this.managedChildPid = undefined;
this.reusedExistingGateway = false;
this.appendLog("error", this.lastError); this.appendLog("error", this.lastError);
this.refreshStatus("error"); this.refreshStatus("error");
}); });
...@@ -1090,6 +1152,7 @@ export class RuntimeManager extends EventEmitter { ...@@ -1090,6 +1152,7 @@ export class RuntimeManager extends EventEmitter {
const wasStopping = this.runtimeStatus.processState === "stopping"; const wasStopping = this.runtimeStatus.processState === "stopping";
this.child = undefined; this.child = undefined;
this.managedChildPid = undefined; this.managedChildPid = undefined;
this.reusedExistingGateway = false;
if (!wasStopping && code !== 0) { if (!wasStopping && code !== 0) {
const stderrHint = this.buildStderrHint(); const stderrHint = this.buildStderrHint();
this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${stderrHint ? `: ${stderrHint}` : ""}.`; this.lastError = `Bundled runtime exited unexpectedly with code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}${stderrHint ? `: ${stderrHint}` : ""}.`;
...@@ -1123,33 +1186,19 @@ export class RuntimeManager extends EventEmitter { ...@@ -1123,33 +1186,19 @@ export class RuntimeManager extends EventEmitter {
async stop(): Promise<RuntimeStatus> { async stop(): Promise<RuntimeStatus> {
if ((!this.child || this.child.exitCode !== null || this.child.killed) && !this.managedChildPid) { if ((!this.child || this.child.exitCode !== null || this.child.killed) && !this.managedChildPid) {
this.lastError = undefined; this.lastError = undefined;
this.reusedExistingGateway = false;
this.refreshStatus("stopped"); this.refreshStatus("stopped");
return this.status(); return this.status();
} }
this.reusedExistingGateway = false;
const child = this.child; const child = this.child;
const managedPid = this.managedChildPid; const managedPid = this.managedChildPid;
this.appendLog("info", `Stopping bundled runtime process ${managedPid ?? child?.pid ?? "unknown"}.`); this.appendLog("info", `Stopping bundled runtime process ${managedPid ?? child?.pid ?? "unknown"}.`);
this.refreshStatus("stopping"); this.refreshStatus("stopping");
try { try {
if (process.platform === "win32") { if (child) {
const pids = [...new Set([managedPid, child?.pid].filter((value): value is number => typeof value === "number" && value > 0))];
if (pids.length === 0) {
throw new Error("No managed runtime pid is available to stop.");
}
const failures: string[] = [];
for (const pid of pids) {
try {
await execFileAsync(WINDOWS_TASKKILL_PATH, ["/PID", String(pid), "/T", "/F"]);
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
}
}
if (failures.length === pids.length) {
throw new Error(failures.join(" | "));
}
} else if (child) {
child.kill("SIGTERM"); child.kill("SIGTERM");
} }
} catch (error) { } catch (error) {
...@@ -1161,6 +1210,11 @@ export class RuntimeManager extends EventEmitter { ...@@ -1161,6 +1210,11 @@ export class RuntimeManager extends EventEmitter {
} }
await delay(500); await delay(500);
if (this.child && this.child.exitCode === null && !this.child.killed) {
this.appendLog("warn", "Bundled runtime did not exit after SIGTERM; sending SIGKILL.");
this.child.kill("SIGKILL");
await delay(200);
}
if (!this.child) { if (!this.child) {
this.lastError = undefined; this.lastError = undefined;
} }
...@@ -1273,6 +1327,51 @@ export class RuntimeManager extends EventEmitter { ...@@ -1273,6 +1327,51 @@ export class RuntimeManager extends EventEmitter {
}; };
} }
private async waitForReusableExistingGateway(): Promise<GatewayProbeResult> {
if (!this.gatewayConnection.url) {
return {
ready: false,
checkedAt: new Date().toISOString(),
lastError: "Managed bundled runtime config did not define a Gateway URL."
};
}
const firstProbe = await probeGatewayReadiness(this.gatewayConnection.url, this.gatewayConnection.token, GATEWAY_PROBE_TIMEOUT_MS, {
requireStatusHealth: true
});
if (firstProbe.ready || isGatewayProbeNoListener(firstProbe.lastError) || !isGatewayProbeStartupTransient(firstProbe.lastError)) {
return firstProbe;
}
this.appendLog("info", `Existing Gateway at ${this.gatewayConnection.url} is starting; waiting for status and health probes to pass.`);
const deadline = Date.now() + GATEWAY_REUSE_READY_TIMEOUT_MS;
let lastProbe = firstProbe;
let lastLoggedProbeError = firstProbe.lastError;
while (Date.now() < deadline) {
await delay(GATEWAY_READY_POLL_INTERVAL_MS);
const probe = await probeGatewayReadiness(this.gatewayConnection.url, this.gatewayConnection.token, GATEWAY_PROBE_TIMEOUT_MS, {
requireStatusHealth: true
});
if (probe.ready || isGatewayProbeNoListener(probe.lastError) || !isGatewayProbeStartupTransient(probe.lastError)) {
return probe;
}
lastProbe = probe;
if (probe.lastError && probe.lastError !== lastLoggedProbeError) {
this.appendLog("warn", `Existing Gateway is not reusable yet: ${probe.lastError}`);
lastLoggedProbeError = probe.lastError;
}
}
return {
...lastProbe,
ready: false,
checkedAt: new Date().toISOString(),
lastError: lastProbe.lastError ?? `Timed out while waiting for reusable Gateway at ${this.gatewayConnection.url}.`
};
}
private async readGatewayConnection(configPath: string): Promise<RuntimeGatewayConnection> { private async readGatewayConnection(configPath: string): Promise<RuntimeGatewayConnection> {
try { try {
const raw = await readFile(configPath, "utf8"); const raw = await readFile(configPath, "utf8");
...@@ -1346,6 +1445,9 @@ export class RuntimeManager extends EventEmitter { ...@@ -1346,6 +1445,9 @@ export class RuntimeManager extends EventEmitter {
if (currentProcessState === "starting") { if (currentProcessState === "starting") {
message = "Starting managed bundled runtime process."; message = "Starting managed bundled runtime process.";
modeReason = "Electron Main is launching the bundled runtime process. Gateway readiness is still pending."; modeReason = "Electron Main is launching the bundled runtime process. Gateway readiness is still pending.";
} else if (currentProcessState === "running" && this.reusedExistingGateway) {
message = "Reusing an existing local OpenClaw Gateway; status and health checks passed.";
modeReason = "Bundled runtime is selected, and Electron Main found a compatible Gateway already listening on the configured port.";
} else if (currentProcessState === "running") { } else if (currentProcessState === "running") {
message = "Managed bundled runtime process is running and Gateway is ready."; message = "Managed bundled runtime process is running and Gateway is ready.";
modeReason = "Bundled runtime is selected, the managed process is alive, and Gateway readiness has completed."; modeReason = "Bundled runtime is selected, the managed process is alive, and Gateway readiness has completed.";
...@@ -1407,6 +1509,9 @@ export class RuntimeManager extends EventEmitter { ...@@ -1407,6 +1509,9 @@ export class RuntimeManager extends EventEmitter {
if (this.runtimeStatus.processState === "stopping" && this.child) { if (this.runtimeStatus.processState === "stopping" && this.child) {
return "stopping"; return "stopping";
} }
if (this.reusedExistingGateway && this.runtimeStatus.processState === "running" && !this.lastError) {
return "running";
}
if (this.child && this.child.exitCode === null && !this.child.killed) { if (this.child && this.child.exitCode === null && !this.child.killed) {
if (this.runtimeStatus.processState === "starting") { if (this.runtimeStatus.processState === "starting") {
return "starting"; return "starting";
...@@ -1422,36 +1527,20 @@ export class RuntimeManager extends EventEmitter { ...@@ -1422,36 +1527,20 @@ export class RuntimeManager extends EventEmitter {
private buildManagedChildEnv(paths: RuntimeResolvedPaths, managedConfigPath: string): NodeJS.ProcessEnv { private buildManagedChildEnv(paths: RuntimeResolvedPaths, managedConfigPath: string): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = {}; const childEnv: NodeJS.ProcessEnv = {};
const inheritedKeys = [ const inheritedKeys = [
"ALLUSERSPROFILE", "HOME",
"APPDATA", "USER",
"COMMONPROGRAMFILES", "TMPDIR",
"COMMONPROGRAMFILES(X86)",
"COMMONPROGRAMW6432",
"COMPUTERNAME",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"NUMBER_OF_PROCESSORS",
"OS",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_IDENTIFIER",
"PROCESSOR_LEVEL",
"PROCESSOR_REVISION",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PUBLIC",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP", "TEMP",
"TMP", "TMP",
"USERNAME", "SHELL",
"USERDOMAIN", "LANG",
"USERDOMAIN_ROAMINGPROFILE", "LC_ALL",
"WINDIR" "PATH",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
"SSL_CERT_FILE"
] as const; ] as const;
for (const key of inheritedKeys) { for (const key of inheritedKeys) {
...@@ -1462,7 +1551,6 @@ export class RuntimeManager extends EventEmitter { ...@@ -1462,7 +1551,6 @@ export class RuntimeManager extends EventEmitter {
} }
childEnv.HOME = this.runtimeDataDir; childEnv.HOME = this.runtimeDataDir;
childEnv.USERPROFILE = this.runtimeDataDir;
childEnv.OPENCLAW_HOME = this.runtimeDataDir; childEnv.OPENCLAW_HOME = this.runtimeDataDir;
childEnv.OPENCLAW_STATE_DIR = paths.runtimeStateDir; childEnv.OPENCLAW_STATE_DIR = paths.runtimeStateDir;
childEnv.OPENCLAW_CONFIG_PATH = managedConfigPath; childEnv.OPENCLAW_CONFIG_PATH = managedConfigPath;
...@@ -1470,8 +1558,8 @@ export class RuntimeManager extends EventEmitter { ...@@ -1470,8 +1558,8 @@ export class RuntimeManager extends EventEmitter {
childEnv.PLAYWRIGHT_BROWSERS_PATH = paths.playwrightBrowsersPath; childEnv.PLAYWRIGHT_BROWSERS_PATH = paths.playwrightBrowsersPath;
childEnv.PYTHONUTF8 = "1"; childEnv.PYTHONUTF8 = "1";
childEnv.PYTHONIOENCODING = "utf-8"; childEnv.PYTHONIOENCODING = "utf-8";
const resolvedFfmpeg = resolveInjectedBinaryPath("FFMPEG_BIN", paths.ffmpegExecutable, ["ffmpeg.exe", "ffmpeg"]); const resolvedFfmpeg = resolveInjectedBinaryPath("FFMPEG_BIN", paths.ffmpegExecutable, ["ffmpeg"]);
const resolvedFfprobe = resolveInjectedBinaryPath("FFPROBE_BIN", paths.ffprobeExecutable, ["ffprobe.exe", "ffprobe"]); const resolvedFfprobe = resolveInjectedBinaryPath("FFPROBE_BIN", paths.ffprobeExecutable, ["ffprobe"]);
if (resolvedFfmpeg) { if (resolvedFfmpeg) {
childEnv.FFMPEG_BIN = resolvedFfmpeg; childEnv.FFMPEG_BIN = resolvedFfmpeg;
} }
...@@ -1479,40 +1567,16 @@ export class RuntimeManager extends EventEmitter { ...@@ -1479,40 +1567,16 @@ export class RuntimeManager extends EventEmitter {
childEnv.FFPROBE_BIN = resolvedFfprobe; childEnv.FFPROBE_BIN = resolvedFfprobe;
} }
childEnv.PATH = [ childEnv.PATH = [
path.join(paths.runtimeDir, "node", "bin"),
path.dirname(paths.pythonExecutable),
paths.ffmpegExecutable ? path.dirname(paths.ffmpegExecutable) : null, paths.ffmpegExecutable ? path.dirname(paths.ffmpegExecutable) : null,
paths.ffprobeExecutable ? path.dirname(paths.ffprobeExecutable) : null, paths.ffprobeExecutable ? path.dirname(paths.ffprobeExecutable) : null,
path.join(paths.runtimeDir, "python", "Scripts"),
path.dirname(paths.pythonExecutable),
process.env.PATH ?? "" process.env.PATH ?? ""
].filter(Boolean).join(path.delimiter); ].filter(Boolean).join(path.delimiter);
return childEnv; return childEnv;
} }
private buildWindowsChildWrapperScript(
paths: RuntimeResolvedPaths,
childArgs: string[],
_childStdoutLogPath: string,
_childStderrLogPath: string,
childEnv: NodeJS.ProcessEnv
): string {
const envAssignments = Object.entries(childEnv)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `Set-Item -Path 'Env:${escapePowerShellSingleQuoted(key)}' -Value '${escapePowerShellSingleQuoted(value)}'`)
.join("; ");
const argumentList = childArgs
.map((value) => `'${escapePowerShellSingleQuoted(value)}'`)
.join(", ");
return [
envAssignments,
`Set-Location -LiteralPath '${escapePowerShellSingleQuoted(paths.runtimeDir)}'`,
`& '${escapePowerShellSingleQuoted(paths.nodeExecutable)}' @(${argumentList})`,
"$exitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 }",
"exit $exitCode"
].join("; ");
}
private captureManagedChildPid(chunk: Buffer): void { private captureManagedChildPid(chunk: Buffer): void {
const text = chunk.toString("utf8"); const text = chunk.toString("utf8");
const match = text.match(/__QJC_MANAGED_CHILD_PID__=(\d+)/); const match = text.match(/__QJC_MANAGED_CHILD_PID__=(\d+)/);
...@@ -1563,7 +1627,3 @@ export class RuntimeManager extends EventEmitter { ...@@ -1563,7 +1627,3 @@ export class RuntimeManager extends EventEmitter {
this.emit("log", entry); this.emit("log", entry);
} }
} }
...@@ -2,15 +2,15 @@ ...@@ -2,15 +2,15 @@
Immutable packaged payload under `vendor/openclaw-runtime/` includes: Immutable packaged payload under `vendor/openclaw-runtime/` includes:
- `node/node.exe` - `node/bin/node`
- `openclaw/index.js` - `openclaw/index.js`
- `openclaw/package/openclaw.mjs` - `openclaw/package/openclaw.mjs`
- `config/openclaw.json` - `config/openclaw.json`
- `python/python.exe` - `python/bin/python3`
- `python/python-manifest.json` - `python/python-manifest.json`
- `python/runtime-requirements.lock.txt` - `python/runtime-requirements.lock.txt`
- `ffmpeg/bin/ffmpeg.exe` - `ffmpeg/bin/ffmpeg`
- `ffmpeg/bin/ffprobe.exe` - `ffmpeg/bin/ffprobe`
- `playwright-browsers/` - `playwright-browsers/`
Mutable runtime data lives outside the installer payload and should be created under Electron `userData/runtime/`. Mutable runtime data lives outside the installer payload and should be created under Electron `userData/runtime/`.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment