mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Yaak Proxy: release build, dry run and updater (#836)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9cc9f7a5b8
commit
6e39955f17
40 files changed
+3024
-536
No files matched your search
@@ -6,7 +6,18 @@ if (!version) {
|
||||
throw new Error("YAAK_VERSION environment variable not set");
|
||||
}
|
||||
|
||||
const tauriConfigPath = path.join(__dirname, "../crates-tauri/yaak-app-client/tauri.conf.json");
|
||||
// The app to stamp, Yaak's client unless another is named: `npm run replace-version -- proxy`
|
||||
const apps = {
|
||||
client: "yaak-app-client",
|
||||
proxy: "yaak-app-proxy",
|
||||
};
|
||||
const appName = process.argv[2] ?? "client";
|
||||
const appDir = apps[appName];
|
||||
if (appDir == null) {
|
||||
throw new Error(`Unknown app "${appName}", expected one of ${Object.keys(apps).join(", ")}`);
|
||||
}
|
||||
|
||||
const tauriConfigPath = path.join(__dirname, "../crates-tauri", appDir, "tauri.conf.json");
|
||||
const tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, "utf8"));
|
||||
|
||||
tauriConfig.version = version;
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Signs the executables Yaak Proxy's bundle takes from Yaak's vendored tools with hardened
|
||||
* runtime, as notarization requires of every executable in the app. Runs after vendoring, in
|
||||
* the bundle's before-build step, with the identity `scripts/bundle-proxy.mjs` chose. Without
|
||||
* one, or off macOS, the bundle is unsigned anyway and this does nothing.
|
||||
* Signs the executables Yaak Proxy's bundle takes from outside its own build with hardened
|
||||
* runtime, as notarization requires of every executable in the app: the privileged helper,
|
||||
* which `scripts/vendor-proxy-helper.cjs` built for this target, and the `yaakprotoc` from
|
||||
* Yaak's vendored tools. Runs after vendoring, in the bundle's before-build step, with the
|
||||
* identity `scripts/bundle-proxy.mjs` chose. Without one, or off macOS, the bundle is unsigned
|
||||
* anyway and this does nothing.
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
const { existsSync } = require("fs");
|
||||
const { existsSync, readdirSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const rootDir = path.join(__dirname, "..");
|
||||
const identity = process.env.APPLE_SIGNING_IDENTITY;
|
||||
|
||||
if (process.platform !== "darwin" || !identity) {
|
||||
console.log("No signing identity, so the vendored protoc is left unsigned");
|
||||
console.log("No signing identity, so the vendored helper and protoc are left unsigned");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// A secure timestamp, as notarization requires; an ad-hoc (`-`) local build can't have one
|
||||
const timestamp = identity === "-" ? [] : ["--timestamp"];
|
||||
|
||||
function sign(file, extraArgs) {
|
||||
execFileSync(
|
||||
"codesign",
|
||||
["--force", "--options", "runtime", ...timestamp, ...extraArgs, "--sign", identity, file],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
console.log(`Signed ${path.relative(rootDir, file)}`);
|
||||
}
|
||||
|
||||
// The helper, under the launchd label it runs as
|
||||
const helperDir = path.join(rootDir, "crates-tauri", "yaak-app-proxy", "vendored", "helper");
|
||||
const helpers = existsSync(helperDir)
|
||||
? readdirSync(helperDir).filter((name) => name.startsWith("yaak-proxy-helper-"))
|
||||
: [];
|
||||
if (helpers.length === 0) {
|
||||
console.error(`No helper in ${helperDir}: run \`npm run proxy:vendor-helper\` first`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const name of helpers) {
|
||||
sign(path.join(helperDir, name), ["--identifier", "app.yaak.proxy.helper"]);
|
||||
}
|
||||
|
||||
const clientDir = path.join(rootDir, "crates-tauri", "yaak-app-client");
|
||||
const protoc = path.join(clientDir, "vendored", "protoc", "yaakprotoc");
|
||||
const entitlements = path.join(clientDir, "macos", "entitlements.yaakprotoc.plist");
|
||||
@@ -28,9 +55,4 @@ if (!existsSync(protoc)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
execFileSync(
|
||||
"codesign",
|
||||
["--force", "--options", "runtime", "--entitlements", entitlements, "--sign", identity, protoc],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
console.log(`Signed ${path.relative(rootDir, protoc)}`);
|
||||
sign(protoc, ["--entitlements", entitlements]);
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Checks a built Yaak Proxy bundle before it ships: its version and architecture, the
|
||||
* privileged helper and its LaunchDaemon plist where `SMAppService` looks for them, and every
|
||||
* signature. With `--signed`, also that the app and helper are signed by the same Developer ID
|
||||
* team with the hardened runtime, that the app meets the requirement the helper holds callers
|
||||
* to, and that Gatekeeper accepts the notarized, stapled app. Without it (an ad-hoc local
|
||||
* build) the signatures need only be intact.
|
||||
*
|
||||
* node scripts/verify-proxy-bundle.mjs "<path>/Yaak Proxy.app" \
|
||||
* [--version 2026.11.0] [--arch arm64|x86_64] [--signed] [--team ABCDE12345]
|
||||
*
|
||||
* Registers nothing: `--helper-status` only asks `SMAppService` what it knows.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import path from "path";
|
||||
import { parseArgs } from "util";
|
||||
|
||||
const APP_IDENTIFIER = "app.yaak.proxy";
|
||||
const HELPER_LABEL = "app.yaak.proxy.helper";
|
||||
const HELPER_PROGRAM = "Contents/MacOS/yaak-proxy-helper";
|
||||
const HELPER_PLIST = `Contents/Library/LaunchDaemons/${HELPER_LABEL}.plist`;
|
||||
const MAIN_PROGRAM = "Contents/MacOS/yaak-app-proxy";
|
||||
const PROTOC = "Contents/Resources/vendored/protoc/yaakprotoc";
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
version: { type: "string" },
|
||||
arch: { type: "string" },
|
||||
signed: { type: "boolean", default: false },
|
||||
team: { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
const app = positionals[0];
|
||||
if (app == null || !existsSync(app)) {
|
||||
console.error(`No app bundle at ${app ?? "(none given)"}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
function check(ok, what) {
|
||||
console.log(`${ok ? "ok " : "FAIL"} ${what}`);
|
||||
if (!ok) failures.push(what);
|
||||
}
|
||||
|
||||
/** Runs a tool and returns its exit status and output, stderr included, as codesign writes there */
|
||||
function run(cmd, args) {
|
||||
const result = spawnSync(cmd, args, { encoding: "utf8" });
|
||||
const error = result.error ? `${result.error.message}\n` : "";
|
||||
return { status: result.status, out: `${error}${result.stdout ?? ""}${result.stderr ?? ""}` };
|
||||
}
|
||||
|
||||
function plistValue(file, key, format = "raw") {
|
||||
const { status, out } = run("plutil", ["-extract", key, format, "-o", "-", file]);
|
||||
return status === 0 ? out.trim() : null;
|
||||
}
|
||||
|
||||
/** What `codesign -dv` says of a signature, by its keys: TeamIdentifier, Identifier, flags… */
|
||||
function signature(file) {
|
||||
const { status, out } = run("codesign", ["-dvvv", file]);
|
||||
const info = { status, authorities: /** @type {string[]} */ ([]) };
|
||||
for (const line of out.split("\n")) {
|
||||
const match = line.match(/^(\w+)=(.*)$/);
|
||||
if (match == null) continue;
|
||||
if (match[1] === "Authority") info.authorities.push(match[2]);
|
||||
else info[match[1]] = match[2];
|
||||
}
|
||||
const flags = out.match(/flags=0x[0-9a-f]+\(([^)]*)\)/);
|
||||
info.flags = flags ? flags[1].split(",") : [];
|
||||
return info;
|
||||
}
|
||||
|
||||
const at = (relative) => path.join(app, relative);
|
||||
const infoPlist = at("Contents/Info.plist");
|
||||
|
||||
// The app itself
|
||||
const identifier = plistValue(infoPlist, "CFBundleIdentifier");
|
||||
check(identifier === APP_IDENTIFIER, `bundle identifier is ${APP_IDENTIFIER} (${identifier})`);
|
||||
const version = plistValue(infoPlist, "CFBundleShortVersionString");
|
||||
if (values.version != null) {
|
||||
const expected = values.version.replace(/^v/, "");
|
||||
// macOS takes the release part of a prerelease version as its short version
|
||||
check(
|
||||
version === expected || version === expected.split("-")[0],
|
||||
`version is ${expected} (${version})`,
|
||||
);
|
||||
}
|
||||
check(version !== "0.0.0", `version is not the unset 0.0.0 (${version})`);
|
||||
|
||||
// The helper and its plist, where SMAppService.daemon(plistName:) expects them
|
||||
const helper = at(HELPER_PROGRAM);
|
||||
const helperPlist = at(HELPER_PLIST);
|
||||
check(existsSync(helper) && (statSync(helper).mode & 0o111) !== 0, `helper at ${HELPER_PROGRAM}`);
|
||||
check(existsSync(helperPlist), `helper plist at ${HELPER_PLIST}`);
|
||||
if (existsSync(helperPlist)) {
|
||||
check(plistValue(helperPlist, "Label") === HELPER_LABEL, `plist Label is ${HELPER_LABEL}`);
|
||||
check(
|
||||
plistValue(helperPlist, "BundleProgram") === HELPER_PROGRAM,
|
||||
`plist BundleProgram is ${HELPER_PROGRAM}`,
|
||||
);
|
||||
const associated = JSON.parse(
|
||||
plistValue(helperPlist, "AssociatedBundleIdentifiers", "json") ?? "[]",
|
||||
);
|
||||
check(
|
||||
associated.includes(identifier),
|
||||
`plist AssociatedBundleIdentifiers includes the app's ${identifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Built for the architecture asked for
|
||||
if (values.arch != null) {
|
||||
for (const file of [MAIN_PROGRAM, HELPER_PROGRAM]) {
|
||||
const { out } = run("lipo", ["-archs", at(file)]);
|
||||
check(
|
||||
out.split(/\s+/).includes(values.arch),
|
||||
`${file} is built for ${values.arch} (${out.trim()})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// What SMAppService makes of the bundled plist, without registering anything. An Intel build
|
||||
// on an Apple silicon runner without Rosetta can't run, so it is only reported as skipped.
|
||||
const helperStatus = run(at(MAIN_PROGRAM), ["--helper-status"]);
|
||||
if (helperStatus.status !== 0 && /bad cpu type|EBADARCH/i.test(helperStatus.out)) {
|
||||
console.log(`skip --helper-status: this Mac can't run ${values.arch ?? "this"} code`);
|
||||
} else {
|
||||
console.log(helperStatus.out.trim().replace(/^/gm, " "));
|
||||
check(helperStatus.status === 0, "--helper-status runs");
|
||||
check(!/^plist: none/m.test(helperStatus.out), "the app finds its helper plist");
|
||||
if (values.signed) {
|
||||
check(
|
||||
/^state: (NotRegistered|RequiresApproval|Enabled)$/m.test(helperStatus.out),
|
||||
"SMAppService knows the helper",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Signatures
|
||||
const deep = run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", app]);
|
||||
check(deep.status === 0, `codesign --verify --deep --strict\n${deep.out.trim()}`);
|
||||
|
||||
if (values.signed) {
|
||||
const appSig = signature(app);
|
||||
const helperSig = signature(helper);
|
||||
const protocSig = signature(at(PROTOC));
|
||||
const team = appSig.TeamIdentifier;
|
||||
check(
|
||||
team != null && team !== "not set" && /^[A-Z0-9]{10}$/.test(team),
|
||||
`app is signed by a team (${team})`,
|
||||
);
|
||||
if (values.team != null) {
|
||||
check(team === values.team, `app team is the helper's built-in team ${values.team} (${team})`);
|
||||
}
|
||||
check(
|
||||
appSig.authorities.some((a) => a.startsWith("Developer ID Application:")),
|
||||
`app is signed with a Developer ID Application identity (${appSig.authorities[0] ?? "none"})`,
|
||||
);
|
||||
check(appSig.flags.includes("runtime"), "app has the hardened runtime");
|
||||
check(
|
||||
helperSig.TeamIdentifier === team,
|
||||
`helper is signed by the app's team (${helperSig.TeamIdentifier})`,
|
||||
);
|
||||
check(helperSig.flags.includes("runtime"), "helper has the hardened runtime");
|
||||
check(
|
||||
protocSig.TeamIdentifier === team,
|
||||
`protoc is signed by the app's team (${protocSig.TeamIdentifier})`,
|
||||
);
|
||||
check(protocSig.flags.includes("runtime"), "protoc has the hardened runtime");
|
||||
|
||||
// The requirement the helper holds its callers to (yaak-proxy-helper's code_requirement)
|
||||
const requirement = `anchor apple generic and (identifier "${APP_IDENTIFIER}") and certificate leaf[subject.OU] = "${values.team ?? team}"`;
|
||||
const meets = run("codesign", ["--verify", `-R=${requirement}`, app]);
|
||||
check(meets.status === 0, `app meets the helper's caller requirement\n${meets.out.trim()}`);
|
||||
|
||||
const gatekeeper = run("spctl", ["--assess", "--type", "execute", "--verbose=4", app]);
|
||||
check(gatekeeper.status === 0, `spctl --assess accepts the app\n${gatekeeper.out.trim()}`);
|
||||
const stapled = run("xcrun", ["stapler", "validate", app]);
|
||||
check(stapled.status === 0, `notarization ticket is stapled\n${stapled.out.trim()}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n${failures.length} check(s) failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll checks passed");
|
||||
Reference in new issue
Block a user