midi-harbor/specs/014-mac-app-store-mode/research.md
James Coleman f059eaef65 docs(specs): drop the spec names used before the renumbering
- The specs index no longer carries a table mapping the three original spec names to the renumbered ones, and spec headers no longer record a feature branch or "first written as" name.
- A task that pointed at research under the old 001 directory now points at its current path, so every reference resolves to a spec that exists.
- The constitution's 1.4.1 amendment still records that the specs were split and renumbered, without listing the retired names.
2026-09-29 14:35:33 -05:00

253 lines
15 KiB
Markdown

# Research: Mac App Store mode
**Feature**: 014-mac-app-store-mode | **Date**: 2026-09-27
Numbered after the Windows port's research (013), whose last entry is R-093. Findings marked **VERIFIED**
were measured on the development Mac (macOS 15, Darwin 24.6, Apple silicon) with the app
bundle from `packaging/macos/build.sh`, re-signed ad hoc with the entitlements below. Scratch
bundles lived in `target/sbx/`.
---
## R-094: How the app knows it is sandboxed, and where the sandbox puts things
**Status**: **VERIFIED** (2026-09-27).
**Decision**: App Store mode is on exactly when `APP_SANDBOX_CONTAINER_ID` is set. The
configuration, the socket and every other path keep coming from the same resolution as today,
which the sandbox already redirects into the container.
**Evidence**: A probe run as the executable of a bundle signed with
`com.apple.security.app-sandbox`, launched from Terminal and through `open`, printed:
```text
APP_SANDBOX_CONTAINER_ID=com.mrgeckosmedia.MidiHarbor
HOME=/Users/user/Library/Containers/com.mrgeckosmedia.MidiHarbor/Data
TMPDIR=/Users/user/Library/Containers/com.mrgeckosmedia.MidiHarbor/Data/tmp/
```
Unsandboxed, the variable is unset and `HOME` and `TMPDIR` are the user's own. The real binary,
signed the same way, reported its configuration at
`~/Library/Containers/com.mrgeckosmedia.MidiHarbor/Data/Library/Application Support/midi-harbor/config.yaml`
with no code change, so the direct-download build and the App Store build never share a setup.
A standalone executable signed with the sandbox entitlement and no bundle dies at launch with
SIGTRAP (exit 133), so the mode only ever arises inside the app bundle.
**Rejected**: Reading the entitlement back with `SecTaskCopyValueForEntitlement`. It answers the
same question through Security framework FFI, where the environment variable is set by the
sandbox itself before `main`.
---
## R-095: The daemon runs as a helper the app starts, not inside the app
**Status**: **VERIFIED** (2026-09-27); decided with the owner.
**Decision**: The App Store bundle carries a second executable,
`Contents/MacOS/midi-harbor-daemon`, a headless build of the same program signed with
`com.apple.security.app-sandbox` and `com.apple.security.inherit`. The app starts it as
`midi-harbor-daemon daemon`, restarts it after a failure with the supervisor's pacing, and stops
it when the user quits Midi Harbor entirely. A daemon already answering on the socket, left
running by a window that crashed, is used rather than started again.
**Evidence**: A sandboxed app that `posix_spawn`s its own executable sees the child die with
SIGTRAP before `main`: a process carrying the sandbox entitlement cannot start inside another
sandbox. The same parent spawning a copy signed with `app-sandbox` and `inherit` ran it to
completion, inside the parent's container:
```text
parent sandbox=com.mrgeckosmedia.MidiHarbor
child sandbox=com.mrgeckosmedia.MidiHarbor tmp=/Users/user/Library/Containers/com.mrgeckosmedia.MidiHarbor/Data/tmp/
```
**Why a helper**: Constitution Principle II requires the daemon to run independently of the GUI
process, so that a window that crashes does not drop a connection. The owner first asked for the
daemon inside the app, and chose the helper once the conflict and the tested alternative were
put to them. With the helper, a libcosmic or wgpu crash takes the window and leaves every port
and session running, and relaunching the app finds the daemon answering. Recovery from a dead
MIDI server (R-079) keeps working as today: the daemon replaces itself with `exec`, inside the
same sandbox, without the window going anywhere.
**Stopping a daemon the app did not start**: through the contract's `StopDaemon`, added for it
in protocol 1.1. The plan was to find the daemon's process from the socket and signal it:
`getsockopt(SOL_LOCAL, LOCAL_PEERPID)` on a connection to the daemon's socket did return 64149,
the process identifier `pgrep` gave for the development Mac's running daemon. But the sandbox
forbids that signal. **VERIFIED** (2026-09-27): the sandboxed app, relaunched after `kill -9` of an
earlier instance, attached to the daemon that instance had started, and on Quit left it running;
the same daemon stopped at once on SIGTERM from outside the sandbox. A sandboxed app may signal
the helper it starts, which the supervisor does, but not one it merely found. The owner chose a
stop request over the socket, already limited to the user, over a stop file in the container or a
daemon that dies with its app, which would break Principle II.
**Why headless**: the helper never draws, and the headless build is about 14 MB per architecture
against 36 MB for the full one, with none of the GUI toolkit loaded into the process that carries
MIDI.
**Rejected**: The daemon in the app's process, which the owner first described. It breaks
Principle II, and recovering from a dead MIDI server would mean re-executing the whole app,
window included. A launchd agent registered with `SMAppService.agent`: it would outlive the app
after the user quits, which App Review guideline 2.4.5(iii) forbids without separate consent,
and it adds an "Allow in the Background" approval for no benefit over the helper.
---
## R-096: The socket's path in the container is close to the limit
**Status**: **VERIFIED** (2026-09-27).
**Decision**: Sandboxed, the socket is `$TMPDIR/daemon.sock`, in the container's own `tmp`,
without the `midi-harbor` directory the unsandboxed build uses inside the shared temporary
directory. The container is already private to Midi Harbor, so the directory adds nothing.
**Evidence**: macOS limits a socket path to 103 bytes. The standard layout in the container is
`/Users/<name>/Library/Containers/com.mrgeckosmedia.MidiHarbor/Data/tmp/midi-harbor/daemon.sock`,
97 bytes for the development account's nine-letter name, so any account name over 15 characters
could not bind it. Without the directory the limit is 27 characters. (Measured when the
identifier was `dev.midiharbor.MidiHarbor`, three bytes shorter; the figures are for the current
one.) The sandboxed daemon bound
and served the standard layout on this Mac, with the command line reaching it from the same
sandbox.
---
## R-097: What works inside the sandbox
**Status**: **VERIFIED** (2026-09-27) for everything but Bluetooth and hardware, which are
checked from the app in quickstart.md.
The real daemon, signed with the entitlements in R-100 and run from Terminal, with the command
line run from the same bundle:
- Created a virtual port "SBX Test"; an unsandboxed CoreMIDI client listed it as a source and a
destination.
- Created a network port "SBX Net", which bound UDP port 60868 and was advertised: `dns-sd -B
_apple-midi._udp` on the same Mac listed it on four interfaces.
- `network discover` found four machines on the LAN, so browsing on raw multicast works.
- `capabilities` reported every capability available, including "service installation", which
App Store mode must report unavailable (FR-A03).
- SIGTERM stopped it through its graceful shutdown, and the port left CoreMIDI.
**Decision**: "service installation" is reported unavailable in App Store mode with the existing
reason `NotBuilt`, and `service` commands exit with the existing code 4 and a message naming
"Start at login". Neither needs a new variant, so neither changes the contract.
Bluetooth was not started from Terminal, since its permission prompt belongs to the app.
---
## R-098: The menu bar item, the Dock icon, the menu and quitting
**Status**: **VERIFIED** (2026-09-27) running, but for the launch at login, which needs the owner
(R-099).
**Decisions**:
- **The Dock icon** follows the window through `NSApplication.setActivationPolicy`: `.regular`
while the window is open, `.accessory` while it is closed. The App Store variant's Info.plist
sets `LSUIElement`, so a login launch with the window closed never shows a Dock icon, even
briefly; opening the window switches to `.regular` and activates the app.
- **The window** is hidden rather than destroyed when closed. libcosmic's
`Settings::exit_on_close(false)` stops a close request from exiting (it otherwise returns
`iced::exit` for the main window, `src/app/cosmic.rs` line 1236), and the app's
`on_close_requested` hides the window. Reopening shows it again with its state intact, which is
what makes SC-A02's one second easy to meet.
- **The menu bar menu** replaces winit's default menu, whose Quit item sends `terminate:` on
Command-Q (`winit-appkit/src/menu.rs`). The app installs its own `NSMenu` once the event loop
runs: the app menu with About, "Close to Menu Bar" on Command-Q, Hide on Command-H, and "Quit
Midi Harbor" on Command-Option-Q, which sends `terminate:`; and a Window menu with Minimize and
Close. Installed from the window's `init`, it was replaced: `init` runs before the event loop,
and winit builds its default menu as launching finishes. It is installed from the first message
the window handles instead.
- **Command-Shift-Q**, which the owner first asked for, is macOS's Log Out, in the Apple menu, and
the Apple menu takes it before the app's menu does. Sent to the running app, it opened the "quit
all applications and log out" dialog, which was cancelled. The owner chose Command-Option-Q.
- **The menu bar item** is an `NSStatusItem` with a menu of "Open Midi Harbor" and "Quit Midi
Harbor". Its image is the anchor from the app icon alone, drawn as a template image: a template
keeps only the alpha, and the full icon's opaque tile would be a plain square.
- **Quitting** goes through `terminate:` whatever starts it (the menu, the menu bar item, logout,
restart, shutdown). The app sets its own `NSApplicationDelegate`, which winit 0.31 leaves free
(it listens for launch and termination through notifications, `event_loop.rs` lines 200 to
227), and answers `applicationShouldTerminate:` with `NSTerminateLater` until the helper has
stopped, so releasing held notes is never skipped. The same delegate answers
`applicationShouldHandleReopen:hasVisibleWindows:`, which is how a second launch from Finder,
and a click on the Dock icon, reach the running app (FR-A11).
- **Actions** from AppKit reach the window as messages: the Objective-C target of each menu item
sends on a channel that an iced subscription reads, so nothing in AppKit calls into the window's
state directly.
**Evidence**, the sandboxed debug variant on the development Mac, driven through System Events:
- Launched from Finder, it started its helper on `…/Data/tmp/daemon.sock`, showed the window with
the Dock icon (`lsappinfo`: `Foreground`), and listed "Open Midi Harbor" and "Quit Midi Harbor"
in its menu bar item.
- Command-Q left the app and the daemon running, with no window and no Dock icon (`UIElement`).
"Open Midi Harbor" brought the window back in 0.49 s, measured from the click with AppleScript's
own delay included, with its content current and the Dock icon back.
- `kill -9` of the app left the daemon running; relaunching attached to the same daemon.
- Opening the app from Finder while it ran with its window closed brought the window back and
started no second process.
- The menu read back as contracts/menus.md, Quit on Command-Option-Q (`AXMenuItemCmdModifiers` 2).
Quit from either menu ended the app and a daemon it started in 2.2 s, and, with the helper built
for protocol 1.1, a daemon it found running in 2.2 s too. `window.yaml` recorded `open: true`.
All of the AppKit and ServiceManagement calls live in `crates/platform`, the only crate where
`unsafe` is allowed. The window calls a safe API there.
---
## R-099: Starting at login, and knowing that it did
**Status**: **VERIFIED** (2026-09-27).
**Decision**: "Start at login" registers and unregisters `SMAppService.mainApp`. Its status is
read back from macOS every time it is shown, never stored. `.enabled` shows on,
`.requiresApproval` shows waiting with a pointer to System Settings > General > Login Items, and
both `.notRegistered` and `.notFound` show off, with `register()` deciding whether it can be
turned on.
A launch at login is recognised from the launch Apple event: its `keyAEPropData` is
`keyAELaunchedAsLogInItem`. The app reads it in `applicationDidFinishLaunching:` on its own
delegate, the only place the event is current.
**Evidence**: A sandboxed bundle signed ad hoc read `SMAppService.mainApp.status` as `.notFound`,
from Terminal and through `open`, before ever registering. Apps on current macOS report the same
for a correctly installed copy that has never registered, and treating `.notFound` as unavailable
leaves the switch stuck off, since `register()` is the only way out of that state
([martonpaulo/mailbell#49](https://github.com/martonpaulo/mailbell/issues/49),
[Apple's documentation of `notFound`](https://developer.apple.com/documentation/servicemanagement/smappservice/status-swift.enum/notfound?language=objc)).
The login-launch check is the one used with `SMAppService.mainApp`
([hisaac.net](https://hisaac.net/blog/how-to-detect-if-your-macos-app-was-launched-as-a-login-item/),
[kernova#1129](https://github.com/nicholas-lonsinger/kernova/issues/1129)).
**Open**: Registering from an ad hoc signed sandboxed build, and the login-launch check at a real
login, are verified with the owner (quickstart.md, scenario 5).
---
## R-100: The App Store variant of the bundle
**Status**: **VERIFIED** (2026-09-27) for the entitlements in R-094 to R-097.
**Decision**: `packaging/macos/build.sh --app-store` builds the variant on a Mac: the full binary
as the app and the headless binary as `midi-harbor-daemon`, both universal when both Rust targets
are installed. It is not built by GoReleaser, since an App Store upload needs Apple's own signing
and packaging tools, which run on a Mac. It differs from the direct-download bundle in:
| | App | Helper |
|---|---|---|
| `com.apple.security.app-sandbox` | yes | yes |
| `com.apple.security.inherit` | | yes |
| `com.apple.security.network.client` | yes | inherited |
| `com.apple.security.network.server` | yes | inherited |
| `com.apple.security.device.bluetooth` | yes | inherited |
| `com.apple.security.device.usb` | yes | inherited |
and in Info.plist, `LSMinimumSystemVersion` 13.0 and `LSUIElement` true. It keeps the bundle
identifier `com.mrgeckosmedia.MidiHarbor`. Signing is ad hoc unless `MIDI_HARBOR_SIGNING_IDENTITY`
names a certificate; a provisioning profile and App Store packaging are the owner's to add.
CoreMIDI needs no entitlement: virtual ports and other applications' ports worked sandboxed with
only the ones above (R-097). USB is declared because the owner asked for USB access; whether
reading a USB MIDI device's serial number and topology through IOKit needs it is checked with
hardware in quickstart.md, and the entitlement is dropped if nothing does.