From 249f6c2fb8f33b036638933d9b8d432fbec934de Mon Sep 17 00:00:00 2001 From: Doan Anh Dung Date: Sat, 26 Sep 2026 11:16:10 +0700 Subject: [PATCH] fix(tray): native arm64 macOS menubar binary, no Rosetta required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systray2 ships only an x86_64 tray_darwin_release and selects it by process.platform with no process.arch branch, so there is no native slice to choose. Apple Silicon users therefore need Rosetta 2, and without it the tray dies with EBADARCH ("bad CPU type in executable") and no icon appears. Overlay a native arm64 build of the same upstream source (felixhao28/systray-portable @ 6eddc91) instead. On darwin/arm64, ensureArm64TrayBin() detects the Intel binary by parsing the Mach-O cputype, downloads the artifact from the pinned tray-binaries release, verifies it against a sha256 constant, atomically renames it over systray2's binary, and busts systray2's copyDir cache — that cached copy is what actually executes, so without the bust the swap has no effect. Intel Macs keep using systray2's binary unchanged and Windows is unaffected (PowerShell NotifyIcon, no binary). Any download or checksum failure leaves the Intel binary in place and tells the user how to install Rosetta; a marker file throttles retries to once per 24h because ensureTrayRuntime runs synchronously on every CLI start, and is cleared on success so a clobbered binary recovers immediately. Binaries stay out of the npm tarball per the existing Kaspersky false-positive constraint — the artifact is fetched on demand. Adds cli/scripts/buildTrayArm64.js (-trimpath, bit-for-bit reproducible for a given Go version and macOS SDK) and a workflow_dispatch action that builds on a macos-15 runner and refuses to publish when the sha diverges from the pin. Also corrects comments claiming the systray -> systray2 switch fixed Apple Silicon; it only fixed the dyld header rejection on macOS 14+, the binary was still amd64-only. --- .github/workflows/tray-binaries.yml | 176 ++++++++++++++++++++++++++++ cli/.gitignore | 1 + cli/hooks/trayRuntime.js | 159 +++++++++++++++++++++++-- cli/package.json | 4 +- cli/scripts/buildTrayArm64.js | 108 +++++++++++++++++ cli/src/cli/tray/tray.js | 13 +- 6 files changed, 448 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/tray-binaries.yml create mode 100644 cli/scripts/buildTrayArm64.js diff --git a/.github/workflows/tray-binaries.yml b/.github/workflows/tray-binaries.yml new file mode 100644 index 00000000..d1d99c98 --- /dev/null +++ b/.github/workflows/tray-binaries.yml @@ -0,0 +1,176 @@ +name: Build macOS tray binary (arm64) + +# systray2 ships only an x86_64 tray_darwin_release, so Apple Silicon users need +# Rosetta 2 for the menubar icon. This builds the native arm64 overlay that +# cli/hooks/trayRuntime.js downloads from the `tray-binaries` release. +# +# Manual-only: the artifact's sha256 is pinned in cli/hooks/trayRuntime.js and +# verified on every download, so a new build is only publishable together with a +# matching pin. Running this with publish=true against a mismatched pin fails +# rather than silently bricking every Apple Silicon client. + +on: + workflow_dispatch: + inputs: + publish: + description: "Upload to the tray-binaries release (requires sha to match ARM64_TRAY_SHA256)" + required: false + default: false + type: boolean + +concurrency: + group: tray-binaries-${{ github.repository }} + cancel-in-progress: false + +permissions: + contents: read + +env: + # Pinned because -trimpath only makes the build reproducible for a given Go + # version and macOS SDK. Bumping this changes the sha256. + GO_VERSION: "1.27.1" + +jobs: + build: + name: Build darwin/arm64 + runs-on: macos-15 + timeout-minutes: 20 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + # The Go module lives in a temp clone of the upstream repo, so there is + # no go.sum at the workspace root for setup-go's cache to key on. + cache: false + + - name: Record SDK provenance + run: | + { + echo "runner macOS: $(sw_vers -productVersion)" + echo "Xcode: $(xcodebuild -version | head -1)" + echo "clang: $(clang --version | head -1)" + echo "Go: $(go version)" + } | tee sdk-provenance.txt + + - name: Build + run: node cli/scripts/buildTrayArm64.js + + - name: Compare against pinned checksum + id: sha + run: | + BUILT=$(shasum -a 256 cli/.tray-build/tray_darwin_arm64 | cut -d' ' -f1) + # Whitespace-tolerant, and a missing constant must fail loudly: a null + # match would otherwise surface as an opaque TypeError from [1]. + PINNED=$(node -e ' + const m = require("fs").readFileSync("cli/hooks/trayRuntime.js", "utf8") + .match(/ARM64_TRAY_SHA256\s*=\s*"([0-9a-f]{64})"/); + if (!m) { console.error("::error::ARM64_TRAY_SHA256 not found in cli/hooks/trayRuntime.js"); process.exit(1); } + process.stdout.write(m[1]); + ') + { + echo "built=$BUILT" + echo "pinned=$PINNED" + if [ "$BUILT" = "$PINNED" ]; then echo "match=true"; else echo "match=false"; fi + } >> "$GITHUB_OUTPUT" + + - name: Write job summary + run: | + { + echo "### tray_darwin_arm64" + echo "" + echo "| | |" + echo "|---|---|" + echo "| built sha256 | \`${{ steps.sha.outputs.built }}\` |" + echo "| pinned sha256 | \`${{ steps.sha.outputs.pinned }}\` |" + echo "| match | ${{ steps.sha.outputs.match }} |" + echo "" + echo '```' + cat sdk-provenance.txt + echo '```' + echo "" + if [ "${{ steps.sha.outputs.match }}" = "true" ]; then + echo "Pin already matches — safe to re-run with \`publish=true\`." + else + echo "⚠️ Pin does **not** match. To publish this build, set \`ARM64_TRAY_SHA256\`" + echo "in \`cli/hooks/trayRuntime.js\` to the built sha256 above and land that" + echo "change first. Publishing without it makes every Apple Silicon client fail" + echo "checksum verification and fall back to the Rosetta binary." + fi + } >> "$GITHUB_STEP_SUMMARY" + + # Uploaded before the mismatch gate below, so a publish run that fails on a + # checksum mismatch still leaves the bytes downloadable — that is exactly + # the run where a maintainer needs them to verify the new sha256. + - uses: actions/upload-artifact@v4 + with: + name: tray_darwin_arm64 + path: | + cli/.tray-build/tray_darwin_arm64 + sdk-provenance.txt + + - name: Refuse to publish on checksum mismatch + if: ${{ inputs.publish && steps.sha.outputs.match != 'true' }} + run: | + echo "::error::publish requested but built sha256 != ARM64_TRAY_SHA256" + echo " built: ${{ steps.sha.outputs.built }}" + echo " pinned: ${{ steps.sha.outputs.pinned }}" + echo "Update cli/hooks/trayRuntime.js and land it before publishing." + exit 1 + + - name: Publish to tray-binaries release + if: ${{ inputs.publish && steps.sha.outputs.match == 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Clients fetch from the hardcoded ARM64_TRAY_URL, so publishing from a + # different repo would gate an asset stream nobody downloads and silently + # decouple the sha pin from the bytes Apple Silicon users execute. + URL_REPO=$(node -e ' + const m = require("fs").readFileSync("cli/hooks/trayRuntime.js", "utf8") + .match(/"https:\/\/github\.com\/([^\/"]+\/[^\/"]+)\/releases\/download\/tray-binaries\/tray_darwin_arm64"/); + if (!m) { console.error("::error::ARM64_TRAY_URL not found in cli/hooks/trayRuntime.js"); process.exit(1); } + process.stdout.write(m[1]); + ') + if [ "${{ github.repository }}" != "$URL_REPO" ]; then + echo "::error::publishing to ${{ github.repository }}, but ARM64_TRAY_URL points clients at $URL_REPO" + echo "Repoint ARM64_TRAY_URL in cli/hooks/trayRuntime.js at this repo, or run the publish from $URL_REPO." + exit 1 + fi + + # gh release upload does not create the release, so bootstrap it on the + # first publish run rather than failing with "release not found". + if ! gh release view tray-binaries >/dev/null 2>&1; then + echo "Release 'tray-binaries' does not exist yet — creating it" + gh release create tray-binaries --latest=false \ + --title "Native macOS tray binaries" \ + --notes "Built by .github/workflows/tray-binaries.yml. Provenance and the pinned sha256 live in that workflow and in cli/hooks/trayRuntime.js (ARM64_TRAY_SHA256)." + fi + + gh release upload tray-binaries cli/.tray-build/tray_darwin_arm64 --clobber + + echo "Uploaded. Verifying public download URL..." + URL="https://github.com/${{ github.repository }}/releases/download/tray-binaries/tray_darwin_arm64" + GOT="" + for attempt in 1 2 3; do + if curl -fsSL --max-time 60 -o /tmp/verify "$URL"; then + GOT=$(shasum -a 256 /tmp/verify | cut -d' ' -f1) + if [ "$GOT" = "${{ steps.sha.outputs.built }}" ]; then break; fi + fi + # A just-uploaded asset can 404 or serve stale bytes until the CDN catches up. + echo "attempt $attempt: got '${GOT:-}' — retrying in 15s" + sleep 15 + done + if [ "$GOT" != "${{ steps.sha.outputs.built }}" ]; then + echo "::error::downloaded asset sha256 '${GOT:-}' != built ${{ steps.sha.outputs.built }} after 3 attempts" + exit 1 + fi + echo "✅ $URL serves the expected bytes" + diff --git a/cli/.gitignore b/cli/.gitignore index 55fd8c49..86262d36 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,2 +1,3 @@ app/* node_modules/* +.tray-build/ diff --git a/cli/hooks/trayRuntime.js b/cli/hooks/trayRuntime.js index dafb2154..50e6cb97 100644 --- a/cli/hooks/trayRuntime.js +++ b/cli/hooks/trayRuntime.js @@ -5,9 +5,17 @@ // // We use the maintained `systray2` fork. The original `systray@1.0.5` package // bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern -// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon. +// dyld (macOS 14+), so it fails to load at all. +// +// Note that systray2 is NOT an Apple Silicon fix: like its predecessor it ships +// only an x86_64 `tray_darwin_release`, and picks it by process.platform with no +// process.arch branch, so there is no native slice to select. On arm64 macOS the +// tray therefore needs Rosetta 2 and dies with EBADARCH without it. We overlay +// our own arm64 build of the same upstream source on top — see ensureArm64TrayBin. const { spawnSync } = require("child_process"); +const crypto = require("crypto"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime"); @@ -15,6 +23,19 @@ const SYSTRAY_PKG = "systray2"; const SYSTRAY_VERSION = "2.1.4"; const LEGACY_SYSTRAY_PKG = "systray"; +// Pinned `tray-binaries` release rather than `latest`, so the URL is stable and +// the artifact can only change by a deliberate re-upload. The workflow's publish +// step re-derives this repo from the literal below and refuses to upload +// anywhere else, so the integrity gate can't drift from what clients fetch. +// +// The asset is built by .github/workflows/tray-binaries.yml on a macos-15 runner. +// cgo compiles AppKit against the runner's SDK, so this value tracks that image: +// when GitHub updates it the sha changes, the workflow refuses to publish, and +// this constant must be bumped in the same change as the re-upload. +const ARM64_TRAY_URL = "https://github.com/decolua/9router/releases/download/tray-binaries/tray_darwin_arm64"; +const ARM64_TRAY_SHA256 = "487e3c365aaa1eb6ad295bf3989711e975b52cee07505bf641c8559954881c81"; +const ARM64_RETRY_COOLDOWN_MS = 24 * 60 * 60 * 1000; + function hasSystray() { return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json")); } @@ -71,6 +92,124 @@ function ensureRuntimeDir() { return dir; } +// A thin (non-fat) 64-bit Mach-O stores its magic then cputype, both LE. +// CPU_TYPE_ARM64 is CPU_TYPE_ARM | CPU_ARCH_ABI64. Fat/universal binaries use a +// different magic and are reported as "not arm64" here, which is fine: we only +// ever overlay a thin arm64 build and only need to tell it apart from x86_64. +function isArm64MachO(file) { + let fd = null; + try { + fd = fs.openSync(file, "r"); + const buf = Buffer.alloc(8); + fs.readSync(fd, buf, 0, 8, 0); + if (buf.readUInt32LE(0) !== 0xfeedfacf) return false; + return buf.readUInt32LE(4) === 0x0100000c; + } catch { + return false; + } finally { + if (fd !== null) try { fs.closeSync(fd); } catch {} + } +} + +// systray2 is constructed with copyDir:true, so what actually executes is +// ~/.cache/node-systray//tray_darwin_release, and index.js only re-copies +// when that path is absent. An overlaid binary stays invisible until this is cleared. +// Scoped to our systray2 version: the parent dir is machine-global and shared +// with any other node-systray consumer. +function bustSystrayCopyCache() { + try { + fs.rmSync(path.join(os.homedir(), ".cache", "node-systray", SYSTRAY_VERSION), { recursive: true, force: true }); + } catch {} +} + +function arm64AttemptMarker() { + return path.join(getRuntimeDir(), ".tray-arm64-attempt"); +} + +// ensureTrayRuntime runs synchronously on every `9router` start (cli.js), so a +// failed download must not re-block the next launch. Retry at most daily. +function recentlyAttemptedArm64() { + try { + const at = Number(fs.readFileSync(arm64AttemptMarker(), "utf8").trim()); + return Number.isFinite(at) && Date.now() - at < ARM64_RETRY_COOLDOWN_MS; + } catch { + return false; + } +} + +function markArm64Attempt() { + try { fs.writeFileSync(arm64AttemptMarker(), String(Date.now())); } catch {} +} + +// Cleared on success so the cooldown only ever throttles *failures*. Without +// this, anything that restores systray2's x86_64 binary later — notably a +// globally installed 9router older than this change, which shares the same +// ~/.9router/runtime — would leave the user waiting out the cooldown. +function clearArm64Attempt() { + try { fs.rmSync(arm64AttemptMarker(), { force: true }); } catch {} +} + +// Throws on any failure so the caller has a single error path. +function downloadFile(url, dest, timeoutSec) { + // darwin-only path, and curl ships with macOS, so this needs no extra dep and + // keeps the caller synchronous. + const res = spawnSync("curl", ["-fsSL", "--max-time", String(timeoutSec), "-o", dest, url], { + encoding: "utf8", + timeout: (timeoutSec + 5) * 1000 + }); + if (res.status === 0 && fs.existsSync(dest)) return; + const detail = (res.stderr || res.error?.message || `curl exit ${res.status}`).trim().split("\n").pop(); + throw new Error(detail || "download failed"); +} + +function sha256File(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +// Replace systray2's x86_64 macOS binary with a native arm64 build so Apple +// Silicon users get a tray without installing Rosetta 2. Any failure leaves the +// Intel binary untouched, which still works under Rosetta. +// +// Takes no `silent` flag on purpose: cli.js calls ensureTrayRuntime({silent:true}) +// synchronously on every start, and a stalled curl would otherwise freeze the +// launch for up to 30s with no output at all. These lines print at most once per +// 24h on failure and once ever on success, so they are worth more than the quiet. +function ensureArm64TrayBin() { + if (process.platform !== "darwin" || process.arch !== "arm64") return { skipped: true }; + + const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", "tray_darwin_release"); + if (!fs.existsSync(binPath)) return { skipped: true }; + if (isArm64MachO(binPath)) return { native: true }; + if (recentlyAttemptedArm64()) return { deferred: true }; + + markArm64Attempt(); + console.log("⏳ Downloading native Apple Silicon tray binary..."); + // pid-scoped: two concurrent starts (postinstall racing cli.js, or two + // terminals) would otherwise interleave writes to one file, fail each other's + // checksum, and delete each other's in-flight download from the catch below. + const tmp = `${binPath}.arm64.${process.pid}.tmp`; + try { + downloadFile(ARM64_TRAY_URL, tmp, 30); + const sum = sha256File(tmp); + // Integrity matters more than usual: this is an executable that runs on + // every Apple Silicon user's machine. + if (sum !== ARM64_TRAY_SHA256) throw new Error(`checksum mismatch (got ${sum.slice(0, 12)}…)`); + if (!isArm64MachO(tmp)) throw new Error("downloaded file is not an arm64 Mach-O"); + fs.chmodSync(tmp, 0o755); + fs.renameSync(tmp, binPath); + bustSystrayCopyCache(); + clearArm64Attempt(); + console.log("✅ Native Apple Silicon tray installed"); + return { native: true, installed: true }; + } catch (e) { + try { fs.rmSync(tmp, { force: true }); } catch {} + console.warn("⚠️ Native tray download failed — falling back to the Intel binary"); + console.warn(` Reason: ${e.message}`); + console.warn(" The Intel tray needs Rosetta 2: softwareupdate --install-rosetta --agree-to-license"); + return { native: false, error: e.message }; + } +} + function npmInstall(pkgs, { silent = false } = {}) { const cwd = ensureRuntimeDir(); if (!silent) console.log("⏳ Installing system tray (first run)..."); @@ -94,14 +233,20 @@ function ensureTrayRuntime({ silent = false } = {}) { if (process.platform === "win32") { return { systray: false, skipped: true }; } - if (hasSystray()) { + + let ready = hasSystray(); + if (!ready) { + ready = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }) && hasSystray(); + } + if (ready) { chmodSystrayBin({ silent }); if (!silent) console.log("✅ System tray ready"); - return { systray: true }; } - const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }); - if (ok) chmodSystrayBin({ silent }); - return { systray: ok && hasSystray() }; + + // Runs after the ready log so a download failure doesn't read as a broken + // tray — the Intel binary still works under Rosetta. + const arm64 = ready ? ensureArm64TrayBin() : { skipped: true }; + return { systray: ready, arm64 }; } -module.exports = { ensureTrayRuntime }; +module.exports = { ensureTrayRuntime, ensureArm64TrayBin }; diff --git a/cli/package.json b/cli/package.json index f7a0c677..4851a71b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -16,6 +16,7 @@ "scripts": { "dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js", "build": "node scripts/build-cli.js", + "build:tray-arm64": "node scripts/buildTrayArm64.js", "pack:cli": "npm run build && npm pack --pack-destination ..", "publish:cli": "npm run build && npm publish", "postinstall": "node hooks/postinstall.js", @@ -29,7 +30,8 @@ "react-dom": "19.2.1" }, "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", - "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", + "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails to load on modern macOS dyld. Neither package ships an arm64 macOS binary, so on Apple Silicon hooks/trayRuntime.js overlays our own arm64 build from the tray-binaries GitHub release; without it the tray requires Rosetta 2.", + "comment_tray_arm64": "tray_darwin_arm64 is built by scripts/buildTrayArm64.js (npm run build:tray-arm64) from felixhao28/systray-portable — the same source systray2's binary comes from — and uploaded to the pinned 'tray-binaries' GitHub release. Its sha256 is pinned as ARM64_TRAY_SHA256 in hooks/trayRuntime.js and verified after every download; rebuild and update both together. .github/workflows/tray-binaries.yml builds the same artifact in CI but refuses to publish when the sha diverges from the pin.", "engines": { "node": ">=18.0.0" }, diff --git a/cli/scripts/buildTrayArm64.js b/cli/scripts/buildTrayArm64.js new file mode 100644 index 00000000..d32990da --- /dev/null +++ b/cli/scripts/buildTrayArm64.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +// Rebuilds tray_darwin_arm64, the native Apple Silicon menubar binary that +// hooks/trayRuntime.js overlays on top of systray2's x86_64-only build. +// +// Must run on macOS: getlantern/systray is cgo against AppKit, so the arm64 +// slice needs a real macOS SDK. Requires Go on PATH (`mise use -g go@latest`). +// +// Output is NOT reproducible across Go versions even with -s -w, so after a +// rebuild you must re-upload the asset and update ARM64_TRAY_SHA256 in +// hooks/trayRuntime.js — this script prints both and fails if they diverge. + +const { execFileSync, spawnSync } = require("child_process"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const UPSTREAM_REPO = "https://github.com/felixhao28/systray-portable.git"; +// master as of 2021-09-15, the commit systray2@2.1.4's own binary was built from. +const UPSTREAM_COMMIT = "6eddc917bf39fcc0d95b57a0741d0c065fbd1e23"; + +const outDir = path.join(__dirname, "..", ".tray-build"); +const outFile = path.join(outDir, "tray_darwin_arm64"); +const trayRuntimePath = path.join(__dirname, "..", "hooks", "trayRuntime.js"); + +function fail(msg) { + console.error(`\n❌ ${msg}`); + process.exit(1); +} + +function run(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, { stdio: "inherit", ...opts }); + if (res.status !== 0) fail(`${cmd} ${args.join(" ")} exited with ${res.status}`); +} + +if (process.platform !== "darwin") fail("must be run on macOS (cgo needs the AppKit SDK)"); + +const go = spawnSync("go", ["version"], { encoding: "utf8" }); +if (go.status !== 0) { + fail("Go toolchain not found on PATH. Install it with: mise use -g go@latest"); +} +console.log(`Go: ${go.stdout.trim()}`); + +const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "systray-portable-")); +// fail() exits through process.exit(), which does not unwind the stack, so a +// try/finally here would leak the clone on every failed build. An exit handler +// covers normal completion, fail(), and uncaught exceptions alike. +process.on("exit", () => { + try { fs.rmSync(srcDir, { recursive: true, force: true }); } catch {} +}); + +console.log(`\nCloning ${UPSTREAM_REPO} @ ${UPSTREAM_COMMIT.slice(0, 7)}`); +run("git", ["clone", "--quiet", UPSTREAM_REPO, srcDir]); +run("git", ["-C", srcDir, "checkout", "--quiet", UPSTREAM_COMMIT]); + +const head = execFileSync("git", ["-C", srcDir, "log", "-1", "--format=%H %ad %s", "--date=short"], { + encoding: "utf8" +}).trim(); +console.log(`HEAD: ${head}`); + +run("go", ["mod", "download"], { cwd: srcDir }); + +console.log("\nBuilding darwin/arm64..."); +// -trimpath strips the local build directory from the binary, so two builds +// from the same commit + Go version hash identically regardless of where they +// ran. Without it the pinned sha256 could never be regenerated. +run("go", ["build", "-trimpath", "-ldflags", "-s -w", "-o", outFile, "tray.go"], { + cwd: srcDir, + env: { ...process.env, CGO_ENABLED: "1", GOOS: "darwin", GOARCH: "arm64" } +}); + +// ── Verify ──────────────────────────────────────────────────────────────── +const buf = Buffer.alloc(8); +const fd = fs.openSync(outFile, "r"); +fs.readSync(fd, buf, 0, 8, 0); +fs.closeSync(fd); +if (buf.readUInt32LE(0) !== 0xfeedfacf || buf.readUInt32LE(4) !== 0x0100000c) { + fail("output is not a thin arm64 Mach-O"); +} + +// Apple Silicon refuses to execute an unsigned binary. Go's linker applies an +// ad-hoc signature automatically; confirm it survived. +const sig = spawnSync("codesign", ["--verify", outFile], { encoding: "utf8" }); +if (sig.status !== 0) fail(`ad-hoc signature invalid: ${(sig.stderr || "").trim()}`); + +const sha256 = crypto.createHash("sha256").update(fs.readFileSync(outFile)).digest("hex"); +const sizeMb = (fs.statSync(outFile).size / 1024 / 1024).toFixed(2); + +console.log(`\n✅ ${outFile}`); +console.log(` arch: arm64 (ad-hoc signed, verified)`); +console.log(` size: ${sizeMb} MB`); +console.log(` sha256: ${sha256}`); + +// Whitespace-tolerant: a formatter could wrap the assignment across lines, and +// a null match must fail loudly rather than silently read as "no pin". +const pinMatch = fs.readFileSync(trayRuntimePath, "utf8").match(/ARM64_TRAY_SHA256\s*=\s*"([0-9a-f]{64})"/); +if (!pinMatch) fail(`could not find ARM64_TRAY_SHA256 in ${trayRuntimePath}`); +const pinned = pinMatch[1]; +if (pinned === sha256) { + console.log(`\n Matches ARM64_TRAY_SHA256 in hooks/trayRuntime.js — no code change needed.`); +} else { + console.log(`\n⚠️ Differs from ARM64_TRAY_SHA256 in hooks/trayRuntime.js (${pinned}).`); + console.log(` Re-upload the release asset, then update that constant to the sha256 above.`); +} + +console.log(`\nNext: upload to the pinned release tag, keeping the asset name stable:`); +console.log(` gh release upload tray-binaries "${outFile}" --clobber`); diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js index 6658e948..0d643f29 100644 --- a/cli/src/cli/tray/tray.js +++ b/cli/src/cli/tray/tray.js @@ -141,11 +141,14 @@ function initWindowsTray(options) { /** * macOS/Linux tray via systray binary * - * Prefers `systray2` (active fork of `systray`, ships newer - * getlantern/systray-portable binaries that work on macOS 14+ and Apple - * Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2 - * is not available, though that binary's Mach-O headers are rejected by - * modern dyld and the icon will not appear. + * Prefers `systray2`, the active fork of `systray`. Both ship only an x86_64 + * `tray_darwin_release` and select it by process.platform alone, so on Apple + * Silicon the tray runs under Rosetta 2 and fails with EBADARCH when Rosetta is + * absent. hooks/trayRuntime.js overlays a native arm64 build over that file to + * avoid the dependency; the fallbacks below are Intel-only. + * + * Falls back to legacy `systray@1.0.5` if systray2 is unavailable, though that + * binary's Mach-O headers are rejected by modern dyld and no icon will appear. */ function resolveSystray() { let runtimeDir = null;