Status: draft Tracks: #29
OpenQuack is a menu-bar app whose value depends on it being always-on — the
hotkey only works when the app is running. Today, after every restart the user
has to manually launch from /Applications (or Spotlight) before dictation
works. For a tool that’s supposed to feel like a system feature, that’s a
recurring papercut and a known reason new users churn after their first reboot.
Issue #29 asks for the standard macOS toggle.
LSSharedFileList. Package.swift already
pins .macOS(.v13), so SMAppService is always available.Use SMAppService.mainApp
(macOS 13+):
import ServiceManagement
try SMAppService.mainApp.register() // user toggle on
try SMAppService.mainApp.unregister() // user toggle off
let status = SMAppService.mainApp.status // .enabled / .notRegistered / .requiresApproval / .notFound
No LaunchAgent plist, no helper bundle, no embedded XPC service — the app
bundle itself is the login item.
A single Bool in UserDefaults under the key launchAtLogin, matching
the existing unqualified-camelCase convention (autoPaste, playSounds,
saveAudio, …). Bound to the Settings UI via @AppStorage("launchAtLogin").
On app launch, run a pure reconciliation step to align the persisted toggle with the actual OS state. The implementer should extract it as a free function for direct unit testing:
enum LaunchAtLoginAction { case register, unregister, resetToggleOff, noop }
func reconcile(desiredEnabled: Bool, currentStatus: SMAppService.Status) -> LaunchAtLoginAction
| desired | currentStatus | action |
|---|---|---|
| true | .enabled |
noop |
| true | .notRegistered |
register |
| true | .requiresApproval |
resetToggleOff |
| true | .notFound |
register |
| false | .enabled |
unregister |
| false | anything else | noop |
resetToggleOff writes false back to UserDefaults so the Settings UI
reflects reality on next render, and surfaces a single-line hint under the
toggle (see “Settings UI” below). We do not silently retry registration
when the user has revoked approval — that’s the user’s stated preference.
When the user flips the toggle, perform register() / unregister()
synchronously on the main actor:
register() throws → roll the toggle back to off, log via Logger, surface
the hint string.unregister() throws → leave the toggle off (the goal state was already
off); log only.SMAppService calls are cheap and need no debounce.
OpenQuack already runs as an accessory app (no Dock icon, menu bar only), so
“started by launchd at login” looks identical to “started by the user” from
the app’s perspective. The only thing that must not happen on a headless
launch is the onboarding flow auto-presenting; that’s already gated on the
existing hasCompletedOnboarding UserDefaults key, so once the user has
completed onboarding once the launch-at-login path is silent by construction.
No new “was I launched at login?” detection is required.
In Sources/OpenQuackApp/SettingsView.swift’s GeneralPane, add a new section
at the end of the pane (after “Custom dictionary”):
Section {
Toggle("Launch OpenQuack at login", isOn: $launchAtLogin)
.help("Start OpenQuack automatically when you sign in to your Mac, so the menu-bar icon and global hotkey are ready without launching the app manually.")
if showsApprovalHint {
Text("macOS blocked OpenQuack from auto-starting. Enable it in System Settings → General → Login Items, then toggle this on again.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
SectionHeader("Startup")
}
showsApprovalHint is true for the rest of the session whenever
reconciliation returns .resetToggleOff or the toggle-write path catches a
register error. Dismissed on next successful register().
x-apple.systempreferences: — nice-to-have, follow-up if users ask.i18n is its own track (SPEC-019); land English here.reconcile(desiredEnabled:currentStatus:) through the
full table above; assert each row returns the expected LaunchAtLoginAction.
No SMAppService mocking required — the function is pure.swift build && swift test green.| PR | Title | SPEC cite | Effort |
|---|---|---|---|
| this | docs(SPEC-023): launch at login |
SPEC-023 | XS |
| PR-A | feat(settings): launch-at-login toggle (SMAppService) |
SPEC-023 | S |
PR-A is small enough to land in one go: a reconciliation function in
OpenQuackKit (unit-tested), an @AppStorage("launchAtLogin") Toggle wired
into GeneralPane, an SMAppService.mainApp register/unregister call on
toggle change, and a single reconciliation call from
OpenQuackApp.applicationDidFinishLaunching.