diff --git a/docs/build-and-release.md b/docs/build-and-release.md index d5f89b6..e916664 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -22,12 +22,20 @@ semver-shaped (`0.1.`) for electrobun's config/updater; `BRANDING.buildLabel` - `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/) fakes a Node version the Angular CLI accepts under Bun. Both preloads are load-bearing. +- `scripts/fix-electrobun-cli.ts` re-signs electrobun's prebuilt CLI binary, + whose upstream ad-hoc signature is invalid — macOS SIGKILLs it and the app + silently never launches, with exit code 0 (see [gotchas.md](gotchas.md)). It + runs from `postinstall` **and** at the start of `dev`/`start`/`build`/ + `release`, because the CLI is downloaded lazily on first use rather than at + install time; when the binary is missing it provokes that download first, so + a fresh checkout self-heals. Equally load-bearing — removing it means + `bun run start` does nothing at all. ## Release builds `bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host). -Steps: gen-version → gen-icons → Angular build → `electrobun build ---env=stable` → copy/rename artifacts into +Steps: gen-version → fix-electrobun-cli → gen-icons → Angular build → +`electrobun build --env=stable` → copy/rename artifacts into `release//miniSCP-Build-.*`. Facts the script encodes: @@ -46,6 +54,10 @@ Facts the script encodes: `dist-macos-x64` core into the electrobun package dir on first use, and packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung on the GitHub redirect chain; the script uses `curl -fsSL` deliberately. +- **Both CLI binaries need re-signing.** The x64 CLI fetched for the Rosetta + path carries the same broken upstream signature as the arm64 one, so + `x64Cli()` re-signs it after download *and* on cache reuse; the native path + is covered by the `fix-electrobun-cli.ts` step up front. - **Core binary downloads**: the installed npm package ships mac-arm64 binaries only; other `dist--` cores are fetched from `github.com/blackboardsh/electrobun/releases` at build time — network to diff --git a/docs/gotchas.md b/docs/gotchas.md index f86eda2..4c01738 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -31,6 +31,21 @@ touching the adjacent code. ## Electrobun +- **The prebuilt CLI binary ships with a broken ad-hoc signature**, so macOS + SIGKILLs it at exec and `bun run start` prints nothing and exits **0** — + `bin/electrobun.cjs` spawns the real binary and forwards + `process.exit(code || 0)`, and a signal death has `code === null`. The binary + is a Bun single-file executable whose payload is appended *after* the linker + signed it, so `codesign -v` reports "code or signature have been modified"; + the arm64 and x64 CLI tarballs are both affected upstream (confirmed by + hashing a fresh download against the shipped copy — it is not local + corruption, and re-installing does not help). `scripts/fix-electrobun-cli.ts` + re-seals it with `codesign --force --sign -`. It runs from `postinstall` + **and** ahead of every electrobun invocation, because the wrapper downloads + the binary **lazily on first use**, not at install time — postinstall alone + would no-op on a fresh checkout. It also fixes `.cache/electrobun`, which the + wrapper restores `bin/` from. Never read a silent `exit 0` from the + electrobun CLI as "nothing happened": run `codesign -v` on it first. - **Window `close` is not cancellable** (response type `{}`; only the app-level `before-quit` supports `event.response = {allow:false}`). All unsaved-state guards must either intercept app-controlled close paths diff --git a/package.json b/package.json index 496656c..f20829d 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,12 @@ "ui" ], "scripts": { - "dev": "bun scripts/gen-version.ts && bun scripts/dev.ts", + "dev": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun scripts/dev.ts", "dev:ui": "bun run --cwd ui start", "build:ui": "bun run --cwd ui build", - "build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build", - "start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev", - "postinstall": "bun scripts/gen-version.ts", + "build": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun build", + "start": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun dev", + "postinstall": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts", "release": "bun scripts/release.ts" }, "dependencies": { diff --git a/scripts/fix-electrobun-cli.ts b/scripts/fix-electrobun-cli.ts new file mode 100644 index 0000000..7b8b437 --- /dev/null +++ b/scripts/fix-electrobun-cli.ts @@ -0,0 +1,46 @@ +// Re-signs electrobun's prebuilt CLI binary, which ships with a BROKEN ad-hoc +// signature: it is a Bun single-file executable whose payload is appended after +// the linker signed it, so `codesign -v` reports "code or signature have been +// modified" and the macOS kernel SIGKILLs it at exec. The failure is invisible: +// electrobun's bin/electrobun.cjs wrapper spawns the real binary and forwards +// `process.exit(code || 0)` — a signal death has code === null, so `bun run +// start` printed nothing and exited 0 while never launching the app. +// `codesign --force --sign -` re-seals it ad-hoc and it runs normally. +// +// The binary is downloaded LAZILY by that wrapper on first use, not by install, +// so this runs both from postinstall and ahead of every electrobun invocation. +// Idempotent, and a no-op off macOS. +import { existsSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.join(import.meta.dir, ".."); + +/** true if `bin` has (or now has) a signature the kernel will accept. */ +export function ensureSigned(bin: string): boolean { + if (process.platform !== "darwin" || !existsSync(bin)) return true; + const valid = () => Bun.spawnSync(["codesign", "-v", bin], { stderr: "ignore" }).success; + if (valid()) return true; + const signed = Bun.spawnSync(["codesign", "--force", "--sign", "-", bin], { stderr: "pipe" }); + if (!signed.success || !valid()) { + console.warn(`[electrobun-cli] could not re-sign ${path.relative(ROOT, bin)} — the app will not launch`); + return false; + } + console.log(`[electrobun-cli] re-signed ${path.relative(ROOT, bin)} (upstream ad-hoc signature is invalid)`); + return true; +} + +if (import.meta.main) { + const pkgDir = path.join(ROOT, "node_modules", "electrobun"); + const bin = path.join(pkgDir, "bin", "electrobun"); + + // The wrapper downloads the CLI on first run and only then execs it, so one + // throwaway invocation leaves a downloaded (still unsigned) binary behind + // even though that exec is killed. + if (process.platform === "darwin" && existsSync(pkgDir) && !existsSync(bin)) { + Bun.spawnSync(["bunx", "electrobun", "--version"], { cwd: ROOT, stdout: "ignore", stderr: "ignore" }); + } + + // .cache is what the wrapper restores bin/ from, so both copies need fixing. + const ok = [bin, path.join(pkgDir, ".cache", "electrobun")].map(ensureSigned).every(Boolean); + if (!ok) process.exit(1); +} diff --git a/scripts/release.ts b/scripts/release.ts index df9c650..608df13 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -9,6 +9,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { ensureSigned } from "./fix-electrobun-cli"; const ROOT = path.join(import.meta.dir, ".."); const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const; @@ -51,6 +52,7 @@ const viaRosetta = requested === "mac-x64" && process.arch === "arm64"; // ---- prepare: version, icons, UI bundle ---- run(["bun", "scripts/gen-version.ts"]); +run(["bun", "scripts/fix-electrobun-cli.ts"]); run(["bun", "scripts/gen-icons.ts"]); run(["bun", "run", "--cwd", "ui", "build"]); @@ -64,7 +66,12 @@ async function x64Cli(): Promise { const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json(); const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`); const bin = path.join(dir, "electrobun"); - if (existsSync(bin)) return bin; + // Same broken upstream ad-hoc signature as the arm64 CLI (see + // scripts/fix-electrobun-cli.ts) — unsigned it is SIGKILLed on exec. + if (existsSync(bin)) { + if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`); + return bin; + } console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`); mkdirSync(dir, { recursive: true }); const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`; @@ -73,6 +80,7 @@ async function x64Cli(): Promise { run(["tar", "-xzf", tarball, "-C", dir]); if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`); run(["chmod", "+x", bin]); + if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`); return bin; }