midi-harbor/specs/014-mac-app-store-mode/spec.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

239 lines
14 KiB
Markdown

# Feature Specification: Mac App Store mode
**Created**: 2026-09-27
**Status**: Implemented
**Input**: User description: "For the Mac side, can we add an option when it determines that its
running in the MacOS container (when ran from mac app store) that it will instead of running the
daemon separate, run it as part of the GUI app and have it change the function to where it hides
its app icon from the doc and moves to the status menu? Also there are no menu items, for this I
think it would change cmd-q to quit to status menu, and cmd-shift-q to quit entirely. For on boot,
instead of launchd it'll ask for a login item to be created and remember the state of the gui
being open or closed when it was quit. The status menu would allow choosing quit or open GUI, when
the gui is up it should restore the dock icon."
Midi Harbor on macOS is a daemon registered with launchd plus a window that is one of its clients.
An app distributed through the Mac App Store runs inside the App Sandbox, where an app cannot
register its own launchd agent. This feature gives that build its own way of running: the app
starts and stops the daemon itself, the app lives in the menu bar while its window is closed, and
it starts at login as a login item. Everything in specs 001 to 013 applies unchanged to the direct-download
build and to Linux and Windows.
**Decided 2026-09-27**: the mode is for the App Store build only, and is chosen by the app
detecting that it runs sandboxed, not by a setting. The App Store build requires macOS 13 or
newer, the first release where a sandboxed app can register itself as a login item without a
helper app; the direct-download build keeps macOS 11. The project adds a sandboxed build variant,
signed ad hoc, so the mode can be run and tested before the owner has App Store signing.
**Decided 2026-09-27, in implementation**: quitting entirely is Command-Option-Q, not
Command-Shift-Q as first asked. Command-Shift-Q is macOS's own Log Out shortcut in the Apple menu,
which takes precedence over any app's menu (research R-098).
**Decided 2026-09-27, in planning**: the daemon stays a separate process, which the app starts
from a copy of the program bundled for it, rather than running inside the app's own process. The
owner first asked for it inside the app; a crash in the window would then drop every connection,
which Principle II forbids, and the sandbox refuses to start the app's own executable as a second
process, so the copy is what makes a separate daemon possible (research R-095).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Connections keep running from the menu bar (Priority: P1)
A musician installs Midi Harbor from the App Store, opens it, sets up a virtual port and a network
session, and closes the window. The ports and sessions keep working, Midi Harbor's icon leaves the
Dock, and a Midi Harbor item stays in the menu bar. Choosing "Open Midi Harbor" from it brings the
window and the Dock icon back.
**Why this priority**: With no launchd service, the running app is what keeps the daemon alive.
If closing the window stopped MIDI, the product would lose the property it exists for.
**Independent Test**: Launch the sandboxed build, create a virtual port routed to a network
session with another machine, close the window, and play into the port from another application:
the far machine keeps receiving, the Dock shows no Midi Harbor icon, and the menu bar item reopens
the window with its Dock icon.
**Acceptance Scenarios**:
1. **Given** the sandboxed app is running with its window open, **When** the user closes the
window, **Then** every endpoint and route keeps running, the Dock icon disappears, and the menu
bar item remains.
2. **Given** the window is closed, **When** the user chooses "Open Midi Harbor" from the menu bar
item, **Then** the window opens showing current state and the Dock icon returns.
3. **Given** the window is open, **When** the user chooses "Quit" from the menu bar item, **Then**
the app stops its daemon through its graceful shutdown, releasing sounding notes, and exits.
4. **Given** the sandboxed app starts, **When** it checks for a daemon, **Then** it starts its own,
or uses one already running from before, and never offers to install a launchd service.
---
### User Story 2 - Quitting the window versus quitting Midi Harbor (Priority: P1)
The app has a standard menu bar menu. Command-Q closes the window and leaves Midi Harbor in the
menu bar with every connection running. Command-Option-Q quits Midi Harbor entirely.
**Why this priority**: Command-Q is the reflex for "I'm done with this window". Quitting the
whole app on it would drop every connection by accident.
**Independent Test**: With the window focused, press Command-Q and confirm connections still pass
MIDI and the menu bar item remains; reopen, press Command-Option-Q, and confirm the process has
exited and its ports are gone from other applications.
**Acceptance Scenarios**:
1. **Given** the window is focused, **When** the user presses Command-Q, **Then** the window
closes, the Dock icon disappears, and connections keep running.
2. **Given** the window is focused, **When** the user presses Command-Option-Q, **Then** Midi
Harbor quits entirely, stopping its daemon.
3. **Given** the window is focused, **When** the user opens the app menu, **Then** it lists a
close-to-menu-bar item with Command-Q, a quit item with Command-Option-Q, and the standard Hide
and window items, each with its shortcut shown.
4. **Given** macOS asks the app to quit, at logout, restart or shutdown, **When** the request
arrives, **Then** Midi Harbor quits entirely rather than hiding.
---
### User Story 3 - Starting at login (Priority: P2)
The user turns on "Start at login". After the next login Midi Harbor starts in the menu bar with
its connections restored, and opens its window only if the window was open when Midi Harbor last
quit.
**Why this priority**: A studio machine should bring its MIDI setup back without anyone opening
an app. It comes after P1 because the app is useful, if less convenient, without it.
**Independent Test**: Turn on "Start at login", quit with the window open, log out and in: the
window is open. Quit with the window closed, log out and in: only the menu bar item appears. In
both cases the ports and sessions come back.
**Acceptance Scenarios**:
1. **Given** the sandboxed app is running and not registered to start at login, **When** the user
opens it for the first time, **Then** it offers once to start at login, saying what that does.
2. **Given** the user turns on "Start at login", **When** macOS needs the user's approval for it,
**Then** the app says so and where to approve it, and shows the setting as waiting.
3. **Given** Midi Harbor quit with its window open, **When** it starts at login, **Then** it opens
the window.
4. **Given** Midi Harbor quit with its window closed, **When** it starts at login, **Then** it
shows only the menu bar item.
5. **Given** the user starts Midi Harbor by hand from Finder or Launchpad, **When** it starts,
**Then** it opens the window, whatever state it quit in.
6. **Given** "Start at login" is on, **When** the user turns it off, **Then** Midi Harbor no
longer starts at login.
---
### User Story 4 - A sandboxed build to test with (Priority: P2)
A developer builds the App Store variant on a Mac, signed ad hoc, and runs it to exercise
everything above before any App Store signing exists.
**Why this priority**: The mode cannot be run, and so cannot be proved, without a sandboxed
build.
**Independent Test**: Build the variant, confirm the bundle runs sandboxed, and run the checks in
User Stories 1 to 3 against it.
**Acceptance Scenarios**:
1. **Given** a Mac with the build tools, **When** the developer builds the App Store variant,
**Then** it produces an app bundle that runs sandboxed, declaring only the access Midi Harbor
uses: network client and server, Bluetooth, and USB devices.
2. **Given** the variant runs sandboxed, **When** the user creates virtual ports, joins network
sessions, uses hardware and scans for Bluetooth devices, **Then** each works as in the
direct-download build, or is reported unavailable with the reason.
### Edge Cases
- The daemon cannot start, because its configuration is from a newer build or its socket cannot
be bound: the window says why, and "Quit" still works.
- A second copy of the app is launched while one runs: the running one opens its window and the
second exits, so two daemons never run in one sandbox.
- The direct-download build's launchd daemon is also running on the same Mac: both run with
separate setups and the user sees two sets of ports; neither can manage the other.
- The window is closed while a dialog is open or an action is in flight: the action finishes, and
the window opens on the current state next time.
- The user denies the login item in System Settings: the setting shows it as off, with the
reason.
- macOS sleeps with the window closed: the daemon handles sleep and wake as it does under
launchd.
- A capability the sandbox withholds, such as the MIDI server recovery that needs another
process: it is reported unavailable with the reason, and the rest runs.
- The configuration is kept inside the app's container, so it is not the file the
direct-download build uses, and `midi-harbor config path` run from the app reports where it is.
- The window crashes: the daemon keeps every connection running, and the next launch finds it
and shows its state. The daemon crashes: the app starts it again, as launchd would.
- The MIDI server dies: the daemon replaces itself as it does under launchd (008-resilience), and the
window shows it as unreachable for those seconds.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-A01**: The app MUST decide at launch whether it runs sandboxed, and MUST use App Store mode
exactly when it does. The direct-download build, Linux and Windows MUST behave as before.
- **FR-A02**: In App Store mode the app MUST start the daemon as a separate process of its own,
restart it after a failure, and stop it when Midi Harbor quits entirely. The daemon serves the
same contract on its socket inside the app's container, and the window remains a client of it.
A daemon already running when the app starts MUST be used rather than a second one started.
- **FR-A03**: In App Store mode the app MUST NOT register, start, or offer a launchd service, and
`service` commands MUST say that the App Store build starts through a login item instead, but
for `service stop`, which stops the running daemon as the app's Quit does.
- **FR-A04**: The app MUST show a menu bar item while it runs in App Store mode, with "Open Midi
Harbor" and "Quit".
- **FR-A05**: The app MUST show its Dock icon while its window is open and hide it while the
window is closed.
- **FR-A06**: Closing the window, by its close button or Command-Q, MUST leave every endpoint and
route running.
- **FR-A07**: The app MUST provide a menu bar menu with a close-to-menu-bar item on Command-Q and
a quit item on Command-Option-Q, plus the standard Hide and window items.
- **FR-A08**: Quitting entirely, from the menu bar item, Command-Option-Q, or a logout, restart or
shutdown, MUST stop the daemon through its graceful shutdown, including releasing sounding notes
(008-resilience, FR-026), before the app exits.
- **FR-A09**: The app MUST let the user turn starting at login on and off, MUST offer it once on
first launch, and MUST report when macOS is waiting for the user's approval.
- **FR-A10**: The app MUST remember whether its window was open when it last quit, and when
started at login MUST open the window only if it was; started any other way, it MUST open the
window.
- **FR-A11**: Only one copy of the app MUST run at a time; a second launch MUST bring the first
one's window forward and exit.
- **FR-A12**: A capability the sandbox withholds MUST be reported unavailable with the reason, as
any other unavailable capability is (002-configuration, Principle IV).
- **FR-A13**: The project MUST build a sandboxed App Store variant of the app bundle, signed ad hoc
by default, declaring network client and server, Bluetooth and USB device access and nothing
more, and requiring macOS 13 or newer.
- **FR-A14**: A crash of the window MUST NOT disturb any connection, and relaunching the app MUST
show the daemon's current state (Principle II).
### Key Entities
- **Window state at quit**: whether the window was open when Midi Harbor last quit, kept per user
inside the app's container and read at the next launch.
- **Login item**: the app's registration to start at login, owned by macOS and read back from it
rather than stored by Midi Harbor.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-A01**: With the window closed for 10 minutes, MIDI sent into a virtual port keeps reaching
a network peer with no message lost that the network did not lose.
- **SC-A02**: The window opens from the menu bar item within 1 second, showing current state.
- **SC-A03**: After a login with "Start at login" on, the ports and sessions are back as fast as
the direct-download build's launchd daemon brings them back, and the window is open exactly when
it was open at quit, in every combination tried.
- **SC-A04**: Command-Q never stops a connection, and Command-Option-Q always ends the process, in
every trial.
- **SC-A05**: The direct-download build passes its existing gates unchanged.
## Assumptions
- The App Store build is signed and provisioned by the owner later; this feature proves the mode
with an ad hoc signed sandboxed build.
- Clicking the window's close button behaves like Command-Q, closing to the menu bar.
- Settings gains the "Start at login" switch in App Store mode, in place of the service offer the
direct-download build shows when no daemon runs.
- Command-line use against the App Store build goes through the app's own executable, which runs
in the same sandbox and so reaches the same socket; the plan verifies this.
- The menu bar item uses the app icon drawn as a template image, so it follows the menu bar's
light and dark appearance.