diff --git a/.gitignore b/.gitignore index 450fd9c..1e30fc9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,6 @@ config/layouts/ # compiled per-machine, source sits next to it modules/propresenter-timers/ltc-capture -# the launcher's build output (built per machine from launcher/macos/build.sh) +# the launcher's build output, and its cache of the Node runtime it embeds launcher/macos/build/ +launcher/macos/.cache/ diff --git a/CLAUDE.md b/CLAUDE.md index ca9a420..ba8b906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,9 @@ Modular production dashboard web app for Waters Church, developed in this repo. The app lives at the repo root and is built: zero-dependency Node server (`server.js`, Node 18+), dashboard shell client in `public/`, admin page at `/admin`, modules in `modules/`, and a native macOS menu-bar launcher in -`launcher/macos/` (Swift, no dependencies — it supervises the server and owns -the macOS permissions modules declare; see its README). Runtime state (module +`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries +the server and its own Node runtime, supervises them, and owns the macOS +permissions modules declare; see its README). Runtime state (module config, named layouts) is written to a per-machine data directory outside the checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never add code that writes runtime state into the repo folder. diff --git a/README.md b/README.md index 16261e6..80696b3 100644 --- a/README.md +++ b/README.md @@ -29,21 +29,28 @@ and a clear degraded state, then recover on their own. ## Run it -Requires [Node.js](https://nodejs.org) 18 or newer. +### macOS — the app -On a Mac, run it from the **menu-bar launcher** — see -[launcher/macos](launcher/macos). It starts and stops the server, can open at -login, owns the port, and holds the permissions the modules need: the -microphone the LTC listener reads, and local network access for ProdCom and -ProPresenter. macOS grants those to the app that owns the process, so a server -started any other way — over SSH, from a launchd job — simply doesn't have -them, and an LTC input goes silently to zeroes. Build it once: +Open the disk image and drag **ProdDash** to Applications. That's the whole +installation: the app carries the dashboard and its own Node runtime, so +nothing else has to be installed and nothing can be missing. It lives in the +menu bar, starts and stops the server, can open at login, and holds the +permissions the modules need — the microphone the LTC listener reads, and +local network access for ProdCom and ProPresenter. macOS grants those to the +app that owns the process, so a server started any other way (over SSH, from +a launchd job) simply doesn't have them, and an LTC input goes silently to +zeroes. + +Build the disk image from a checkout with +[launcher/macos](launcher/macos)/`build.sh`: ```bash -cd launcher/macos && ./build.sh --install +cd launcher/macos && ./build.sh --dmg ``` -Otherwise, from the app folder: +### Anywhere else, or from a checkout + +Requires [Node.js](https://nodejs.org) 18 or newer. ```bash node server.js @@ -81,7 +88,10 @@ it at startup (`Settings : …`). The directory holds `modules.json` (module config), `layouts/` (named layouts) and `modules/` (modules installed from the admin page), all written -by the app itself. It may also hold a `proddash.json` +by the app itself. Installed from the macOS app, it also holds `app/` — the +copy of ProdDash that actually runs, laid down from inside the app bundle on +first launch and updated in place from then on (nothing may write inside a +signed .app, and ProdDash updates itself). It may also hold a `proddash.json` with this machine's shell settings — the same keys as the checked-in [config/proddash.json](config/proddash.json), which supplies the defaults: diff --git a/launcher/macos/README.md b/launcher/macos/README.md index d26892e..9ea0e6d 100644 --- a/launcher/macos/README.md +++ b/launcher/macos/README.md @@ -1,50 +1,104 @@ -# ProdDash Launcher (macOS) +# ProdDash for macOS -A menu-bar app that starts, stops and watches over the ProdDash server on a -Mac — and, just as importantly, **owns the permissions its modules need**. +The Mac application: a menu-bar app that carries the dashboard, its own Node +runtime, and the permissions its modules need. Installing it is dragging +**ProdDash** to Applications — nothing else has to be present on the machine, +and nothing can go missing from under it. -It is a native Swift app: about 1 MB, no dependencies, nothing to install but -the app itself. Build it with `./build.sh`. +Build it with `./build.sh`. The app is native Swift with no dependencies of +its own; the Node it ships is fetched from nodejs.org at build time and +checksum-verified. -## Why a launcher, and not just `Start ProdDash.command` +## What's in the bundle -Two reasons, and the second is the one that bites. +``` +ProdDash.app/Contents/ + MacOS/ProdDash the launcher (Swift, ~1 MB) + Resources/node/bin/node the Node runtime ProdDash runs on + Resources/app/ server.js, public/, modules/ — ProdDash itself +``` -**Supervision.** The server is started as a child process, its output is kept -in a log, and it is brought back if it stops on its own. The shell was built -for this: with `PRODDASH_LAUNCHER=1` in its environment it exits **75** rather -than respawning itself when the admin page applies an update, which the -launcher reads as "start me again" instead of as a fault. A crash gets a -backed-off restart (1s, 2s, 5s, 10s); four crashes in two minutes stops the -cycle, so a broken install doesn't spin forever. +Everything mutable lives outside, in `~/Library/Application Support/ProdDash`: +module config, layouts, modules installed from the admin page — and `app/`, +the copy of ProdDash that actually runs. + +**Why a copy, when the bundle already has one.** ProdDash updates itself: the +admin page's **Update now** downloads a new version and writes it over the app +folder. Nothing may write inside a signed `.app` without breaking its +signature, so the bundle carries a read-only baseline and the data directory +carries the copy that runs. Both routes to a new version work, and they don't +fight: installing a newer `.app` replaces the copy, while a copy that has +already updated itself past the bundle is left alone. + +## Why it exists **Permissions.** macOS grants privacy permissions to the *application* that -owns a process, and a child inherits its parent's identity. Start ProdDash -from here and node — plus the Timers module's `ltc-capture` tool — belong to -this app bundle, so the microphone and local-network prompts are asked, and -remembered, as **ProdDash**. Start it over SSH or from a bare launchd job and -there is no owner to ask: the permission is silently absent rather than -refused, and an LTC input reads as endless zeroes with no error anywhere. -That is the failure `modules/propresenter-timers/ltc-listener.js` warns about, -and this is the fix. +owns a process, and a child inherits its parent's identity. Started from here, +node — and the Timers module's `ltc-capture` — belong to this app bundle, so +the microphone and local-network prompts are asked, and remembered, as +ProdDash. Started over SSH or from a bare launchd job there is nobody to ask: +the permission is silently absent rather than refused, which is the "endless +zeroes, no error" failure `ltc-listener.js` warns about. + +**Supervision.** The shell was built for this: with `PRODDASH_LAUNCHER=1` it +exits **75** instead of respawning itself when the admin page applies an +update, which the launcher reads as "start me again" rather than as a fault. +A crash gets a backed-off restart (1s, 2s, 5s, 10s); four in two minutes stops +the cycle rather than spinning forever. ## Build ```bash -cd launcher/macos -./build.sh # build/ProdDash.app, universal (Apple Silicon + Intel) -./build.sh --install # …and copy it to /Applications -./build.sh --run # …and open it -./build.sh --arch native # this Mac's architecture only — faster while iterating +./build.sh # build/ProdDash.app — the whole application +./build.sh --dmg # …and build/ProdDash--arm64.dmg +./build.sh --install # …and copy it to /Applications +./build.sh --run # …and open it +./build.sh --notarize # with --dmg: notarise and staple (Developer ID) +./build.sh --dev # launcher only: no Node, no server — runs against + # the checkout. Seconds, for work on the launcher. ``` Needs the Xcode command line tools (`xcode-select --install`) and macOS 13 or -newer. The app icon is built from `public/icons/icon-512.png`, so ProdDash's -own icon is the app's icon. +newer. The first build downloads Node (~50 MB) into `.cache/` and reuses it. +A full build takes about ten seconds and produces a 117 MB app, 43 MB +compressed in the disk image. -The build is signed ad-hoc, which is all a local tool needs — but the -signature changes on every rebuild, so macOS may treat a rebuilt app as a new -one and ask for the microphone again. Build once, install it, and leave it. +Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit +`ARCH` and `NODE_VERSION` at the top of `build.sh` — the download is verified +against nodejs.org's published checksums either way. + +The app icon is built from `public/icons/icon-512.png`, so ProdDash's own icon +is the app's icon. A module's native helper is compiled during the build +(`.swift` beside a module's files becomes ``), so the Mac running +ProdDash never needs a compiler. + +## Signing + +`build.sh` uses a **Developer ID Application** certificate when one is +installed, and signs ad-hoc when none is — which is fine on your own Macs, but +another Mac will refuse the app until someone approves it under System +Settings → Privacy & Security. + +To sign properly you need the Apple Developer Program (paid) and a Developer +ID certificate — note this is not the same as the "Apple Development" +certificate Xcode creates for you: + +1. Xcode → Settings → Accounts → your team → **Manage Certificates** → + **+** → **Developer ID Application**. (Requires Account Holder or Admin + on the team.) `security find-identity -v -p codesigning` should then + list a `Developer ID Application: …` identity. +2. Store notarisation credentials once, with an app-specific password from + [appleid.apple.com](https://appleid.apple.com): + + ```bash + xcrun notarytool store-credentials proddash --apple-id you@example.com --team-id TEAMID + ``` + +3. `./build.sh --notarize` + +Then the disk image opens by double-click on any Mac, with no warnings. +`CODESIGN_IDENTITY` overrides the certificate; `NOTARY_PROFILE` overrides the +keychain profile name. ## Using it @@ -56,7 +110,8 @@ hollow when it is stopped. Its menu has the whole app in it: - **Start / Stop / Restart ProdDash**. - **Launcher…** — the window below. - **Open at Login** — a login item, through the modern `SMAppService` API. -- **Quit ProdDash** — stops the server too, and asks first while it is running. +- **Quit ProdDash** — stops the server too, and asks first while it is + running. The window has the status, both addresses the dashboard answers on, and three tabs: @@ -64,10 +119,10 @@ tabs: - **Log** — everything the server prints, plus what the launcher did and why. Also written to `~/Library/Logs/ProdDash/proddash-.log`, kept a week. - **Settings** — the port, and how it starts (at login, server on open, - restart on crash, open a browser, show this window). It also shows the - ProdDash folder, the node binary in use and where settings are kept. -- **Permissions** — what the installed modules declare they need, who needs - it, and what macOS currently allows. See below. + restart on crash, open a browser, show this window). It also shows which + copy of ProdDash is running, which node, and where settings are kept. +- **Permissions** — one line per permission: a status dot, its name, and the + one thing you can do about it. See below. ### Port @@ -78,9 +133,8 @@ it while the server runs restarts it. ### Permissions -One line per permission: a status dot, its name, and the one thing you can do -about it. What appears is what the installed modules declare in their -manifests (`"permissions": [ … ]` — see +What appears is what the installed modules declare in their manifests +(`"permissions": [ … ]` — see [docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which module asked, and why. @@ -88,8 +142,8 @@ module asked, and why. - **Local Network** — macOS has no API that reports whether this was granted, so **Check Access** asks the network instead: it browses for Bonjour services (which is what raises the prompt) and tries every upstream the - admin page is pointed at, then says what the result means. Nothing - answering, when the gear is on, means macOS is withholding access. + admin page is pointed at. Nothing answering, when the gear is on, means + macOS is withholding access. - **Notifications** — the launcher's own, so a server that stops while nobody is watching still reaches someone. Turn them off for ProdDash in System Settings if you'd rather not have them. @@ -110,21 +164,17 @@ still at the keyboard, rather than halfway through a service. Monitor. - **Signals.** `killall ProdDash`, a logout or a shutdown stops the server first rather than leaving node running. -- **Where the settings live.** ProdDash's port is in the data directory's - `proddash.json`; everything else (auto-start, restart-on-crash, the paths) - is the launcher's own, in its preferences. -- **Finding things.** The ProdDash folder is the checkout the app was built - inside, or `~/Apps/ProdDash` and the other usual spots, or whatever you - pick under Settings. Node is the checkout's bundled `runtime/bin/node` if - there is one, then Homebrew, `/usr/local`, nvm, Volta, and finally whatever - a login shell finds. +- **Nothing is required outside the app.** A development build (`--dev`) + carries no server and no Node, and runs the checkout it was built in with + whatever node is on the machine — Settings → ProdDash folder points it + somewhere else. ## Troubleshooting | What you see | What it means | | --- | --- | -| "Can't find the ProdDash folder" | Point it at the folder with `server.js` in it, under Settings. | -| "Node.js 18 or newer isn't installed" | Install Node. A GUI app inherits almost no `PATH`, so one in an unusual place may not be found — Settings shows which one is in use. | +| "ProdDash can't be opened because Apple cannot check it" | The app is ad-hoc signed. System Settings → Privacy & Security → **Open Anyway**, once. Signing it properly (above) removes this. | | "ProdDash stopped 4 times in two minutes" | The server is failing on startup — the Log tab has its own error. | -| LTC shows no signal | Permissions tab → Microphone. If it says granted and the input is still silent, the server may be an older copy started outside the launcher — Stop, then Start. | +| LTC shows no signal | Permissions tab → Microphone. If it says granted and the input is still silent, the server may be an older copy started outside the app — Stop, then Start. | | Nothing on the network answers | Permissions tab → **Check Access**. | +| A development build says it can't find ProdDash | `--dev` builds need a checkout: Settings → ProdDash folder. | diff --git a/launcher/macos/Resources/Info.plist b/launcher/macos/Resources/Info.plist index 97d105d..9108e0b 100644 --- a/launcher/macos/Resources/Info.plist +++ b/launcher/macos/Resources/Info.plist @@ -16,6 +16,8 @@ 1.0.0 CFBundleVersion 1.0.0 + ProdDashLauncherVersion + 1.0.0 CFBundleIconFile AppIcon LSApplicationCategoryType diff --git a/launcher/macos/Resources/ProdDash.entitlements b/launcher/macos/Resources/ProdDash.entitlements new file mode 100644 index 0000000..0cf521e --- /dev/null +++ b/launcher/macos/Resources/ProdDash.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/launcher/macos/Resources/helper.entitlements b/launcher/macos/Resources/helper.entitlements new file mode 100644 index 0000000..98ab339 --- /dev/null +++ b/launcher/macos/Resources/helper.entitlements @@ -0,0 +1,10 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/launcher/macos/Resources/node.entitlements b/launcher/macos/Resources/node.entitlements new file mode 100644 index 0000000..60cef26 --- /dev/null +++ b/launcher/macos/Resources/node.entitlements @@ -0,0 +1,15 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/launcher/macos/Sources/AppDelegate.swift b/launcher/macos/Sources/AppDelegate.swift index 134aeab..5c6cf81 100644 --- a/launcher/macos/Sources/AppDelegate.swift +++ b/launcher/macos/Sources/AppDelegate.swift @@ -10,8 +10,10 @@ import Combine */ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + /// The launcher's own version. The app's CFBundleShortVersionString is + /// ProdDash's, because the app *is* ProdDash now. static var version: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + Bundle.main.infoDictionary?["ProdDashLauncherVersion"] as? String ?? "dev" } /// Sent by a second copy of the launcher so the first one shows itself. static let showNotification = Notification.Name("org.waterschurch.proddash.launcher.show") diff --git a/launcher/macos/Sources/LauncherConfig.swift b/launcher/macos/Sources/LauncherConfig.swift index 7268fdd..eb859a3 100644 --- a/launcher/macos/Sources/LauncherConfig.swift +++ b/launcher/macos/Sources/LauncherConfig.swift @@ -32,6 +32,95 @@ enum ProdDash { .appendingPathComponent("Library/Application Support/ProdDash") } + // MARK: - What the app carries + + /** + A shipped ProdDash lives read-only inside the app bundle and runs from a + copy in the data directory. + + The copy isn't ceremony: ProdDash updates itself in place — the admin + page's "Update now" writes a fresh download over the app folder — and + nothing may write inside a signed .app without breaking its signature. + So the bundle carries the baseline, the data directory carries the copy + that actually runs, and both routes to a new version keep working: drop + in a newer .app, or press Update now. + + A development build ships no payload and falls through to the checkout it + was built in. + */ + static func bundledPayload() -> URL? { + guard let resources = Bundle.main.resourceURL else { return nil } + let payload = resources.appendingPathComponent("app") + return isRoot(payload) ? payload : nil + } + + /// The Node the app carries, so nothing depends on what this Mac happens + /// to have installed — or on it staying installed. + static func bundledNode() -> URL? { + guard let resources = Bundle.main.resourceURL else { return nil } + let node = resources.appendingPathComponent("node/bin/node") + return FileManager.default.isExecutableFile(atPath: node.path) ? node : nil + } + + static func workingCopy(dataDir: URL) -> URL { dataDir.appendingPathComponent("app") } + + /** + Put the bundled ProdDash in place: when there is no copy yet, or when the + app carries a newer one than the copy — someone installed a new .app. A + copy that has updated itself *past* the bundle is left alone, so a new + install never drags a running system backwards. + */ + @discardableResult + static func seedWorkingCopy(dataDir: URL, log: (String) -> Void) -> URL? { + guard let payload = bundledPayload() else { return nil } + let working = workingCopy(dataDir: dataDir) + let shipped = shellVersion(root: payload) + if isRoot(working) { + let installed = shellVersion(root: working) + guard compareVersions(shipped, installed) > 0 else { return working } + log("this app carries ProdDash \(shipped) — replacing the \(installed) copy it runs") + } else { + log("first run — installing ProdDash \(shipped)") + } + + let fm = FileManager.default + let previous = working.appendingPathExtension("previous") + do { + try fm.createDirectory(at: dataDir, withIntermediateDirectories: true) + try? fm.removeItem(at: previous) + if fm.fileExists(atPath: working.path) { try fm.moveItem(at: working, to: previous) } + try fm.copyItem(at: payload, to: working) + makeWritable(working) + try? fm.removeItem(at: previous) + return working + } catch { + log("could not install the bundled ProdDash: \(error.localizedDescription)") + // Put back whatever was working before rather than leaving nothing. + if !fm.fileExists(atPath: working.path), fm.fileExists(atPath: previous.path) { + try? fm.moveItem(at: previous, to: working) + } + return isRoot(working) ? working : nil + } + } + + /// Files copied out of a bundle can arrive read-only, and this copy is + /// exactly where ProdDash writes its own updates. + private static func makeWritable(_ root: URL) { + let fm = FileManager.default + var targets = [root] + if let walker = fm.enumerator(at: root, includingPropertiesForKeys: nil) { + for case let url as URL in walker { targets.append(url) } + } + for url in targets { + guard let mode = (try? fm.attributesOfItem(atPath: url.path))?[.posixPermissions] as? NSNumber + else { continue } + let writable = mode.uint16Value | 0o200 + if writable != mode.uint16Value { + try? fm.setAttributes([.posixPermissions: NSNumber(value: writable)], ofItemAtPath: url.path) + } + } + } + // MARK: - Finding the checkout /// A folder is ProdDash if it has the two files the server cannot run without. @@ -46,7 +135,7 @@ enum ProdDash { this .app was built inside (the app usually sits in `launcher/macos/build`), else the handful of places people actually keep it. */ - static func findRoot(preferred: String?) -> URL? { + static func findRoot(preferred: String?, dataDir: URL) -> URL? { if let preferred, !preferred.isEmpty { let url = URL(fileURLWithPath: preferred).standardizedFileURL if isRoot(url) { return url } @@ -55,6 +144,12 @@ enum ProdDash { let url = URL(fileURLWithPath: env).standardizedFileURL if isRoot(url) { return url } } + // A shipped app runs the copy it installed; only a development build + // (no payload) goes looking for a checkout. + if bundledPayload() != nil { + let working = workingCopy(dataDir: dataDir) + if isRoot(working) { return working } + } var up = Bundle.main.bundleURL.standardizedFileURL for _ in 0..<6 { up.deleteLastPathComponent() @@ -89,6 +184,8 @@ enum ProdDash { static func findNode(root: URL?, preferred: String?) -> URL? { var tries: [URL] = [] if let preferred, !preferred.isEmpty { tries.append(URL(fileURLWithPath: preferred)) } + // What the app carries, so a shipped ProdDash needs nothing installed. + if let bundled = bundledNode() { tries.append(bundled) } // The runtime a production machine ships with — same preference as Start ProdDash.command. if let root { tries.append(root.appendingPathComponent("runtime/bin/node")) } tries += ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/opt/local/bin/node", "/usr/bin/node"] diff --git a/launcher/macos/Sources/ServerController.swift b/launcher/macos/Sources/ServerController.swift index c2a7bc6..f4eabb4 100644 --- a/launcher/macos/Sources/ServerController.swift +++ b/launcher/macos/Sources/ServerController.swift @@ -82,7 +82,13 @@ final class ServerController: ObservableObject { /// Re-resolve the checkout, the node binary and the port. Cheap, and run /// again before every start so a folder fixed in Settings takes effect. func refreshEnvironment() { - let root = ProdDash.findRoot(preferred: settings.rootPath) + // A shipped app installs (or re-installs) its copy of ProdDash before + // going looking for one. An explicitly chosen folder skips all that. + let base = ProdDash.dataDir(root: nil) + if settings.rootPath.isEmpty { + ProdDash.seedWorkingCopy(dataDir: base) { [log] message in log.launcher(message) } + } + let root = ProdDash.findRoot(preferred: settings.rootPath, dataDir: base) rootURL = root let node = ProdDash.findNode(root: root, preferred: settings.nodePath) if node?.path != nodeURL?.path { diff --git a/launcher/macos/build.sh b/launcher/macos/build.sh index 1019e1a..5bb9b0b 100755 --- a/launcher/macos/build.sh +++ b/launcher/macos/build.sh @@ -1,72 +1,92 @@ #!/bin/zsh -# Build ProdDash.app — the menu-bar launcher. +# Build ProdDash.app — the whole application: launcher, server and its own +# Node runtime, with nothing to install first. # -# ./build.sh build into launcher/macos/build/ProdDash.app +# ./build.sh build/ProdDash.app, self-contained +# ./build.sh --dmg …and build/ProdDash--arm64.dmg # ./build.sh --install …and copy it to /Applications -# ./build.sh --run …and launch it when it's built -# ./build.sh --arch native build for this Mac only (faster; default is universal) +# ./build.sh --run …and launch it +# ./build.sh --notarize with --dmg: notarise and staple (needs a +# Developer ID and a notarytool keychain profile) +# ./build.sh --dev launcher only — no Node, no server payload, so +# it runs against the checkout. Seconds, not +# minutes, while working on the launcher itself. # -# Needs the Xcode command line tools (swiftc). No other dependencies — the app -# is Swift and system frameworks, nothing to install and nothing to vendor. +# Signing: a "Developer ID Application" certificate is used when one is +# installed (override with CODESIGN_IDENTITY). Without one the app is signed +# ad-hoc, which is fine on your own Macs but makes another Mac ask before it +# will open the app. +# +# Needs the Xcode command line tools. Everything else it fetches or builds. set -e -u cd "$(dirname "$0")" APP_NAME="ProdDash" BUNDLE_ID="org.waterschurch.proddash" +LAUNCHER_VERSION="1.0.0" +# The Node the app ships. Bump it here; the download is checksum-verified +# against nodejs.org's own SHASUMS256.txt and cached in .cache/. +NODE_VERSION="v24.21.0" +ARCH="arm64" +DEPLOYMENT_TARGET="13.0" + +REPO_ROOT="../.." OUT_DIR="build" +CACHE_DIR=".cache" APP="$OUT_DIR/$APP_NAME.app" -ARCHS=(arm64 x86_64) +CONTENTS="$APP/Contents" +PAYLOAD="$CONTENTS/Resources/app" + +DEV=0 INSTALL=0 RUN=0 +MAKE_DMG=0 +NOTARIZE=0 while [[ $# -gt 0 ]]; do case "$1" in + --dev) DEV=1 ;; --install) INSTALL=1 ;; --run) RUN=1 ;; - --arch) shift; [[ "${1:-}" == "native" ]] && ARCHS=($(uname -m)) || ARCHS=("${1:-arm64}") ;; - -h|--help) sed -n '2,10p' "$0"; exit 0 ;; + --dmg) MAKE_DMG=1 ;; + --notarize) NOTARIZE=1; MAKE_DMG=1 ;; + -h|--help) sed -n '2,22p' "$0"; exit 0 ;; *) echo "build.sh: unknown option $1" >&2; exit 2 ;; esac shift done -if ! command -v swiftc >/dev/null; then +command -v swiftc >/dev/null || { echo "build.sh: swiftc not found — install the Xcode command line tools:" >&2 echo " xcode-select --install" >&2 exit 1 -fi +} -VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist) -echo "ProdDash Launcher $VERSION — building for ${ARCHS[*]}" +# The product's version is ProdDash's own; the launcher keeps its own number +# for when something is wrong with the launcher rather than the dashboard. +VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1) +[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; } + +echo "ProdDash $VERSION (launcher $LAUNCHER_VERSION) — building for $ARCH" rm -rf "$OUT_DIR" -mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$OUT_DIR/obj" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" -# One binary per architecture, then lipo them together, so the same .app runs -# on the booth's Apple Silicon and on any Intel Mac still in service. -SLICES=() -for arch in "${ARCHS[@]}"; do - echo " compiling $arch" - swiftc -O -whole-module-optimization \ - -target "$arch-apple-macos13.0" \ - -module-name ProdDashLauncher \ - -o "$OUT_DIR/obj/$APP_NAME-$arch" \ - Sources/*.swift - SLICES+=("$OUT_DIR/obj/$APP_NAME-$arch") -done +# ── the launcher ───────────────────────────────────────────────────────── +echo " compiling the launcher" +swiftc -O -whole-module-optimization \ + -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \ + -module-name ProdDashLauncher \ + -o "$CONTENTS/MacOS/$APP_NAME" \ + Sources/*.swift -if [[ ${#SLICES[@]} -gt 1 ]]; then - lipo -create "${SLICES[@]}" -output "$APP/Contents/MacOS/$APP_NAME" -else - cp "${SLICES[1]}" "$APP/Contents/MacOS/$APP_NAME" -fi -chmod +x "$APP/Contents/MacOS/$APP_NAME" +cp Resources/Info.plist "$CONTENTS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null +/usr/libexec/PlistBuddy -c "Set :ProdDashLauncherVersion $LAUNCHER_VERSION" "$CONTENTS/Info.plist" >/dev/null +printf 'APPL????' > "$CONTENTS/PkgInfo" -cp Resources/Info.plist "$APP/Contents/Info.plist" -printf 'APPL????' > "$APP/Contents/PkgInfo" - -# App icon: ProdDash's own, straight from the web app's icons. -SOURCE_ICON="../../public/icons/icon-512.png" +SOURCE_ICON="$REPO_ROOT/public/icons/icon-512.png" if [[ -f "$SOURCE_ICON" ]]; then echo " building the icon" ICONSET="$OUT_DIR/AppIcon.iconset" @@ -74,33 +94,122 @@ if [[ -f "$SOURCE_ICON" ]]; then for size in 16 32 64 128 256 512 1024; do sips -z $size $size "$SOURCE_ICON" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null 2>&1 done - # iconutil wants Apple's names, including the @2x pairs. mv "$ICONSET/icon_32x32.png" "$ICONSET/icon_16x16@2x.png" cp "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32@2x.png" mv "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32.png" cp "$ICONSET/icon_256x256.png" "$ICONSET/icon_128x128@2x.png" cp "$ICONSET/icon_512x512.png" "$ICONSET/icon_256x256@2x.png" mv "$ICONSET/icon_1024x1024.png" "$ICONSET/icon_512x512@2x.png" - iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" + iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns" rm -rf "$ICONSET" -else - echo " (no $SOURCE_ICON — the app will use the generic icon)" fi -rm -rf "$OUT_DIR/obj" +# ── the server, and the Node that runs it ──────────────────────────────── +if [[ $DEV -eq 0 ]]; then + TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz" + mkdir -p "$CACHE_DIR" + if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then + echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)" + curl -fsSL --retry 3 -o "$CACHE_DIR/$TARBALL.part" "https://nodejs.org/dist/$NODE_VERSION/$TARBALL" + mv "$CACHE_DIR/$TARBALL.part" "$CACHE_DIR/$TARBALL" + fi + SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt" + [[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt" + # This binary is about to be signed with your name on it — check it every build. + WANT=$(awk -v f="$TARBALL" '$2 == f {print $1}' "$SUMS") + GOT=$(shasum -a 256 "$CACHE_DIR/$TARBALL" | awk '{print $1}') + if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then + echo "build.sh: $TARBALL does not match nodejs.org's checksum — refusing to ship it" >&2 + exit 1 + fi + echo " embedding Node $NODE_VERSION" + mkdir -p "$CONTENTS/Resources/node/bin" + tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \ + "node-$NODE_VERSION-darwin-$ARCH/bin/node" -# Ad-hoc signature: enough for macOS to run it and to hang privacy permissions -# off it. Note that the signature changes on every rebuild, so macOS may treat -# a rebuilt app as a new one and ask for the microphone again. -codesign --force --sign - --identifier "$BUNDLE_ID" "$APP" >/dev/null 2>&1 \ - && echo " signed (ad-hoc)" \ - || echo " warning: could not sign the app — permissions may not stick" + echo " embedding ProdDash $VERSION" + mkdir -p "$PAYLOAD/config" + cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/" + cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/" + # Only the checked-in defaults: config/ also holds this machine's own + # modules.json and layouts when ProdDash has ever run from the checkout. + cp "$REPO_ROOT/config/proddash.json" "$PAYLOAD/config/" + find "$PAYLOAD" -name '.DS_Store' -delete -echo "built $APP" + # A module's native helper is built here, so the Mac running ProdDash needs + # no compiler: .swift beside a module's files becomes . + for source in "$PAYLOAD"/modules/*/*.swift; do + [[ -e "$source" ]] || continue + helper="${source%.swift}" + echo " compiling $(basename "$(dirname "$source")")/$(basename "$helper")" + swiftc -O -target "$ARCH-apple-macos$DEPLOYMENT_TARGET" -o "$helper" "$source" + done +fi + +# ── signing ────────────────────────────────────────────────────────────── +IDENTITY="${CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" ]]; then + IDENTITY=$(security find-identity -v -p codesigning 2>/dev/null \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}') +fi +if [[ -n "$IDENTITY" ]]; then + echo " signing as $IDENTITY" + SIGN=(--force --sign "$IDENTITY" --options runtime --timestamp) +else + echo " signing ad-hoc (no Developer ID installed — other Macs will ask before opening it)" + IDENTITY="-" + SIGN=(--force --sign -) +fi + +# Nested code first, outermost last, or the outer signature seals a lie. +if [[ -f "$CONTENTS/Resources/node/bin/node" ]]; then + codesign "${SIGN[@]}" --entitlements Resources/node.entitlements "$CONTENTS/Resources/node/bin/node" +fi +if [[ $DEV -eq 0 ]]; then + for helper in "$PAYLOAD"/modules/*/*; do + # A compiled helper is the extensionless executable beside a module's + # sources — test the name, not the path, which has dots all over it. + name=$(basename "$helper") + [[ -f "$helper" && -x "$helper" && "$name" != *.* ]] || continue + echo " signing $name" + codesign "${SIGN[@]}" --entitlements Resources/helper.entitlements "$helper" + done +fi +codesign "${SIGN[@]}" --entitlements Resources/ProdDash.entitlements --identifier "$BUNDLE_ID" "$APP" +codesign --verify --deep --strict "$APP" && echo " signature verifies" + +SIZE=$(du -sh "$APP" | awk '{print $1}') +echo "built $APP ($SIZE)" + +# ── disk image ─────────────────────────────────────────────────────────── +if [[ $MAKE_DMG -eq 1 ]]; then + DMG="$OUT_DIR/$APP_NAME-$VERSION-$ARCH.dmg" + STAGE="$OUT_DIR/dmg" + rm -rf "$STAGE" + mkdir -p "$STAGE" + cp -R "$APP" "$STAGE/" + ln -s /Applications "$STAGE/Applications" # drag-to-install, the usual way + hdiutil create -volname "$APP_NAME $VERSION" -srcfolder "$STAGE" \ + -ov -format UDZO -quiet "$DMG" + rm -rf "$STAGE" + [[ "$IDENTITY" == "-" ]] || codesign --force --sign "$IDENTITY" --timestamp "$DMG" + echo "built $DMG ($(du -sh "$DMG" | awk '{print $1}'))" + + if [[ $NOTARIZE -eq 1 ]]; then + if [[ "$IDENTITY" == "-" ]]; then + echo "build.sh: can't notarise an ad-hoc signed app — a Developer ID is required" >&2 + exit 1 + fi + PROFILE="${NOTARY_PROFILE:-proddash}" + echo " notarising as keychain profile \"$PROFILE\" (a few minutes)" + xcrun notarytool submit "$DMG" --keychain-profile "$PROFILE" --wait + xcrun stapler staple "$DMG" + echo " notarised and stapled — this opens anywhere with no warning" + fi +fi if [[ $INSTALL -eq 1 ]]; then DEST="/Applications/$APP_NAME.app" - # A running copy holds its own bundle open; stop it before replacing it. osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true sleep 1 rm -rf "$DEST" @@ -109,6 +218,5 @@ if [[ $INSTALL -eq 1 ]]; then APP="$DEST" fi -if [[ $RUN -eq 1 ]]; then - open "$APP" -fi +[[ $RUN -eq 1 ]] && open "$APP" +exit 0