Merge pull request #13 from michaelmcgary2008/claude/proddash-menubar-launcher-6eb34e
ProdDash for macOS: one self-contained, notarized app
This commit is contained in:
@@ -10,3 +10,7 @@ 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, and its cache of the Node runtime it embeds
|
||||||
|
launcher/macos/build/
|
||||||
|
launcher/macos/.cache/
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
Modular production dashboard web app for Waters Church, developed in this repo.
|
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/`. Runtime state (module config, named layouts)
|
`/admin`, modules in `modules/`, and a native macOS menu-bar launcher in
|
||||||
is written to a per-machine data directory outside the checkout
|
`launcher/macos/` (Swift, no dependencies — the shipped macOS app: it carries
|
||||||
(`PRODDASH_DATA_DIR` overrides; see README "Where settings are kept") — never
|
the server and its own Node runtime, supervises them, and owns the macOS
|
||||||
add code that writes runtime state into the repo folder. [SPEC.md](SPEC.md) was the authority for the
|
permissions modules declare; see its README). Runtime state (module
|
||||||
initial build; [README.md](README.md) has run/configure/use instructions.
|
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.
|
||||||
|
[SPEC.md](SPEC.md) was the authority for the initial build;
|
||||||
|
[README.md](README.md) has run/configure/use instructions.
|
||||||
|
|
||||||
## Building a new module — read docs/MODULE-GUIDE.md first
|
## Building a new module — read docs/MODULE-GUIDE.md first
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ and a clear degraded state, then recover on their own.
|
|||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
|
### macOS — the app
|
||||||
|
|
||||||
|
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 --dmg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anywhere else, or from a checkout
|
||||||
|
|
||||||
Requires [Node.js](https://nodejs.org) 18 or newer.
|
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -67,13 +88,16 @@ 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:
|
||||||
|
|
||||||
| Key | Meaning |
|
| Key | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `port` | Port ProdDash serves on (default `24500`) |
|
| `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. |
|
||||||
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. |
|
||||||
|
|
||||||
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
Environment variables override both files: `PORT`, `PRODDASH_PASSCODE`, and
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ the app); its settings are kept for a later reinstall.
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "One line shown in the Add-tile picker and the admin page",
|
"description": "One line shown in the Add-tile picker and the admin page",
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "microphone", "reason": "Why this module needs it, in one line" }
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
@@ -88,6 +91,7 @@ the app); its settings are kept for a later reinstall.
|
|||||||
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
|
| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
|
||||||
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
|
||||||
| `description` | no | Shown in the admin page / picker. |
|
| `description` | no | Shown in the admin page / picker. |
|
||||||
|
| `permissions` | no | What macOS must allow before this module can work — see **Permissions macOS has to grant**. Ignored by the shell; read by the macOS launcher. |
|
||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
@@ -207,6 +211,38 @@ Admin config lives in `modules.json` inside the server's data directory
|
|||||||
survives updates of the app folder). Per-tile settings live inside each
|
survives updates of the app folder). Per-tile settings live inside each
|
||||||
browser's layout.
|
browser's layout.
|
||||||
|
|
||||||
|
### Permissions macOS has to grant
|
||||||
|
|
||||||
|
Some things a module does need the operating system's permission, not just
|
||||||
|
configuration: reading an audio input, reaching gear on the local network.
|
||||||
|
macOS grants those to the **application that owns the process**, which for
|
||||||
|
ProdDash is the macOS launcher (`launcher/macos`) — the server is its child,
|
||||||
|
so the prompts are asked, and remembered, in ProdDash's name. A server
|
||||||
|
started over SSH or from a bare launchd job has no such owner, and the
|
||||||
|
permission is silently absent rather than refused: an LTC input reads as
|
||||||
|
endless zeroes, a local host simply never answers.
|
||||||
|
|
||||||
|
Declare what you need, and why, so the launcher's Permissions tab can list
|
||||||
|
it and ask for it before a service rather than during one:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
||||||
|
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| `kind` | When to declare it |
|
||||||
|
| --- | --- |
|
||||||
|
| `microphone` | The module reads an audio input (`ltc-capture` and anything like it). |
|
||||||
|
| `local-network` | The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
|
||||||
|
|
||||||
|
`reason` is one line, shown to the operator as-is when they hover the
|
||||||
|
permission; modules giving the same reason are listed together. A bare `"permissions": ["microphone"]` works when
|
||||||
|
the standing explanation is enough. A `kind` the launcher doesn't manage is
|
||||||
|
still listed, so nothing goes unmentioned — the shell itself ignores the key
|
||||||
|
entirely, and other platforms ignore it too.
|
||||||
|
|
||||||
## Client entry (`client.js`)
|
## Client entry (`client.js`)
|
||||||
|
|
||||||
An ES module whose **default export is a factory**. The shell calls it once
|
An ES module whose **default export is a factory**. The shell calls it once
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# ProdDash for macOS
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## What's in the bundle
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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. 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
|
||||||
|
./build.sh # build/ProdDash.app — the whole application
|
||||||
|
./build.sh --dmg # …and build/ProdDash-<version>-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 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
(`<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
|
||||||
|
|
||||||
|
The menu-bar icon is ProdDash's 2×2 tile mark; the accent tile is lit green
|
||||||
|
while the server runs, amber mid-move, red when something needs a person, and
|
||||||
|
hollow when it is stopped. Its menu has the whole app in it:
|
||||||
|
|
||||||
|
- **Open Dashboard** / **Open Admin** — the running server, in a browser.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
The window has the status, both addresses the dashboard answers on, and three
|
||||||
|
tabs:
|
||||||
|
|
||||||
|
- **Log** — everything the server prints, plus what the launcher did and why.
|
||||||
|
Also written to `~/Library/Logs/ProdDash/proddash-<date>.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 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
|
||||||
|
|
||||||
|
The port belongs to ProdDash, not to the launcher, so it is written to
|
||||||
|
`proddash.json` in the data directory — the file the server already reads.
|
||||||
|
Set it here and ProdDash keeps that port however it is started next. Changing
|
||||||
|
it while the server runs restarts it.
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **Microphone** — asked for directly, with the status macOS reports.
|
||||||
|
- **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. 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.
|
||||||
|
|
||||||
|
On first run the launcher asks for what the modules declare, while someone is
|
||||||
|
still at the keyboard, rather than halfway through a service.
|
||||||
|
|
||||||
|
## Details worth knowing
|
||||||
|
|
||||||
|
- **One instance.** Opening a second copy raises the first one's window.
|
||||||
|
- **Port already in use.** If something already holds the port, the launcher
|
||||||
|
asks before doing anything. When that something answers like ProdDash — an
|
||||||
|
SSH-started copy, say, with no microphone access — it offers to take the
|
||||||
|
port over.
|
||||||
|
- **Stopping is thorough.** The server is spawned into its own process group,
|
||||||
|
so stopping it also stops anything it started. An orphaned `ltc-capture`
|
||||||
|
would otherwise hold an audio device until someone found it in Activity
|
||||||
|
Monitor.
|
||||||
|
- **Signals.** `killall ProdDash`, a logout or a shutdown stops the server
|
||||||
|
first rather than leaving node running.
|
||||||
|
- **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 |
|
||||||
|
| --- | --- |
|
||||||
|
| "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 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. |
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?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>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>ProdDash</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>ProdDash</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>ProdDash</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.waterschurch.proddash</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>ProdDashLauncherVersion</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.utilities</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<!-- Menu-bar app: no Dock icon, no app menu. -->
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<!-- The prompts this app exists to own. macOS shows these sentences
|
||||||
|
verbatim, so they say what ProdDash does with each permission. -->
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_http._tcp</string>
|
||||||
|
</array>
|
||||||
|
</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>
|
||||||
|
<!-- 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>
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/**
|
||||||
|
The launcher itself: one status-bar item, one window, one server.
|
||||||
|
|
||||||
|
It is an accessory app (no Dock icon, no app menu), so everything it can do
|
||||||
|
is reachable from the menu-bar menu — including quitting, which stops the
|
||||||
|
server with it.
|
||||||
|
*/
|
||||||
|
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?["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")
|
||||||
|
|
||||||
|
let settings = LauncherSettings()
|
||||||
|
let permissions = PermissionsModel()
|
||||||
|
lazy var server = ServerController(settings: settings)
|
||||||
|
|
||||||
|
private var statusItem: NSStatusItem?
|
||||||
|
private var windowController: LauncherWindowController?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var appearanceObserver: NSKeyValueObservation?
|
||||||
|
private var signalSources: [DispatchSourceSignal] = []
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
guard !handOffToRunningInstance() else { return }
|
||||||
|
|
||||||
|
LogStore.shared.launcher("ProdDash Launcher \(Self.version)")
|
||||||
|
setUpStatusItem()
|
||||||
|
|
||||||
|
server.onPortBusy = { [weak self] port, isProdDash, reply in
|
||||||
|
self?.askAboutBusyPort(port, isProdDash: isProdDash, reply: reply)
|
||||||
|
}
|
||||||
|
// Any change in the server's state re-draws the icon; the menu rebuilds
|
||||||
|
// itself when it opens.
|
||||||
|
server.objectWillChange
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] _ in self?.updateStatusItem() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
DistributedNotificationCenter.default().addObserver(
|
||||||
|
self, selector: #selector(showWindow), name: Self.showNotification, object: nil)
|
||||||
|
|
||||||
|
catchShutdownSignals()
|
||||||
|
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
||||||
|
updateStatusItem()
|
||||||
|
|
||||||
|
let firstRun = !settings.hasRunBefore
|
||||||
|
settings.hasRunBefore = true
|
||||||
|
|
||||||
|
if settings.showWindowAtLaunch || firstRun { showWindow() }
|
||||||
|
if settings.startServerAtLaunch { server.start() }
|
||||||
|
|
||||||
|
// Owning the permissions means asking for them at a sensible moment —
|
||||||
|
// now, with someone at the keyboard — rather than halfway through a
|
||||||
|
// service when a module first reaches for the microphone.
|
||||||
|
if firstRun { requestDeclaredPermissions() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something else is quitting us — a logout, a restart, `osascript … quit`.
|
||||||
|
/// Stop the server first, but never let that wedge a logout: the backstop
|
||||||
|
/// leaves anyway if the reply hasn't gone out in time.
|
||||||
|
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||||
|
guard server.state.isBusy else { return .terminateNow }
|
||||||
|
LogStore.shared.launcher("quitting — stopping ProdDash first")
|
||||||
|
server.stop()
|
||||||
|
replyWhenStopped(within: 36)
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 12) { exit(0) }
|
||||||
|
return .terminateLater
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waiting here with a Timer would hang the quit outright: while the app
|
||||||
|
/// sits on `.terminateLater` the run loop is not in its default mode, so a
|
||||||
|
/// scheduled timer never fires.
|
||||||
|
private func replyWhenStopped(within ticks: Int) {
|
||||||
|
guard server.state.isBusy, ticks > 0 else {
|
||||||
|
return NSApp.reply(toApplicationShouldTerminate: true)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||||
|
self?.replyWhenStopped(within: ticks - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true }
|
||||||
|
|
||||||
|
/// A Cocoa app doesn't run its normal quit path when something sends it a
|
||||||
|
/// signal — `killall`, a logout, a stop from a terminal. Without this the
|
||||||
|
/// launcher would vanish and leave node (and its audio capture) running.
|
||||||
|
private func catchShutdownSignals() {
|
||||||
|
for number in [SIGTERM, SIGINT, SIGHUP] {
|
||||||
|
signal(number, SIG_IGN)
|
||||||
|
let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
|
||||||
|
source.setEventHandler { [weak self] in
|
||||||
|
LogStore.shared.launcher("signal \(number) — stopping ProdDash and quitting")
|
||||||
|
self?.server.stop()
|
||||||
|
self?.exitOnceStopped(within: 32)
|
||||||
|
}
|
||||||
|
source.resume()
|
||||||
|
signalSources.append(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exitOnceStopped(within ticks: Int) {
|
||||||
|
guard server.state.isBusy, ticks > 0 else { exit(0) }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||||
|
self?.exitOnceStopped(within: ticks - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A second launch just shows the copy that is already running.
|
||||||
|
private func handOffToRunningInstance() -> Bool {
|
||||||
|
guard let id = Bundle.main.bundleIdentifier else { return false }
|
||||||
|
let mine = ProcessInfo.processInfo.processIdentifier
|
||||||
|
let others = NSRunningApplication.runningApplications(withBundleIdentifier: id)
|
||||||
|
.filter { $0.processIdentifier != mine }
|
||||||
|
guard !others.isEmpty else { return false }
|
||||||
|
DistributedNotificationCenter.default().post(name: Self.showNotification, object: nil)
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status item
|
||||||
|
|
||||||
|
private func setUpStatusItem() {
|
||||||
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
let menu = NSMenu()
|
||||||
|
menu.delegate = self
|
||||||
|
menu.autoenablesItems = false
|
||||||
|
item.menu = menu
|
||||||
|
statusItem = item
|
||||||
|
// The glyph is drawn, not templated, so it has to be redrawn when the
|
||||||
|
// menu bar switches between light and dark.
|
||||||
|
appearanceObserver = item.button?.observe(\.effectiveAppearance) { [weak self] _, _ in
|
||||||
|
DispatchQueue.main.async { self?.updateStatusItem() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStatusItem() {
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
button.image = StatusIcon.image(for: server.state, healthy: server.healthy)
|
||||||
|
button.toolTip = "ProdDash — \(server.state.label)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Menu
|
||||||
|
|
||||||
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
|
menu.removeAllItems()
|
||||||
|
|
||||||
|
let version = server.shellVersion.isEmpty ? "" : " \(server.shellVersion)"
|
||||||
|
menu.addItem(disabled("ProdDash\(version) — \(server.state.label)"))
|
||||||
|
switch server.state {
|
||||||
|
case .running:
|
||||||
|
menu.addItem(disabled("localhost:\(server.port)"))
|
||||||
|
case .failed(let why):
|
||||||
|
menu.addItem(disabled(why.prefix(60) + (why.count > 60 ? "…" : "")))
|
||||||
|
default:
|
||||||
|
if !server.message.isEmpty { menu.addItem(disabled(String(server.message.prefix(60)))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.addItem(.separator())
|
||||||
|
let dashboard = item("Open Dashboard", #selector(openDashboard), key: "d")
|
||||||
|
dashboard.isEnabled = server.state == .running
|
||||||
|
menu.addItem(dashboard)
|
||||||
|
let admin = item("Open Admin", #selector(openAdmin), key: "a")
|
||||||
|
admin.isEnabled = server.state == .running
|
||||||
|
menu.addItem(admin)
|
||||||
|
|
||||||
|
menu.addItem(.separator())
|
||||||
|
switch server.state {
|
||||||
|
case .running, .starting:
|
||||||
|
menu.addItem(item("Stop ProdDash", #selector(toggleServer)))
|
||||||
|
case .stopping:
|
||||||
|
menu.addItem(disabled("Stopping…"))
|
||||||
|
case .stopped, .failed:
|
||||||
|
menu.addItem(item("Start ProdDash", #selector(toggleServer)))
|
||||||
|
}
|
||||||
|
let restart = item("Restart ProdDash", #selector(restartServer))
|
||||||
|
restart.isEnabled = server.state == .running || server.state == .starting
|
||||||
|
menu.addItem(restart)
|
||||||
|
|
||||||
|
menu.addItem(.separator())
|
||||||
|
menu.addItem(item("Launcher…", #selector(showWindow), key: ","))
|
||||||
|
let login = item("Open at Login", #selector(toggleLoginItem))
|
||||||
|
login.state = LoginItem.isEnabled ? .on : .off
|
||||||
|
menu.addItem(login)
|
||||||
|
|
||||||
|
menu.addItem(.separator())
|
||||||
|
menu.addItem(item("Quit ProdDash", #selector(quit), key: "q"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func item(_ title: String, _ action: Selector, key: String = "") -> NSMenuItem {
|
||||||
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: key)
|
||||||
|
item.target = self
|
||||||
|
item.isEnabled = true
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disabled(_ title: any StringProtocol) -> NSMenuItem {
|
||||||
|
let item = NSMenuItem(title: String(title), action: nil, keyEquivalent: "")
|
||||||
|
item.isEnabled = false
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc private func openDashboard() { server.openDashboard() }
|
||||||
|
@objc private func openAdmin() { server.openAdmin() }
|
||||||
|
@objc private func toggleServer() { server.toggle() }
|
||||||
|
@objc private func restartServer() { server.restart() }
|
||||||
|
|
||||||
|
@objc func showWindow() {
|
||||||
|
if windowController == nil {
|
||||||
|
windowController = LauncherWindowController(delegate: self)
|
||||||
|
}
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
windowController?.showWindow(nil)
|
||||||
|
windowController?.window?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the window but stay in the menu bar.
|
||||||
|
func hideWindow() {
|
||||||
|
windowController?.window?.orderOut(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func toggleLoginItem() {
|
||||||
|
let turningOn = !LoginItem.isEnabled
|
||||||
|
if let error = LoginItem.set(turningOn) {
|
||||||
|
present(alert: "Couldn't change the login item", detail: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogStore.shared.launcher("open at login: \(turningOn ? "on" : "off")")
|
||||||
|
if turningOn && LoginItem.needsApproval {
|
||||||
|
present(alert: "Approve ProdDash in Login Items",
|
||||||
|
detail: "macOS needs you to allow ProdDash under System Settings → General → Login Items.",
|
||||||
|
openSettings: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A modal run started while the menu is still closing doesn't reliably
|
||||||
|
/// appear — let the menu finish first, then ask.
|
||||||
|
@objc func quit() {
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.confirmThenQuit() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmThenQuit() {
|
||||||
|
// A booth machine shouldn't lose its dashboard to a stray click.
|
||||||
|
if server.state == .running || server.state == .starting {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Quit ProdDash?"
|
||||||
|
alert.informativeText = "The server stops with the launcher, and every dashboard on the network goes dark."
|
||||||
|
alert.addButton(withTitle: "Quit")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||||
|
}
|
||||||
|
// Deliberately not NSApp.terminate: begun here, while the alert's modal
|
||||||
|
// session is still unwinding, it leaves AppKit wedged with the main
|
||||||
|
// queue unserviced — the server stops and the app never finishes
|
||||||
|
// quitting. Stopping is our own job anyway, so do it and leave.
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { exit(0) }
|
||||||
|
LogStore.shared.launcher("quitting — stopping ProdDash first")
|
||||||
|
self.server.stop()
|
||||||
|
self.exitOnceStopped(within: 36)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompts
|
||||||
|
|
||||||
|
/// Something already holds the port. If it answers like ProdDash, offer the
|
||||||
|
/// same hand-over Start ProdDash.command does — that other copy was very
|
||||||
|
/// likely started over SSH and has no microphone access.
|
||||||
|
private func askAboutBusyPort(_ port: Int, isProdDash: Bool, reply: @escaping (Bool) -> Void) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
if isProdDash {
|
||||||
|
alert.messageText = "ProdDash is already running on port \(port)"
|
||||||
|
alert.informativeText = "It was started outside this launcher — over SSH or from Start ProdDash.command "
|
||||||
|
+ "— so it has no microphone access and this app can't watch over it. Take it over?"
|
||||||
|
alert.addButton(withTitle: "Take Over")
|
||||||
|
} else {
|
||||||
|
alert.messageText = "Port \(port) is already in use"
|
||||||
|
alert.informativeText = "Another app is listening on port \(port). Stop that app, or give ProdDash a "
|
||||||
|
+ "different port under Settings. Stopping whatever holds it anyway is rarely what you want."
|
||||||
|
alert.addButton(withTitle: "Stop It Anyway")
|
||||||
|
}
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
alert.alertStyle = isProdDash ? .informational : .warning
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
reply(alert.runModal() == .alertFirstButtonReturn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for what the installed modules actually declare they need.
|
||||||
|
func requestDeclaredPermissions() {
|
||||||
|
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
||||||
|
for need in permissions.needs {
|
||||||
|
switch need.kind {
|
||||||
|
case .microphone:
|
||||||
|
if permissions.microphone == .notDetermined { permissions.requestMicrophone() }
|
||||||
|
case .localNetwork:
|
||||||
|
permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir)
|
||||||
|
case .other:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deferred for the same reason the quit confirmation is: a modal run
|
||||||
|
/// started while the menu is still closing doesn't reliably appear.
|
||||||
|
private func present(alert title: String, detail: String, openSettings: Bool = false) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.runAlert(title: title, detail: detail, openSettings: openSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAlert(title: String, detail: String, openSettings: Bool) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = title
|
||||||
|
alert.informativeText = detail
|
||||||
|
alert.addButton(withTitle: openSettings ? "Open System Settings" : "OK")
|
||||||
|
if openSettings { alert.addButton(withTitle: "Later") }
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
if alert.runModal() == .alertFirstButtonReturn && openSettings {
|
||||||
|
LoginItem.openSystemSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/**
|
||||||
|
The ProdDash server process, spawned into its OWN process group.
|
||||||
|
|
||||||
|
Why not Foundation's `Process`: the server starts children of its own — the
|
||||||
|
Timers module runs `ltc-capture`, which holds an audio input device open. If
|
||||||
|
the launcher force-kills only node, that capture tool is orphaned and keeps
|
||||||
|
the device (and the microphone indicator) until someone hunts it down in
|
||||||
|
Activity Monitor. `posix_spawn` with POSIX_SPAWN_SETPGROUP makes the server a
|
||||||
|
process-group leader, so `kill(-pid, …)` reaches the whole tree.
|
||||||
|
|
||||||
|
Output arrives line by line on the main queue; `onExit` fires once, also on
|
||||||
|
main, with the exit code and whether a signal killed it.
|
||||||
|
*/
|
||||||
|
final class ChildProcess {
|
||||||
|
|
||||||
|
/// A spawn that never got off the ground, with something an operator can act on.
|
||||||
|
struct SpawnError: LocalizedError {
|
||||||
|
let message: String
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid: pid_t
|
||||||
|
/// What we ran, for the log.
|
||||||
|
let commandLine: String
|
||||||
|
|
||||||
|
/// Held for the life of the child: a FileHandle's readability handler does
|
||||||
|
/// not retain it, so letting these go closes the pipes and the output
|
||||||
|
/// silently stops.
|
||||||
|
private let outPipe: Pipe
|
||||||
|
private let errPipe: Pipe
|
||||||
|
private var exitSource: DispatchSourceProcess?
|
||||||
|
private var terminated = false
|
||||||
|
|
||||||
|
private init(pid: pid_t, commandLine: String, outPipe: Pipe, errPipe: Pipe) {
|
||||||
|
self.pid = pid
|
||||||
|
self.commandLine = commandLine
|
||||||
|
self.outPipe = outPipe
|
||||||
|
self.errPipe = errPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { releasePipes() }
|
||||||
|
|
||||||
|
// MARK: - Spawning
|
||||||
|
|
||||||
|
static func run(executable: String,
|
||||||
|
arguments: [String],
|
||||||
|
directory: String,
|
||||||
|
environment: [String: String],
|
||||||
|
onLine: @escaping (String, Bool) -> Void,
|
||||||
|
onExit: @escaping (Int32, Bool) -> Void) throws -> ChildProcess {
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
|
||||||
|
var actions: posix_spawn_file_actions_t?
|
||||||
|
posix_spawn_file_actions_init(&actions)
|
||||||
|
defer { posix_spawn_file_actions_destroy(&actions) }
|
||||||
|
// The server never reads stdin; /dev/null keeps a stray read from blocking it.
|
||||||
|
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
|
||||||
|
posix_spawn_file_actions_adddup2(&actions, outPipe.fileHandleForWriting.fileDescriptor, 1)
|
||||||
|
posix_spawn_file_actions_adddup2(&actions, errPipe.fileHandleForWriting.fileDescriptor, 2)
|
||||||
|
posix_spawn_file_actions_addchdir_np(&actions, directory)
|
||||||
|
|
||||||
|
var attr: posix_spawnattr_t?
|
||||||
|
posix_spawnattr_init(&attr)
|
||||||
|
defer { posix_spawnattr_destroy(&attr) }
|
||||||
|
// SETPGROUP + pgroup 0: the child leads its own group (see the note above).
|
||||||
|
// CLOEXEC_DEFAULT: nothing but the three descriptors above is inherited.
|
||||||
|
posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT))
|
||||||
|
posix_spawnattr_setpgroup(&attr, 0)
|
||||||
|
|
||||||
|
let argv: [UnsafeMutablePointer<CChar>?] = ([executable] + arguments).map { strdup($0) } + [nil]
|
||||||
|
let envp: [UnsafeMutablePointer<CChar>?] = environment
|
||||||
|
.map { strdup("\($0.key)=\($0.value)") } + [nil]
|
||||||
|
defer {
|
||||||
|
for p in argv where p != nil { free(p) }
|
||||||
|
for p in envp where p != nil { free(p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var pid: pid_t = 0
|
||||||
|
let rc = posix_spawn(&pid, executable, &actions, &attr, argv, envp)
|
||||||
|
guard rc == 0 else {
|
||||||
|
outPipe.closeBothEnds()
|
||||||
|
errPipe.closeBothEnds()
|
||||||
|
throw SpawnError(message: "could not start \(executable): \(String(cString: strerror(rc))) (\(rc))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = ChildProcess(pid: pid,
|
||||||
|
commandLine: ([executable] + arguments).joined(separator: " "),
|
||||||
|
outPipe: outPipe, errPipe: errPipe)
|
||||||
|
|
||||||
|
// Our copies of the write ends must go, or the reads never see EOF.
|
||||||
|
try? outPipe.fileHandleForWriting.close()
|
||||||
|
try? errPipe.fileHandleForWriting.close()
|
||||||
|
child.pump(outPipe.fileHandleForReading, isError: false, onLine: onLine)
|
||||||
|
child.pump(errPipe.fileHandleForReading, isError: true, onLine: onLine)
|
||||||
|
|
||||||
|
let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main)
|
||||||
|
source.setEventHandler { [weak child] in
|
||||||
|
var status: Int32 = 0
|
||||||
|
waitpid(pid, &status, 0)
|
||||||
|
child?.exitSource?.cancel()
|
||||||
|
child?.exitSource = nil
|
||||||
|
// Give the last lines a moment to arrive before letting the pipes go.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { child?.releasePipes() }
|
||||||
|
// Low 7 bits hold the signal that killed it; 0 means a normal exit.
|
||||||
|
let signal = status & 0x7f
|
||||||
|
if signal == 0 {
|
||||||
|
onExit((status >> 8) & 0xff, false)
|
||||||
|
} else {
|
||||||
|
onExit(signal, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.exitSource = source
|
||||||
|
source.resume()
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver whole lines on the main queue; partial trailing text waits for the rest.
|
||||||
|
private func pump(_ handle: FileHandle, isError: Bool, onLine: @escaping (String, Bool) -> Void) {
|
||||||
|
var buffer = Data()
|
||||||
|
handle.readabilityHandler = { file in
|
||||||
|
let chunk = file.availableData
|
||||||
|
if chunk.isEmpty { // EOF — the child closed this end
|
||||||
|
file.readabilityHandler = nil
|
||||||
|
if !buffer.isEmpty, let tail = String(data: buffer, encoding: .utf8) {
|
||||||
|
DispatchQueue.main.async { onLine(tail, isError) }
|
||||||
|
}
|
||||||
|
try? file.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buffer.append(chunk)
|
||||||
|
var lines: [String] = []
|
||||||
|
while let nl = buffer.firstIndex(of: 0x0a) {
|
||||||
|
let raw = buffer.subdata(in: buffer.startIndex..<nl)
|
||||||
|
buffer.removeSubrange(buffer.startIndex...nl)
|
||||||
|
lines.append(String(data: raw, encoding: .utf8) ?? "")
|
||||||
|
}
|
||||||
|
if lines.isEmpty { return }
|
||||||
|
DispatchQueue.main.async { for line in lines { onLine(line, isError) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releasePipes() {
|
||||||
|
for handle in [outPipe.fileHandleForReading, errPipe.fileHandleForReading] {
|
||||||
|
handle.readabilityHandler = nil
|
||||||
|
try? handle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stopping
|
||||||
|
|
||||||
|
var isAlive: Bool { kill(pid, 0) == 0 }
|
||||||
|
|
||||||
|
/// SIGTERM to the whole group. The server unmounts its modules and exits
|
||||||
|
/// within ~1.5 s; `forceKill()` is the backstop if it doesn't.
|
||||||
|
func terminate() {
|
||||||
|
guard !terminated else { return }
|
||||||
|
terminated = true
|
||||||
|
signalGroup(SIGTERM)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceKill() {
|
||||||
|
signalGroup(SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signalGroup(_ sig: Int32) {
|
||||||
|
// Only address the group when the child really leads one — otherwise a
|
||||||
|
// negative pid could reach processes that are none of our business.
|
||||||
|
if getpgid(pid) == pid {
|
||||||
|
kill(-pid, sig)
|
||||||
|
} else {
|
||||||
|
kill(pid, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Pipe {
|
||||||
|
func closeBothEnds() {
|
||||||
|
try? fileHandleForReading.close()
|
||||||
|
try? fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/**
|
||||||
|
Where things are, and which settings belong to whom.
|
||||||
|
|
||||||
|
Two kinds of setting live in this app, and they are deliberately kept apart:
|
||||||
|
|
||||||
|
* **ProdDash settings** (the port) belong to ProdDash, so they are written
|
||||||
|
where ProdDash already looks for them — `proddash.json` in the per-machine
|
||||||
|
data directory. Start the server any other way (`Start ProdDash.command`,
|
||||||
|
a terminal) and it still comes up on the port set here.
|
||||||
|
* **Launcher settings** (auto-start, restart-on-crash, where the checkout
|
||||||
|
lives) belong to this app and live in its own preferences.
|
||||||
|
|
||||||
|
The data-directory and port rules mirror server.js exactly — if that file's
|
||||||
|
`resolveDataDir` / `PORT` logic changes, change it here too.
|
||||||
|
*/
|
||||||
|
enum ProdDash {
|
||||||
|
|
||||||
|
// MARK: - Data directory (mirrors server.js resolveDataDir)
|
||||||
|
|
||||||
|
/// `PRODDASH_DATA_DIR` if the environment sets one (relative paths resolve
|
||||||
|
/// against the checkout, as in server.js), else the per-machine default.
|
||||||
|
static func dataDir(root: URL?) -> URL {
|
||||||
|
let fromEnv = (ProcessInfo.processInfo.environment["PRODDASH_DATA_DIR"] ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !fromEnv.isEmpty {
|
||||||
|
if fromEnv.hasPrefix("/") { return URL(fileURLWithPath: fromEnv) }
|
||||||
|
if let root { return root.appendingPathComponent(fromEnv).standardizedFileURL }
|
||||||
|
}
|
||||||
|
return FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.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.
|
||||||
|
static func isRoot(_ url: URL) -> Bool {
|
||||||
|
let fm = FileManager.default
|
||||||
|
return fm.fileExists(atPath: url.appendingPathComponent("server.js").path)
|
||||||
|
&& fm.fileExists(atPath: url.appendingPathComponent("public/index.html").path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Find the ProdDash folder: the one the operator chose, else the checkout
|
||||||
|
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?, dataDir: URL) -> URL? {
|
||||||
|
if let preferred, !preferred.isEmpty {
|
||||||
|
let url = URL(fileURLWithPath: preferred).standardizedFileURL
|
||||||
|
if isRoot(url) { return url }
|
||||||
|
}
|
||||||
|
if let env = ProcessInfo.processInfo.environment["PRODDASH_ROOT"], !env.isEmpty {
|
||||||
|
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()
|
||||||
|
if up.path == "/" { break }
|
||||||
|
if isRoot(up) { return up }
|
||||||
|
}
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
var candidates = [
|
||||||
|
home.appendingPathComponent("Apps/ProdDash"),
|
||||||
|
home.appendingPathComponent("ProdDash"),
|
||||||
|
home.appendingPathComponent("Documents/ProdDash"),
|
||||||
|
URL(fileURLWithPath: "/Applications/ProdDash"),
|
||||||
|
URL(fileURLWithPath: "/Users/Shared/ProdDash"),
|
||||||
|
]
|
||||||
|
// …and next to the app itself, for a "ProdDash Launcher.app beside the folder" layout.
|
||||||
|
candidates.append(Bundle.main.bundleURL.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("ProdDash"))
|
||||||
|
return candidates.first(where: isRoot)?.standardizedFileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shell's version, straight from the checkout's package.json.
|
||||||
|
static func shellVersion(root: URL) -> String {
|
||||||
|
guard let data = try? Data(contentsOf: root.appendingPathComponent("package.json")),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let version = json["version"] as? String else { return "" }
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Finding node
|
||||||
|
|
||||||
|
/// Everywhere a Mac plausibly keeps node, in the order worth trying.
|
||||||
|
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"]
|
||||||
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
tries += newestNodeUnder(home.appendingPathComponent(".nvm/versions/node"))
|
||||||
|
tries += [home.appendingPathComponent(".volta/bin/node")]
|
||||||
|
if let found = tries.first(where: isExecutable) { return found }
|
||||||
|
// Last resort: ask a login shell, which is where a fancier version
|
||||||
|
// manager (asdf, fnm, mise) puts node on the PATH.
|
||||||
|
if let fromShell = loginShellNode(), isExecutable(fromShell) { return fromShell }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isExecutable(_ url: URL) -> Bool {
|
||||||
|
FileManager.default.isExecutableFile(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func newestNodeUnder(_ dir: URL) -> [URL] {
|
||||||
|
guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return [] }
|
||||||
|
return names.sorted { compareVersions($0, $1) > 0 }
|
||||||
|
.map { dir.appendingPathComponent($0).appendingPathComponent("bin/node") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loginShellNode() -> URL? {
|
||||||
|
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
||||||
|
let task = Process()
|
||||||
|
task.executableURL = URL(fileURLWithPath: shell)
|
||||||
|
task.arguments = ["-lc", "command -v node"]
|
||||||
|
let pipe = Pipe()
|
||||||
|
task.standardOutput = pipe
|
||||||
|
task.standardError = FileHandle.nullDevice
|
||||||
|
do { try task.run() } catch { return nil }
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
task.waitUntilExit()
|
||||||
|
let path = String(data: data, encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return path.hasPrefix("/") ? URL(fileURLWithPath: path) : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `node -v`, so the window can say which runtime is about to be used.
|
||||||
|
static func nodeVersion(_ node: URL) -> String {
|
||||||
|
let task = Process()
|
||||||
|
task.executableURL = node
|
||||||
|
task.arguments = ["-v"]
|
||||||
|
let pipe = Pipe()
|
||||||
|
task.standardOutput = pipe
|
||||||
|
task.standardError = FileHandle.nullDevice
|
||||||
|
do { try task.run() } catch { return "" }
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
task.waitUntilExit()
|
||||||
|
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// node 18 is the floor the server's package.json declares.
|
||||||
|
static func nodeIsTooOld(_ version: String) -> Bool {
|
||||||
|
let major = Int(version.dropFirst(version.hasPrefix("v") ? 1 : 0)
|
||||||
|
.prefix(while: { $0.isNumber })) ?? 0
|
||||||
|
return major > 0 && major < 18
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The port (mirrors server.js: PORT env, then data dir, then checkout, then 24500)
|
||||||
|
|
||||||
|
static let defaultPort = 24500
|
||||||
|
|
||||||
|
static func port(root: URL?, dataDir: URL) -> Int {
|
||||||
|
if let fromFile = jsonNumber(at: dataDir.appendingPathComponent("proddash.json"), key: "port") {
|
||||||
|
return fromFile
|
||||||
|
}
|
||||||
|
if let root, let fromRepo = jsonNumber(at: root.appendingPathComponent("config/proddash.json"), key: "port") {
|
||||||
|
return fromRepo
|
||||||
|
}
|
||||||
|
return defaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Write the port into the data directory's proddash.json, preserving every
|
||||||
|
other key (the admin passcode lives in the same file). The launcher also
|
||||||
|
passes PORT to the server it starts, so this write is what keeps a start
|
||||||
|
by any other means on the same port.
|
||||||
|
*/
|
||||||
|
static func setPort(_ port: Int, dataDir: URL) throws {
|
||||||
|
let file = dataDir.appendingPathComponent("proddash.json")
|
||||||
|
var object: [String: Any] = [:]
|
||||||
|
if let data = try? Data(contentsOf: file),
|
||||||
|
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
object = existing
|
||||||
|
}
|
||||||
|
object["port"] = port
|
||||||
|
try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||||
|
// Same atomic-ish write the server uses: never leave a half-written config.
|
||||||
|
let tmp = file.appendingPathExtension("tmp")
|
||||||
|
try Data((inHouseStyle(data) + "\n").utf8).write(to: tmp)
|
||||||
|
_ = try FileManager.default.replaceItemAt(file, withItemAt: tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foundation writes `"port" : 24500`; the server writes `"port": 24500`.
|
||||||
|
/// This file is read and hand-edited by people — keep one house style.
|
||||||
|
private static func inHouseStyle(_ json: Data) -> String {
|
||||||
|
(String(data: json, encoding: .utf8) ?? "")
|
||||||
|
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
.map { line in
|
||||||
|
guard line.trimmingCharacters(in: .whitespaces).hasPrefix("\""),
|
||||||
|
let colon = line.range(of: "\" : ") else { return String(line) }
|
||||||
|
return line.replacingCharacters(in: colon, with: "\": ")
|
||||||
|
}
|
||||||
|
.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isValidPort(_ port: Int) -> Bool { port >= 1 && port <= 65535 }
|
||||||
|
|
||||||
|
private static func jsonNumber(at url: URL, key: String) -> Int? {
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let value = json[key] as? NSNumber else { return nil }
|
||||||
|
let port = value.intValue
|
||||||
|
return isValidPort(port) ? port : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Misc
|
||||||
|
|
||||||
|
/// "1.10.0" > "1.9.0" — the numeric comparison version strings deserve.
|
||||||
|
static func compareVersions(_ a: String, _ b: String) -> Int {
|
||||||
|
let lhs = a.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
|
||||||
|
let rhs = b.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
|
||||||
|
for i in 0..<max(lhs.count, rhs.count) {
|
||||||
|
let l = i < lhs.count ? lhs[i] : 0
|
||||||
|
let r = i < rhs.count ? rhs[i] : 0
|
||||||
|
if l != r { return l < r ? -1 : 1 }
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The launcher's own preferences — everything that is about *running* ProdDash
|
||||||
|
/// rather than about ProdDash itself. (The port is not here: it belongs to
|
||||||
|
/// ProdDash and is written to its proddash.json — see LauncherConfig.)
|
||||||
|
final class LauncherSettings: ObservableObject {
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
@Published var rootPath: String { didSet { defaults.set(rootPath, forKey: "rootPath") } }
|
||||||
|
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
|
||||||
|
/// Start the server as soon as the launcher opens (so a login item brings the
|
||||||
|
/// whole dashboard up by itself).
|
||||||
|
@Published var startServerAtLaunch: Bool { didSet { defaults.set(startServerAtLaunch, forKey: "startServerAtLaunch") } }
|
||||||
|
/// Bring the server back if it dies on its own. Backs off, and gives up
|
||||||
|
/// after a burst of fast crashes rather than looping forever.
|
||||||
|
@Published var restartOnCrash: Bool { didSet { defaults.set(restartOnCrash, forKey: "restartOnCrash") } }
|
||||||
|
/// Open the dashboard in a browser whenever the server comes up.
|
||||||
|
@Published var openDashboardOnStart: Bool { didSet { defaults.set(openDashboardOnStart, forKey: "openDashboardOnStart") } }
|
||||||
|
/// Show this window at launch. Off means the launcher starts straight into
|
||||||
|
/// the menu bar (Companion's "start minimised").
|
||||||
|
@Published var showWindowAtLaunch: Bool { didSet { defaults.set(showWindowAtLaunch, forKey: "showWindowAtLaunch") } }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
defaults.register(defaults: [
|
||||||
|
"startServerAtLaunch": true,
|
||||||
|
"restartOnCrash": true,
|
||||||
|
"openDashboardOnStart": false,
|
||||||
|
"showWindowAtLaunch": true,
|
||||||
|
])
|
||||||
|
rootPath = defaults.string(forKey: "rootPath") ?? ""
|
||||||
|
nodePath = defaults.string(forKey: "nodePath") ?? ""
|
||||||
|
startServerAtLaunch = defaults.bool(forKey: "startServerAtLaunch")
|
||||||
|
restartOnCrash = defaults.bool(forKey: "restartOnCrash")
|
||||||
|
openDashboardOnStart = defaults.bool(forKey: "openDashboardOnStart")
|
||||||
|
showWindowAtLaunch = defaults.bool(forKey: "showWindowAtLaunch")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// False until the launcher has run once — used to open the window (and ask
|
||||||
|
/// for the permissions ProdDash needs) the first time only.
|
||||||
|
var hasRunBefore: Bool {
|
||||||
|
get { defaults.bool(forKey: "hasRunBefore") }
|
||||||
|
set { defaults.set(newValue, forKey: "hasRunBefore") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/**
|
||||||
|
The launcher window: what ProdDash is doing at the top, and the three things
|
||||||
|
an operator ever needs underneath — what it is saying (Log), how it runs
|
||||||
|
(Settings), and what macOS is letting it do (Permissions).
|
||||||
|
*/
|
||||||
|
struct LauncherView: View {
|
||||||
|
|
||||||
|
@ObservedObject var server: ServerController
|
||||||
|
@ObservedObject var settings: LauncherSettings
|
||||||
|
@ObservedObject var permissions: PermissionsModel
|
||||||
|
let onHide: () -> Void
|
||||||
|
let onQuit: () -> Void
|
||||||
|
|
||||||
|
private enum Tab: String, CaseIterable, Identifiable {
|
||||||
|
case log = "Log", settings = "Settings", permissions = "Permissions"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
@State private var tab: Tab = .log
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
addresses
|
||||||
|
Divider()
|
||||||
|
Picker("", selection: $tab) {
|
||||||
|
ForEach(Tab.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
switch tab {
|
||||||
|
case .log:
|
||||||
|
LogPane()
|
||||||
|
case .settings:
|
||||||
|
SettingsPane(server: server, settings: settings)
|
||||||
|
case .permissions:
|
||||||
|
PermissionsPane(server: server, permissions: permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(minWidth: 540, idealWidth: 580, maxWidth: .infinity,
|
||||||
|
minHeight: 520, idealHeight: 740, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(nsImage: NSApp.applicationIconImage)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 46, height: 46)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("ProdDash").font(.system(size: 19, weight: .semibold))
|
||||||
|
Text(subtitle).font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 5) {
|
||||||
|
StatusPill(state: server.state, healthy: server.healthy)
|
||||||
|
if !server.message.isEmpty {
|
||||||
|
Text(server.message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: 260, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 22)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subtitle: String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if !server.shellVersion.isEmpty { parts.append("v\(server.shellVersion)") }
|
||||||
|
if !server.nodeVersion.isEmpty { parts.append("node \(server.nodeVersion)") }
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Addresses and the controls that matter
|
||||||
|
|
||||||
|
private var addresses: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
if server.state == .running {
|
||||||
|
AddressRow(label: "This Mac", url: server.localURL.isEmpty
|
||||||
|
? "http://localhost:\(server.port)" : server.localURL)
|
||||||
|
ForEach(server.networkURLs, id: \.self) { url in
|
||||||
|
AddressRow(label: "Network", url: url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Not serving. ProdDash will come up on port " + String(server.port) + ".")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
switch server.state {
|
||||||
|
case .running, .starting:
|
||||||
|
Button("Stop") { server.stop() }
|
||||||
|
case .stopping:
|
||||||
|
Button("Stopping…") {}.disabled(true)
|
||||||
|
case .stopped, .failed:
|
||||||
|
Button("Start") { server.start() }.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
Button("Restart") { server.restart() }
|
||||||
|
.disabled(server.state != .running && server.state != .starting)
|
||||||
|
Spacer()
|
||||||
|
Button("Open Dashboard") { server.openDashboard() }
|
||||||
|
.disabled(server.state != .running)
|
||||||
|
Button("Admin") { server.openAdmin() }
|
||||||
|
.disabled(server.state != .running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
Button("Hide") { onHide() }
|
||||||
|
.help("Closes this window; ProdDash keeps running in the menu bar.")
|
||||||
|
Spacer()
|
||||||
|
Text("Launcher \(AppDelegate.version)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Spacer()
|
||||||
|
Button("Quit ProdDash") { onQuit() }
|
||||||
|
.help("Stops the server and quits the launcher.")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pieces
|
||||||
|
|
||||||
|
private struct StatusPill: View {
|
||||||
|
let state: ServerState
|
||||||
|
let healthy: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(nsColor: StatusIcon.color(for: state, healthy: healthy)))
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
Text(state == .running && !healthy ? "Running (not answering)" : state.label)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 11)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Color(nsColor: .quaternaryLabelColor).opacity(0.5), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AddressRow: View {
|
||||||
|
let label: String
|
||||||
|
let url: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 58, alignment: .leading)
|
||||||
|
Text(url)
|
||||||
|
.font(.system(.callout, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Button {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(url, forType: .string)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "doc.on.doc")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Copy this address")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log
|
||||||
|
|
||||||
|
private struct LogPane: View {
|
||||||
|
@ObservedObject private var log = LogStore.shared
|
||||||
|
@State private var follow = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(log.lines) { line in
|
||||||
|
Text(line.text)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(color(for: line.kind))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.id(line.id)
|
||||||
|
}
|
||||||
|
Color.clear.frame(height: 1).id("bottom")
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.background(Color(nsColor: .textBackgroundColor))
|
||||||
|
.onChange(of: log.lines.count) { _ in
|
||||||
|
guard follow else { return }
|
||||||
|
withAnimation(.none) { proxy.scrollTo("bottom", anchor: .bottom) }
|
||||||
|
}
|
||||||
|
.onAppear { proxy.scrollTo("bottom", anchor: .bottom) }
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Toggle("Follow", isOn: $follow).toggleStyle(.checkbox)
|
||||||
|
Spacer()
|
||||||
|
Button("Copy") {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(log.plainText, forType: .string)
|
||||||
|
}
|
||||||
|
Button("Reveal Log File") {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([log.currentFile])
|
||||||
|
}
|
||||||
|
Button("Clear") { log.clear() }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func color(for kind: LogStore.Kind) -> Color {
|
||||||
|
switch kind {
|
||||||
|
case .launcher: return .accentColor
|
||||||
|
case .err: return .orange
|
||||||
|
case .out: return .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
private struct SettingsPane: View {
|
||||||
|
@ObservedObject var server: ServerController
|
||||||
|
@ObservedObject var settings: LauncherSettings
|
||||||
|
|
||||||
|
@State private var portText = ""
|
||||||
|
@State private var loginItemOn = LoginItem.isEnabled
|
||||||
|
@State private var loginNeedsApproval = LoginItem.needsApproval
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Network") {
|
||||||
|
HStack {
|
||||||
|
TextField("Port", text: $portText)
|
||||||
|
.frame(width: 90)
|
||||||
|
.onSubmit(applyPort)
|
||||||
|
Button("Apply", action: applyPort)
|
||||||
|
.disabled(Int(portText) == server.port || Int(portText) == nil)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Startup") {
|
||||||
|
Toggle("Open ProdDash at login", isOn: Binding(
|
||||||
|
get: { loginItemOn },
|
||||||
|
set: { setLoginItem($0) }))
|
||||||
|
if loginNeedsApproval {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("macOS is holding this until you allow it.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Button("Open Login Items") { LoginItem.openSystemSettings() }
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Start the server when the launcher opens", isOn: $settings.startServerAtLaunch)
|
||||||
|
Toggle("Restart the server if it stops unexpectedly", isOn: $settings.restartOnCrash)
|
||||||
|
Toggle("Open the dashboard in a browser when the server starts", isOn: $settings.openDashboardOnStart)
|
||||||
|
Toggle("Show this window at launch", isOn: $settings.showWindowAtLaunch)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Where things are") {
|
||||||
|
PathRow(title: "ProdDash folder",
|
||||||
|
value: server.rootURL?.path ?? "Not found — choose it",
|
||||||
|
missing: server.rootURL == nil) {
|
||||||
|
chooseFolder()
|
||||||
|
}
|
||||||
|
PathRow(title: "Node.js",
|
||||||
|
value: server.nodeURL.map { "\($0.path) \(server.nodeVersion)" } ?? "Not found",
|
||||||
|
missing: server.nodeURL == nil, action: nil)
|
||||||
|
PathRow(title: "Settings", value: server.settingsDir.isEmpty
|
||||||
|
? server.dataDir.path : server.settingsDir, missing: false, action: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.onAppear { portText = String(server.port) }
|
||||||
|
.onChange(of: server.port) { newValue in portText = String(newValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPort() {
|
||||||
|
guard let port = Int(portText) else {
|
||||||
|
portText = String(server.port)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.applyPort(port)
|
||||||
|
portText = String(server.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setLoginItem(_ on: Bool) {
|
||||||
|
if let error = LoginItem.set(on) {
|
||||||
|
LogStore.shared.launcher("couldn't change the login item: \(error)")
|
||||||
|
}
|
||||||
|
loginItemOn = LoginItem.isEnabled
|
||||||
|
loginNeedsApproval = LoginItem.needsApproval
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chooseFolder() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.message = "Choose the ProdDash folder — the one with server.js in it."
|
||||||
|
panel.prompt = "Choose"
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
|
guard ProdDash.isRoot(url) else {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "That isn't a ProdDash folder"
|
||||||
|
alert.informativeText = "Pick the folder that contains server.js and public/."
|
||||||
|
alert.runModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings.rootPath = url.path
|
||||||
|
server.refreshEnvironment()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PathRow: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let missing: Bool
|
||||||
|
var action: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
Text(value)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(missing ? Color.orange : Color.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.lineLimit(2)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let action {
|
||||||
|
Button("Choose…", action: action).controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The launcher's one window. Closing it leaves the app in the menu bar —
|
||||||
|
/// the same "Hide, don't Quit" habit Companion's launcher asks for.
|
||||||
|
final class LauncherWindowController: NSWindowController {
|
||||||
|
|
||||||
|
convenience init(delegate: AppDelegate) {
|
||||||
|
let view = LauncherView(server: delegate.server,
|
||||||
|
settings: delegate.settings,
|
||||||
|
permissions: delegate.permissions,
|
||||||
|
onHide: { [weak delegate] in delegate?.hideWindow() },
|
||||||
|
onQuit: { [weak delegate] in delegate?.quit() })
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 580, height: 740),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
|
backing: .buffered, defer: false)
|
||||||
|
window.title = "ProdDash"
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.isMovableByWindowBackground = true
|
||||||
|
window.contentView = NSHostingView(rootView: view)
|
||||||
|
window.center()
|
||||||
|
window.setFrameAutosaveName("ProdDashLauncherWindow")
|
||||||
|
self.init(window: window)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/**
|
||||||
|
Everything the server prints, plus what the launcher did and why.
|
||||||
|
|
||||||
|
Kept twice over: the last few hundred lines in memory for the window's Log
|
||||||
|
tab, and a dated file under ~/Library/Logs/ProdDash so a problem from last
|
||||||
|
Sunday can still be read on Tuesday. Files older than a week are removed on
|
||||||
|
startup — the same retention Companion's launcher keeps.
|
||||||
|
*/
|
||||||
|
final class LogStore: ObservableObject {
|
||||||
|
|
||||||
|
enum Kind { case launcher, out, err }
|
||||||
|
|
||||||
|
struct Line: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let at: Date
|
||||||
|
let text: String
|
||||||
|
let kind: Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = LogStore()
|
||||||
|
|
||||||
|
/// Enough to cover a start-up and a fault, not enough to bloat the window.
|
||||||
|
private let capacity = 800
|
||||||
|
private let retentionDays = 7
|
||||||
|
|
||||||
|
@Published private(set) var lines: [Line] = []
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "org.waterschurch.proddash.launcher.log")
|
||||||
|
private var handle: FileHandle?
|
||||||
|
private var handleDay: String = ""
|
||||||
|
|
||||||
|
private lazy var directory: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Logs/ProdDash")
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
queue.async { [weak self] in self?.pruneOldFiles() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The launcher's own voice in the log — start/stop decisions, failures.
|
||||||
|
func launcher(_ text: String) { append(text, kind: .launcher) }
|
||||||
|
|
||||||
|
func append(_ text: String, kind: Kind) {
|
||||||
|
let line = Line(at: Date(), text: text, kind: kind)
|
||||||
|
if Thread.isMainThread {
|
||||||
|
store(line)
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async { self.store(line) }
|
||||||
|
}
|
||||||
|
queue.async { [weak self] in self?.write(line) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func store(_ line: Line) {
|
||||||
|
lines.append(line)
|
||||||
|
if lines.count > capacity { lines.removeFirst(lines.count - capacity) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
lines.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's file, for "Reveal in Finder".
|
||||||
|
var currentFile: URL { directory.appendingPathComponent("proddash-\(Self.dayFormatter.string(from: Date())).log") }
|
||||||
|
|
||||||
|
var plainText: String {
|
||||||
|
lines.map { "\(Self.timeFormatter.string(from: $0.at)) \($0.text)" }.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File
|
||||||
|
|
||||||
|
private func write(_ line: Line) {
|
||||||
|
let day = Self.dayFormatter.string(from: line.at)
|
||||||
|
if handle == nil || handleDay != day {
|
||||||
|
try? handle?.close()
|
||||||
|
handle = nil
|
||||||
|
handleDay = day
|
||||||
|
let file = directory.appendingPathComponent("proddash-\(day).log")
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
if !FileManager.default.fileExists(atPath: file.path) {
|
||||||
|
FileManager.default.createFile(atPath: file.path, contents: nil)
|
||||||
|
}
|
||||||
|
handle = try? FileHandle(forWritingTo: file)
|
||||||
|
_ = try? handle?.seekToEnd()
|
||||||
|
}
|
||||||
|
let prefix = line.kind == .launcher ? "[launcher] " : ""
|
||||||
|
let text = "\(Self.stampFormatter.string(from: line.at)) \(prefix)\(line.text)\n"
|
||||||
|
try? handle?.write(contentsOf: Data(text.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruneOldFiles() {
|
||||||
|
let cutoff = Date().addingTimeInterval(-Double(retentionDays) * 86400)
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||||
|
at: directory, includingPropertiesForKeys: [.contentModificationDateKey]) else { return }
|
||||||
|
for file in files where file.lastPathComponent.hasPrefix("proddash-") {
|
||||||
|
let modified = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
|
||||||
|
if let modified, modified < cutoff { try? FileManager.default.removeItem(at: file) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let dayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "HH:mm:ss"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let stampFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/**
|
||||||
|
Small connection probes, used for two jobs:
|
||||||
|
|
||||||
|
* is something already listening on ProdDash's port (a server started over
|
||||||
|
SSH or from the .command file, which the launcher offers to take over), and
|
||||||
|
* can this machine actually reach the gear the modules are configured for —
|
||||||
|
which on macOS 15+ is the only practical way to tell whether the Local
|
||||||
|
Network permission was granted, because there is no API that reports it.
|
||||||
|
|
||||||
|
Network.framework rather than a bare socket on purpose: a blocked local
|
||||||
|
network connection surfaces here as a plain unreachable/timeout instead of a
|
||||||
|
silent hang.
|
||||||
|
*/
|
||||||
|
enum Net {
|
||||||
|
|
||||||
|
enum Probe: Equatable {
|
||||||
|
case open // something accepted the connection
|
||||||
|
case refused // nothing listening, but the host answered
|
||||||
|
case unreachable // no route — or macOS is withholding local network access
|
||||||
|
case timedOut
|
||||||
|
case failed(String)
|
||||||
|
|
||||||
|
var isReachableHost: Bool { self == .open || self == .refused }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe host:port, calling back on the main queue exactly once.
|
||||||
|
static func probe(host: String, port: UInt16, timeout: TimeInterval = 2.0,
|
||||||
|
completion: @escaping (Probe) -> Void) {
|
||||||
|
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||||
|
return DispatchQueue.main.async { completion(.failed("invalid port")) }
|
||||||
|
}
|
||||||
|
let tcp = NWProtocolTCP.Options()
|
||||||
|
tcp.connectionTimeout = max(1, Int(timeout))
|
||||||
|
tcp.noDelay = true
|
||||||
|
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort,
|
||||||
|
using: NWParameters(tls: nil, tcp: tcp))
|
||||||
|
|
||||||
|
var settled = false
|
||||||
|
let finish: (Probe) -> Void = { result in
|
||||||
|
guard !settled else { return }
|
||||||
|
settled = true
|
||||||
|
connection.cancel()
|
||||||
|
DispatchQueue.main.async { completion(result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
finish(.open)
|
||||||
|
case .waiting(let error):
|
||||||
|
// "waiting" means it would keep retrying — for a probe that is the answer.
|
||||||
|
finish(map(error))
|
||||||
|
case .failed(let error):
|
||||||
|
finish(map(error))
|
||||||
|
case .cancelled:
|
||||||
|
finish(.failed("cancelled"))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: .global(qos: .utility))
|
||||||
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { finish(.timedOut) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking form, for preflight on a background queue. Never call it on main.
|
||||||
|
static func probeSync(host: String, port: UInt16, timeout: TimeInterval = 2.0) -> Probe {
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var result: Probe = .timedOut
|
||||||
|
probe(host: host, port: port, timeout: timeout) { outcome in
|
||||||
|
result = outcome
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
_ = semaphore.wait(timeout: .now() + timeout + 1.0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func map(_ error: NWError) -> Probe {
|
||||||
|
if case .posix(let code) = error {
|
||||||
|
switch code {
|
||||||
|
case .ECONNREFUSED: return .refused
|
||||||
|
case .EHOSTUNREACH, .ENETUNREACH, .EHOSTDOWN, .ENETDOWN: return .unreachable
|
||||||
|
case .ETIMEDOUT: return .timedOut
|
||||||
|
default: return .failed(String(describing: code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this address on this building's network (so the Local Network
|
||||||
|
/// permission applies) rather than out on the internet?
|
||||||
|
static func isLocalAddress(_ host: String) -> Bool {
|
||||||
|
let name = host.lowercased()
|
||||||
|
if name == "localhost" || name.hasSuffix(".local") || name.hasSuffix(".lan") { return true }
|
||||||
|
let parts = name.split(separator: ".").map(String.init)
|
||||||
|
guard parts.count == 4, let a = Int(parts[0]), let b = Int(parts[1]),
|
||||||
|
parts.allSatisfy({ Int($0) != nil }) else {
|
||||||
|
// A bare hostname with no dots is a LAN name; anything else is the internet.
|
||||||
|
return !name.contains(".")
|
||||||
|
}
|
||||||
|
if a == 10 || a == 127 { return true }
|
||||||
|
if a == 192 && b == 168 { return true }
|
||||||
|
if a == 172 && (16...31).contains(b) { return true }
|
||||||
|
if a == 169 && b == 254 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PIDs listening on a TCP port, so a stray ProdDash can be handed over.
|
||||||
|
static func listenerPIDs(port: Int) -> [pid_t] {
|
||||||
|
let task = Process()
|
||||||
|
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
|
||||||
|
task.arguments = ["-t", "-iTCP:\(port)", "-sTCP:LISTEN"]
|
||||||
|
let pipe = Pipe()
|
||||||
|
task.standardOutput = pipe
|
||||||
|
task.standardError = FileHandle.nullDevice
|
||||||
|
do { try task.run() } catch { return [] }
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
task.waitUntilExit()
|
||||||
|
return (String(data: data, encoding: .utf8) ?? "")
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/**
|
||||||
|
The permissions ProdDash's modules need, and this app's job of holding them.
|
||||||
|
|
||||||
|
A module can say what it needs in its manifest, which is how this pane knows
|
||||||
|
to mention the microphone at all:
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "microphone", "reason": "Decoding LTC timecode from an audio input" }
|
||||||
|
]
|
||||||
|
|
||||||
|
The shell ignores the key; only the launcher reads it. A bare
|
||||||
|
`"permissions": ["microphone"]` works too, and a kind this launcher doesn't
|
||||||
|
manage is still listed — better to name it than to pretend it isn't needed.
|
||||||
|
*/
|
||||||
|
enum PermissionKind: Hashable {
|
||||||
|
case microphone
|
||||||
|
case localNetwork
|
||||||
|
case other(String)
|
||||||
|
|
||||||
|
init(raw: String) {
|
||||||
|
switch raw.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||||
|
.replacingOccurrences(of: "_", with: "-") {
|
||||||
|
case "microphone", "audio-input", "mic": self = .microphone
|
||||||
|
case "local-network", "localnetwork", "lan": self = .localNetwork
|
||||||
|
case let other: self = .other(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .microphone: return "microphone"
|
||||||
|
case .localNetwork: return "local-network"
|
||||||
|
case .other(let raw): return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .microphone: return "Microphone"
|
||||||
|
case .localNetwork: return "Local Network"
|
||||||
|
case .other(let raw): return raw.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every module that needs LTC needs the same thing; this is the standing
|
||||||
|
/// explanation when a module doesn't give its own.
|
||||||
|
var defaultReason: String {
|
||||||
|
switch self {
|
||||||
|
case .microphone:
|
||||||
|
return "Reading an audio input on this machine."
|
||||||
|
case .localNetwork:
|
||||||
|
return "Reaching production gear on this network."
|
||||||
|
case .other:
|
||||||
|
return "Required by a module."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why one set of modules needs a permission. Modules that give the same
|
||||||
|
/// reason are listed together, so three modules reaching the same network
|
||||||
|
/// don't produce three near-identical lines.
|
||||||
|
struct PermissionClaim: Identifiable, Equatable {
|
||||||
|
let reason: String
|
||||||
|
var modules: [String]
|
||||||
|
var id: String { reason }
|
||||||
|
|
||||||
|
var askedBy: String { modules.joined(separator: ", ") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One permission, and everyone who asked for it.
|
||||||
|
struct ModuleNeed: Identifiable, Equatable {
|
||||||
|
let kind: PermissionKind
|
||||||
|
var claims: [PermissionClaim] = []
|
||||||
|
var id: String { kind.id }
|
||||||
|
|
||||||
|
var modules: [String] {
|
||||||
|
var seen: [String] = []
|
||||||
|
for claim in claims where !claim.modules.isEmpty {
|
||||||
|
for module in claim.modules where !seen.contains(module) { seen.append(module) }
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configured upstream and whether this machine can reach it.
|
||||||
|
struct HostCheck: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let module: String
|
||||||
|
let host: String
|
||||||
|
let port: Int
|
||||||
|
var result: Net.Probe?
|
||||||
|
|
||||||
|
var label: String { "\(host):\(port)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PermissionsModel: ObservableObject {
|
||||||
|
|
||||||
|
@Published private(set) var needs: [ModuleNeed] = []
|
||||||
|
@Published private(set) var microphone: AVAuthorizationStatus = .notDetermined
|
||||||
|
@Published private(set) var notifications: UNAuthorizationStatus = .notDetermined
|
||||||
|
@Published private(set) var hostChecks: [HostCheck] = []
|
||||||
|
@Published private(set) var checking = false
|
||||||
|
|
||||||
|
private var browser: NWBrowser?
|
||||||
|
|
||||||
|
// MARK: - Status
|
||||||
|
|
||||||
|
func refresh(root: URL?, dataDir: URL) {
|
||||||
|
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||||
|
if status != microphone { microphone = status }
|
||||||
|
Notifier.shared.refreshStatus { [weak self] status in
|
||||||
|
if status != self?.notifications { self?.notifications = status }
|
||||||
|
}
|
||||||
|
let scanned = Self.scanModules(root: root, dataDir: dataDir)
|
||||||
|
if scanned != needs { needs = scanned }
|
||||||
|
if hostChecks.isEmpty {
|
||||||
|
let hosts = Self.configuredLocalHosts(root: root, dataDir: dataDir)
|
||||||
|
if !hosts.isEmpty { hostChecks = hosts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestMicrophone() {
|
||||||
|
guard microphone == .notDetermined else {
|
||||||
|
return SystemSettings.open(SystemSettings.microphone)
|
||||||
|
}
|
||||||
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.microphone = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||||
|
LogStore.shared.launcher("microphone access: \(self?.microphone.description ?? "?")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotifications() {
|
||||||
|
guard notifications == .notDetermined else {
|
||||||
|
return SystemSettings.open(SystemSettings.notifications)
|
||||||
|
}
|
||||||
|
Notifier.shared.requestAuthorization { [weak self] _ in
|
||||||
|
Notifier.shared.refreshStatus { status in self?.notifications = status }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local network
|
||||||
|
|
||||||
|
/**
|
||||||
|
macOS never reports whether local network access was granted, so this
|
||||||
|
does the only thing that actually answers the question: touch the network
|
||||||
|
the way the modules do — a Bonjour browse (which is what raises the
|
||||||
|
prompt) and a connection attempt to each configured upstream — then say
|
||||||
|
what the results mean.
|
||||||
|
*/
|
||||||
|
func checkLocalNetwork(root: URL?, dataDir: URL) {
|
||||||
|
guard !checking else { return }
|
||||||
|
var checks = Self.configuredLocalHosts(root: root, dataDir: dataDir)
|
||||||
|
checking = true
|
||||||
|
for index in checks.indices { checks[index].result = nil }
|
||||||
|
hostChecks = checks
|
||||||
|
startBonjourBrowse()
|
||||||
|
|
||||||
|
guard !checks.isEmpty else {
|
||||||
|
// Nothing configured yet: the browse alone still raises the prompt.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
|
||||||
|
self?.checking = false
|
||||||
|
LogStore.shared.launcher("local network check: nothing configured to try yet")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = DispatchGroup()
|
||||||
|
for (index, check) in checks.enumerated() {
|
||||||
|
group.enter()
|
||||||
|
Net.probe(host: check.host, port: UInt16(check.port), timeout: 3) { [weak self] result in
|
||||||
|
self?.hostChecks[index].result = result
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.notify(queue: .main) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.checking = false
|
||||||
|
self.stopBonjourBrowse()
|
||||||
|
let results = self.hostChecks.compactMap(\.result)
|
||||||
|
let answered = results.filter(\.isReachableHost).count
|
||||||
|
let verdict: String
|
||||||
|
if answered == results.count {
|
||||||
|
verdict = "every configured host answered"
|
||||||
|
} else if answered == 0 {
|
||||||
|
verdict = "nothing answered — if the gear is on, macOS is withholding local network access"
|
||||||
|
} else {
|
||||||
|
verdict = "\(answered) of \(results.count) hosts answered"
|
||||||
|
}
|
||||||
|
LogStore.shared.launcher("local network check: \(verdict)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browsing for Bonjour services is what makes macOS raise the local
|
||||||
|
/// network prompt; the results themselves don't matter here.
|
||||||
|
private func startBonjourBrowse() {
|
||||||
|
stopBonjourBrowse()
|
||||||
|
let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: .tcp)
|
||||||
|
browser.start(queue: .global(qos: .utility))
|
||||||
|
self.browser = browser
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in self?.stopBonjourBrowse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopBonjourBrowse() {
|
||||||
|
browser?.cancel()
|
||||||
|
browser = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading the modules
|
||||||
|
|
||||||
|
/// Manifests from both places the shell looks: the checkout and the
|
||||||
|
/// modules installed from the admin page.
|
||||||
|
private static func moduleManifests(root: URL?, dataDir: URL) -> [(id: String, name: String, json: [String: Any])] {
|
||||||
|
var found: [String: (String, [String: Any])] = [:]
|
||||||
|
for base in [root?.appendingPathComponent("modules"), dataDir.appendingPathComponent("modules")].compactMap({ $0 }) {
|
||||||
|
let dirs = (try? FileManager.default.contentsOfDirectory(atPath: base.path)) ?? []
|
||||||
|
for dir in dirs where !dir.hasPrefix(".") {
|
||||||
|
let file = base.appendingPathComponent(dir).appendingPathComponent("module.json")
|
||||||
|
guard let data = try? Data(contentsOf: file),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
|
||||||
|
found[dir] = (json["name"] as? String ?? dir, json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found.map { (id: $0.key, name: $0.value.0, json: $0.value.1) }
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func scanModules(root: URL?, dataDir: URL) -> [ModuleNeed] {
|
||||||
|
var byKind: [PermissionKind: ModuleNeed] = [:]
|
||||||
|
for module in moduleManifests(root: root, dataDir: dataDir) {
|
||||||
|
guard let raw = module.json["permissions"] as? [Any] else { continue }
|
||||||
|
for entry in raw {
|
||||||
|
var kind: PermissionKind?
|
||||||
|
var reason = ""
|
||||||
|
if let name = entry as? String {
|
||||||
|
kind = PermissionKind(raw: name)
|
||||||
|
} else if let object = entry as? [String: Any], let name = object["kind"] as? String {
|
||||||
|
kind = PermissionKind(raw: name)
|
||||||
|
reason = (object["reason"] as? String ?? "").trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
guard let kind else { continue }
|
||||||
|
var need = byKind[kind] ?? ModuleNeed(kind: kind)
|
||||||
|
let text = reason.isEmpty ? kind.defaultReason : reason
|
||||||
|
if let index = need.claims.firstIndex(where: { $0.reason == text }) {
|
||||||
|
if !need.claims[index].modules.contains(module.name) {
|
||||||
|
need.claims[index].modules.append(module.name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
need.claims.append(PermissionClaim(reason: text, modules: [module.name]))
|
||||||
|
}
|
||||||
|
byKind[kind] = need
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stable order: the two the launcher manages first, then anything else.
|
||||||
|
let order: [PermissionKind] = [.microphone, .localNetwork]
|
||||||
|
return byKind.values.sorted {
|
||||||
|
let a = order.firstIndex(of: $0.kind) ?? order.count
|
||||||
|
let b = order.firstIndex(of: $1.kind) ?? order.count
|
||||||
|
return a == b ? $0.kind.title < $1.kind.title : a < b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The upstreams the admin page has been pointed at, pulled out of the
|
||||||
|
module config the server writes (modules.json). Endpoint settings are
|
||||||
|
`{ host, port }` objects and URLs are strings — both shapes appear, and
|
||||||
|
both are worth testing, so this walks the config rather than knowing any
|
||||||
|
particular module's key names.
|
||||||
|
*/
|
||||||
|
static func configuredLocalHosts(root: URL?, dataDir: URL) -> [HostCheck] {
|
||||||
|
guard let data = try? Data(contentsOf: dataDir.appendingPathComponent("modules.json")),
|
||||||
|
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
|
||||||
|
let names = Dictionary(uniqueKeysWithValues: moduleManifests(root: root, dataDir: dataDir).map { ($0.id, $0.name) })
|
||||||
|
var checks: [HostCheck] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
func consider(_ host: String, _ port: Int, module: String) {
|
||||||
|
let host = host.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !host.isEmpty, Net.isLocalAddress(host), host != "localhost", host != "127.0.0.1" else { return }
|
||||||
|
let key = "\(host):\(port)"
|
||||||
|
guard !seen.contains(key) else { return }
|
||||||
|
seen.insert(key)
|
||||||
|
checks.append(HostCheck(module: module, host: host, port: port))
|
||||||
|
}
|
||||||
|
|
||||||
|
func walk(_ value: Any, module: String) {
|
||||||
|
if let object = value as? [String: Any] {
|
||||||
|
if let host = object["host"] as? String {
|
||||||
|
consider(host, (object["port"] as? NSNumber)?.intValue ?? 80, module: module)
|
||||||
|
}
|
||||||
|
for nested in object.values { walk(nested, module: module) }
|
||||||
|
} else if let list = value as? [Any] {
|
||||||
|
for nested in list { walk(nested, module: module) }
|
||||||
|
} else if let text = value as? String, text.contains("://"),
|
||||||
|
let parts = URLComponents(string: text), let host = parts.host {
|
||||||
|
consider(host, parts.port ?? (parts.scheme == "https" ? 443 : 80), module: module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, entry) in config {
|
||||||
|
guard let entry = entry as? [String: Any], let moduleConfig = entry["config"] else { continue }
|
||||||
|
walk(moduleConfig, module: names[id] ?? id)
|
||||||
|
}
|
||||||
|
return checks.sorted { $0.module < $1.module }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AVAuthorizationStatus {
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .authorized: return "granted"
|
||||||
|
case .denied: return "denied"
|
||||||
|
case .restricted: return "restricted"
|
||||||
|
case .notDetermined: return "not asked yet"
|
||||||
|
@unknown default: return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/**
|
||||||
|
The permissions pane: one line per permission — a status dot, its name, and
|
||||||
|
the one thing you can do about it.
|
||||||
|
|
||||||
|
What appears here is what the installed modules declare in their manifests,
|
||||||
|
so the list matches what is actually installed. Each row's tooltip carries
|
||||||
|
the modules' own reasons; the row itself stays out of the way.
|
||||||
|
*/
|
||||||
|
struct PermissionsPane: View {
|
||||||
|
|
||||||
|
@ObservedObject var server: ServerController
|
||||||
|
@ObservedObject var permissions: PermissionsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if permissions.needs.isEmpty {
|
||||||
|
Text("No installed module declares a permission.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(permissions.needs) { need in
|
||||||
|
row(for: need)
|
||||||
|
}
|
||||||
|
notifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
|
||||||
|
Button("Re-check") {
|
||||||
|
permissions.refresh(root: server.rootURL, dataDir: server.dataDir)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
.onAppear { permissions.refresh(root: server.rootURL, dataDir: server.dataDir) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rows
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func row(for need: ModuleNeed) -> some View {
|
||||||
|
switch need.kind {
|
||||||
|
case .microphone:
|
||||||
|
PermissionRow(title: need.kind.title,
|
||||||
|
tone: microphoneTone,
|
||||||
|
help: help(for: need),
|
||||||
|
actionTitle: microphoneActionTitle,
|
||||||
|
action: microphoneActionTitle == nil ? nil : { requestMicrophone() })
|
||||||
|
|
||||||
|
case .localNetwork:
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
PermissionRow(title: need.kind.title,
|
||||||
|
tone: localNetworkTone,
|
||||||
|
help: help(for: need),
|
||||||
|
busy: permissions.checking,
|
||||||
|
actionTitle: "Check Access",
|
||||||
|
action: { permissions.checkLocalNetwork(root: server.rootURL, dataDir: server.dataDir) })
|
||||||
|
if !results.isEmpty { hostResults }
|
||||||
|
}
|
||||||
|
|
||||||
|
case .other:
|
||||||
|
PermissionRow(title: need.kind.title,
|
||||||
|
tone: .unknown,
|
||||||
|
help: "The launcher doesn't manage this one — grant it in System Settings.\n"
|
||||||
|
+ help(for: need),
|
||||||
|
actionTitle: "Open System Settings",
|
||||||
|
action: { SystemSettings.open(SystemSettings.privacy) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notifications: some View {
|
||||||
|
PermissionRow(title: "Notifications",
|
||||||
|
tone: notificationTone,
|
||||||
|
help: "Tells you when ProdDash stops on its own.",
|
||||||
|
actionTitle: notificationActionTitle,
|
||||||
|
action: notificationActionTitle == nil ? nil : { permissions.requestNotifications() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the modules said, for the tooltip — one line per distinct reason.
|
||||||
|
private func help(for need: ModuleNeed) -> String {
|
||||||
|
need.claims
|
||||||
|
.map { $0.modules.isEmpty ? $0.reason : "\($0.reason) — \($0.askedBy)" }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local network results
|
||||||
|
|
||||||
|
private var results: [Net.Probe] { permissions.hostChecks.compactMap(\.result) }
|
||||||
|
|
||||||
|
private var hostResults: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(permissions.hostChecks) { check in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(tone(for: check.result).color)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(check.label)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
Text(describe(check.result))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !results.isEmpty, !results.contains(where: \.isReachableHost) {
|
||||||
|
// Only when everything failed, because that is the shape a
|
||||||
|
// withheld permission takes.
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Nothing answered.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Open System Settings") { SystemSettings.open(SystemSettings.localNetwork) }
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 17)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func describe(_ result: Net.Probe?) -> String {
|
||||||
|
switch result {
|
||||||
|
case .none: return "—"
|
||||||
|
case .open: return "answered"
|
||||||
|
case .refused: return "reachable"
|
||||||
|
case .unreachable: return "no route"
|
||||||
|
case .timedOut: return "no answer"
|
||||||
|
case .failed(let why): return why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tone(for result: Net.Probe?) -> Tone {
|
||||||
|
guard let result else { return .unknown }
|
||||||
|
return result.isReachableHost ? .good : .bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status and actions
|
||||||
|
|
||||||
|
private func requestMicrophone() {
|
||||||
|
permissions.requestMicrophone()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var microphoneTone: Tone {
|
||||||
|
switch permissions.microphone {
|
||||||
|
case .authorized: return .good
|
||||||
|
case .denied, .restricted: return .bad
|
||||||
|
default: return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var microphoneActionTitle: String? {
|
||||||
|
switch permissions.microphone {
|
||||||
|
case .authorized: return nil
|
||||||
|
case .notDetermined: return "Allow"
|
||||||
|
default: return "Open System Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything answering means the permission is there; only a clean sweep of
|
||||||
|
/// failures points at macOS withholding it.
|
||||||
|
private var localNetworkTone: Tone {
|
||||||
|
guard !results.isEmpty else { return .unknown }
|
||||||
|
return results.contains(where: \.isReachableHost) ? .good : .bad
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notificationTone: Tone {
|
||||||
|
switch permissions.notifications {
|
||||||
|
case .authorized, .provisional, .ephemeral: return .good
|
||||||
|
case .denied: return .bad
|
||||||
|
default: return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notificationActionTitle: String? {
|
||||||
|
switch permissions.notifications {
|
||||||
|
case .authorized, .provisional, .ephemeral: return nil
|
||||||
|
case .notDetermined: return "Enable"
|
||||||
|
default: return "Open System Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pieces
|
||||||
|
|
||||||
|
enum Tone {
|
||||||
|
case good, bad, unknown
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .good: return Color(nsColor: StatusIcon.accent)
|
||||||
|
case .bad: return Color(nsColor: StatusIcon.danger)
|
||||||
|
case .unknown: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Said out loud for anyone who can't see the dot.
|
||||||
|
var word: String {
|
||||||
|
switch self {
|
||||||
|
case .good: return "allowed"
|
||||||
|
case .bad: return "not allowed"
|
||||||
|
case .unknown: return "not checked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PermissionRow: View {
|
||||||
|
let title: String
|
||||||
|
let tone: Tone
|
||||||
|
let help: String
|
||||||
|
var busy = false
|
||||||
|
var actionTitle: String?
|
||||||
|
var action: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 9) {
|
||||||
|
Circle()
|
||||||
|
.fill(tone.color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.accessibilityHidden(true) // the name carries the status instead
|
||||||
|
Text(title)
|
||||||
|
.accessibilityLabel("\(title): \(tone.word)")
|
||||||
|
Spacer()
|
||||||
|
if busy { ProgressView().controlSize(.small) }
|
||||||
|
if let actionTitle, let action {
|
||||||
|
Button(actionTitle, action: action).disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.help(help)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ServerState: Equatable {
|
||||||
|
case stopped
|
||||||
|
case starting
|
||||||
|
case running
|
||||||
|
case stopping
|
||||||
|
case failed(String)
|
||||||
|
|
||||||
|
var isBusy: Bool { self == .starting || self == .running || self == .stopping }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .stopped: return "Stopped"
|
||||||
|
case .starting: return "Starting…"
|
||||||
|
case .running: return "Running"
|
||||||
|
case .stopping: return "Stopping…"
|
||||||
|
case .failed: return "Problem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Starts, stops and watches over the ProdDash server.
|
||||||
|
|
||||||
|
The shell was built for this: it takes `PRODDASH_LAUNCHER=1` to mean "someone
|
||||||
|
is supervising me", and then exits **75** instead of respawning itself when
|
||||||
|
the admin page applies an update — so a 75 here is a restart request, not a
|
||||||
|
fault. Anything else unexpected is a crash: it gets a backed-off restart, and
|
||||||
|
a burst of fast crashes stops the cycle rather than looping forever.
|
||||||
|
|
||||||
|
Starting the server from this app is also what gives the modules their
|
||||||
|
permissions. macOS hands a child process the *responsible process* of its
|
||||||
|
parent, so node — and the `ltc-capture` tool the Timers module runs — inherit
|
||||||
|
this app bundle's identity, and the microphone and local-network prompts are
|
||||||
|
asked, and remembered, in ProdDash's name. A server started over SSH or from
|
||||||
|
a bare launchd job has no such identity, which is why its LTC input is
|
||||||
|
silently all zeros.
|
||||||
|
*/
|
||||||
|
final class ServerController: ObservableObject {
|
||||||
|
|
||||||
|
@Published private(set) var state: ServerState = .stopped
|
||||||
|
/// One line under the status pill: why it stopped, what to fix.
|
||||||
|
@Published private(set) var message = ""
|
||||||
|
@Published private(set) var port: Int = ProdDash.defaultPort
|
||||||
|
@Published private(set) var shellVersion = ""
|
||||||
|
@Published private(set) var localURL = ""
|
||||||
|
@Published private(set) var networkURLs: [String] = []
|
||||||
|
@Published private(set) var settingsDir = ""
|
||||||
|
/// The server answered /api/modules — listening, not merely alive.
|
||||||
|
@Published private(set) var healthy = false
|
||||||
|
@Published private(set) var rootURL: URL?
|
||||||
|
@Published private(set) var nodeURL: URL?
|
||||||
|
@Published private(set) var nodeVersion = ""
|
||||||
|
|
||||||
|
/// Asked when the port is already taken: (port, looks like ProdDash, reply).
|
||||||
|
var onPortBusy: ((Int, Bool, @escaping (Bool) -> Void) -> Void)?
|
||||||
|
|
||||||
|
private let settings: LauncherSettings
|
||||||
|
private let log = LogStore.shared
|
||||||
|
|
||||||
|
private var child: ChildProcess?
|
||||||
|
private var intentionalStop = false
|
||||||
|
private var restartAfterExit = false
|
||||||
|
private var startedAt: Date?
|
||||||
|
private var crashTimes: [Date] = []
|
||||||
|
private var pendingRestart: DispatchWorkItem?
|
||||||
|
private var healthTimer: Timer?
|
||||||
|
private var healthMisses = 0
|
||||||
|
private var openedDashboardThisRun = false
|
||||||
|
/// The most recent line that reads like a reason, shown with a failure.
|
||||||
|
private var lastProblemLine = ""
|
||||||
|
|
||||||
|
init(settings: LauncherSettings) {
|
||||||
|
self.settings = settings
|
||||||
|
refreshEnvironment()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Environment
|
||||||
|
|
||||||
|
/// 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() {
|
||||||
|
// 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 {
|
||||||
|
nodeURL = node
|
||||||
|
nodeVersion = node.map(ProdDash.nodeVersion) ?? ""
|
||||||
|
}
|
||||||
|
if let root {
|
||||||
|
let version = ProdDash.shellVersion(root: root)
|
||||||
|
if !version.isEmpty { shellVersion = version }
|
||||||
|
}
|
||||||
|
if !state.isBusy {
|
||||||
|
port = ProdDash.port(root: root, dataDir: dataDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataDir: URL { ProdDash.dataDir(root: rootURL) }
|
||||||
|
var dashboardURL: URL { URL(string: "http://localhost:\(port)/")! }
|
||||||
|
var adminURL: URL { URL(string: "http://localhost:\(port)/admin")! }
|
||||||
|
|
||||||
|
// MARK: - Start
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !state.isBusy else { return }
|
||||||
|
pendingRestart?.cancel()
|
||||||
|
pendingRestart = nil
|
||||||
|
refreshEnvironment()
|
||||||
|
|
||||||
|
guard let root = rootURL else {
|
||||||
|
return fail("Can't find the ProdDash folder. Choose it under Settings.")
|
||||||
|
}
|
||||||
|
guard let node = nodeURL else {
|
||||||
|
return fail("Node.js 18 or newer isn't installed. Install it from nodejs.org, or set the path under Settings.")
|
||||||
|
}
|
||||||
|
if ProdDash.nodeIsTooOld(nodeVersion) {
|
||||||
|
return fail("This machine's node is \(nodeVersion) — ProdDash needs 18 or newer.")
|
||||||
|
}
|
||||||
|
|
||||||
|
state = .starting
|
||||||
|
message = ""
|
||||||
|
localURL = ""
|
||||||
|
networkURLs = []
|
||||||
|
healthy = false
|
||||||
|
healthMisses = 0
|
||||||
|
openedDashboardThisRun = false
|
||||||
|
lastProblemLine = ""
|
||||||
|
|
||||||
|
let port = self.port
|
||||||
|
// The port probe and the "is that ProdDash?" request both block; keep
|
||||||
|
// them off the main queue so the window stays live.
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
let busy = Net.probeSync(host: "127.0.0.1", port: UInt16(port), timeout: 1.0) == .open
|
||||||
|
let isProdDash = busy && Self.respondsAsProdDash(port: port)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self, self.state == .starting else { return }
|
||||||
|
guard busy else { return self.spawn(root: root, node: node, port: port) }
|
||||||
|
guard let ask = self.onPortBusy else {
|
||||||
|
return self.fail("Port \(port) is already in use.")
|
||||||
|
}
|
||||||
|
ask(port, isProdDash) { takeOver in
|
||||||
|
guard takeOver else {
|
||||||
|
self.state = .stopped
|
||||||
|
self.message = "Port \(port) is already in use."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.takeOverPort(port)
|
||||||
|
self.spawn(root: root, node: node, port: port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spawn(root: URL, node: URL, port: Int) {
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["PRODDASH_LAUNCHER"] = "1" // "someone is supervising me" — see the note above
|
||||||
|
env["PORT"] = String(port)
|
||||||
|
// A GUI app inherits almost no PATH. Put node's own folder first, and
|
||||||
|
// keep the usual system ones so the Timers module can still find swiftc
|
||||||
|
// when it needs to build its capture tool.
|
||||||
|
let base = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
env["PATH"] = node.deletingLastPathComponent().path + ":" + base + ":/usr/local/bin:/opt/homebrew/bin"
|
||||||
|
|
||||||
|
log.launcher("starting: \(node.path) server.js (in \(root.path), port \(port))")
|
||||||
|
do {
|
||||||
|
child = try ChildProcess.run(
|
||||||
|
executable: node.path,
|
||||||
|
arguments: ["server.js"],
|
||||||
|
directory: root.path,
|
||||||
|
environment: env,
|
||||||
|
onLine: { [weak self] line, isError in self?.handleOutput(line, isError: isError) },
|
||||||
|
onExit: { [weak self] code, bySignal in self?.handleExit(code: code, bySignal: bySignal) })
|
||||||
|
intentionalStop = false
|
||||||
|
startedAt = Date()
|
||||||
|
startHealthPolling()
|
||||||
|
} catch {
|
||||||
|
fail(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the port over from a ProdDash somebody started another way. The
|
||||||
|
/// server we are about to start waits for the port to free up on its own.
|
||||||
|
private func takeOverPort(_ port: Int) {
|
||||||
|
let pids = Net.listenerPIDs(port: port)
|
||||||
|
guard !pids.isEmpty else { return }
|
||||||
|
log.launcher("taking port \(port) over from pid \(pids.map(String.init).joined(separator: ", "))")
|
||||||
|
for pid in pids { kill(pid, SIGTERM) }
|
||||||
|
Thread.sleep(forTimeInterval: 0.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stop / restart
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
pendingRestart?.cancel()
|
||||||
|
pendingRestart = nil
|
||||||
|
guard let child else {
|
||||||
|
state = .stopped
|
||||||
|
return
|
||||||
|
}
|
||||||
|
intentionalStop = true
|
||||||
|
state = .stopping
|
||||||
|
message = ""
|
||||||
|
log.launcher("stopping ProdDash")
|
||||||
|
child.terminate()
|
||||||
|
let pid = child.pid
|
||||||
|
// The server closes in about a second and a half; this is the backstop.
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
|
||||||
|
guard let self, let current = self.child, current.pid == pid, current.isAlive else { return }
|
||||||
|
self.log.launcher("ProdDash didn't stop on its own after 8s — forcing it")
|
||||||
|
current.forceKill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restart() {
|
||||||
|
guard child != nil else { return start() }
|
||||||
|
restartAfterExit = true
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle() {
|
||||||
|
switch state {
|
||||||
|
case .running, .starting: stop()
|
||||||
|
case .stopped, .failed: start()
|
||||||
|
case .stopping: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRestart(after seconds: TimeInterval) {
|
||||||
|
let work = DispatchWorkItem { [weak self] in self?.start() }
|
||||||
|
pendingRestart = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fail(_ reason: String) {
|
||||||
|
state = .failed(reason)
|
||||||
|
message = reason
|
||||||
|
log.launcher(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Child output
|
||||||
|
|
||||||
|
private func handleOutput(_ line: String, isError: Bool) {
|
||||||
|
log.append(line, kind: isError ? .err : .out)
|
||||||
|
if line.contains("cannot start") || line.contains("Error:") || line.contains("EADDRINUSE") {
|
||||||
|
lastProblemLine = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
parseBanner(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The server prints a short banner once it is listening:
|
||||||
|
|
||||||
|
ProdDash 1.3.0
|
||||||
|
Local : http://localhost:24500
|
||||||
|
Network : http://10.0.1.42:24500
|
||||||
|
Modules : clock, prodcom-transcript, …
|
||||||
|
Settings : /Users/booth/Library/Application Support/ProdDash
|
||||||
|
|
||||||
|
Reading it is how the launcher learns it is really up (before the health
|
||||||
|
poll gets there) and what to show in the window.
|
||||||
|
*/
|
||||||
|
private func parseBanner(_ line: String) {
|
||||||
|
let text = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if text.hasPrefix("ProdDash "), text.dropFirst(9).first?.isNumber == true {
|
||||||
|
shellVersion = String(text.dropFirst(9)).trimmingCharacters(in: .whitespaces)
|
||||||
|
if state == .starting {
|
||||||
|
state = .running
|
||||||
|
message = ""
|
||||||
|
log.launcher("ProdDash \(shellVersion) is up on port \(port)")
|
||||||
|
if settings.openDashboardOnStart { openDashboard() }
|
||||||
|
openedDashboardThisRun = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let colon = text.firstIndex(of: ":") else { return }
|
||||||
|
let field = text[text.startIndex..<colon].trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||||
|
switch field {
|
||||||
|
case "Local": localURL = value
|
||||||
|
case "Network": if !networkURLs.contains(value) { networkURLs.append(value) }
|
||||||
|
case "Settings": settingsDir = value
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Exit
|
||||||
|
|
||||||
|
private func handleExit(code: Int32, bySignal: Bool) {
|
||||||
|
stopHealthPolling()
|
||||||
|
child = nil
|
||||||
|
healthy = false
|
||||||
|
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
||||||
|
|
||||||
|
if restartAfterExit {
|
||||||
|
restartAfterExit = false
|
||||||
|
state = .stopped
|
||||||
|
start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if intentionalStop {
|
||||||
|
state = .stopped
|
||||||
|
message = "Stopped."
|
||||||
|
log.launcher("ProdDash stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 75 is the shell's "start me again" — the admin page applied an update.
|
||||||
|
if !bySignal && code == 75 {
|
||||||
|
log.launcher("ProdDash applied an update and asked to be restarted")
|
||||||
|
state = .stopped
|
||||||
|
message = "Restarting to apply an update…"
|
||||||
|
start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let how = bySignal ? "was killed by signal \(code)" : "exited with code \(code)"
|
||||||
|
let detail = lastProblemLine.isEmpty ? "" : " — \(lastProblemLine)"
|
||||||
|
crashTimes.append(Date())
|
||||||
|
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||||
|
|
||||||
|
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||||
|
let why = settings.restartOnCrash
|
||||||
|
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||||
|
: "ProdDash \(how)."
|
||||||
|
let last = lastProblemLine.isEmpty ? "" : " Last message: \(lastProblemLine)"
|
||||||
|
fail(why + last)
|
||||||
|
Notifier.shared.post(title: "ProdDash stopped", body: why)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let delay = [1.0, 2.0, 5.0, 10.0][min(crashTimes.count - 1, 3)]
|
||||||
|
state = .stopped
|
||||||
|
message = "ProdDash \(how) — restarting…"
|
||||||
|
log.launcher("ProdDash \(how)\(detail); restarting in \(Int(delay))s")
|
||||||
|
Notifier.shared.post(title: "ProdDash stopped", body: "It \(how)\(detail). Restarting…")
|
||||||
|
scheduleRestart(after: delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Health
|
||||||
|
|
||||||
|
private func startHealthPolling() {
|
||||||
|
healthTimer?.invalidate()
|
||||||
|
let timer = Timer(timeInterval: 3, repeats: true) { [weak self] _ in self?.pollHealth() }
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
healthTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopHealthPolling() {
|
||||||
|
healthTimer?.invalidate()
|
||||||
|
healthTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pollHealth() {
|
||||||
|
let port = self.port
|
||||||
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||||
|
let ok = Self.respondsAsProdDash(port: port)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self, self.child != nil else { return }
|
||||||
|
if ok {
|
||||||
|
self.healthMisses = 0
|
||||||
|
self.healthy = true
|
||||||
|
if self.state == .starting {
|
||||||
|
self.state = .running
|
||||||
|
if self.settings.openDashboardOnStart, !self.openedDashboardThisRun {
|
||||||
|
self.openedDashboardThisRun = true
|
||||||
|
self.openDashboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.healthMisses += 1
|
||||||
|
if self.healthMisses >= 2 { self.healthy = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does something on this port answer ProdDash's own unauthenticated
|
||||||
|
/// module list? Blocking — background queues only.
|
||||||
|
static func respondsAsProdDash(port: Int) -> Bool {
|
||||||
|
guard let url = URL(string: "http://127.0.0.1:\(port)/api/modules") else { return false }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 2
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var ok = false
|
||||||
|
URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||||
|
defer { semaphore.signal() }
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
|
||||||
|
let data, let body = String(data: data, encoding: .utf8) else { return }
|
||||||
|
ok = body.contains("\"modules\"")
|
||||||
|
}.resume()
|
||||||
|
_ = semaphore.wait(timeout: .now() + 3)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings ProdDash owns
|
||||||
|
|
||||||
|
/// Write the port where ProdDash reads it, then restart if it is running.
|
||||||
|
func applyPort(_ newPort: Int) {
|
||||||
|
guard ProdDash.isValidPort(newPort) else {
|
||||||
|
message = "\(newPort) isn't a usable port number."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard newPort != port else { return }
|
||||||
|
do {
|
||||||
|
try ProdDash.setPort(newPort, dataDir: dataDir)
|
||||||
|
port = newPort
|
||||||
|
log.launcher("port set to \(newPort)")
|
||||||
|
if child != nil { restart() }
|
||||||
|
} catch {
|
||||||
|
message = "Couldn't save the port: \(error.localizedDescription)"
|
||||||
|
log.launcher(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Opening things
|
||||||
|
|
||||||
|
func openDashboard() { NSWorkspace.shared.open(dashboardURL) }
|
||||||
|
func openAdmin() { NSWorkspace.shared.open(adminURL) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/**
|
||||||
|
The menu-bar glyph: ProdDash's own icon — a 2×2 grid of tiles — with the
|
||||||
|
accent tile carrying the state, exactly as the app icon does. Filled and
|
||||||
|
green while it serves; hollow and dim when stopped; amber mid-move; red when
|
||||||
|
something needs a person.
|
||||||
|
|
||||||
|
Drawn rather than templated because a template image can only be one colour.
|
||||||
|
The outline uses `labelColor`, so it follows a light or dark menu bar; the
|
||||||
|
status item redraws it when the appearance changes.
|
||||||
|
*/
|
||||||
|
enum StatusIcon {
|
||||||
|
|
||||||
|
static let accent = NSColor(srgbRed: 0.18, green: 0.90, blue: 0.60, alpha: 1) // #2ee59a, the shell's --accent
|
||||||
|
static let warn = NSColor(srgbRed: 0.94, green: 0.71, blue: 0.16, alpha: 1) // #f0b429
|
||||||
|
static let danger = NSColor(srgbRed: 0.96, green: 0.26, blue: 0.24, alpha: 1) // #f4433c
|
||||||
|
|
||||||
|
static func color(for state: ServerState, healthy: Bool) -> NSColor {
|
||||||
|
switch state {
|
||||||
|
case .running: return healthy ? accent : warn
|
||||||
|
case .starting, .stopping: return warn
|
||||||
|
case .failed: return danger
|
||||||
|
case .stopped: return NSColor.secondaryLabelColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func image(for state: ServerState, healthy: Bool) -> NSImage {
|
||||||
|
let side: CGFloat = 18
|
||||||
|
let fill: NSColor? = {
|
||||||
|
switch state {
|
||||||
|
case .stopped: return nil // nothing lit: plainly off
|
||||||
|
default: return color(for: state, healthy: healthy)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { _ in
|
||||||
|
let inset: CGFloat = 1.5
|
||||||
|
let gap: CGFloat = 1.6
|
||||||
|
let tile = (side - inset * 2 - gap) / 2
|
||||||
|
let outline = NSColor.labelColor.withAlphaComponent(state == .stopped ? 0.55 : 0.9)
|
||||||
|
|
||||||
|
for row in 0..<2 {
|
||||||
|
for column in 0..<2 {
|
||||||
|
let rect = NSRect(x: inset + CGFloat(column) * (tile + gap),
|
||||||
|
y: inset + CGFloat(row) * (tile + gap),
|
||||||
|
width: tile, height: tile)
|
||||||
|
// Bottom-right is the accent tile, as on the app icon.
|
||||||
|
let isAccent = (row == 0 && column == 1)
|
||||||
|
let path = NSBezierPath(roundedRect: rect.insetBy(dx: 0.6, dy: 0.6), xRadius: 2, yRadius: 2)
|
||||||
|
if isAccent, let fill {
|
||||||
|
fill.setFill()
|
||||||
|
path.fill()
|
||||||
|
} else {
|
||||||
|
outline.setStroke()
|
||||||
|
path.lineWidth = 1.3
|
||||||
|
path.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
image.isTemplate = false
|
||||||
|
image.accessibilityDescription = "ProdDash — \(state.label)"
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Notifications about the server, for a booth machine nobody is watching.
|
||||||
|
/// Quiet by design: only a stop nobody asked for is worth interrupting anyone.
|
||||||
|
final class Notifier {
|
||||||
|
|
||||||
|
static let shared = Notifier()
|
||||||
|
|
||||||
|
private var authorized = false
|
||||||
|
private var asked = false
|
||||||
|
|
||||||
|
private var available: Bool { Bundle.main.bundleIdentifier != nil }
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// Ask once, when the operator turns the setting on.
|
||||||
|
func requestAuthorization(_ completion: ((Bool) -> Void)? = nil) {
|
||||||
|
guard available else { return completion?(false) ?? () }
|
||||||
|
asked = true
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.authorized = granted
|
||||||
|
completion?(granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshStatus(_ completion: @escaping (UNAuthorizationStatus) -> Void) {
|
||||||
|
guard available else { return completion(.denied) }
|
||||||
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.authorized = settings.authorizationStatus == .authorized
|
||||||
|
completion(settings.authorizationStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posting never asks for permission: a prompt that appears because
|
||||||
|
/// something just failed, unattended, is the worst possible moment for it.
|
||||||
|
/// It is asked for from the Permissions tab; until then this does nothing.
|
||||||
|
func post(title: String, body: String) {
|
||||||
|
guard available, authorized else { return }
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Open ProdDash at login", through the modern (macOS 13+) login-item API.
|
||||||
|
/// The operator may still have to approve it in System Settings — hence
|
||||||
|
/// `needsApproval`, which the window surfaces rather than silently failing.
|
||||||
|
enum LoginItem {
|
||||||
|
|
||||||
|
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
|
||||||
|
static var needsApproval: Bool { SMAppService.mainApp.status == .requiresApproval }
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func set(_ enabled: Bool) -> String? {
|
||||||
|
do {
|
||||||
|
if enabled {
|
||||||
|
if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() }
|
||||||
|
} else {
|
||||||
|
if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func openSystemSettings() {
|
||||||
|
SMAppService.openSystemSettingsLoginItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SystemSettings {
|
||||||
|
/// Deep links into the Privacy & Security panes. Opening the app itself is
|
||||||
|
/// the fallback when a pane identifier stops working on a later macOS.
|
||||||
|
static func open(_ pane: String) {
|
||||||
|
guard let url = URL(string: pane) else { return }
|
||||||
|
if !NSWorkspace.shared.open(url) {
|
||||||
|
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Settings.app"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let microphone = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
||||||
|
static let localNetwork = "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork"
|
||||||
|
static let notifications = "x-apple.systempreferences:com.apple.Notifications-Settings.extension"
|
||||||
|
static let privacy = "x-apple.systempreferences:com.apple.preference.security?Privacy"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// ProdDash Launcher — a menu-bar app, so no Dock icon and no app menu
|
||||||
|
// (LSUIElement in Info.plist says the same thing to Launch Services).
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
let application = NSApplication.shared
|
||||||
|
application.delegate = delegate
|
||||||
|
application.setActivationPolicy(.accessory)
|
||||||
|
application.run()
|
||||||
Executable
+222
@@ -0,0 +1,222 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
# Build ProdDash.app — the whole application: launcher, server and its own
|
||||||
|
# Node runtime, with nothing to install first.
|
||||||
|
#
|
||||||
|
# ./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 --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.
|
||||||
|
#
|
||||||
|
# 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"
|
||||||
|
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 ;;
|
||||||
|
--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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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 "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
SOURCE_ICON="$REPO_ROOT/public/icons/icon-512.png"
|
||||||
|
if [[ -f "$SOURCE_ICON" ]]; then
|
||||||
|
echo " building the icon"
|
||||||
|
ICONSET="$OUT_DIR/AppIcon.iconset"
|
||||||
|
mkdir -p "$ICONSET"
|
||||||
|
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
|
||||||
|
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 "$CONTENTS/Resources/AppIcon.icns"
|
||||||
|
rm -rf "$ICONSET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 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"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
DEST="/Applications/$APP_NAME.app"
|
||||||
|
osascript -e "tell application \"$APP_NAME\" to quit" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
rm -rf "$DEST"
|
||||||
|
cp -R "$APP" "$DEST"
|
||||||
|
echo "installed $DEST"
|
||||||
|
APP="$DEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ $RUN -eq 1 ]] && open "$APP"
|
||||||
|
exit 0
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript stream",
|
"description": "Live ProdCom transcript stream",
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "local-network", "reason": "Reaching the ProdCom API on this network" }
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-now-next",
|
"id": "propresenter-now-next",
|
||||||
"name": "ProPresenter",
|
"name": "ProPresenter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "What is live in ProPresenter and what comes next",
|
"description": "What is live in ProPresenter and what comes next",
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.2.0",
|
"proddash": ">=1.2.0",
|
||||||
"description": "Live ProPresenter timers and LTC timecode",
|
"description": "Live ProPresenter timers and LTC timecode",
|
||||||
|
"permissions": [
|
||||||
|
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
|
||||||
|
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
|
||||||
|
],
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
"server": "server.js",
|
"server": "server.js",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
|
|||||||
Reference in New Issue
Block a user