Ship ProdDash as one app: its own Node, its own copy, a .dmg
Installing is dragging ProdDash to Applications. The app carries the
dashboard and the Node that runs it, so nothing has to be installed first and
nothing can go missing underneath it.
ProdDash.app/Contents/
MacOS/ProdDash the launcher
Resources/node/bin/node Node 24.21.0 LTS, fetched and checksum-verified
Resources/app/ server.js, public/, modules/
Everything mutable stays where it already was, in ~/Library/Application
Support/ProdDash — and that now includes app/, the copy of ProdDash that
actually runs. The copy is not ceremony: ProdDash updates itself in place,
and nothing may write inside a signed .app. So the bundle holds a read-only
baseline and the data directory holds the running copy, which means both
routes to a new version keep working and don't fight — installing a newer
.app replaces the copy, while a copy that has already updated itself past the
bundle is left alone.
build.sh now assembles all of it: fetches Node against nodejs.org's published
checksums (cached in .cache/), embeds the server, compiles each module's
native helper so the Mac running ProdDash needs no compiler, signs nested
code before the bundle, and builds the disk image. --dev skips the payload
for launcher work, which still runs against a checkout.
Signing is set up for a Developer ID with the hardened runtime and
notarisation (--notarize), and falls back to ad-hoc when no such certificate
is installed. Entitlements are split by what each binary actually does: JIT
for node, audio input for the launcher and for module helpers that read one.
Built and run end to end: first-run install, bundled node, dashboard and
admin serving, re-seed in both directions (newer app replaces an older copy;
an updated copy survives), disk image mounts and its app verifies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1bceb3e60
commit
adac3a4785
+2
-1
@@ -11,5 +11,6 @@ config/layouts/
|
|||||||
# compiled per-machine, source sits next to it
|
# compiled per-machine, source sits next to it
|
||||||
modules/propresenter-timers/ltc-capture
|
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/build/
|
||||||
|
launcher/macos/.cache/
|
||||||
|
|||||||
@@ -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
|
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
|
(`server.js`, Node 18+), dashboard shell client in `public/`, admin page at
|
||||||
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
|
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
|
||||||
`launcher/macos/` (Swift, no dependencies — it supervises the server and owns
|
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||||
the macOS permissions modules declare; see its README). Runtime state (module
|
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
|
config, named layouts) is written to a per-machine data directory outside the
|
||||||
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
checkout (`PRODDASH_DATA_DIR` overrides; see README "Where settings are
|
||||||
kept") — never add code that writes runtime state into the repo folder.
|
kept") — never add code that writes runtime state into the repo folder.
|
||||||
|
|||||||
@@ -29,21 +29,28 @@ and a clear degraded state, then recover on their own.
|
|||||||
|
|
||||||
## Run it
|
## 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
|
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
||||||
[launcher/macos](launcher/macos). It starts and stops the server, can open at
|
installation: the app carries the dashboard and its own Node runtime, so
|
||||||
login, owns the port, and holds the permissions the modules need: the
|
nothing else has to be installed and nothing can be missing. It lives in the
|
||||||
microphone the LTC listener reads, and local network access for ProdCom and
|
menu bar, starts and stops the server, can open at login, and holds the
|
||||||
ProPresenter. macOS grants those to the app that owns the process, so a server
|
permissions the modules need — the microphone the LTC listener reads, and
|
||||||
started any other way — over SSH, from a launchd job — simply doesn't have
|
local network access for ProdCom and ProPresenter. macOS grants those to the
|
||||||
them, and an LTC input goes silently to zeroes. Build it once:
|
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
|
```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
|
```bash
|
||||||
node server.js
|
node server.js
|
||||||
@@ -81,7 +88,10 @@ it at startup (`Settings : …`).
|
|||||||
|
|
||||||
The directory holds `modules.json` (module config), `layouts/` (named
|
The directory holds `modules.json` (module config), `layouts/` (named
|
||||||
layouts) and `modules/` (modules installed from the admin page), all written
|
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
|
with this machine's shell settings — the same keys as the checked-in
|
||||||
[config/proddash.json](config/proddash.json), which supplies the defaults:
|
[config/proddash.json](config/proddash.json), which supplies the defaults:
|
||||||
|
|
||||||
|
|||||||
+103
-53
@@ -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
|
The Mac application: a menu-bar app that carries the dashboard, its own Node
|
||||||
Mac — and, just as importantly, **owns the permissions its modules need**.
|
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
|
Build it with `./build.sh`. The app is native Swift with no dependencies of
|
||||||
the app itself. Build it with `./build.sh`.
|
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
|
Everything mutable lives outside, in `~/Library/Application Support/ProdDash`:
|
||||||
in a log, and it is brought back if it stops on its own. The shell was built
|
module config, layouts, modules installed from the admin page — and `app/`,
|
||||||
for this: with `PRODDASH_LAUNCHER=1` in its environment it exits **75** rather
|
the copy of ProdDash that actually runs.
|
||||||
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
|
**Why a copy, when the bundle already has one.** ProdDash updates itself: the
|
||||||
backed-off restart (1s, 2s, 5s, 10s); four crashes in two minutes stops the
|
admin page's **Update now** downloads a new version and writes it over the app
|
||||||
cycle, so a broken install doesn't spin forever.
|
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
|
**Permissions.** macOS grants privacy permissions to the *application* that
|
||||||
owns a process, and a child inherits its parent's identity. Start ProdDash
|
owns a process, and a child inherits its parent's identity. Started from here,
|
||||||
from here and node — plus the Timers module's `ltc-capture` tool — belong to
|
node — and the Timers module's `ltc-capture` — belong to this app bundle, so
|
||||||
this app bundle, so the microphone and local-network prompts are asked, and
|
the microphone and local-network prompts are asked, and remembered, as
|
||||||
remembered, as **ProdDash**. Start it over SSH or from a bare launchd job and
|
ProdDash. Started over SSH or from a bare launchd job there is nobody to ask:
|
||||||
there is no owner to ask: the permission is silently absent rather than
|
the permission is silently absent rather than refused, which is the "endless
|
||||||
refused, and an LTC input reads as endless zeroes with no error anywhere.
|
zeroes, no error" failure `ltc-listener.js` warns about.
|
||||||
That is the failure `modules/propresenter-timers/ltc-listener.js` warns about,
|
|
||||||
and this is the fix.
|
**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
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd launcher/macos
|
./build.sh # build/ProdDash.app — the whole application
|
||||||
./build.sh # build/ProdDash.app, universal (Apple Silicon + Intel)
|
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
||||||
./build.sh --install # …and copy it to /Applications
|
./build.sh --install # …and copy it to /Applications
|
||||||
./build.sh --run # …and open it
|
./build.sh --run # …and open it
|
||||||
./build.sh --arch native # this Mac's architecture only — faster while iterating
|
./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
|
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
|
newer. The first build downloads Node (~50 MB) into `.cache/` and reuses it.
|
||||||
own icon is the app's icon.
|
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
|
Builds for **Apple Silicon**. To change that, or to move to a newer Node, edit
|
||||||
signature changes on every rebuild, so macOS may treat a rebuilt app as a new
|
`ARCH` and `NODE_VERSION` at the top of `build.sh` — the download is verified
|
||||||
one and ask for the microphone again. Build once, install it, and leave it.
|
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
|
||||||
|
(`<name>.swift` beside a module's files becomes `<name>`), 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
|
## Using it
|
||||||
|
|
||||||
@@ -56,7 +110,8 @@ hollow when it is stopped. Its menu has the whole app in it:
|
|||||||
- **Start / Stop / Restart ProdDash**.
|
- **Start / Stop / Restart ProdDash**.
|
||||||
- **Launcher…** — the window below.
|
- **Launcher…** — the window below.
|
||||||
- **Open at Login** — a login item, through the modern `SMAppService` API.
|
- **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
|
The window has the status, both addresses the dashboard answers on, and three
|
||||||
tabs:
|
tabs:
|
||||||
@@ -64,10 +119,10 @@ tabs:
|
|||||||
- **Log** — everything the server prints, plus what the launcher did and why.
|
- **Log** — everything the server prints, plus what the launcher did and why.
|
||||||
Also written to `~/Library/Logs/ProdDash/proddash-<date>.log`, kept a week.
|
Also written to `~/Library/Logs/ProdDash/proddash-<date>.log`, kept a week.
|
||||||
- **Settings** — the port, and how it starts (at login, server on open,
|
- **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
|
restart on crash, open a browser, show this window). It also shows which
|
||||||
ProdDash folder, the node binary in use and where settings are kept.
|
copy of ProdDash is running, which node, and where settings are kept.
|
||||||
- **Permissions** — what the installed modules declare they need, who needs
|
- **Permissions** — one line per permission: a status dot, its name, and the
|
||||||
it, and what macOS currently allows. See below.
|
one thing you can do about it. See below.
|
||||||
|
|
||||||
### Port
|
### Port
|
||||||
|
|
||||||
@@ -78,9 +133,8 @@ it while the server runs restarts it.
|
|||||||
|
|
||||||
### Permissions
|
### Permissions
|
||||||
|
|
||||||
One line per permission: a status dot, its name, and the one thing you can do
|
What appears is what the installed modules declare in their manifests
|
||||||
about it. What appears is what the installed modules declare in their
|
(`"permissions": [ … ]` — see
|
||||||
manifests (`"permissions": [ … ]` — see
|
|
||||||
[docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which
|
[docs/MODULE-GUIDE.md](../../docs/MODULE-GUIDE.md)); hover a row to see which
|
||||||
module asked, and why.
|
module asked, and why.
|
||||||
|
|
||||||
@@ -88,8 +142,8 @@ module asked, and why.
|
|||||||
- **Local Network** — macOS has no API that reports whether this was granted,
|
- **Local Network** — macOS has no API that reports whether this was granted,
|
||||||
so **Check Access** asks the network instead: it browses for Bonjour
|
so **Check Access** asks the network instead: it browses for Bonjour
|
||||||
services (which is what raises the prompt) and tries every upstream the
|
services (which is what raises the prompt) and tries every upstream the
|
||||||
admin page is pointed at, then says what the result means. Nothing
|
admin page is pointed at. Nothing answering, when the gear is on, means
|
||||||
answering, when the gear is on, means macOS is withholding access.
|
macOS is withholding access.
|
||||||
- **Notifications** — the launcher's own, so a server that stops while nobody
|
- **Notifications** — the launcher's own, so a server that stops while nobody
|
||||||
is watching still reaches someone. Turn them off for ProdDash in System
|
is watching still reaches someone. Turn them off for ProdDash in System
|
||||||
Settings if you'd rather not have them.
|
Settings if you'd rather not have them.
|
||||||
@@ -110,21 +164,17 @@ still at the keyboard, rather than halfway through a service.
|
|||||||
Monitor.
|
Monitor.
|
||||||
- **Signals.** `killall ProdDash`, a logout or a shutdown stops the server
|
- **Signals.** `killall ProdDash`, a logout or a shutdown stops the server
|
||||||
first rather than leaving node running.
|
first rather than leaving node running.
|
||||||
- **Where the settings live.** ProdDash's port is in the data directory's
|
- **Nothing is required outside the app.** A development build (`--dev`)
|
||||||
`proddash.json`; everything else (auto-start, restart-on-crash, the paths)
|
carries no server and no Node, and runs the checkout it was built in with
|
||||||
is the launcher's own, in its preferences.
|
whatever node is on the machine — Settings → ProdDash folder points it
|
||||||
- **Finding things.** The ProdDash folder is the checkout the app was built
|
somewhere else.
|
||||||
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.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| What you see | What it means |
|
| What you see | What it means |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| "Can't find the ProdDash folder" | Point it at the folder with `server.js` in it, under Settings. |
|
| "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. |
|
||||||
| "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 stopped 4 times in two minutes" | The server is failing on startup — the Log tab has its own error. |
|
| "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**. |
|
| 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. |
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<string>1.0.0</string>
|
<string>1.0.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0.0</string>
|
<string>1.0.0</string>
|
||||||
|
<key>ProdDashLauncherVersion</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>AppIcon</string>
|
<string>AppIcon</string>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- The launcher asks for microphone access on the modules' behalf; under
|
||||||
|
the hardened runtime that needs saying out loud. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Module helpers that read an audio input — ltc-capture, and anything
|
||||||
|
like it a later module ships. -->
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- V8 compiles JavaScript as it runs; without these the hardened runtime
|
||||||
|
kills node on its first optimisation. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<!-- A module may ship a native addon that nobody signed with our identity. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -10,8 +10,10 @@ import Combine
|
|||||||
*/
|
*/
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
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 {
|
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.
|
/// Sent by a second copy of the launcher so the first one shows itself.
|
||||||
static let showNotification = Notification.Name("org.waterschurch.proddash.launcher.show")
|
static let showNotification = Notification.Name("org.waterschurch.proddash.launcher.show")
|
||||||
|
|||||||
@@ -32,6 +32,95 @@ enum ProdDash {
|
|||||||
.appendingPathComponent("Library/Application Support/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
|
// MARK: - Finding the checkout
|
||||||
|
|
||||||
/// A folder is ProdDash if it has the two files the server cannot run without.
|
/// 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`),
|
this .app was built inside (the app usually sits in `launcher/macos/build`),
|
||||||
else the handful of places people actually keep it.
|
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 {
|
if let preferred, !preferred.isEmpty {
|
||||||
let url = URL(fileURLWithPath: preferred).standardizedFileURL
|
let url = URL(fileURLWithPath: preferred).standardizedFileURL
|
||||||
if isRoot(url) { return url }
|
if isRoot(url) { return url }
|
||||||
@@ -55,6 +144,12 @@ enum ProdDash {
|
|||||||
let url = URL(fileURLWithPath: env).standardizedFileURL
|
let url = URL(fileURLWithPath: env).standardizedFileURL
|
||||||
if isRoot(url) { return url }
|
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
|
var up = Bundle.main.bundleURL.standardizedFileURL
|
||||||
for _ in 0..<6 {
|
for _ in 0..<6 {
|
||||||
up.deleteLastPathComponent()
|
up.deleteLastPathComponent()
|
||||||
@@ -89,6 +184,8 @@ enum ProdDash {
|
|||||||
static func findNode(root: URL?, preferred: String?) -> URL? {
|
static func findNode(root: URL?, preferred: String?) -> URL? {
|
||||||
var tries: [URL] = []
|
var tries: [URL] = []
|
||||||
if let preferred, !preferred.isEmpty { tries.append(URL(fileURLWithPath: preferred)) }
|
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.
|
// The runtime a production machine ships with — same preference as Start ProdDash.command.
|
||||||
if let root { tries.append(root.appendingPathComponent("runtime/bin/node")) }
|
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"]
|
tries += ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/opt/local/bin/node", "/usr/bin/node"]
|
||||||
|
|||||||
@@ -82,7 +82,13 @@ final class ServerController: ObservableObject {
|
|||||||
/// Re-resolve the checkout, the node binary and the port. Cheap, and run
|
/// 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.
|
/// again before every start so a folder fixed in Settings takes effect.
|
||||||
func refreshEnvironment() {
|
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
|
rootURL = root
|
||||||
let node = ProdDash.findNode(root: root, preferred: settings.nodePath)
|
let node = ProdDash.findNode(root: root, preferred: settings.nodePath)
|
||||||
if node?.path != nodeURL?.path {
|
if node?.path != nodeURL?.path {
|
||||||
|
|||||||
+161
-53
@@ -1,72 +1,92 @@
|
|||||||
#!/bin/zsh
|
#!/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-<version>-arm64.dmg
|
||||||
# ./build.sh --install …and copy it to /Applications
|
# ./build.sh --install …and copy it to /Applications
|
||||||
# ./build.sh --run …and launch it when it's built
|
# ./build.sh --run …and launch it
|
||||||
# ./build.sh --arch native build for this Mac only (faster; default is universal)
|
# ./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
|
# Signing: a "Developer ID Application" certificate is used when one is
|
||||||
# is Swift and system frameworks, nothing to install and nothing to vendor.
|
# 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
|
set -e -u
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
APP_NAME="ProdDash"
|
APP_NAME="ProdDash"
|
||||||
BUNDLE_ID="org.waterschurch.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"
|
OUT_DIR="build"
|
||||||
|
CACHE_DIR=".cache"
|
||||||
APP="$OUT_DIR/$APP_NAME.app"
|
APP="$OUT_DIR/$APP_NAME.app"
|
||||||
ARCHS=(arm64 x86_64)
|
CONTENTS="$APP/Contents"
|
||||||
|
PAYLOAD="$CONTENTS/Resources/app"
|
||||||
|
|
||||||
|
DEV=0
|
||||||
INSTALL=0
|
INSTALL=0
|
||||||
RUN=0
|
RUN=0
|
||||||
|
MAKE_DMG=0
|
||||||
|
NOTARIZE=0
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
--dev) DEV=1 ;;
|
||||||
--install) INSTALL=1 ;;
|
--install) INSTALL=1 ;;
|
||||||
--run) RUN=1 ;;
|
--run) RUN=1 ;;
|
||||||
--arch) shift; [[ "${1:-}" == "native" ]] && ARCHS=($(uname -m)) || ARCHS=("${1:-arm64}") ;;
|
--dmg) MAKE_DMG=1 ;;
|
||||||
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
|
--notarize) NOTARIZE=1; MAKE_DMG=1 ;;
|
||||||
|
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||||
*) echo "build.sh: unknown option $1" >&2; exit 2 ;;
|
*) echo "build.sh: unknown option $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
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 "build.sh: swiftc not found — install the Xcode command line tools:" >&2
|
||||||
echo " xcode-select --install" >&2
|
echo " xcode-select --install" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
}
|
||||||
|
|
||||||
VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)
|
# The product's version is ProdDash's own; the launcher keeps its own number
|
||||||
echo "ProdDash Launcher $VERSION — building for ${ARCHS[*]}"
|
# 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"
|
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
|
# ── the launcher ─────────────────────────────────────────────────────────
|
||||||
# on the booth's Apple Silicon and on any Intel Mac still in service.
|
echo " compiling the launcher"
|
||||||
SLICES=()
|
swiftc -O -whole-module-optimization \
|
||||||
for arch in "${ARCHS[@]}"; do
|
-target "$ARCH-apple-macos$DEPLOYMENT_TARGET" \
|
||||||
echo " compiling $arch"
|
-module-name ProdDashLauncher \
|
||||||
swiftc -O -whole-module-optimization \
|
-o "$CONTENTS/MacOS/$APP_NAME" \
|
||||||
-target "$arch-apple-macos13.0" \
|
Sources/*.swift
|
||||||
-module-name ProdDashLauncher \
|
|
||||||
-o "$OUT_DIR/obj/$APP_NAME-$arch" \
|
|
||||||
Sources/*.swift
|
|
||||||
SLICES+=("$OUT_DIR/obj/$APP_NAME-$arch")
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ${#SLICES[@]} -gt 1 ]]; then
|
cp Resources/Info.plist "$CONTENTS/Info.plist"
|
||||||
lipo -create "${SLICES[@]}" -output "$APP/Contents/MacOS/$APP_NAME"
|
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||||
else
|
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||||
cp "${SLICES[1]}" "$APP/Contents/MacOS/$APP_NAME"
|
/usr/libexec/PlistBuddy -c "Set :ProdDashLauncherVersion $LAUNCHER_VERSION" "$CONTENTS/Info.plist" >/dev/null
|
||||||
fi
|
printf 'APPL????' > "$CONTENTS/PkgInfo"
|
||||||
chmod +x "$APP/Contents/MacOS/$APP_NAME"
|
|
||||||
|
|
||||||
cp Resources/Info.plist "$APP/Contents/Info.plist"
|
SOURCE_ICON="$REPO_ROOT/public/icons/icon-512.png"
|
||||||
printf 'APPL????' > "$APP/Contents/PkgInfo"
|
|
||||||
|
|
||||||
# App icon: ProdDash's own, straight from the web app's icons.
|
|
||||||
SOURCE_ICON="../../public/icons/icon-512.png"
|
|
||||||
if [[ -f "$SOURCE_ICON" ]]; then
|
if [[ -f "$SOURCE_ICON" ]]; then
|
||||||
echo " building the icon"
|
echo " building the icon"
|
||||||
ICONSET="$OUT_DIR/AppIcon.iconset"
|
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
|
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
|
sips -z $size $size "$SOURCE_ICON" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null 2>&1
|
||||||
done
|
done
|
||||||
# iconutil wants Apple's names, including the @2x pairs.
|
|
||||||
mv "$ICONSET/icon_32x32.png" "$ICONSET/icon_16x16@2x.png"
|
mv "$ICONSET/icon_32x32.png" "$ICONSET/icon_16x16@2x.png"
|
||||||
cp "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32@2x.png"
|
cp "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32@2x.png"
|
||||||
mv "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32.png"
|
mv "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32.png"
|
||||||
cp "$ICONSET/icon_256x256.png" "$ICONSET/icon_128x128@2x.png"
|
cp "$ICONSET/icon_256x256.png" "$ICONSET/icon_128x128@2x.png"
|
||||||
cp "$ICONSET/icon_512x512.png" "$ICONSET/icon_256x256@2x.png"
|
cp "$ICONSET/icon_512x512.png" "$ICONSET/icon_256x256@2x.png"
|
||||||
mv "$ICONSET/icon_1024x1024.png" "$ICONSET/icon_512x512@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"
|
rm -rf "$ICONSET"
|
||||||
else
|
|
||||||
echo " (no $SOURCE_ICON — the app will use the generic icon)"
|
|
||||||
fi
|
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
|
echo " embedding ProdDash $VERSION"
|
||||||
# off it. Note that the signature changes on every rebuild, so macOS may treat
|
mkdir -p "$PAYLOAD/config"
|
||||||
# a rebuilt app as a new one and ask for the microphone again.
|
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$PAYLOAD/"
|
||||||
codesign --force --sign - --identifier "$BUNDLE_ID" "$APP" >/dev/null 2>&1 \
|
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$PAYLOAD/"
|
||||||
&& echo " signed (ad-hoc)" \
|
# Only the checked-in defaults: config/ also holds this machine's own
|
||||||
|| echo " warning: could not sign the app — permissions may not stick"
|
# 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: <name>.swift beside a module's files becomes <name>.
|
||||||
|
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
|
if [[ $INSTALL -eq 1 ]]; then
|
||||||
DEST="/Applications/$APP_NAME.app"
|
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
|
osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true
|
||||||
sleep 1
|
sleep 1
|
||||||
rm -rf "$DEST"
|
rm -rf "$DEST"
|
||||||
@@ -109,6 +218,5 @@ if [[ $INSTALL -eq 1 ]]; then
|
|||||||
APP="$DEST"
|
APP="$DEST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $RUN -eq 1 ]]; then
|
[[ $RUN -eq 1 ]] && open "$APP"
|
||||||
open "$APP"
|
exit 0
|
||||||
fi
|
|
||||||
|
|||||||
Reference in New Issue
Block a user