18 KiB
| description |
|---|
| Task list for the Mac App Store mode |
Tasks: Mac App Store mode
Input: Design documents from /specs/014-mac-app-store-mode/
Prerequisites: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
Tests: Two tiers, per the constitution's Principle VI and AGENTS.md "Testing". The mode keys on
APP_SANDBOX_CONTAINER_ID, so its effect on the CLI, the daemon and the daemon's owner is proved
by integration tests that run the real binary with that variable set, on any Mac, unsigned. One
unit test pins the SMAppService status mapping, a fact about macOS (research R-099). AppKit,
the login item and the signed sandbox cannot run without a desktop session and are proved by
hand through quickstart.md.
Organization: Tasks are grouped by user story. The sandboxed build (User Story 4) is foundational here, since every other story is checked by hand against it.
Format: [ID] [P?] [Story] Description
- [P]: Can run in parallel (different files, no dependencies)
- [Story]: Which user story this task belongs to (US1 to US4)
Phase 1: Setup
Purpose: The governance change and the dependencies every later phase needs
- T001 Amend
.specify/memory/constitution.mdto 1.4.0: Principle II's list of what runs the daemon independently of the GUI gains "on macOS through the App Store, a bundled helper the app starts and supervises"; add the amendment note dated 2026-09-27 citing feature 014 (then 003) and research R-095; mirror it in AGENTS.md's seam table and daemon-contract section — done: constitution 1.4.0, Principle II and the matrix name the App Store helper; AGENTS.md's seam table too - T002 [P] Add macOS-only dependencies to crates/platform/Cargo.toml:
objc20.6,objc2-foundation0.3,objc2-app-kit0.3 (NSApplication, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSImage, NSRunningApplication, NSEvent features) andobjc2-service-management0.3 (SMAppService), at the versions winit already locks (research R-098); addmidi-harbor-platformto crates/gui/Cargo.toml — done: objc2 0.6, objc2-foundation, objc2-app-kit and objc2-service-management 0.3 in the platform crate; only ServiceManagement's bindings are new to the lock
Phase 2: Foundational
Purpose: The mode's detection and its effect on paths, the service commands and the capability list, the supervisor's stop, and the sandboxed build every manual check runs against
⚠️ CRITICAL: No user story work can begin until this phase is complete
- T003 Add
pub fn sandboxed() -> boolto crates/core/src/paths.rs, true exactly whenAPP_SANDBOX_CONTAINER_IDis set (R-094), and makePaths::resolve()put the runtime directory atstd::env::temp_dir()itself when sandboxed, so the socket is$TMPDIR/daemon.sock(R-096); document the 103-byte limit it keeps within — done:paths::sandboxed(); sandboxed, the runtime directory is the container'stmpitself - T004 [P] Report
CapabilityName::ServiceManagerunavailable withUnavailableReason::NotBuiltwhenpaths::sandboxed()in crates/platform/src/capability.rs — done: incrates/daemon/src/state.rs, where the daemon turnsservice::detect()'s error into the capability's reason:ServiceError::AppStorereads asNotBuilt - T005 [P] Make
service::detect()in crates/service/src/lib.rs return a newServiceError::AppStorewhen sandboxed, displayed exactly asthe App Store build is started by Midi Harbor itself; turn on "Start at login" in its settings(contracts/cli.md), which crates/cli/src/service_cmd.rs already maps to exit code 4 — done:ServiceError::AppStore, exit 4 through the existing mapping - T006 [P] Add
pub fn peer_pid(socket: &Path) -> io::Result<u32>to crates/platform/src/socket.rs for macOS: connect,getsockopt(SOL_LOCAL, LOCAL_PEERPID), close, with a// SAFETY:comment (R-095) — done: built, then removed: the sandbox forbids signalling a daemon the app did not start, so a stop request over the socket replaced it (R-095);LOCAL_PEERPIDitself worked - T007 Add a stoppable supervisor to crates/service/src/supervisor.rs:
Supervisor::start(program, arguments)runs the existing restart loop on a thread of its own and records the current child;stop(&self, grace: Duration)sends SIGTERM to the child, stops restarting, and waits up togracefor the child to exit before killing it; the blockingsupervise()Windows uses keeps its behaviour — done:Supervisor::startandstop, the restart loop on its own thread, waking early from a restart delay for a stop - T008 Integration test in tests/app_store_mode.rs, running
CARGO_BIN_EXE_midi-harborwithAPP_SANDBOX_CONTAINER_ID,HOMEandTMPDIRpointed at a scratch directory:service installandservice statusexit 4 and print the contracts/cli.md message;capabilities --jsonreports service installation unavailable with reasonNotBuilt;daemonbinds$TMPDIR/daemon.sockandstatusreaches it there; unset,service statusdoes not exit 4 — done: tests/app_store_mode.rs, macOS only; mutation-checked: without the sandbox refusal and without the container socket layout each test fails - T009 Integration test in tests/app_store_mode.rs: a
Supervisorrunning the real binary asdaemon --socket <scratch>is stopped bystop(5 s), the child exits successfully, is not started again, and the socket stops answering;peer_pidon the socket while it served returned the child's pid (macOS only) — done: stops within the daemon's own grace, not the kill after it, and nothing restarts; mutation-checked without SIGTERM - T010 [P] Create packaging/macos/app-store.entitlements and packaging/macos/helper.entitlements with exactly the keys in contracts/bundle.md — done
- T011 Add the App Store variant to packaging/macos/bundle.sh (a
--helper <binary>option that copies it toContents/MacOS/midi-harbor-daemon, signs it with helper.entitlements first, signs the bundle with app-store.entitlements, setsLSMinimumSystemVersion13.0 andLSUIElementtrue in Info.plist, and makes no disk image) and to packaging/macos/build.sh (--app-store: builds the full and--no-default-featuresbinaries, universal when both targets are installed, into target/package/macos-app-store/) — done: verified: the bundle signs and verifies, each executable carries exactly its entitlements, Info.plist has 13.0 andLSUIElement
Checkpoint: make test-integration passes with T008 and T009; build.sh --app-store makes a
bundle that verifies and carries the entitlements in contracts/bundle.md
Phase 3: User Story 1 - Connections keep running from the menu bar (Priority: P1) 🎯 MVP
Goal: The sandboxed app starts or attaches to its daemon, hides to a menu bar item when its window closes, and stops the daemon when quit from that item
Independent Test: quickstart.md scenarios 1 and 3 against the variant from T011
Tests for User Story 1
- T012 [US1] Integration test in crates/gui/tests/app_store.rs for the daemon owner, with the
real binary on a scratch socket: with nothing serving,
DaemonOwner::startstarts one daemon; with a daemon already serving, it attaches and starts none; a daemon that is killed is started again;stop()ends an attached daemon too, found throughpeer_pid— done: in tests/app_store_mode.rs, since only the root package can run the real binary; stopping the attached daemon is throughStopDaemon, mutation-checked without it
Implementation for User Story 1
- T013 [US1] Implement
DaemonOwnerin crates/gui/src/app_store.rs: probe the socket withmidi_harbor_daemon::already_serving-style probing through the IPC transport; attach, or startContents/MacOS/midi-harbor-daemon daemon(the helper beside the current executable) with theSupervisorfrom T007;stop()stops its supervisor, or SIGTERMs thepeer_pidof an attached daemon and waits up to 5 s; log start, restart, attach and stop at info with the reason — done:DaemonOwnerin crates/gui/src/app_store.rs; an attached daemon is stopped through the contract's newStopDaemon(protocol 1.1), chosen by the owner once the sandbox refused the signal - T014 [P] [US1] Implement the menu bar item and the Dock icon in
crates/platform/src/appkit/menus.rs and crates/platform/src/appkit/dock.rs: an
NSStatusItemwith the app icon as a template image and the items "Open Midi Harbor" and "Quit Midi Harbor" (contracts/menus.md);set_dock_visible(bool)switching the activation policy between.regularwith activation and.accessory(R-098); every action sent on afuturesunbounded channel as aShellAction, exported safely from crates/platform/src/appkit/mod.rs — done: the glyph is the anchor alone,packaging/macos/MenuBarIcon.svg, since a template image keeps only the alpha - T015 [US1] Implement the app delegate in crates/platform/src/appkit/delegate.rs, set before
the event loop starts:
applicationShouldTerminate:returnsNSTerminateLaterand sendsShellAction::Quit, andreply_to_terminate()answers it once the daemon has stopped;applicationShouldHandleReopen:hasVisibleWindows:sendsShellAction::Open(FR-A11) — done - T016 [US1] In crates/gui/src/lib.rs and crates/gui/src/app.rs, when
paths::sandboxed(): build Settings withexit_on_close(false);on_close_requestedhides the main window withwindow::set_mode(Hidden)and hides the Dock icon; subscribe to theShellActionchannel, whereOpenshows the window, restores the Dock icon and focuses it, andQuitstops theDaemonOwneroff the UI thread, then replies to the terminate and exits — done: the menus are installed from the first message, since winit builds its default menu afterinit(R-098) - T017 [US1] In crates/gui/src/onboarding.rs, when sandboxed, never offer the service; while the
daemon is starting say so, and if it cannot start show why, with Quit still working — done:
Starting Midi Harbor…, or why it could not, and no service offer - T018 [US1] In src/main.rs, when sandboxed and running the GUI: install the delegate and menu
bar item, start the
DaemonOwner, then run the window, keeping main.rs to dispatch only — done: ingui::runrather than main.rs, which stays dispatch only - T019 [US1] Run quickstart.md scenarios 1 and 3 against the variant; record the results in research.md R-098 — done: verified on the debug variant, R-098
Checkpoint: The sandboxed app keeps MIDI running with the window closed, survives its window crashing, and stops everything on Quit
Phase 4: User Story 2 - Quitting the window versus quitting Midi Harbor (Priority: P1)
Goal: A real app menu where ⌘Q closes to the menu bar and ⌥⌘Q quits
Independent Test: quickstart.md scenario 2
- T020 [US2] Replace winit's default menu in crates/platform/src/appkit/menus.rs with the menu
in contracts/menus.md: "Close to Menu Bar" on ⌘Q and Close on ⌘W sending
ShellAction::CloseWindow, "Quit Midi Harbor" on ⌥⌘Q sendingterminate:, and the standard About, Hide, Hide Others, Show All and Minimize; installed once the event loop runs, in App Store mode only — done: Quit is ⌥⌘Q, not ⇧⌘Q, which is macOS's Log Out and takes precedence; the owner chose ⌥⌘Q - T021 [US2] Handle
ShellAction::CloseWindowin crates/gui/src/app.rs exactly as the close button (T016) — done - T022 [US2] Run quickstart.md scenario 2, including a logout-style
terminate:sent withosascript -e 'tell application "Midi Harbor" to quit', and record the results in R-098 — done: verified by clicking the menu items and reading their shortcuts back; no synthetic ⌥⌘Q or ⇧⌘Q was sent after ⇧⌘Q opened the log-out dialog, which was cancelled
Checkpoint: ⌘Q never stops a connection and ⌥⌘Q always ends the process
Phase 5: User Story 3 - Starting at login (Priority: P2)
Goal: A Start at login switch, and a login launch that restores the window's state at quit
Independent Test: quickstart.md scenario 5, with the owner
Tests for User Story 3
- T023 [P] [US3] Unit test in crates/platform/src/appkit/login_item.rs pinning the mapping in
data-model.md:
enabledshows on,requiresApprovalwaiting, and bothnotRegisteredandnotFoundoff withregister()allowed, citing R-099 for whynotFoundis not "unavailable" — done
Implementation for User Story 3
- T024 [US3] Implement crates/platform/src/appkit/login_item.rs:
status(),register()andunregister()overSMAppService.mainApp, and the switch state mapping from data-model.md — done - T025 [US3] Record in crates/platform/src/appkit/delegate.rs whether the launch Apple event's
keyAEPropDataiskeyAELaunchedAsLogInItem, read inapplicationDidFinishLaunching:, and expose it aslaunched_at_login()(R-099) — done: the Apple event'seventIDandparamDescriptorForKeyword:are called by message, avoiding the CoreServices bindings - T026 [US3] Keep the window state in crates/gui/src/app_store.rs as
window.yamlin the configuration directory, with the fields in data-model.md:open("A missing or unreadable file reads as open") andlogin_offered; written atomically only when quitting entirely — done - T027 [US3] In crates/gui/src/app.rs and crates/gui/src/view.rs: at a login launch start with
the window hidden unless
openwas true; any other launch opens it; add the Start at login switch to Settings with its on, waiting and off states and anyregister()error beside it; offer it once on first launch whenlogin_offeredis not set — done: the offer is a banner above every page until answered - T028 [US3] Run quickstart.md scenario 5, and record in R-099 whether registration from an ad hoc signed sandboxed build and the login-launch check work
Checkpoint: The app starts at login and opens its window exactly when it was open at quit
Phase 6: User Story 4 - A sandboxed build to test with (Priority: P2)
Goal: Show that everything Midi Harbor does works inside the sandbox, or says why not
Independent Test: quickstart.md scenario 4, and scenario 6 with the owner
- T029 [US4] Run quickstart.md scenario 4: Bluetooth from the window, a USB MIDI device's
identity with and without
com.apple.security.device.usb, andcapabilities; drop the USB entitlement from packaging/macos/app-store.entitlements and contracts/bundle.md if nothing needs it, and record the result in R-100 - T030 [US4] Run quickstart.md scenario 6, and record in R-097
whether the daemon's replacement after
killall MIDIServerworks as the helper
Checkpoint: The variant is proved against every capability
Phase 7: Polish & Cross-Cutting Concerns
- T031 [P] Document the App Store build in docs/installation.md (how it starts, the menu bar,
Start at login,
config pathin the container) and docs/platforms.md (the sandbox's effects), andbuild.sh --app-storein packaging/README.md — done - T032 Prune any test in the suites this feature touched that breaks the testing rules, and
confirm no helper or import was left unused — done: the new suites hold four integration tests and one unit test;
peer_pid,stop_processandSupervisor::pid, which only tests would have used onceStopDaemonreplaced signalling, were removed rather than kept for them - T033 Run the gates on macOS, the Arch VM and Windows (cross-clippy, headless build, and
scripts/windows-test.sh), confirming the headless build still carries no GUI or AppKit code — done: fmt, clippy,
make test,make test-integrationand the headless build pass on macOS and the Arch VM; Windows cross-clippy and headless build pass, and all 47 test binaries pass on the Windows VM - T034 Update research.md statuses and mark the spec implemented — done: research statuses updated; the spec is marked implemented
Dependencies & Execution Order
Phase Dependencies
- Setup (Phase 1): no dependencies
- Foundational (Phase 2): after Setup; blocks every story. T008 needs T003 to T005; T009 needs T006 and T007; T011 needs T010
- US1 (Phase 3): after Foundational. T013 needs T007 and T006; T016 needs T014 and T015; T018 needs T013 to T016
- US2 (Phase 4): after US1's T014 to T016, which it extends
- US3 (Phase 5): after US1; T027 needs T024 to T026
- US4 (Phase 6): after US1, since it runs the app
- Polish (Phase 7): after the stories
Parallel Opportunities
- T002 alongside T001
- T004, T005 and T006 alongside each other after T003; T010 at any point in Phase 2
- T014 alongside T013
- T023 alongside T024 to T026
Parallel Example: Foundational
Task: "Report ServiceManager unavailable when sandboxed in crates/platform/src/capability.rs"
Task: "Refuse service::detect() when sandboxed in crates/service/src/lib.rs"
Task: "Add peer_pid to crates/platform/src/socket.rs"
Task: "Create the entitlements files in packaging/macos/"
Implementation Strategy
MVP First (User Story 1)
- Phases 1 and 2: the mode is detected, the CLI and daemon behave, and the variant builds
- Phase 3: the app owns its daemon and lives in the menu bar
- Validate with quickstart.md scenarios 1 and 3
Incremental Delivery
- US2 adds the menu and shortcuts
- US3 adds Start at login, needing the owner for its final check
- US4 closes the capability checks, needing the owner for the MIDI server check
Notes
- Commit after each task or logical group, in Conventional Commits
- A new integration test fails without the change it covers
- Ask the owner before anything that adds a login item, logs out, or kills
MIDIServer