Files
openwork/apps/desktop/scripts/prepare-computer-use-helper.mjs

147 lines
5.7 KiB
JavaScript

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");