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;