diff --git a/AGENTS.md b/AGENTS.md index 5111bc4..663d147 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,11 +29,15 @@ cable so any app (Zoom, Meet, Discord, OBS, …) receives studio-clean audio. - `VoicePreset` — preset → DSP params + voice-chain settings (single source of truth). - `CLIArguments` + `AudioDenoiseOptions` — shared CLI parser (live device, `--action`, `--denoise`). - `AudioFileDenoiser` — offline file decode → mono 48 kHz → `DeepFilterNetDSP` → optional `VoiceChain` → write WAV/CAF/etc. Waits on `DeepFilterNetDSP.waitUntilReady()` before processing; writes to a temp sibling then moves on success. -- `Sources/App` — SwiftUI menu-bar app: `NoNoiseMacApp`, `ContentView` (popover), `SettingsView`. +- `Sources/App` — SwiftUI menu-bar app: `NoNoiseMacApp`, `ContentView` (popover), `SettingsView`, and `LaunchAtLoginManager` (macOS Service Management adapter). - `Sources/CLI` — `NoNoiseMacCLI`: live device pipeline, one-shot `--action` URL dispatch, and `--denoise` offline file mode (audio containers only — no MP4/video remux in v1). - `Resources` — `DeepFilterNet3_Streaming.mlmodelc`, `AppIcon.icns`, `NoNoiseMacLogo.png`, `Info.plist`, `NoNoiseMac.entitlements`. - `Tests/NoNoiseMacTests` — pure DSP / preset / voice-chain unit tests (run headless). +Launch at Startup uses macOS `SMAppService.mainApp` as the system-owned source of truth. It does +not duplicate state in `UserDefaults`, create a LaunchAgent/helper, or add an entitlement; the +setting works only when the installed app is running as a bundled application. + ## Build, run, test ```bash swift build # debug diff --git a/Package.swift b/Package.swift index 41d2755..453d9fb 100644 --- a/Package.swift +++ b/Package.swift @@ -45,6 +45,9 @@ let package = Package( path: "Sources/App", resources: [ .process("../../Resources") + ], + linkerSettings: [ + .linkedFramework("ServiceManagement") ] ), .executableTarget( diff --git a/README.md b/README.md index c727d2c..2dd50c3 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Everything happens **on your device**. Your audio never leaves your Mac. - **🔒 100% private** — fully on-device on the Neural Engine; nothing is uploaded, ever. - **🎛️ One-click modes** — Meeting, Podcast, Tutorial, or Custom, with strength + tone control. - **↩️ Safe reset** — restore audio/device settings to defaults from Settings without deleting saved Voice Profiles or custom Hotkeys. +- **🚀 Launch at Startup** — open **Settings → General → Launch at Startup** to start the menu-bar app automatically when you log in. The setting is off by default and requires the bundled app; if macOS asks for approval, enable NoNoise Mac under **System Settings → General → Login Items**. - **🎙️ Broadcast Voice** — a one-tap clarity lift (Off / Low / Medium / High) that adds studio presence and tames sibilance, so you sound clearer and more present while still sounding like *you*. - **🎧 Clean Incoming / Guest** — de-noise the *other* side too. NoNoise Mac captures all system audio via a native macOS process tap (no loopback device or BlackHole required) and cleans **what you hear** in real time with the same on-device AI — no cloud, no subscription. Requires macOS 14.4+. - **🫦 Mouth Noise** — an optional de-plosive (P-pop / B-thump suppressor) and de-click (lip-smack / mouth-click suppressor) stage (Off / Low / Medium / High). Both are identity at rest — only the artifact is removed, never the voice. diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index 53e739a..e0daa92 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -7,6 +7,7 @@ struct ContentView: View { @ObservedObject var dispatcher: ActionDispatcher @ObservedObject var hotkeyManager: HotkeyManager @ObservedObject var updaterController: UpdaterController + @ObservedObject var launchAtLoginManager: LaunchAtLoginManager var body: some View { VStack(spacing: 14) { @@ -46,7 +47,7 @@ struct ContentView: View { } Spacer() Button { - WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController) + WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager) } label: { Image(systemName: "gearshape.fill") .font(.system(size: 14)) @@ -274,7 +275,7 @@ struct ContentView: View { private var footer: some View { HStack(spacing: 8) { Button { - WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController) + WindowManager.openSettings(model: audioModel, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager) } label: { Label("Settings", systemImage: "slider.horizontal.3") } @@ -440,12 +441,13 @@ extension View { // MARK: - Settings window +@MainActor class WindowManager { static var settingsWindow: NSWindow? - static func openSettings(model: AudioModel, hotkeyManager: HotkeyManager, updaterController: UpdaterController) { + static func openSettings(model: AudioModel, hotkeyManager: HotkeyManager, updaterController: UpdaterController, launchAtLoginManager: LaunchAtLoginManager) { if settingsWindow == nil { - let view = SettingsView(audioModel: model, hotkeyManager: hotkeyManager, updaterController: updaterController) + let view = SettingsView(audioModel: model, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager) let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 520, height: 460), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) @@ -472,6 +474,7 @@ class WindowManager { model?.endMeterObservation(.settings) } } + launchAtLoginManager.refresh() settingsWindow?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } diff --git a/Sources/App/LaunchAtLoginManager.swift b/Sources/App/LaunchAtLoginManager.swift new file mode 100644 index 0000000..81edf2c --- /dev/null +++ b/Sources/App/LaunchAtLoginManager.swift @@ -0,0 +1,55 @@ +import Combine +import Core +import Foundation +import ServiceManagement + +@MainActor +final class LaunchAtLoginManager: ObservableObject { + @Published private(set) var state: LaunchAtLoginState = .notRegistered + @Published private(set) var errorMessage: String? + + var isEnabled: Bool { state.isEnabled } + + init() { + refresh() + } + + func refresh() { + switch SMAppService.mainApp.status { + case .enabled: + state = .enabled + case .notRegistered: + state = .notRegistered + case .requiresApproval: + state = .requiresApproval + case .notFound: + state = .notFound + @unknown default: + state = .notFound + } + } + + func setEnabled(_ enabled: Bool) { + errorMessage = nil + + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch let error as NSError where enabled && error.code == Int(kSMErrorAlreadyRegistered) { + // Registration is idempotent from the user's perspective. + errorMessage = nil + } catch { + errorMessage = "NoNoise Mac could not update its login item." + } + + refresh() + } + + func openLoginItems() { + guard #available(macOS 14.0, *) else { return } + SMAppService.openSystemSettingsLoginItems() + } +} diff --git a/Sources/App/NoNoiseMacApp.swift b/Sources/App/NoNoiseMacApp.swift index 6b0b39a..847823d 100644 --- a/Sources/App/NoNoiseMacApp.swift +++ b/Sources/App/NoNoiseMacApp.swift @@ -14,6 +14,7 @@ struct NoNoiseMacApp: App { @StateObject private var dispatcher: ActionDispatcher @StateObject private var hotkeyManager: HotkeyManager @StateObject private var updaterController: UpdaterController + @StateObject private var launchAtLoginManager: LaunchAtLoginManager init() { // Init order matters: AudioModel → ActionDispatcher(model:) → HotkeyManager(dispatcher:). @@ -29,6 +30,8 @@ struct NoNoiseMacApp: App { // to the AppDelegate so it can fire one prompt background check in didFinishLaunching. let updater = UpdaterController() _updaterController = StateObject(wrappedValue: updater) + let launchAtLogin = LaunchAtLoginManager() + _launchAtLoginManager = StateObject(wrappedValue: launchAtLogin) // Hand the dispatcher to the AppDelegate at LAUNCH (finding #3) — NOT in // ContentView.onAppear. A MenuBarExtra's content view isn't instantiated until the @@ -43,7 +46,7 @@ struct NoNoiseMacApp: App { var body: some Scene { MenuBarExtra { - ContentView(audioModel: audioModel, dispatcher: dispatcher, hotkeyManager: hotkeyManager, updaterController: updaterController) + ContentView(audioModel: audioModel, dispatcher: dispatcher, hotkeyManager: hotkeyManager, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager) } label: { Image(nsImage: NoNoiseLogoImage.menuBar(isActive: audioModel.isAIEnabled)) } diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index cdf735a..441fcaf 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -6,10 +6,11 @@ struct SettingsView: View { @ObservedObject var audioModel: AudioModel @ObservedObject var hotkeyManager: HotkeyManager @ObservedObject var updaterController: UpdaterController + @ObservedObject var launchAtLoginManager: LaunchAtLoginManager var body: some View { TabView { - GeneralSettingsView(audioModel: audioModel, meterModel: audioModel.meterModel, updaterController: updaterController) + GeneralSettingsView(audioModel: audioModel, meterModel: audioModel.meterModel, updaterController: updaterController, launchAtLoginManager: launchAtLoginManager) .tabItem { Label("General", systemImage: "slider.horizontal.3") } @@ -37,6 +38,7 @@ struct GeneralSettingsView: View { // MeterModel — observe it so the Settings readouts stay live while the popover is closed. @ObservedObject var meterModel: MeterModel @ObservedObject var updaterController: UpdaterController + @ObservedObject var launchAtLoginManager: LaunchAtLoginManager @State private var isShowingSaveSheet = false @State private var newProfileName: String = "" @@ -48,6 +50,7 @@ struct GeneralSettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { brandedHeader + launchAtStartupCard suppressionCard inputVolumeCard profilesCard @@ -90,6 +93,48 @@ struct GeneralSettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) } + private var launchAtStartupCard: some View { + VStack(alignment: .leading, spacing: 10) { + Toggle(isOn: Binding( + get: { launchAtLoginManager.isEnabled }, + set: { launchAtLoginManager.setEnabled($0) } + )) { + VStack(alignment: .leading, spacing: 2) { + Text("Launch at Startup") + .font(.subheadline) + Text("Start NoNoise Mac automatically when you log in to your Mac.") + .font(.caption) + .foregroundColor(.secondary) + } + } + .toggleStyle(.switch) + + if launchAtLoginManager.state == .requiresApproval { + loginItemsGuidance("macOS requires approval in System Settings > General > Login Items.") + } else if launchAtLoginManager.state == .notFound { + loginItemsGuidance("Launch at Startup works when NoNoise Mac is running as a bundled app.") + } + if let errorMessage = launchAtLoginManager.errorMessage { + loginItemsGuidance(errorMessage) + } + } + .padding(14) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private func loginItemsGuidance(_ message: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(message) + .font(.caption) + .foregroundColor(.secondary) + Button("Open Login Items") { + launchAtLoginManager.openLoginItems() + } + .buttonStyle(.bordered) + } + } + // MARK: Suppression private var suppressionCard: some View { diff --git a/Sources/Core/LaunchAtLoginState.swift b/Sources/Core/LaunchAtLoginState.swift new file mode 100644 index 0000000..a2fc799 --- /dev/null +++ b/Sources/Core/LaunchAtLoginState.swift @@ -0,0 +1,14 @@ +public enum LaunchAtLoginState: Equatable { + case enabled + case notRegistered + case requiresApproval + case notFound + + public var isEnabled: Bool { + self == .enabled + } + + public var needsSystemSettings: Bool { + self == .requiresApproval || self == .notFound + } +} diff --git a/Tests/NoNoiseMacTests/LaunchAtLoginStateTests.swift b/Tests/NoNoiseMacTests/LaunchAtLoginStateTests.swift new file mode 100644 index 0000000..bbaa379 --- /dev/null +++ b/Tests/NoNoiseMacTests/LaunchAtLoginStateTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import Core + +final class LaunchAtLoginStateTests: XCTestCase { + func testOnlyEnabledStateTurnsToggleOn() { + XCTAssertTrue(LaunchAtLoginState.enabled.isEnabled) + XCTAssertFalse(LaunchAtLoginState.notRegistered.isEnabled) + XCTAssertFalse(LaunchAtLoginState.requiresApproval.isEnabled) + XCTAssertFalse(LaunchAtLoginState.notFound.isEnabled) + } + + func testApprovalAndNotFoundStatesRequestSystemSettingsGuidance() { + XCTAssertTrue(LaunchAtLoginState.requiresApproval.needsSystemSettings) + XCTAssertTrue(LaunchAtLoginState.notFound.needsSystemSettings) + XCTAssertFalse(LaunchAtLoginState.enabled.needsSystemSettings) + XCTAssertFalse(LaunchAtLoginState.notRegistered.needsSystemSettings) + } +} diff --git a/docs/knowledge/timeline1.md b/docs/knowledge/timeline1.md index 66fbe3d..ea61885 100644 --- a/docs/knowledge/timeline1.md +++ b/docs/knowledge/timeline1.md @@ -2,6 +2,11 @@ Chronological log of notable changes. Newest on top. +### 2026-07-12 — Launch at Startup setting +- **What:** added a General Settings toggle that starts the NoNoise Mac menu-bar app when the user logs in. +- **How:** uses `SMAppService.mainApp` as the system-owned source of truth, with approval and recoverable error guidance in the UI; no duplicate preference, helper, LaunchAgent, or entitlement was added. +- **Verification:** the Service Management path requires manual verification in the bundled app, including System Settings → General → Login Items when approval is requested. + ### 2026-06-22 — Repin NoNoise Mic Engine after hardware churn (Codex) - **What:** Added a tested `VirtualMicRouting.shouldRepinPlaybackAfterHardwareRefresh` predicate and wired `AudioModel.fetchOutputDevices()` to explicitly rebuild the playback graph when a hardware diff --git a/docs/plans/2026-07-12-launch-at-startup.md b/docs/plans/2026-07-12-launch-at-startup.md new file mode 100644 index 0000000..f1737b9 --- /dev/null +++ b/docs/plans/2026-07-12-launch-at-startup.md @@ -0,0 +1,299 @@ +# Launch at Startup Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a General Settings toggle that registers NoNoise Mac to launch automatically when the user logs in. + +**Architecture:** Use macOS 13+ `SMAppService.mainApp` as the system-owned source of truth. Add a small app-layer manager that maps Service Management status into UI state, performs register/unregister operations, refreshes when Settings opens, and reports approval/errors without storing a duplicate preference in `UserDefaults`. The menu-bar app remains a single bundled executable; no helper, LaunchAgent plist, entitlement, or audio-model change is needed. + +**Tech Stack:** Swift 5.9, SwiftUI, `ServiceManagement.SMAppService`, Swift Package Manager, XCTest. + +**GitHub Issue:** #17 — https://github.com/ivalsaraj/NoNoise-Mac/issues/17 + +**Pull Request:** #18 — https://github.com/ivalsaraj/NoNoise-Mac/pull/18 + +**Assumptions:** The setting is off by default on clean and existing installs unless macOS already reports the app as enabled. The user can change this default later without changing the architecture. + +--- + +### Task 1: Add a headless launch-at-login state model + +**Files:** +- Create: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Sources/Core/LaunchAtLoginState.swift` +- Create: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Tests/NoNoiseMacTests/LaunchAtLoginStateTests.swift` + +**Step 1: Write the failing tests** + +Cover the user-visible state rules without constructing `AudioModel` or calling the macOS Service Management daemon: + +```swift +import XCTest +@testable import Core + +final class LaunchAtLoginStateTests: XCTestCase { + func testOnlyEnabledStateTurnsToggleOn() { + XCTAssertTrue(LaunchAtLoginState.enabled.isEnabled) + XCTAssertFalse(LaunchAtLoginState.notRegistered.isEnabled) + XCTAssertFalse(LaunchAtLoginState.requiresApproval.isEnabled) + XCTAssertFalse(LaunchAtLoginState.notFound.isEnabled) + } + + func testApprovalAndNotFoundStatesRequestSystemSettingsGuidance() { + XCTAssertTrue(LaunchAtLoginState.requiresApproval.needsSystemSettings) + XCTAssertTrue(LaunchAtLoginState.notFound.needsSystemSettings) + XCTAssertFalse(LaunchAtLoginState.enabled.needsSystemSettings) + XCTAssertFalse(LaunchAtLoginState.notRegistered.needsSystemSettings) + } +} +``` + +Run: `swift test --filter LaunchAtLoginStateTests` + +Expected: FAIL because `LaunchAtLoginState` does not exist yet. + +**Step 2: Implement the minimal state type** + +Define an `Equatable` public enum with cases matching the four `SMAppService.Status` values: `.enabled`, `.notRegistered`, `.requiresApproval`, and `.notFound`. Add `public` computed properties `isEnabled` and `needsSystemSettings`; only `.enabled` returns true for `isEnabled`, while `.requiresApproval` and `.notFound` return true for `needsSystemSettings`. The access modifiers on these members are required because the App target consumes this Core type across the module boundary. + +Do not import `ServiceManagement` into `Core`; keeping this value type Foundation-free preserves the existing headless test boundary. + +**Step 3: Run the focused tests** + +Run: `swift test --filter LaunchAtLoginStateTests` + +Expected: PASS. + +**Step 4: Commit the testable state contract** + +```bash +git add Sources/Core/LaunchAtLoginState.swift Tests/NoNoiseMacTests/LaunchAtLoginStateTests.swift +git commit -m "test(core): define launch-at-login state contract" +``` + +### Task 2: Add the macOS Service Management adapter + +**Files:** +- Create: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Sources/App/LaunchAtLoginManager.swift` +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Package.swift` + +**Step 1: Implement the manager around `SMAppService.mainApp`** + +Create an `@MainActor final class LaunchAtLoginManager: ObservableObject` with: + +- `@Published private(set) var state: LaunchAtLoginState = .notRegistered`. +- `private(set) var errorMessage: String?` for recoverable UI feedback. +- `var isEnabled: Bool { state.isEnabled }`. +- `init()` calling `refresh()`. +- `refresh()` reading `SMAppService.mainApp.status` and mapping all four statuses explicitly: + - `.enabled` → `.enabled`; + - `.notRegistered` → `.notRegistered`; + - `.requiresApproval` → `.requiresApproval`; + - `.notFound` → `.notFound`. +- `setEnabled(_ enabled: Bool)` that clears the previous error, calls `SMAppService.mainApp.register()` when enabling, calls `unregister()` when disabling, then calls `refresh()`. +- A recovery method that calls `SMAppService.openSystemSettingsLoginItems()` for the Settings button. + +Treat `kSMErrorAlreadyRegistered` / an equivalent already-registered result as success by refreshing the system status. For other thrown errors, keep the previous effective state, set a concise `errorMessage`, and refresh so the UI cannot claim the item is enabled when macOS rejected the operation. Do not write a `mv.*` preference; Service Management owns persistence and reflects manual changes made in System Settings. + +**Step 2: Link the system framework explicitly** + +Add `.linkedFramework("ServiceManagement")` to the `NoNoiseMac` executable target’s `linkerSettings` in `Package.swift`. Keep the framework out of `Core` and do not add any entitlement. + +**Step 3: Build the app target** + +Run: `swift build` + +Expected: PASS with `ServiceManagement` imported only by the app target. + +**Step 4: Commit the adapter** + +```bash +git add Sources/App/LaunchAtLoginManager.swift Package.swift +git commit -m "feat(app): manage launch at login with SMAppService" +``` + +### Task 3: Own the manager for the app lifetime and refresh it when Settings opens + +**Files:** +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Sources/App/NoNoiseMacApp.swift` +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Sources/App/ContentView.swift` +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Sources/App/SettingsView.swift` + +**Step 1: Add one app-lifetime `@StateObject`** + +Instantiate `LaunchAtLoginManager` in `NoNoiseMacApp.init()` alongside `AudioModel`, `ActionDispatcher`, `HotkeyManager`, and `UpdaterController`. Retain it in `@StateObject` so there is one system-state owner, not a new manager each time the reused Settings panel is created. + +**Step 2: Thread the manager through the existing Settings opening path** + +Pass the observed manager into `ContentView`, extend `WindowManager.openSettings(...)` to accept it, and pass it into `SettingsView`. Add the matching `@ObservedObject` manager properties to `SettingsView` and `GeneralSettingsView`, forwarding the same instance into the existing `GeneralSettingsView` initializer. This is initializer/property plumbing only; the visible card is added in Task 4. Immediately before making the existing panel key and ordering it front, call `launchAtLoginManager.refresh()` so changes made externally in System Settings are reflected when the panel reopens. + +Do not alter the Settings window’s existing meter-observation lifecycle or create a second window manager. + +**Step 3: Build the app target** + +Run: `swift build` + +Expected: PASS with a single manager instance wired from `NoNoiseMacApp` to `SettingsView`. + +**Step 4: Commit the lifecycle wiring** + +```bash +git add Sources/App/NoNoiseMacApp.swift Sources/App/ContentView.swift Sources/App/SettingsView.swift +git commit -m "feat(app): retain launch-at-login state at app scope" +``` + +### Task 4: Add the General Settings card and user feedback + +**Files:** +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/Sources/App/SettingsView.swift` + +**Step 1: Add a Launch at Startup card** + +Using the manager properties added in Task 3, place a new app-level card immediately after `brandedHeader` and before the audio controls. Use a SwiftUI `Toggle` backed by a `Binding` whose getter reads `launchAtLoginManager.isEnabled` and whose setter calls `setEnabled(_:)`; never bind directly to a writable `@Published` flag because the system registration can fail. + +Use copy equivalent to: + +```swift +Toggle(isOn: Binding( + get: { launchAtLoginManager.isEnabled }, + set: { launchAtLoginManager.setEnabled($0) } +)) { + VStack(alignment: .leading, spacing: 2) { + Text("Launch at Startup").font(.subheadline) + Text("Start NoNoise Mac automatically when you log in to your Mac.") + .font(.caption) + .foregroundColor(.secondary) + } +} +.toggleStyle(.switch) +``` + +Below the toggle, show status-specific guidance: + +- `.requiresApproval`: explain that macOS requires approval in System Settings → General → Login Items, with an `Open Login Items` button. +- `.notFound`: explain that the feature requires a bundled app and offer the same button. +- `.failed` is not a state case; display `errorMessage` below the card when present, with an `Open Login Items` recovery button if the manager reports one. +- `.enabled` / `.notRegistered`: no extra message. + +Keep the card clear that this launches the menu-bar app; it does not automatically enable or disable noise cancellation. Do not add the setting to Voice Profiles, `AudioModel`, or the audio Settings reset list. + +**Step 2: Build and run headless tests** + +Run: + +```bash +swift test --filter LaunchAtLoginStateTests +swift build +``` + +Expected: both PASS. The Service Management call itself is intentionally excluded from headless tests because it depends on the installed, bundled application and the user’s Login Items authorization state. + +**Step 3: Commit the Settings UI** + +```bash +git add Sources/App/SettingsView.swift +git commit -m "feat(settings): add launch at startup toggle" +``` + +### Task 5: Update user and agent documentation + +**Files:** +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/README.md` +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/AGENTS.md` +- Modify: `/Users/valsaraj/Documents/projects/NoNoise-Mac/docs/knowledge/timeline1.md` + +**Step 1: Document the user-facing setting** + +Add a concise README bullet and Settings guidance: open Settings → General → Launch at Startup; if macOS requests approval, enable NoNoise Mac under System Settings → General → Login Items. State that the setting is off by default and that the app must be installed/bundled for the system login item to work. + +**Step 2: Document the implementation invariant** + +Update the App architecture map in `AGENTS.md` to mention `LaunchAtLoginManager` and record that launch-at-login uses `SMAppService.mainApp` as the source of truth, not `UserDefaults`, LaunchAgents, or a new entitlement. + +**Step 3: Append the timeline entry** + +Add a dated entry under `2026-07-12` describing the new Settings toggle, the `SMAppService.mainApp` decision, the approval/error UI, and the bundled-app manual verification requirement. + +**Step 4: Review documentation references** + +Run: + +```bash +rg -n -i "launch at startup|launch at login|SMAppService|Login Items" README.md AGENTS.md docs Sources Tests +``` + +Expected: all user-facing and implementation references use the same terminology and no stale LaunchAgent instructions exist. + +**Step 5: Commit documentation** + +```bash +git add README.md AGENTS.md docs/knowledge/timeline1.md +git commit -m "docs: document launch-at-startup behavior" +``` + +### Task 6: Verify the bundled-app integration + +**Files:** +- No additional source files. + +**Step 1: Run the full automated checks** + +Run: + +```bash +swift test +swift build +swift build -c release --arch arm64 +``` + +Expected: all tests pass and both debug/release builds succeed. + +**Step 2: Build the signed bundle** + +Run: `./bundle.sh` + +Expected: `NoNoiseMac.app` is created and `codesign --verify --deep --strict NoNoiseMac.app` passes. + +**Step 3: Verify the Settings state machine in the bundled app** + +1. Launch `NoNoiseMac.app`, open Settings → General, and confirm the new toggle is off on a clean install. +2. Turn it on and confirm the state settles to enabled, or that the UI gives the System Settings approval path. +3. If approval is required, click `Open Login Items`, enable NoNoise Mac, return to the app, reopen Settings, and confirm the toggle reflects the system state. +4. Turn it off and confirm the toggle returns to off after reopening Settings. +5. Log out and back in, or restart the Mac, and confirm the menu-bar app launches without opening a foreground window. +6. Run the app from `swift run` only as a negative check: the UI should explain that the bundled app is required rather than silently claiming registration succeeded. + +**Step 4: Verify the existing audio behavior is unchanged** + +With the app launched at login, confirm the existing menu-bar flow, microphone routing, AI default, hotkeys, and Settings reset behavior remain unchanged. No audio code or `SettingsResetPolicy` key list should be involved in this feature. + +**Step 5: Final diff and status check** + +Run: + +```bash +git diff main...HEAD --stat +git status --short +``` + +Expected: only the feature commits are present; unrelated pre-existing files such as `.claude/` remain untouched. + +--- + +## API reference + +- [Apple: `SMAppService`](https://developer.apple.com/documentation/servicemanagement/smappservice) — macOS 13+ Service Management API. +- [Apple: `SMAppService.mainApp`](https://developer.apple.com/documentation/servicemanagement/smappservice/mainapp) — configures the main app to launch at login. +- [Apple: `SMAppService.register()`](https://developer.apple.com/documentation/servicemanagement/smappservice/register()) — registration behavior and approval handling. + +## Scope boundaries + +- Do not create a `~/Library/LaunchAgents` plist. +- Do not add a login helper executable; the app itself is the login item. +- Do not add a UserDefaults key or migrate existing `mv.*` settings. +- Do not add entitlements or change the CoreAudio/CoreML pipeline. +- Do not make startup launch enable AI, auto-route devices, or alter any persisted audio setting. + +## Status + +Completed. Automated checks and bundled signature verification passed. Manual Login Items behavior +still requires verification on an installed macOS bundle.