源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(desktopRoot, "../..");
|
||||
|
||||
const desktopBridgePath = resolve(repoRoot, "apps/app/src/app/lib/desktop.ts");
|
||||
const electronMainPath = resolve(desktopRoot, "electron/main.mjs");
|
||||
|
||||
const desktopBridgeSource = readFileSync(desktopBridgePath, "utf8");
|
||||
const electronMainSource = readFileSync(electronMainPath, "utf8");
|
||||
|
||||
const destructure = desktopBridgeSource.match(/const\s*\{([\s\S]*?)\}\s*=\s*desktopBridge;/);
|
||||
if (!destructure?.[1]) {
|
||||
throw new Error(`Could not find desktopBridge export destructure in ${desktopBridgePath}`);
|
||||
}
|
||||
|
||||
const clientOnlyBridgeMethods = new Set([
|
||||
// Pure helper implemented in apps/app/src/app/lib/desktop.ts and
|
||||
// intentionally satisfied inside the renderer proxy, not over Electron IPC.
|
||||
"resolveWorkspaceListSelectedId",
|
||||
]);
|
||||
|
||||
const bridgeMethods = destructure[1]
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\/\/.*$/, "").trim().replace(/,$/, ""))
|
||||
.filter(Boolean)
|
||||
.filter((name) => !clientOnlyBridgeMethods.has(name));
|
||||
|
||||
const electronHandlers = new Set(
|
||||
// Registry entries look like `"workspaceCreate": async (event, ...args) =>`.
|
||||
Array.from(electronMainSource.matchAll(/^ "([^"]+)": async \(event/gm)).map((match) => match[1]),
|
||||
);
|
||||
|
||||
const missing = bridgeMethods.filter((name) => !electronHandlers.has(name));
|
||||
if (missing.length > 0) {
|
||||
console.error("Electron desktop bridge is missing IPC handlers:");
|
||||
for (const name of missing) console.error(`- ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Electron desktop bridge covers ${bridgeMethods.length} renderer methods.`);
|
||||
@@ -0,0 +1,181 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const asar = require("@electron/asar");
|
||||
|
||||
const computerUseHelperAppName = "OpenWork Computer Use.app";
|
||||
|
||||
const sidecarBases = [
|
||||
"opencode",
|
||||
];
|
||||
|
||||
function targetTriple(platformName, arch) {
|
||||
if (platformName === "darwin") {
|
||||
if (arch === "arm64") return "aarch64-apple-darwin";
|
||||
if (arch === "x64") return "x86_64-apple-darwin";
|
||||
}
|
||||
if (platformName === "linux") {
|
||||
if (arch === "arm64") return "aarch64-unknown-linux-gnu";
|
||||
if (arch === "x64") return "x86_64-unknown-linux-gnu";
|
||||
}
|
||||
if (platformName === "win32") {
|
||||
if (arch === "arm64") return "aarch64-pc-windows-msvc";
|
||||
if (arch === "x64") return "x86_64-pc-windows-msvc";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSidecarsDir(context) {
|
||||
if (context.electronPlatformName === "darwin") {
|
||||
const entries = fs.existsSync(context.appOutDir) ? fs.readdirSync(context.appOutDir) : [];
|
||||
const appName = entries.find((entry) => entry.endsWith(".app"));
|
||||
return appName ? path.join(context.appOutDir, appName, "Contents", "Resources", "sidecars") : null;
|
||||
}
|
||||
return path.join(context.appOutDir, "resources", "sidecars");
|
||||
}
|
||||
|
||||
function resolveMacAppPath(context) {
|
||||
if (context.electronPlatformName !== "darwin") return null;
|
||||
const appName = `${context.packager.appInfo.productFilename}.app`;
|
||||
const direct = path.join(context.appOutDir, appName);
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
|
||||
const entries = fs.existsSync(context.appOutDir) ? fs.readdirSync(context.appOutDir) : [];
|
||||
const fallback = entries.find((entry) => entry.endsWith(".app"));
|
||||
return fallback ? path.join(context.appOutDir, fallback) : null;
|
||||
}
|
||||
|
||||
function resolveAppAsarPath(context) {
|
||||
if (context.electronPlatformName === "darwin") {
|
||||
const appPath = resolveMacAppPath(context);
|
||||
return appPath ? path.join(appPath, "Contents", "Resources", "app.asar") : null;
|
||||
}
|
||||
return path.join(context.appOutDir, "resources", "app.asar");
|
||||
}
|
||||
|
||||
function normalizeAsarEntryPath(entry, separator) {
|
||||
return entry.split(separator).join("/");
|
||||
}
|
||||
|
||||
function verifyRuntimeDependencies(context) {
|
||||
const appAsarPath = resolveAppAsarPath(context);
|
||||
if (!appAsarPath || !fs.existsSync(appAsarPath)) {
|
||||
throw new Error(`Missing packaged app.asar at ${appAsarPath || context.appOutDir}`);
|
||||
}
|
||||
const packagedFiles = new Set(
|
||||
asar
|
||||
.listPackage(appAsarPath, { isPack: false })
|
||||
.map((entry) => normalizeAsarEntryPath(entry, path.sep)),
|
||||
);
|
||||
const stagedNodeModules = path.resolve(__dirname, "..", ".electron-runtime", "node_modules");
|
||||
const packageJsonPaths = [];
|
||||
function visitNodeModules(nodeModulesPath, archivePrefix) {
|
||||
for (const entry of fs.readdirSync(nodeModulesPath, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith("@")) {
|
||||
visitNodeModules(path.join(nodeModulesPath, entry.name), `${archivePrefix}/${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
const packagePath = path.join(nodeModulesPath, entry.name);
|
||||
if (!fs.existsSync(path.join(packagePath, "package.json"))) continue;
|
||||
const packageJsonPath = `${archivePrefix}/${entry.name}/package.json`;
|
||||
packageJsonPaths.push(packageJsonPath);
|
||||
const nestedNodeModules = path.join(packagePath, "node_modules");
|
||||
if (fs.existsSync(nestedNodeModules)) {
|
||||
visitNodeModules(nestedNodeModules, `${archivePrefix}/${entry.name}/node_modules`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(stagedNodeModules)) {
|
||||
throw new Error(`Missing staged MCP runtime at ${stagedNodeModules}`);
|
||||
}
|
||||
visitNodeModules(stagedNodeModules, "/node_modules");
|
||||
if (packageJsonPaths.length === 0) throw new Error("The staged MCP runtime is empty.");
|
||||
for (const packageJsonPath of packageJsonPaths) {
|
||||
if (!packagedFiles.has(packageJsonPath)) {
|
||||
throw new Error(`Missing staged MCP runtime package from app.asar: ${packageJsonPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function signComputerUseHelper(context) {
|
||||
const appPath = resolveMacAppPath(context);
|
||||
if (!appPath) return;
|
||||
|
||||
const helperPath = path.join(appPath, "Contents", "Resources", "helpers", computerUseHelperAppName);
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
throw new Error(`Missing Computer Use helper app at ${helperPath}`);
|
||||
}
|
||||
|
||||
const identity = process.env.OPENWORK_COMPUTER_USE_CODESIGN_IDENTITY
|
||||
|| process.env.CSC_NAME
|
||||
|| process.env.APPLE_CODESIGN_IDENTITY
|
||||
|| "-";
|
||||
const args = ["--force", "--deep", "--options", "runtime", "--sign", identity];
|
||||
if (identity !== "-") args.push("--timestamp");
|
||||
args.push(helperPath);
|
||||
|
||||
const result = spawnSync("codesign", args, { stdio: "inherit" });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`codesign failed for Computer Use helper app with status ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyExecutableTargetToAlias(sidecarsDir, targetName, aliasName) {
|
||||
const targetPath = path.join(sidecarsDir, targetName);
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
throw new Error(`Missing packaged sidecar for target: ${targetName}`);
|
||||
}
|
||||
|
||||
const aliasPath = path.join(sidecarsDir, aliasName);
|
||||
fs.copyFileSync(targetPath, aliasPath);
|
||||
try {
|
||||
fs.chmodSync(aliasPath, 0o755);
|
||||
} catch {
|
||||
// Windows and some filesystems may ignore chmod.
|
||||
}
|
||||
}
|
||||
|
||||
async function afterPack(context) {
|
||||
verifyRuntimeDependencies(context);
|
||||
const triple = targetTriple(context.electronPlatformName, context.arch);
|
||||
if (!triple) return;
|
||||
|
||||
const sidecarsDir = resolveSidecarsDir(context);
|
||||
if (!sidecarsDir || !fs.existsSync(sidecarsDir)) return;
|
||||
|
||||
const isWindows = context.electronPlatformName === "win32";
|
||||
const executableSuffix = isWindows ? ".exe" : "";
|
||||
const keep = new Set();
|
||||
|
||||
for (const base of sidecarBases) {
|
||||
const aliasName = `${base}${executableSuffix}`;
|
||||
const targetName = `${base}-${triple}${executableSuffix}`;
|
||||
copyExecutableTargetToAlias(sidecarsDir, targetName, aliasName);
|
||||
keep.add(aliasName);
|
||||
keep.add(targetName);
|
||||
}
|
||||
|
||||
const versionsAlias = "versions.json";
|
||||
const versionsTarget = `versions.json-${triple}${executableSuffix}`;
|
||||
const versionsTargetPath = path.join(sidecarsDir, versionsTarget);
|
||||
if (!fs.existsSync(versionsTargetPath)) {
|
||||
throw new Error(`Missing packaged sidecar metadata for target: ${versionsTarget}`);
|
||||
}
|
||||
fs.copyFileSync(versionsTargetPath, path.join(sidecarsDir, versionsAlias));
|
||||
keep.add(versionsAlias);
|
||||
keep.add(versionsTarget);
|
||||
|
||||
for (const entry of fs.readdirSync(sidecarsDir)) {
|
||||
if (!keep.has(entry)) {
|
||||
fs.rmSync(path.join(sidecarsDir, entry), { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
signComputerUseHelper(context);
|
||||
}
|
||||
|
||||
module.exports = afterPack;
|
||||
module.exports.default = afterPack;
|
||||
module.exports.normalizeAsarEntryPath = normalizeAsarEntryPath;
|
||||
@@ -0,0 +1,103 @@
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { existsSync, mkdtempSync, rmSync } = require("node:fs");
|
||||
const { tmpdir } = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const computerUseHelperAppName = "OpenWork Computer Use.app";
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed with status ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithRetry(command, args, attempts, baseDelayMs = 30_000) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const result = spawnSync(command, args, { stdio: "inherit" });
|
||||
if (result.status === 0) return;
|
||||
if (attempt >= attempts) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed with status ${result.status} after ${attempts} attempts`);
|
||||
}
|
||||
const delayMs = baseDelayMs * attempt;
|
||||
console.warn(
|
||||
`[electron-after-sign] ${command} ${args.join(" ")} failed with status ${result.status}; retrying in ${delayMs / 1000}s (attempt ${attempt}/${attempts}).`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required to notarize the Electron macOS app`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function computerUseHelperPath(appPath) {
|
||||
return path.join(appPath, "Contents", "Resources", "helpers", computerUseHelperAppName);
|
||||
}
|
||||
|
||||
function verifyComputerUseHelper(appPath, requireDistributionSignature) {
|
||||
const helperPath = computerUseHelperPath(appPath);
|
||||
if (!existsSync(helperPath)) {
|
||||
throw new Error(`Computer Use helper app is missing from packaged app: ${helperPath}`);
|
||||
}
|
||||
|
||||
run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", helperPath]);
|
||||
|
||||
if (!requireDistributionSignature) return;
|
||||
const result = spawnSync("codesign", ["--display", "--verbose=4", helperPath], { encoding: "utf8" });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`codesign --display failed for Computer Use helper with status ${result.status}`);
|
||||
}
|
||||
if (result.stderr.includes("Signature=adhoc")) {
|
||||
throw new Error("Computer Use helper app is ad-hoc signed; notarized builds require a Developer ID signature.");
|
||||
}
|
||||
}
|
||||
|
||||
async function afterSign(context) {
|
||||
if (context.electronPlatformName !== "darwin") return;
|
||||
|
||||
if (process.env.MACOS_NOTARIZE !== "true") {
|
||||
console.warn("[electron-after-sign] MACOS_NOTARIZE is not true; skipping notarization.");
|
||||
return;
|
||||
}
|
||||
|
||||
const appName = `${context.packager.appInfo.productFilename}.app`;
|
||||
const appPath = path.join(context.appOutDir, appName);
|
||||
verifyComputerUseHelper(appPath, process.env.MACOS_NOTARIZE === "true");
|
||||
|
||||
const notaryTempDir = mkdtempSync(path.join(tmpdir(), "openwork-electron-notary-"));
|
||||
const notaryZipPath = path.join(notaryTempDir, `${context.packager.appInfo.productFilename}-notary.zip`);
|
||||
const keyPath = requireEnv("APPLE_API_KEY_PATH");
|
||||
const keyId = requireEnv("APPLE_API_KEY");
|
||||
const issuer = requireEnv("APPLE_API_ISSUER");
|
||||
|
||||
try {
|
||||
run("ditto", ["-c", "-k", "--keepParent", appPath, notaryZipPath]);
|
||||
run("xcrun", [
|
||||
"notarytool",
|
||||
"submit",
|
||||
notaryZipPath,
|
||||
"--key",
|
||||
keyPath,
|
||||
"--key-id",
|
||||
keyId,
|
||||
"--issuer",
|
||||
issuer,
|
||||
"--wait",
|
||||
]);
|
||||
// Notarization tickets can take minutes to propagate to Apple's CDN after acceptance; stapler can transiently fail with status 65 ("CloudKit query failed").
|
||||
await runWithRetry("xcrun", ["stapler", "staple", appPath], 5);
|
||||
run("xcrun", ["stapler", "validate", appPath]);
|
||||
} finally {
|
||||
rmSync(notaryTempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = afterSign;
|
||||
module.exports.default = afterSign;
|
||||
module.exports.runWithRetry = runWithRetry;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, cpSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(desktopRoot, "../..");
|
||||
const electronSidecarDir = resolve(desktopRoot, "resources", "sidecars");
|
||||
const electronHelperDir = resolve(desktopRoot, "resources", "helpers");
|
||||
const electronRoot = resolve(desktopRoot, "electron");
|
||||
const packagedServerRoot = resolve(desktopRoot, "server");
|
||||
const packagedRuntimeRoot = resolve(desktopRoot, ".electron-runtime", "node_modules");
|
||||
const sentryBuildConfigPath = resolve(desktopRoot, ".electron-runtime", "openwork-sentry.json");
|
||||
|
||||
const pnpmCmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
const nodeCmd = process.execPath;
|
||||
|
||||
function needsShell(command) {
|
||||
return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
function run(command, args, cwd, env) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
shell: needsShell(command),
|
||||
env: env ? { ...process.env, ...env } : process.env,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSentryBuildConfig() {
|
||||
const dsn = process.env.OPENWORK_DESKTOP_SENTRY_DSN?.trim() ?? "";
|
||||
const tracesSampleRateRaw = process.env.OPENWORK_DESKTOP_SENTRY_TRACES_SAMPLE_RATE?.trim() ?? "";
|
||||
const tracesSampleRate = tracesSampleRateRaw ? Number(tracesSampleRateRaw) : 0.01;
|
||||
const config = {
|
||||
dsn: dsn || null,
|
||||
tracesSampleRate: Number.isFinite(tracesSampleRate) && tracesSampleRate >= 0 && tracesSampleRate <= 1
|
||||
? tracesSampleRate
|
||||
: 0.01,
|
||||
};
|
||||
writeFileSync(sentryBuildConfigPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
run(nodeCmd, [resolve(__dirname, "prepare-sidecar.mjs"), "--force", "--outdir", electronSidecarDir], desktopRoot);
|
||||
run(nodeCmd, [resolve(__dirname, "prepare-computer-use-helper.mjs"), "--force", "--outdir", electronHelperDir], desktopRoot);
|
||||
run(nodeCmd, [resolve(__dirname, "prepare-runtime-node-modules.mjs"), "--outdir", packagedRuntimeRoot], desktopRoot);
|
||||
writeSentryBuildConfig();
|
||||
// Build the server TS → JS so Electron can import it in-process
|
||||
run(pnpmCmd, ["--filter", "openwork-server", "build"], repoRoot);
|
||||
// automation-runner.mjs imports @openwork/headless-threads through its
|
||||
// published "default" export (dist/index.js); build it so plain-node
|
||||
// consumers resolve it in packaged layouts.
|
||||
run(pnpmCmd, ["--filter", "@openwork/headless-threads", "build"], repoRoot);
|
||||
// OPENWORK_ELECTRON_BUILD tells Vite to emit relative asset paths so
|
||||
// index.html resolves /assets/* correctly when loaded via file:// from
|
||||
// inside the packaged .app bundle.
|
||||
run(pnpmCmd, ["--filter", "@openwork/app", "build"], repoRoot, {
|
||||
OPENWORK_ELECTRON_BUILD: "1",
|
||||
});
|
||||
// Copy constants.json next to server dist so the packaged asar can resolve it.
|
||||
// Also patch the compiled import path so it works from both dev and packaged layouts.
|
||||
const serverDistDir = resolve(repoRoot, "apps", "server", "dist");
|
||||
const constantsSrc = resolve(repoRoot, "constants.json");
|
||||
copyFileSync(constantsSrc, resolve(serverDistDir, "constants.json"));
|
||||
const serverJsPath = resolve(serverDistDir, "server.js");
|
||||
const serverJsSrc = readFileSync(serverJsPath, "utf8");
|
||||
const patched = serverJsSrc.replace(
|
||||
/from\s+["']\.\.\/\.\.\/\.\.\/constants\.json["']/,
|
||||
'from "./constants.json"',
|
||||
);
|
||||
if (patched !== serverJsSrc) {
|
||||
writeFileSync(serverJsPath, patched, "utf8");
|
||||
}
|
||||
rmSync(packagedServerRoot, { recursive: true, force: true });
|
||||
cpSync(serverDistDir, resolve(packagedServerRoot, "dist"), { recursive: true });
|
||||
copyFileSync(resolve(repoRoot, "apps", "server", "package.json"), resolve(packagedServerRoot, "package.json"));
|
||||
for (const fileName of readdirSync(electronRoot).filter((name) => name.endsWith(".mjs")).sort()) {
|
||||
run(nodeCmd, ["--check", resolve(electronRoot, fileName)], repoRoot);
|
||||
}
|
||||
run(nodeCmd, [resolve(__dirname, "check-electron-bridge.mjs")], repoRoot);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
renderer: "apps/app/dist",
|
||||
electronMain: "apps/desktop/electron/main.mjs",
|
||||
electronPreload: "apps/desktop/electron/preload.mjs",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
@@ -0,0 +1,299 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import net from "node:net";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(desktopRoot, "../..");
|
||||
const electronSidecarDir = resolve(desktopRoot, "resources", "sidecars");
|
||||
const electronHelperDir = resolve(desktopRoot, "resources", "helpers");
|
||||
const defaultDevDataDir = resolve(
|
||||
process.env.HOME ?? process.env.USERPROFILE ?? repoRoot,
|
||||
".openwork",
|
||||
"openwork-server-dev",
|
||||
);
|
||||
|
||||
const pnpmCmd = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
const nodeCmd = process.execPath;
|
||||
|
||||
async function findFreeTcpPort() {
|
||||
return new Promise((resolvePort, rejectPort) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", rejectPort);
|
||||
server.listen({ port: 0, host: "127.0.0.1" }, () => {
|
||||
const address = server.address();
|
||||
server.close(() => {
|
||||
if (address && typeof address === "object") {
|
||||
resolvePort(address.port);
|
||||
return;
|
||||
}
|
||||
rejectPort(new Error("Unable to resolve a free Vite dev port"));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const rawPort = process.env.PORT?.trim() ?? "";
|
||||
const portValue = Number.parseInt(rawPort, 10);
|
||||
const devPort = rawPort === "0"
|
||||
? await findFreeTcpPort()
|
||||
: Number.isFinite(portValue) && portValue > 0 ? portValue : 5173;
|
||||
if (rawPort === "0") {
|
||||
console.log(`[electron-dev] Vite dev server will use free port ${devPort}`);
|
||||
}
|
||||
const explicitStartUrl = process.env.OPENWORK_ELECTRON_START_URL?.trim() || "";
|
||||
const startUrl = explicitStartUrl || `http://localhost:${devPort}`;
|
||||
const viteProbeUrls = explicitStartUrl
|
||||
? [explicitStartUrl]
|
||||
: [
|
||||
`http://127.0.0.1:${devPort}`,
|
||||
`http://[::1]:${devPort}`,
|
||||
`http://localhost:${devPort}`,
|
||||
];
|
||||
|
||||
function needsShell(command) {
|
||||
return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return spawn(command, args, {
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
shell: needsShell(command),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function runSync(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: "inherit",
|
||||
shell: needsShell(command),
|
||||
...options,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, timeoutMs = 4000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function probeHost(host, port) {
|
||||
return new Promise((resolveCheck) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const onDone = (ready) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolveCheck(ready);
|
||||
};
|
||||
socket.setTimeout(1200);
|
||||
socket.once("connect", () => onDone(true));
|
||||
socket.once("timeout", () => onDone(false));
|
||||
socket.once("error", () => onDone(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function looksLikeVite(url) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${url}/@vite/client`);
|
||||
if (!response.ok) return false;
|
||||
const body = await response.text();
|
||||
return body.includes("@vite/client") || body.includes("import.meta.hot");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function portIsOpenForVite(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
const port = Number.parseInt(parsed.port || (parsed.protocol === "https:" ? "443" : "80"), 10);
|
||||
if (!Number.isFinite(port)) return false;
|
||||
return probeHost(host, port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVite(url, timeoutMs = 60_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
for (const candidate of [url, ...viteProbeUrls].filter(Boolean)) {
|
||||
if (await looksLikeVite(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
for (const candidate of [url, ...viteProbeUrls].filter(Boolean)) {
|
||||
if (await portIsOpenForVite(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
|
||||
}
|
||||
throw new Error(`Timed out waiting for Vite dev server at ${viteProbeUrls.join(", ")}`);
|
||||
}
|
||||
|
||||
function signalTree(child, signal) {
|
||||
if (!child?.pid) return;
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTerminal() {
|
||||
try {
|
||||
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
|
||||
return new Promise((resolveWait) => {
|
||||
let settled = false;
|
||||
const finish = (clean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.off("exit", onExit);
|
||||
resolveWait(clean);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForChildren(children, timeoutMs) {
|
||||
const results = await Promise.all(children.map((child) => waitForExit(child, timeoutMs)));
|
||||
return results.every(Boolean);
|
||||
}
|
||||
|
||||
let uiChild = null;
|
||||
let electronChild = null;
|
||||
let stopping = false;
|
||||
|
||||
async function stopAll(exitCode = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
restoreTerminal();
|
||||
|
||||
const children = [electronChild, uiChild].filter(Boolean);
|
||||
for (const child of children) signalTree(child, "SIGINT");
|
||||
|
||||
const stoppedCleanly = await waitForChildren(children, 2_000);
|
||||
if (!stoppedCleanly) {
|
||||
for (const child of children) signalTree(child, "SIGTERM");
|
||||
await waitForChildren(children, 1_000);
|
||||
}
|
||||
|
||||
restoreTerminal();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => void stopAll(130));
|
||||
process.once("SIGTERM", () => void stopAll(143));
|
||||
|
||||
if (process.env.OPENWORK_ELECTRON_SKIP_SHARED_PREPARE !== "1") {
|
||||
runSync(nodeCmd, [resolve(__dirname, "prepare-sidecar.mjs"), "--force", "--outdir", electronSidecarDir], { cwd: desktopRoot });
|
||||
runSync(nodeCmd, [resolve(__dirname, "prepare-computer-use-helper.mjs"), "--force", "--outdir", electronHelperDir], { cwd: desktopRoot });
|
||||
}
|
||||
|
||||
// Build workspace packages that Electron imports from their dist output.
|
||||
console.log("[electron-dev] Building Electron workspace dependencies...");
|
||||
runSync(pnpmCmd, ["--filter", "@openwork/headless-threads", "build"], { cwd: repoRoot });
|
||||
runSync(pnpmCmd, ["--filter", "openwork-server", "build"], { cwd: repoRoot });
|
||||
|
||||
const initialProbeUrls = [startUrl, ...viteProbeUrls].filter(Boolean);
|
||||
let viteReady = false;
|
||||
for (const candidate of initialProbeUrls) {
|
||||
if (await looksLikeVite(candidate)) {
|
||||
viteReady = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!viteReady) {
|
||||
for (const candidate of initialProbeUrls) {
|
||||
if (await portIsOpenForVite(candidate)) {
|
||||
viteReady = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!viteReady) {
|
||||
uiChild = run(pnpmCmd, ["-w", "dev:ui"], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(devPort),
|
||||
OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1",
|
||||
OPENWORK_DATA_DIR: process.env.OPENWORK_DATA_DIR ?? defaultDevDataDir,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedStartUrl = await waitForVite(startUrl);
|
||||
|
||||
// Native dependencies installed for the host Node ABI must be rebuilt before
|
||||
// Electron loads the embedded server and terminal runtime.
|
||||
if (process.env.OPENWORK_ELECTRON_SKIP_NATIVE_REBUILD === "1") {
|
||||
console.log("[electron-dev] Using prebuilt Electron native dependencies.");
|
||||
} else {
|
||||
console.log("[electron-dev] Rebuilding native dependencies for Electron...");
|
||||
runSync(pnpmCmd, ["--filter", "@openwork/desktop", "run", "rebuild:electron-native"], { cwd: repoRoot });
|
||||
}
|
||||
|
||||
// Optional Electron CDP for external debugging / raw CDP clients.
|
||||
// NOT required for the built-in browser (uses native webContents APIs).
|
||||
// Set OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=9823 to enable.
|
||||
const cdpPortRaw = process.env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT?.trim() ?? "";
|
||||
const cdpPort = cdpPortRaw === "" || cdpPortRaw === "0" ? "" : cdpPortRaw;
|
||||
|
||||
const blankSlateArgs = process.argv.includes("--blank-slate") ? ["--blank-slate"] : [];
|
||||
electronChild = run(pnpmCmd, ["exec", "electron", "./electron/main.mjs", ...blankSlateArgs], {
|
||||
cwd: desktopRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE ?? "1",
|
||||
OPENWORK_DATA_DIR: process.env.OPENWORK_DATA_DIR ?? defaultDevDataDir,
|
||||
OPENWORK_ELECTRON_START_URL: resolvedStartUrl,
|
||||
...(cdpPort ? { OPENWORK_ELECTRON_REMOTE_DEBUG_PORT: cdpPort } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (cdpPort) {
|
||||
console.log(`[openwork] Electron CDP exposed at http://127.0.0.1:${cdpPort}`);
|
||||
}
|
||||
|
||||
electronChild.on("exit", (code) => {
|
||||
if (stopping) return;
|
||||
void stopAll(code ?? 0);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(desktopRoot, "../..");
|
||||
const packagePath = resolve(repoRoot, "packages", "handsfree", "native", "HandsFree");
|
||||
const iconPath = resolve(desktopRoot, "resources", "icons", "icon.icns");
|
||||
const productName = "HandsFreeComputerUse";
|
||||
const helperExecutableName = "ComputerUse";
|
||||
const helperAppName = "OpenWork Computer Use.app";
|
||||
const bundleIdentifier = "com.differentai.openwork.computer-use";
|
||||
|
||||
const readArg = (name) => {
|
||||
const raw = process.argv.slice(2);
|
||||
const direct = raw.find((arg) => arg.startsWith(`${name}=`));
|
||||
if (direct) return direct.slice(name.length + 1);
|
||||
const index = raw.indexOf(name);
|
||||
if (index >= 0 && raw[index + 1]) return raw[index + 1];
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasFlag = (name) => process.argv.slice(2).includes(name);
|
||||
const outDir = resolve(readArg("--outdir") ?? join(desktopRoot, "resources", "helpers"));
|
||||
const force = hasFlag("--force") || process.env.OPENWORK_COMPUTER_USE_FORCE_BUILD === "1";
|
||||
const appPath = join(outDir, helperAppName);
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
stdio: options.stdio ?? "pipe",
|
||||
...options,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim();
|
||||
throw new Error(`${command} ${args.join(" ")} failed${stderr ? `: ${stderr}` : ""}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function signingIdentity() {
|
||||
const fromEnv = process.env.OPENWORK_COMPUTER_USE_SIGN_IDENTITY;
|
||||
if (fromEnv) return fromEnv;
|
||||
const result = spawnSync("security", ["find-identity", "-v", "-p", "codesigning"], { encoding: "utf8" });
|
||||
if (result.status !== 0) return "-";
|
||||
const match = result.stdout.match(/"(Developer ID Application: [^"]+)"/);
|
||||
// Prefer a stable Developer ID signature so macOS TCC permission grants
|
||||
// (Accessibility, Screen Recording) survive rebuilds. Ad-hoc signatures are
|
||||
// content hashes, so every rebuild would invalidate existing grants.
|
||||
return match ? match[1] : "-";
|
||||
}
|
||||
|
||||
function signHelperApp() {
|
||||
if (process.platform !== "darwin") return;
|
||||
const identity = signingIdentity();
|
||||
const args = ["--force", "--deep", "--sign", identity];
|
||||
if (identity !== "-") args.push("--options", "runtime");
|
||||
const result = spawnSync("codesign", [...args, appPath], {
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
if (identity !== "-" && (result.status !== 0 || result.error)) {
|
||||
// Keychain may refuse non-interactive signing; fall back to ad-hoc.
|
||||
const fallback = spawnSync("codesign", ["--force", "--deep", "--sign", "-", appPath], {
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
if (fallback.status === 0) return;
|
||||
}
|
||||
if (result.error) {
|
||||
if (result.error.code === "ENOENT") {
|
||||
throw new Error("codesign is required to prepare the Computer Use helper app");
|
||||
}
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to codesign ${appPath}: ${result.stderr?.trim() ?? "unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function infoPlist() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenWork Computer Use</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>${helperExecutableName}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${bundleIdentifier}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>OpenWork Computer Use</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
process.stdout.write(JSON.stringify({ ok: true, skipped: true, reason: "computer-use-helper-is-macos-only" }, null, 2) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!force && existsSync(join(appPath, "Contents", "MacOS", helperExecutableName))) {
|
||||
process.stdout.write(JSON.stringify({ ok: true, skipped: true, appPath }, null, 2) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run("swift", ["build", "--package-path", packagePath, "-c", "release", "--product", productName], { stdio: "inherit" });
|
||||
const binPathResult = run("swift", ["build", "--package-path", packagePath, "-c", "release", "--show-bin-path"]);
|
||||
const binDir = binPathResult.stdout.trim();
|
||||
const builtExecutable = join(binDir, productName);
|
||||
if (!existsSync(builtExecutable)) {
|
||||
throw new Error(`Swift build succeeded, but ${builtExecutable} was not found`);
|
||||
}
|
||||
|
||||
rmSync(appPath, { recursive: true, force: true });
|
||||
mkdirSync(join(appPath, "Contents", "MacOS"), { recursive: true });
|
||||
mkdirSync(join(appPath, "Contents", "Resources"), { recursive: true });
|
||||
writeFileSync(join(appPath, "Contents", "Info.plist"), infoPlist(), "utf8");
|
||||
writeFileSync(join(appPath, "Contents", "PkgInfo"), "APPL????", "utf8");
|
||||
copyFileSync(builtExecutable, join(appPath, "Contents", "MacOS", helperExecutableName));
|
||||
if (existsSync(iconPath)) {
|
||||
copyFileSync(iconPath, join(appPath, "Contents", "Resources", "AppIcon.icns"));
|
||||
}
|
||||
chmodSync(join(appPath, "Contents", "MacOS", helperExecutableName), 0o755);
|
||||
signHelperApp();
|
||||
|
||||
process.stdout.write(JSON.stringify({ ok: true, appPath, executable: join(appPath, "Contents", "MacOS", helperExecutableName) }, null, 2) + "\n");
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cpSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const dirnameHere = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(dirnameHere, "..");
|
||||
const requiredRoots = [{
|
||||
name: "@modelcontextprotocol/sdk",
|
||||
resolveTarget: "@modelcontextprotocol/sdk/validation/ajv",
|
||||
}];
|
||||
|
||||
function packageRoot(name, fromPackageJson, resolveTarget = name) {
|
||||
const requireFromPackage = createRequire(pathToFileURL(fromPackageJson));
|
||||
let current;
|
||||
try {
|
||||
current = dirname(requireFromPackage.resolve(resolveTarget));
|
||||
} catch (error) {
|
||||
const linkedPackageRoot = resolve(dirname(dirname(fromPackageJson)), name);
|
||||
try {
|
||||
const linkedPackageJson = JSON.parse(readFileSync(join(linkedPackageRoot, "package.json"), "utf8"));
|
||||
if (linkedPackageJson.name === name) return { root: linkedPackageRoot, packageJson: linkedPackageJson };
|
||||
} catch {
|
||||
// Preserve the original module-resolution error below.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
while (current !== dirname(current)) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(join(current, "package.json"), "utf8"));
|
||||
if (packageJson.name === name) return { root: current, packageJson };
|
||||
} catch {
|
||||
// Keep walking from the resolved entry to its package root.
|
||||
}
|
||||
current = dirname(current);
|
||||
}
|
||||
throw new Error(`Could not resolve runtime package root for ${name}.`);
|
||||
}
|
||||
|
||||
export function stageRuntimeNodeModules(outdir) {
|
||||
const output = resolve(outdir);
|
||||
const desktopPackageJson = resolve(desktopRoot, "package.json");
|
||||
const staged = [];
|
||||
const rootPackages = new Map();
|
||||
const placements = new Set();
|
||||
rmSync(output, { recursive: true, force: true });
|
||||
mkdirSync(output, { recursive: true });
|
||||
|
||||
function stagePackage({ name, fromPackageJson, resolveTarget }, parentDestination, forceRoot = false) {
|
||||
const resolvedPackage = packageRoot(name, fromPackageJson, resolveTarget);
|
||||
const rootPackage = rootPackages.get(name);
|
||||
if (!rootPackage) rootPackages.set(name, resolvedPackage.root);
|
||||
if (!forceRoot && rootPackage === resolvedPackage.root) return;
|
||||
const nodeModulesRoot = forceRoot || !rootPackage
|
||||
? output
|
||||
: resolve(parentDestination, "node_modules");
|
||||
const destination = resolve(nodeModulesRoot, name);
|
||||
const placementKey = `${destination}\0${resolvedPackage.root}`;
|
||||
if (placements.has(placementKey)) return;
|
||||
placements.add(placementKey);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
cpSync(resolvedPackage.root, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (source) => source === resolvedPackage.root
|
||||
|| !relative(resolvedPackage.root, source).split(sep).includes("node_modules"),
|
||||
});
|
||||
staged.push(destination.slice(output.length + 1));
|
||||
const dependencies = resolvedPackage.packageJson.dependencies ?? {};
|
||||
for (const dependencyName of Object.keys(dependencies)) {
|
||||
stagePackage(
|
||||
{ name: dependencyName, fromPackageJson: resolve(resolvedPackage.root, "package.json") },
|
||||
destination,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of requiredRoots) {
|
||||
stagePackage({ ...item, fromPackageJson: desktopPackageJson }, output, true);
|
||||
}
|
||||
|
||||
return staged.sort();
|
||||
}
|
||||
|
||||
const outdirIndex = process.argv.indexOf("--outdir");
|
||||
if (outdirIndex !== -1) {
|
||||
const outdir = process.argv[outdirIndex + 1];
|
||||
if (!outdir) throw new Error("--outdir requires a path.");
|
||||
process.stdout.write(`${JSON.stringify({ staged: stageRuntimeNodeModules(outdir) })}\n`);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const readArg = (name) => {
|
||||
const raw = process.argv.slice(2);
|
||||
const direct = raw.find((arg) => arg.startsWith(`${name}=`));
|
||||
if (direct) return direct.split("=")[1];
|
||||
const index = raw.indexOf(name);
|
||||
if (index >= 0 && raw[index + 1]) return raw[index + 1];
|
||||
return null;
|
||||
};
|
||||
|
||||
const sidecarOverride = process.env.OPENWORK_SIDECAR_DIR?.trim() || readArg("--outdir");
|
||||
const sidecarDir = sidecarOverride ? resolve(sidecarOverride) : join(__dirname, "..", "resources", "sidecars");
|
||||
const constantsPath = resolve(__dirname, "..", "..", "..", "constants.json");
|
||||
|
||||
const opencodeGithubRepo = (() => {
|
||||
const raw =
|
||||
process.env.OPENCODE_GITHUB_REPO?.trim() ||
|
||||
process.env.OPENWORK_OPENCODE_GITHUB_REPO?.trim() ||
|
||||
"anomalyco/opencode";
|
||||
const normalized = raw
|
||||
.replace(/^https:\/\/github\.com\//i, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.trim();
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized)) {
|
||||
return "anomalyco/opencode";
|
||||
}
|
||||
return normalized;
|
||||
})();
|
||||
const opencodeVersion = (() => {
|
||||
try {
|
||||
const raw = readFileSync(constantsPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed.opencodeVersion === "string" ? parsed.opencodeVersion.trim() || null : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const normalizeVersion = (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
if (raw.toLowerCase() === "latest") return null;
|
||||
return raw.startsWith("v") ? raw.slice(1) : raw;
|
||||
};
|
||||
|
||||
const opencodeAssetOverride = process.env.OPENCODE_ASSET?.trim() || null;
|
||||
|
||||
// Target triple for native platform binaries
|
||||
const resolvedTargetTriple = (() => {
|
||||
const envTarget =
|
||||
process.env.TAURI_ENV_TARGET_TRIPLE ??
|
||||
process.env.CARGO_CFG_TARGET_TRIPLE ??
|
||||
process.env.TARGET;
|
||||
if (envTarget) return envTarget;
|
||||
if (process.platform === "darwin") {
|
||||
return process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu";
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return process.arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc";
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const isWindowsTarget = process.platform === "win32" || resolvedTargetTriple?.includes("windows") === true;
|
||||
|
||||
const opencodeBaseName = isWindowsTarget ? "opencode.exe" : "opencode";
|
||||
const opencodePath = join(sidecarDir, opencodeBaseName);
|
||||
const opencodeTargetName = resolvedTargetTriple
|
||||
? `opencode-${resolvedTargetTriple}${isWindowsTarget ? ".exe" : ""}`
|
||||
: null;
|
||||
const opencodeTargetPath = opencodeTargetName ? join(sidecarDir, opencodeTargetName) : null;
|
||||
|
||||
const opencodeCandidatePath = opencodeTargetPath ?? opencodePath;
|
||||
let existingOpencodeVersion = null;
|
||||
|
||||
// openwork-server paths
|
||||
const openworkServerDir = resolve(__dirname, "..", "..", "server");
|
||||
|
||||
const readHeader = (filePath, length = 256) => {
|
||||
const fd = openSync(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const bytesRead = readSync(fd, buffer, 0, length, 0);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
};
|
||||
|
||||
const isStubBinary = (filePath) => {
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (!stat.isFile()) return true;
|
||||
if (stat.size < 1024) return true;
|
||||
const header = readHeader(filePath);
|
||||
if (header.startsWith("#!")) return true;
|
||||
if (header.includes("Sidecar missing") || header.includes("Bun is required")) return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const readDirectory = (dir) => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries.flatMap((entry) => {
|
||||
const next = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return readDirectory(next);
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
return [next];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const findOpencodeBinary = (dir) => {
|
||||
const candidates = readDirectory(dir);
|
||||
return (
|
||||
candidates.find((file) => file.endsWith(`/${opencodeBaseName}`) || file.endsWith(`\\${opencodeBaseName}`)) ??
|
||||
candidates.find((file) => file.endsWith("/opencode.exe") || file.endsWith("\\opencode.exe")) ??
|
||||
candidates.find((file) => file.endsWith("/opencode") || file.endsWith("\\opencode")) ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const readBinaryVersion = (filePath) => {
|
||||
try {
|
||||
const result = spawnSync(filePath, ["--version"], { encoding: "utf8" });
|
||||
if (result.status === 0 && result.stdout) return result.stdout.trim();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sha256File = (filePath) => {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(readFileSync(filePath));
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
const adHocSignDarwin = (filePath) => {
|
||||
if (process.platform !== "darwin" || !filePath || !existsSync(filePath)) return;
|
||||
const remove = spawnSync("codesign", ["--remove-signature", filePath], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (remove.error && remove.error.code === "ENOENT") {
|
||||
throw new Error("codesign is required to prepare runnable macOS sidecars");
|
||||
}
|
||||
|
||||
const sign = spawnSync("codesign", ["--force", "--sign", "-", filePath], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (sign.error) {
|
||||
if (sign.error.code === "ENOENT") {
|
||||
throw new Error("codesign is required to prepare runnable macOS sidecars");
|
||||
}
|
||||
throw sign.error;
|
||||
}
|
||||
if (sign.status !== 0) {
|
||||
const stderr = sign.stderr?.trim();
|
||||
throw new Error(`Failed to codesign ${filePath}${stderr ? `: ${stderr}` : ""}`);
|
||||
}
|
||||
};
|
||||
|
||||
const adHocSignDarwinSidecars = (paths) => {
|
||||
if (process.platform !== "darwin") return;
|
||||
for (const filePath of [...new Set(paths.filter(Boolean))]) {
|
||||
adHocSignDarwin(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
// openwork-server is no longer compiled as a sidecar binary — it runs
|
||||
// in-process inside Electron via a direct import of the server library.
|
||||
// Server binary copy/sign skipped — runs in-process.
|
||||
|
||||
if (!existingOpencodeVersion && opencodeCandidatePath) {
|
||||
existingOpencodeVersion =
|
||||
existsSync(opencodeCandidatePath) && !isStubBinary(opencodeCandidatePath)
|
||||
? readBinaryVersion(opencodeCandidatePath)
|
||||
: null;
|
||||
}
|
||||
|
||||
const normalizedOpencodeVersion = normalizeVersion(opencodeVersion);
|
||||
|
||||
if (!normalizedOpencodeVersion) {
|
||||
console.error(
|
||||
`OpenCode version could not be resolved from ${constantsPath}.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opencodeAssetByTarget = {
|
||||
"aarch64-apple-darwin": "opencode-darwin-arm64.zip",
|
||||
"x86_64-apple-darwin": "opencode-darwin-x64-baseline.zip",
|
||||
"x86_64-unknown-linux-gnu": "opencode-linux-x64-baseline.tar.gz",
|
||||
"aarch64-unknown-linux-gnu": "opencode-linux-arm64.tar.gz",
|
||||
"x86_64-pc-windows-msvc": "opencode-windows-x64-baseline.zip",
|
||||
"aarch64-pc-windows-msvc": "opencode-windows-arm64.zip",
|
||||
};
|
||||
|
||||
const opencodeAsset =
|
||||
opencodeAssetOverride ?? (resolvedTargetTriple ? opencodeAssetByTarget[resolvedTargetTriple] : null);
|
||||
|
||||
const opencodeUrl = opencodeAsset
|
||||
? `https://github.com/${opencodeGithubRepo}/releases/download/v${normalizedOpencodeVersion}/${opencodeAsset}`
|
||||
: null;
|
||||
|
||||
const shouldDownloadOpencode =
|
||||
!opencodeCandidatePath ||
|
||||
!existsSync(opencodeCandidatePath) ||
|
||||
isStubBinary(opencodeCandidatePath) ||
|
||||
!existingOpencodeVersion ||
|
||||
existingOpencodeVersion !== normalizedOpencodeVersion;
|
||||
|
||||
if (!shouldDownloadOpencode) {
|
||||
console.log(`OpenCode sidecar already present (${existingOpencodeVersion}).`);
|
||||
}
|
||||
|
||||
if (shouldDownloadOpencode) {
|
||||
if (!opencodeAsset || !opencodeUrl) {
|
||||
console.error(
|
||||
`No OpenCode asset configured for target ${resolvedTargetTriple ?? "unknown"}. Set OPENCODE_ASSET to override.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(sidecarDir, { recursive: true });
|
||||
|
||||
const stamp = Date.now();
|
||||
const archivePath = join(tmpdir(), `opencode-${stamp}-${opencodeAsset}`);
|
||||
const extractDir = join(tmpdir(), `opencode-${stamp}`);
|
||||
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const psQuote = (value) => `'${value.replace(/'/g, "''")}'`;
|
||||
const psScript = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`Invoke-WebRequest -Uri ${psQuote(opencodeUrl)} -OutFile ${psQuote(archivePath)}`,
|
||||
`Expand-Archive -Path ${psQuote(archivePath)} -DestinationPath ${psQuote(extractDir)} -Force`,
|
||||
].join("; ");
|
||||
|
||||
const result = spawnSync("powershell", ["-NoProfile", "-Command", psScript], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
} else {
|
||||
const downloadResult = spawnSync("curl", ["-fsSL", "-o", archivePath, opencodeUrl], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (downloadResult.status !== 0) {
|
||||
process.exit(downloadResult.status ?? 1);
|
||||
}
|
||||
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
if (opencodeAsset.endsWith(".zip")) {
|
||||
const unzipResult = spawnSync("unzip", ["-q", archivePath, "-d", extractDir], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (unzipResult.status !== 0) {
|
||||
process.exit(unzipResult.status ?? 1);
|
||||
}
|
||||
} else if (opencodeAsset.endsWith(".tar.gz")) {
|
||||
const tarResult = spawnSync("tar", ["-xzf", archivePath, "-C", extractDir], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (tarResult.status !== 0) {
|
||||
process.exit(tarResult.status ?? 1);
|
||||
}
|
||||
} else {
|
||||
console.error(`Unknown OpenCode archive type: ${opencodeAsset}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const extractedBinary = findOpencodeBinary(extractDir);
|
||||
if (!extractedBinary) {
|
||||
console.error("OpenCode binary not found after extraction.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opencodeTargets = [opencodeTargetPath, opencodePath].filter(Boolean);
|
||||
for (const target of opencodeTargets) {
|
||||
try {
|
||||
if (existsSync(target)) {
|
||||
unlinkSync(target);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
copyFileSync(extractedBinary, target);
|
||||
try {
|
||||
chmodSync(target, 0o755);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`OpenCode sidecar updated to ${normalizedOpencodeVersion}.`);
|
||||
}
|
||||
|
||||
adHocSignDarwinSidecars([
|
||||
opencodePath,
|
||||
opencodeTargetPath,
|
||||
// openwork-server runs in-process — no binary to sign.
|
||||
]);
|
||||
|
||||
const openworkServerVersion = (() => {
|
||||
try {
|
||||
const raw = readFileSync(resolve(openworkServerDir, "package.json"), "utf8");
|
||||
return String(JSON.parse(raw).version ?? "").trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const versions = {
|
||||
opencode: {
|
||||
version: normalizedOpencodeVersion,
|
||||
sha256: opencodeCandidatePath && existsSync(opencodeCandidatePath) ? sha256File(opencodeCandidatePath) : null,
|
||||
},
|
||||
"openwork-server": {
|
||||
version: openworkServerVersion,
|
||||
sha256: "in-process",
|
||||
},
|
||||
};
|
||||
|
||||
const missing = Object.entries(versions)
|
||||
.filter(([, info]) => !info.version || !info.sha256)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (missing.length) {
|
||||
console.error(`Sidecar version metadata incomplete for: ${missing.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const versionsPath = join(sidecarDir, "versions.json");
|
||||
try {
|
||||
mkdirSync(sidecarDir, { recursive: true });
|
||||
const content = JSON.stringify(versions, null, 2) + "\n";
|
||||
writeFileSync(versionsPath, content, "utf8");
|
||||
if (resolvedTargetTriple) {
|
||||
const targetSuffix = isWindowsTarget ? ".exe" : "";
|
||||
const targetVersionsPath = join(sidecarDir, `versions.json-${resolvedTargetTriple}${targetSuffix}`);
|
||||
writeFileSync(targetVersionsPath, content, "utf8");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to write versions.json: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user