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

8.8 KiB

Implementation Plan: Mac App Store mode

Date: 2026-09-27 | Spec: spec.md

Input: Feature specification from /specs/014-mac-app-store-mode/spec.md

Summary

When the app runs sandboxed, it becomes a menu bar app that owns its daemon: it starts a bundled headless copy of the program as daemon, supervises it, and stops it when the user quits Midi Harbor entirely. Its window hides to the menu bar on close and Command-Q, the Dock icon follows the window, and "Start at login" registers the app as a login item. A new --app-store option of packaging/macos/build.sh builds the sandboxed variant, signed ad hoc. Unsandboxed, nothing changes.

Technical Context

Language/Version: Rust 1.96, edition 2024.

Primary Dependencies: new in crates/platform, macOS only: objc2 0.6, objc2-foundation 0.3, objc2-app-kit 0.3 and objc2-service-management 0.3. The first three are already built for winit at those versions, so only ServiceManagement's bindings are new.

Storage: the window's state at quit, one small file beside the configuration (data-model.md). Everything else stays in the daemon's configuration, which the sandbox moves into the container.

Testing: integration tests drive the real midi-harbor binary with APP_SANDBOX_CONTAINER_ID set, which is all the mode keys on: service commands exit 4 and name "Start at login", capabilities reports service installation unavailable, and the daemon binds $TMPDIR/daemon.sock. The supervisor's new stop is proved against the real daemon, releasing a held note. One unit test maps each SMAppService status onto the switch, since notFound meaning "off" is a fact about macOS (R-099). The AppKit behaviour, the login item and the sandboxed build are proved by hand on this Mac (quickstart.md).

Target Platform: macOS 13 or newer for the App Store variant; macOS 11 for the direct build, unchanged. Linux and Windows unchanged.

Project Type: desktop app and daemon, one binary.

Performance Goals: the window opens from the menu bar within 1 s (SC-A02), which hiding rather than destroying it makes a redraw.

Constraints: unsafe only in crates/platform; the GUI stays a client of the contract; the proto gains only the additive StopDaemon (protocol 1.1); no new FailureReason or exit code.

Scale/Scope: macOS only. About 700 lines of Rust across platform, service, core, gui and main, plus packaging.

Decisions

Area Decision Research
Detecting the mode APP_SANDBOX_CONTAINER_ID set means App Store mode R-094
The daemon A helper, Contents/MacOS/midi-harbor-daemon, the headless build signed with app-sandbox and inherit; the app starts it as daemon, restarts it with the supervisor's pacing, stops it with SIGTERM and waits R-095
An orphaned daemon A daemon already answering on the socket is used, not started again, and becomes the one Quit stops, through the contract's new StopDaemon, since the sandbox forbids signalling it R-095
The socket $TMPDIR/daemon.sock when sandboxed, keeping every account name under 31 characters within the 103-byte limit R-096
Service commands service::detect() fails in the sandbox with a message naming "Start at login"; the capability reports NotBuilt R-097
Dock icon LSUIElement in the variant's Info.plist; .regular activation policy while the window is open, .accessory while closed R-098
Closing the window exit_on_close(false); close and Command-Q hide the window R-098
Menus The app's own NSMenu over winit's default, and an NSStatusItem; their actions arrive as window messages through a channel R-098
Quitting Everything goes through terminate:; the app's delegate answers NSTerminateLater until the daemon has stopped R-098
Second launch applicationShouldHandleReopen: opens the window R-098
Login item SMAppService.mainApp, status read back each time; notFound shows off R-099
Login launch The launch event's keyAELaunchedAsLogInItem, read in applicationDidFinishLaunching: R-099
The variant build.sh --app-store, on a Mac only, not GoReleaser; macOS 13, same bundle identifier R-100

Constitution Check

GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.

  • I. Resilience: the app restarts a failed daemon with the supervisor's pacing, as launchd does for the direct build. A window crash leaves the daemon running. MIDI server recovery is the daemon's own and unchanged.
  • II. Daemon owns state, GUI is a client: holds, with the helper. The daemon is a separate process and the window reaches it only over the contract. Principle II's list of what starts the daemon names launchd, systemd and Task Scheduler; this feature adds "the App Store app, as a bundled helper it supervises". That is an amendment, MINOR, 1.4.0, made in the first task.
  • III. Real-time safety: untouched. Nothing new runs in the daemon, and the window and menus are in the other process.
  • IV. Platform parity: the capability set is unchanged; service installation is reported unavailable with a reason in the App Store build, as Principle IV asks.
  • V. Protocol correctness: unchanged.
  • VI. Testable without hardware: the mode is keyed on one environment variable, so its behaviour through the CLI and daemon is tested on any Mac without signing. The AppKit shell cannot run without a desktop session and is verified by hand, as the GUI is.
  • VII. Observable: the app logs when it starts, restarts and stops the daemon, with the reason, and the window says why when the daemon cannot start.
  • Quality gates: unchanged; the App Store variant is built and checked by hand on a Mac.

Result: passes, with the Principle II amendment recorded as task work. Re-checked after the design below: no change.

Project Structure

Documentation (this feature)

specs/014-mac-app-store-mode/
├── plan.md              # This file
├── research.md          # R-094 to R-100
├── data-model.md        # Window state at quit, login item status
├── quickstart.md        # Validation on a Mac
├── contracts/
│   ├── menus.md         # Menu bar menu, menu bar item, shortcuts
│   ├── cli.md           # service commands and capabilities in App Store mode
│   └── bundle.md        # App Store variant layout and entitlements
└── tasks.md             # /speckit-tasks

Source Code (repository root)

crates/core/src/paths.rs            sandboxed(); the socket at $TMPDIR/daemon.sock when sandboxed
crates/platform/src/appkit/         macOS only, the one place with AppKit and ServiceManagement FFI
├── mod.rs                          safe API the window calls; ShellAction
├── delegate.rs                     NSApplicationDelegate: terminate, reopen, login launch
├── menus.rs                        the menu bar menu and the menu bar item
├── dock.rs                         activation policy
└── login_item.rs                   SMAppService.mainApp
crates/platform/src/capability.rs   service installation unavailable when sandboxed
proto/midiharbor/v1/harbor.proto    StopDaemon, protocol 1.1
crates/service/src/lib.rs           detect() refuses in the sandbox, naming Start at login
crates/service/src/supervisor.rs    a supervisor that can be told to stop its daemon
crates/gui/src/app_store.rs         the mode: owning the daemon, the window state, the shell messages
crates/gui/src/app.rs               close hides, shell messages, Start at login in Settings
crates/gui/src/onboarding.rs        in the mode, no service offer; starting or why it failed
src/main.rs                         in the mode, sets up the shell before the window runs
packaging/macos/build.sh            --app-store
packaging/macos/bundle.sh           the helper, the entitlements, the variant's Info.plist
packaging/macos/app-store.entitlements
packaging/macos/helper.entitlements
docs/installation.md, docs/platforms.md
tests/app_store_mode.rs             the mode through the real CLI and daemon
crates/daemon/tests/supervisor.rs   stopping a supervised daemon releases held notes

Structure Decision: the AppKit code joins the platform crate as a macOS-only module with no trait, since it has one implementation and is not a seam. The mode's logic lives in the GUI crate, which already depends on the service crate; src/main.rs only calls into it.

Complexity Tracking

Addition Why needed Simpler alternative rejected because
A second executable in the App Store bundle The sandbox kills the app's own executable started as a child (R-095) The daemon in the app's process breaks Principle II
An NSApplicationDelegate of the app's own Quitting must wait for the daemon; reopen and login launch are only told to a delegate winit's notifications arrive after AppKit has decided to exit