diff --git a/CLAUDE.md b/CLAUDE.md index 7c3c8f1..44fe165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,8 @@ Auto-generated from all feature plans. Last updated: 2026-03-29 - JSON file — `~/Library/Application Support/Wisp/transcription-log.json` (004-transcription-log) - Swift 6.1+ with strict concurrency checking enabled + AppKit (NSPanel, Core Animation), WhisperKit (existing), AVFoundation (existing) (005-escape-cancel-countdown) - JSON file at `~/Library/Application Support/Wisp/transcription-log.json` (existing) (005-escape-cancel-countdown) +- Swift 6.1+ with strict concurrency checking enabled + AppKit (NSSound, NSStatusItem, NSMenu), ServiceManagement (SMAppService), AVFoundation (existing) (006-polish-and-cleanup) +- UserDefaults (startup preference, keyed on existing PreferencesStore) (006-polish-and-cleanup) - Swift 5.9+ with strict concurrency checking + WhisperKit (Argmax), KeyboardShortcuts (Sindre Sorhus), AppKi (001-core-dictation-flow) @@ -29,9 +31,9 @@ tests/ Swift 5.9+ with strict concurrency checking: Follow standard conventions ## Recent Changes +- 006-polish-and-cleanup: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSSound, NSStatusItem, NSMenu), ServiceManagement (SMAppService), AVFoundation (existing) - 005-escape-cancel-countdown: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSPanel, Core Animation), WhisperKit (existing), AVFoundation (existing) - 004-transcription-log: Added Swift 6.1+ with strict concurrency checking enabled + AppKit (NSWindow, NSMenu), SwiftUI (List, Button), Foundation (Codable, JSONEncoder/Decoder, FileManager) -- 003-config-screen: Added Swift 6.2 with strict concurrency checking enabled + WhisperKit (existing), KeyboardShortcuts 2.x (Sindre Sorhus — re-add to Package.swift), AVFoundation (existing), CoreAudio (system framework), Apple FoundationModels (existing), AppKit + SwiftUI diff --git a/Wisp/App/AppDelegate.swift b/Wisp/App/AppDelegate.swift index 4c1917f..7067bb7 100644 --- a/Wisp/App/AppDelegate.swift +++ b/Wisp/App/AppDelegate.swift @@ -1,9 +1,10 @@ import AppKit @preconcurrency import AVFoundation @preconcurrency import ApplicationServices +import ServiceManagement @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var statusItem: NSStatusItem? private var state: AppState = .loading @@ -22,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var logStore = TranscriptionLogStore() private var logWindow: LogWindow? private var escapeMonitor: Any? + private var launchOnStartupItem: NSMenuItem? // Cancel-countdown state (set when the first Escape is pressed during recording) private var pendingAudioBuffer: Data? @@ -31,6 +33,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) let store = PreferencesStore() + // Reconcile stored preference with actual system state (handles manual System Settings changes) + store.syncLaunchOnStartup(SMAppService.mainApp.status == .enabled) preferencesStore = store microphoneList = MicrophoneList() setupMenuBar() @@ -49,6 +53,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menuBarController?.updateState(.loading) let menu = NSMenu() + menu.delegate = self menu.addItem( NSMenuItem( title: "Preferences\u{2026}", @@ -63,6 +68,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { keyEquivalent: "" ) ) + + let startupItem = NSMenuItem( + title: "Launch on Startup", + action: #selector(toggleLaunchOnStartup), + keyEquivalent: "" + ) + startupItem.state = (preferencesStore?.launchOnStartup ?? false) ? .on : .off + launchOnStartupItem = startupItem + menu.addItem(startupItem) + menu.addItem(.separator()) menu.addItem( NSMenuItem( @@ -86,6 +101,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { PreferencesWindow.show(preferences: store, microphoneList: mics) } + @objc private func toggleLaunchOnStartup() { + guard let store = preferencesStore else { return } + do { + try store.setLaunchOnStartup(!store.launchOnStartup) + launchOnStartupItem?.state = store.launchOnStartup ? .on : .off + } catch { + let alert = NSAlert() + alert.messageText = "Could Not Update Login Item" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.runModal() + } + } + + // MARK: - NSMenuDelegate + + func menuWillOpen(_ menu: NSMenu) { + let isEnabled = SMAppService.mainApp.status == .enabled + preferencesStore?.syncLaunchOnStartup(isEnabled) + launchOnStartupItem?.state = isEnabled ? .on : .off + } + private func setupOverlay() { overlayWindow = StatusOverlayWindow() overlayWindow?.show(state: .modelLoading) @@ -303,14 +340,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { state = newState print("[Wisp] Recording started") menuBarController?.updateState(state) - menuBarController?.playStartSound() overlayWindow?.show(state: .recording) - currentSession = DictationSession() - audioCaptureService?.startRecording { [weak self] result in - DispatchQueue.main.async { - self?.handleAutoStop(result: result) + menuBarController?.playStartSound { [weak self] in + self?.audioCaptureService?.startRecording { [weak self] result in + DispatchQueue.main.async { + self?.handleAutoStop(result: result) + } } } } diff --git a/Wisp/Models/PreferencesStore.swift b/Wisp/Models/PreferencesStore.swift index 0e0bf9d..d5a1b15 100644 --- a/Wisp/Models/PreferencesStore.swift +++ b/Wisp/Models/PreferencesStore.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import ServiceManagement enum PreferencesError: Error, Equatable { case emptyPrompt @@ -21,12 +22,14 @@ final class PreferencesStore { private(set) var cleanupPrompt: String private(set) var selectedMicrophoneUID: String? + private(set) var launchOnStartup: Bool private let defaults: UserDefaults private enum Keys { static let cleanupPrompt = "com.wisp.cleanupPrompt" static let selectedMicrophoneUID = "com.wisp.selectedMicrophoneUID" + static let launchOnStartup = "com.wisp.launchOnStartup" } init(defaults: UserDefaults = .standard) { @@ -35,6 +38,7 @@ final class PreferencesStore { let trimmed = stored.trimmingCharacters(in: .whitespacesAndNewlines) self.cleanupPrompt = trimmed.isEmpty ? Self.defaultCleanupPrompt : trimmed self.selectedMicrophoneUID = defaults.string(forKey: Keys.selectedMicrophoneUID) + self.launchOnStartup = defaults.bool(forKey: Keys.launchOnStartup) } // MARK: - Cleanup Prompt @@ -65,4 +69,30 @@ final class PreferencesStore { func resetMicrophone() { setMicrophoneUID(nil) } + + // MARK: - Launch on Startup + + /// Registers or unregisters the app as a login item and persists the preference. + /// Throws if the system call fails (e.g. permission denied, MDM restriction). + func setLaunchOnStartup(_ enabled: Bool) throws { + let currentStatus = SMAppService.mainApp.status + if enabled { + if currentStatus != .enabled { + try SMAppService.mainApp.register() + } + } else { + if currentStatus == .enabled || currentStatus == .requiresApproval { + try SMAppService.mainApp.unregister() + } + } + launchOnStartup = enabled + defaults.set(enabled, forKey: Keys.launchOnStartup) + } + + /// Updates the stored preference to match the actual system state without calling SMAppService. + /// Called at app launch to reconcile with changes made outside the app (e.g. System Settings). + func syncLaunchOnStartup(_ enabled: Bool) { + launchOnStartup = enabled + defaults.set(enabled, forKey: Keys.launchOnStartup) + } } diff --git a/Wisp/UI/MenuBarController.swift b/Wisp/UI/MenuBarController.swift index 025a798..8fb320c 100644 --- a/Wisp/UI/MenuBarController.swift +++ b/Wisp/UI/MenuBarController.swift @@ -1,11 +1,52 @@ import AppKit -final class MenuBarController { +@MainActor +final class MenuBarController: NSObject { private let statusItem: NSStatusItem + // Beep-timing state + private var activeStartSound: NSSound? + private var startSoundCompletion: (() -> Void)? + private var startSoundFallbackTask: Task? + + // Ghost icon drawn programmatically as a template image (original design, not Nintendo's Wisp) + private static let ghostIcon: NSImage = { + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size, flipped: false) { _ in + NSColor.black.setFill() + let path = NSBezierPath() + path.windingRule = .evenOdd + // Ghost head (top semicircle) — center (9,11), radius 6 + path.move(to: NSPoint(x: 3, y: 11)) + path.appendArc( + withCenter: NSPoint(x: 9, y: 11), radius: 6, startAngle: 180, endAngle: 0, + clockwise: true) + // Straight sides down to scalloped skirt + path.line(to: NSPoint(x: 15, y: 2)) + // Three downward scallops, right → left + path.appendArc( + withCenter: NSPoint(x: 13, y: 2), radius: 2, startAngle: 0, endAngle: 180, + clockwise: true) + path.appendArc( + withCenter: NSPoint(x: 9, y: 2), radius: 2, startAngle: 0, endAngle: 180, + clockwise: true) + path.appendArc( + withCenter: NSPoint(x: 5, y: 2), radius: 2, startAngle: 0, endAngle: 180, + clockwise: true) + path.close() + path.appendOval(in: NSRect(x: 5.3, y: 11.8, width: 2.4, height: 2.4)) + path.appendOval(in: NSRect(x: 10.3, y: 11.8, width: 2.4, height: 2.4)) + path.fill() + return true + } + image.isTemplate = true + return image + }() + init(statusItem: NSStatusItem) { self.statusItem = statusItem + super.init() updateState(.idle) } @@ -18,21 +59,13 @@ final class MenuBarController { systemSymbolName: "hourglass", accessibilityDescription: "Wisp — Loading" ) - case .idle: - button.image = NSImage( - systemSymbolName: "waveform", - accessibilityDescription: "Wisp — Idle" - ) + case .idle, .cancelling: + button.image = Self.ghostIcon case .recording: button.image = NSImage( systemSymbolName: "mic.fill", accessibilityDescription: "Wisp — Recording" ) - case .cancelling: - button.image = NSImage( - systemSymbolName: "waveform", - accessibilityDescription: "Wisp — Cancelling" - ) case .processing: button.image = NSImage( systemSymbolName: "ellipsis.circle", @@ -41,20 +74,56 @@ final class MenuBarController { } } - func playStartSound() { - playSound(named: "record-start") - } + /// Plays the start beep and calls `completion` only after the sound finishes. + /// Falls back to calling `completion` immediately if the sound asset is unavailable, + /// and after 1 second if the delegate callback never fires. + func playStartSound(completion: @escaping @MainActor () -> Void) { + guard let url = Bundle.module.url(forResource: "record-start", withExtension: "wav"), + let sound = NSSound(contentsOf: url, byReference: true) + else { + completion() + return + } - func playStopSound() { - playSound(named: "record-stop") + startSoundCompletion = completion + activeStartSound = sound + + startSoundFallbackTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return // Cancelled when delegate fired normally + } + self?.fireSoundCompletion() + } + + sound.delegate = self + sound.play() } - private func playSound(named name: String) { - guard let url = Bundle.module.url(forResource: name, withExtension: "wav") else { - print("[Wisp] Sound not found: \(name).wav") + func playStopSound() { + guard let url = Bundle.module.url(forResource: "record-stop", withExtension: "wav") else { + print("[Wisp] Sound not found: record-stop.wav") return } let sound = NSSound(contentsOf: url, byReference: true) sound?.play() } + + private func fireSoundCompletion() { + startSoundFallbackTask?.cancel() + startSoundFallbackTask = nil + activeStartSound = nil + let completion = startSoundCompletion + startSoundCompletion = nil + completion?() + } +} + +extension MenuBarController: NSSoundDelegate { + nonisolated func sound(_ sound: NSSound, didFinishPlaying flag: Bool) { + Task { @MainActor [weak self] in + self?.fireSoundCompletion() + } + } } diff --git a/Wisp/UI/PreferencesView.swift b/Wisp/UI/PreferencesView.swift index 5ee5a83..1f1e44e 100644 --- a/Wisp/UI/PreferencesView.swift +++ b/Wisp/UI/PreferencesView.swift @@ -13,6 +13,7 @@ struct PreferencesView: View { Form { shortcutSection microphoneSection + startupSection promptSection } .formStyle(.grouped) @@ -54,6 +55,18 @@ struct PreferencesView: View { } } + private var startupSection: some View { + Section("System") { + Toggle( + "Launch Wisp on Startup", + isOn: Binding( + get: { preferences.launchOnStartup }, + set: { try? preferences.setLaunchOnStartup($0) } + ) + ) + } + } + private var promptSection: some View { Section("Transcription Cleanup Prompt") { TextEditor(text: $promptDraft) diff --git a/dist/Wisp.app/Contents/MacOS/Wisp b/dist/Wisp.app/Contents/MacOS/Wisp index f6a66dc..1923051 100755 Binary files a/dist/Wisp.app/Contents/MacOS/Wisp and b/dist/Wisp.app/Contents/MacOS/Wisp differ diff --git a/dist/Wisp.dmg b/dist/Wisp.dmg index a320ba2..3d1d9d8 100644 Binary files a/dist/Wisp.dmg and b/dist/Wisp.dmg differ diff --git a/dist/Wisp.pkg b/dist/Wisp.pkg index 34d811c..6d56361 100644 Binary files a/dist/Wisp.pkg and b/dist/Wisp.pkg differ diff --git a/specs/006-polish-and-cleanup/checklists/requirements.md b/specs/006-polish-and-cleanup/checklists/requirements.md new file mode 100644 index 0000000..43b3b6e --- /dev/null +++ b/specs/006-polish-and-cleanup/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Polish and Cleanup + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass. Spec is ready for `/speckit.plan`. diff --git a/specs/006-polish-and-cleanup/data-model.md b/specs/006-polish-and-cleanup/data-model.md new file mode 100644 index 0000000..3b85b93 --- /dev/null +++ b/specs/006-polish-and-cleanup/data-model.md @@ -0,0 +1,58 @@ +# Data Model: Polish and Cleanup + +**Branch**: `006-polish-and-cleanup` | **Date**: 2026-03-29 + +--- + +## Changed Entities + +### PreferencesStore (modified) + +Existing model at `Wisp/Models/PreferencesStore.swift`. One new persisted property added: + +| Property | Type | Storage Key | Default | Notes | +|----------|------|-------------|---------|-------| +| `launchOnStartup` | `Bool` | `"launchOnStartup"` | `false` | Synced bidirectionally with SMAppService status on app launch | + +**State transitions**: + +``` +launchOnStartup == false + → user enables toggle + → SMAppService.mainApp.register() called + → on success: launchOnStartup = true, menu item state = .on + → on failure: revert to false, show NSAlert + +launchOnStartup == true + → user disables toggle + → SMAppService.mainApp.unregister() called + → on success: launchOnStartup = false, menu item state = .off + → on failure: retain true, show NSAlert + +App launches + → read SMAppService.mainApp.status + → if .enabled and stored == false: set stored = true (external enable) + → if !enabled and stored == true: set stored = false (external disable, e.g. from System Settings) +``` + +--- + +## New Assets + +### StatusBarIcon (new image asset) + +| Property | Value | +|----------|-------| +| Asset name | `StatusBarIcon` | +| Format | PDF or SVG | +| Render mode | Template Image | +| Usage | Idle state icon in `MenuBarController` | +| Dimensions | ~18×18 pt (menu bar standard) | + +The ghost design is purely a visual asset — no structured data model. + +--- + +## No New Persistent Entities + +The beep timing fix and icon replacement involve no new stored data. The startup preference is the only data model change. diff --git a/specs/006-polish-and-cleanup/plan.md b/specs/006-polish-and-cleanup/plan.md new file mode 100644 index 0000000..1e4a922 --- /dev/null +++ b/specs/006-polish-and-cleanup/plan.md @@ -0,0 +1,133 @@ +# Implementation Plan: Polish and Cleanup + +**Branch**: `006-polish-and-cleanup` | **Date**: 2026-03-29 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/006-polish-and-cleanup/spec.md` + +## Summary + +Three independent polish items before 1.0: +1. Replace the placeholder SF Symbol menu bar icon with a custom circular ghost illustration. +2. Fix a timing bug where `NSSound.play()` is asynchronous — recording begins before the beep finishes, so the beep can be captured by the microphone. +3. Add a "Launch Wisp on Startup" toggle to the menu, backed by `SMAppService`. + +## Technical Context + +**Language/Version**: Swift 6.1+ with strict concurrency checking enabled +**Primary Dependencies**: AppKit (NSSound, NSStatusItem, NSMenu), ServiceManagement (SMAppService), AVFoundation (existing) +**Storage**: UserDefaults (startup preference, keyed on existing PreferencesStore) +**Testing**: XCTest +**Target Platform**: macOS 26+, Apple Silicon and Intel +**Project Type**: macOS menu bar desktop app (LSUIElement) +**Performance Goals**: Beep-to-recording delay ≤ beep duration (no added latency beyond the beep itself) +**Constraints**: App is sandboxed/notarized — SMAppService is the correct modern API for login items in this context; legacy SMLoginItemEnabled is not appropriate +**Scale/Scope**: Single-user background utility + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Assessment | Notes | +|-----------|-----------|-------| +| I. Privacy-First Local Processing | ✅ PASS | No network calls introduced. Icon is a local asset. SMAppService is a local system call. | +| II. Type Safety & Correctness | ✅ PASS | NSSound delegate callback is typed. SMAppService returns typed errors. No force-unwraps needed. | +| III. Test-First Development | ✅ PASS | Beep-timing fix and startup-pref toggle are unit-testable with mocks. Icon is visual-only (manual verification). | +| IV. Performance-Conscious Design | ✅ PASS | Adding a delegate callback to NSSound does not affect the audio capture hot path. | +| V. Simplicity & YAGNI | ✅ PASS | Three targeted fixes, no new abstractions. Startup pref reuses PreferencesStore pattern. | + +**Post-design re-check**: All principles still satisfied after Phase 1 design (no surprises; changes are additive and contained). + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-polish-and-cleanup/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks — NOT created here) +``` + +### Source Code (repository root) + +```text +Wisp/ +├── App/ +│ └── AppDelegate.swift # MODIFY: menu setup (startup toggle), recording start sequence +├── Models/ +│ └── PreferencesStore.swift # MODIFY: add launchOnStartup Bool property +├── UI/ +│ ├── MenuBarController.swift # MODIFY: icon asset swap + NSSound delegate for beep timing +│ └── PreferencesView.swift # MODIFY: add Launch on Startup toggle +├── Resources/ +│ ├── Assets.xcassets/ # ADD: ghost icon image set (AppIcon equivalent for status bar) +│ └── Sounds/ # unchanged +└── Wisp.entitlements # likely unchanged (SMAppService works in sandbox) +``` + +**Structure Decision**: Single-project option. All changes are additive modifications to existing files plus one new image asset. + +## Complexity Tracking + +> No constitution violations — table omitted. + +--- + +## Phase 0: Research + +*All findings consolidated in [research.md](research.md)* + +**Research tasks executed**: +1. NSSound delegate API for play-completion callback +2. SMAppService API for login items (macOS 13+) +3. SF Symbol / NSImage template rendering for menu bar icons +4. Beep-recording timing root cause analysis + +--- + +## Phase 1: Design + +### Beep Timing Fix + +**Root cause**: `AppDelegate.swift` lines ~306-311 call `menuBarController?.playStartSound()` and then immediately `audioCaptureService?.startRecording(...)`. `NSSound.play()` is fire-and-forget; there is no await or callback, so recording begins while the beep is still playing. + +**Fix design**: +- Adopt `NSSoundDelegate` on `MenuBarController` (or a small dedicated helper). +- Implement `sound(_:didFinishPlaying:)`. +- In `AppDelegate`, instead of calling `startRecording` directly after `playStartSound`, pass a completion closure into `playStartSound(completion:)`. +- `MenuBarController` holds the closure and fires it from the delegate callback. +- This adds zero extra delay — recording starts the instant the beep ends. + +**Fallback**: If `NSSoundDelegate` never fires (e.g. sound file missing), start recording after a conservative 600 ms timeout so the feature degrades gracefully. + +### Ghost Icon + +**Design**: +- A new `StatusBarIcon.pdf` (vector, template-mode) is added to `Assets.xcassets`. +- The asset uses "Template Image" rendering so macOS automatically inverts it for dark/light mode and active/highlight states. +- The ghost shape: a filled circle with a small scalloped bottom edge and two small dot eyes — clearly a ghost, clearly not Nintendo's Wisp (different colour, shape language, no ears, minimal expression). +- `MenuBarController` loads the asset via `NSImage(named:)` and sets `isTemplate = true`. +- The existing state-based icon switching (waveform, mic.fill, etc.) remains — only the idle-state icon changes to the ghost; alternatively all states use a tinted variant of the ghost. Decision: use ghost only for idle, keep existing SF Symbol states for recording/processing so users retain clear feedback. + +### Launch on Startup + +**Design**: +- Add `launchOnStartup: Bool` to `PreferencesStore`, backed by `UserDefaults`. +- On `didSet`, call `SMAppService.mainApp.register()` or `.unregister()`. +- Add a `Toggle("Launch Wisp on Startup", isOn: $preferences.launchOnStartup)` to `PreferencesView.swift`. +- Additionally add a menu item to the dropdown (`AppDelegate.setupMenuBar`) so it is accessible without opening preferences: + - Item title: "Launch on Startup" with a checkmark when enabled. + - `NSMenuItem.state` set to `.on`/`.off` based on current preference. + - Toggling via the menu calls the same PreferencesStore setter. +- On app launch, sync toggle state with `SMAppService.mainApp.status == .enabled` to handle cases where the user removed the login item from System Settings manually. + +**Error handling**: `SMAppService` throws on register/unregister failure. Catch and log; surface an `NSAlert` if the operation fails so users understand the preference was not saved. + +### Contracts + +This is a menu bar utility with no external API surface. No contracts/ directory needed. + +### Agent Context Update + +See below — agent context file updated after writing plan. diff --git a/specs/006-polish-and-cleanup/quickstart.md b/specs/006-polish-and-cleanup/quickstart.md new file mode 100644 index 0000000..639824b --- /dev/null +++ b/specs/006-polish-and-cleanup/quickstart.md @@ -0,0 +1,60 @@ +# Quickstart: Polish and Cleanup + +**Branch**: `006-polish-and-cleanup` | **Date**: 2026-03-29 + +## Three independent changes — each can be built and tested separately + +--- + +### 1. Ghost Icon + +**What to build**: +- Create or procure a simple circular ghost SVG/PDF (round head, scalloped or wavy base, two dot eyes; monochrome, template-mode). +- Add it to `Wisp/Resources/Assets.xcassets` as `StatusBarIcon`, Render As: Template Image. +- In `MenuBarController.updateState()`, replace the `"waveform"` SF Symbol used for the `.idle` state with `NSImage(named: "StatusBarIcon")`. + +**How to verify**: +1. Build and run. The menu bar icon in the idle state shows the ghost. +2. Toggle dark/light mode — icon remains visible and inverts correctly. +3. Start a recording session — icon reverts to `mic.fill` (existing behaviour unchanged). + +--- + +### 2. Beep Timing Fix + +**What to build**: +- In `MenuBarController`, conform to `NSSoundDelegate`. +- Add `playStartSound(completion: @escaping () -> Void)` — stores the closure, sets `sound.delegate = self`, calls `sound.play()`. +- In `sound(_:didFinishPlaying:)`, fire the stored closure. +- Add a fallback: if the delegate never fires within 1000 ms, fire the closure anyway (use a `DispatchWorkItem` that is cancelled on delegate callback). +- In `AppDelegate`, replace the two-step `playStartSound()` + `startRecording(...)` with: + ```swift + menuBarController?.playStartSound { + self.audioCaptureService?.startRecording(autoStopHandler: ...) + } + ``` + +**How to verify**: +1. Trigger dictation with the MacBook's built-in microphone and speakers at medium volume. +2. Say nothing — just let the session run for 2 seconds. +3. Stop the session. The transcript should be empty (or contain only silence artefacts), never the beep sound. +4. Repeat 5 times — beep should never appear in transcript. + +--- + +### 3. Launch on Startup + +**What to build**: +- Add `launchOnStartup: Bool` to `PreferencesStore` with `UserDefaults` backing. +- On `didSet`, call `SMAppService.mainApp.register()` or `.unregister()`; handle errors with `NSAlert`. +- On app launch, reconcile stored value with `SMAppService.mainApp.status`. +- In `AppDelegate.setupMenuBar()`, add a "Launch on Startup" `NSMenuItem` above the separator, with state bound to `PreferencesStore.launchOnStartup`. +- Add corresponding `Toggle` to `PreferencesView`. + +**How to verify**: +1. Open the menu. "Launch on Startup" item is present and unchecked by default. +2. Click it. Checkmark appears. Open System Settings > General > Login Items — Wisp is listed. +3. Quit and log out, log back in — Wisp launches automatically. +4. Open the menu. Click "Launch on Startup" again. Checkmark disappears. +5. Quit and log out, log back in — Wisp does NOT launch automatically. +6. Remove Wisp from Login Items in System Settings manually, then relaunch Wisp — the menu item shows unchecked (reconciliation worked). diff --git a/specs/006-polish-and-cleanup/research.md b/specs/006-polish-and-cleanup/research.md new file mode 100644 index 0000000..43311ce --- /dev/null +++ b/specs/006-polish-and-cleanup/research.md @@ -0,0 +1,82 @@ +# Research: Polish and Cleanup + +**Branch**: `006-polish-and-cleanup` | **Date**: 2026-03-29 + +--- + +## 1. NSSound Delegate for Play-Completion Callback + +**Decision**: Use `NSSoundDelegate.sound(_:didFinishPlaying:)` to detect beep completion. + +**Rationale**: `NSSound.play()` is asynchronous and returns immediately. The delegate callback `sound(_:didFinishPlaying:)` fires on the main thread when playback ends (or fails). This is the only supported mechanism to know when an NSSound has finished — there is no async/await alternative in AppKit for NSSound as of macOS 26. Storing a closure and firing it from the delegate is idiomatic. + +**Alternatives considered**: +- **Fixed delay (e.g. `DispatchQueue.main.asyncAfter`)**: Fragile — depends on sound file duration staying constant; wastes time if the sound is short. +- **AVAudioPlayer with `audioPlayerDidFinishPlaying`**: Works but introduces a second audio framework dependency when NSSound is already used. Rejected per Simplicity principle. +- **Replace beep with silent lead-in**: Would change the user experience without fixing the root cause. + +**Implementation note**: Set `sound.delegate = self` (or a dedicated helper object) before calling `play()`. The delegate object must be retained for the duration of playback. + +--- + +## 2. SMAppService for Login Items (macOS 13+) + +**Decision**: Use `SMAppService.mainApp` to register/unregister the app as a login item. + +**Rationale**: `SMAppService` (introduced macOS 13, ServiceManagement framework) is the modern, sandboxing-compatible API for login items. It replaces the deprecated `SMLoginItemEnabled` and the `LaunchAgent` plist approach. The app's bundle identifier (`com.wisp.Wisp`) is already set; no helper app or bundle embedding is required for `mainApp` registration. The entitlements file does not need changes — `SMAppService` works within the existing sandbox. + +**Status codes to handle**: +- `.enabled` — registered and will launch at login +- `.requiresApproval` — registered but waiting for user approval in System Settings (macOS 13 behaviour; should surface a prompt directing users to System Settings > General > Login Items) +- `.notFound` / `.notRegistered` — not registered +- Throws `SMAppServiceError` on failure + +**Alternatives considered**: +- **`SMLoginItemEnabled`** (deprecated): Does not work for main app registration in modern macOS. +- **`LaunchAgent` plist in `~/Library/LaunchAgents`**: Not sandboxing-compatible without additional entitlements. Overly complex. +- **`ServiceManagement.framework` + helper bundle**: Unnecessary — `SMAppService.mainApp` registers the app itself without a helper. + +**Sync on launch**: Compare `SMAppService.mainApp.status` with the stored `UserDefaults` value on startup and reconcile — the user may have toggled login items in System Settings between launches. + +--- + +## 3. Template Image Rendering for Menu Bar Icons + +**Decision**: Add the ghost icon as a PDF vector asset in `Assets.xcassets` with "Render As: Template Image". + +**Rationale**: Template images are single-channel (alpha-only); macOS composites them with the appropriate tint colour automatically for light mode, dark mode, menu bar active/highlight states, and accessibility high-contrast mode. This is the standard approach for all NSStatusItem icons. Using PDF preserves sharpness at all resolutions (1x, 2x Retina, future densities). + +**Required asset configuration**: +- Name: e.g. `StatusBarIcon` +- Type: PDF or SVG (Xcode 15+ supports SVG natively) +- Render As: Template Image (set in asset catalogue) +- Sizes: "Single Scale" is sufficient when using PDF/SVG + +**Loading in code**: +```swift +let image = NSImage(named: "StatusBarIcon") +image?.isTemplate = true // belt-and-suspenders; asset catalogue already sets this +statusItem.button?.image = image +``` + +**Alternatives considered**: +- **SF Symbol**: No existing "round ghost" in SF Symbols 5. Creating a custom SF Symbol is possible but requires the SF Symbols app and is harder to iterate on visually. +- **PNG @1x/@2x**: Works but resolution-dependent. PDF/SVG is strictly better. + +--- + +## 4. Beep-Recording Timing Root Cause Confirmation + +**Root cause confirmed**: In `AppDelegate.swift`, the call sequence is: + +```swift +menuBarController?.playStartSound() // fires NSSound.play() — returns immediately +// ... a few more synchronous lines ... +audioCaptureService?.startRecording(autoStopHandler:) // installs AVAudioEngine tap +``` + +`NSSound.play()` returns before the beep has played. The `AVAudioEngine` tap is installed almost simultaneously, meaning the first audio frames captured by the microphone overlap with the tail of the beep being played through the speakers. On macs with minimal speaker-microphone isolation (MacBook built-in speakers) this causes the beep to be picked up. + +**Fix**: Gate `startRecording` on the `NSSoundDelegate` callback. The `record-start.wav` asset duration determines the actual delay — typically 0.2–0.5 s. No artificial sleep or hardcoded delay is needed. + +**Fallback timer**: If `didFinishPlaying` does not fire within 1000 ms (sound file missing, delegate not set, etc.), fall back to starting recording unconditionally, preserving existing behaviour. diff --git a/specs/006-polish-and-cleanup/spec.md b/specs/006-polish-and-cleanup/spec.md new file mode 100644 index 0000000..ed8aa11 --- /dev/null +++ b/specs/006-polish-and-cleanup/spec.md @@ -0,0 +1,98 @@ +# Feature Specification: Polish and Cleanup + +**Feature Branch**: `006-polish-and-cleanup` +**Created**: 2026-03-29 +**Status**: Draft +**Input**: User description: "couple of issues to clean up before this is done. one, can we make the logo a circular ghost that looks a bit like (but is legally distinct from) wisp from animal crossing? second, sometimes the transcript includes the initial 'beep' - is there a timing issue? third, can we add a 'launch wisp on startup' option to the dropdown menu? then i think the project is in a good shape" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Custom Ghost Logo (Priority: P1) + +The app's menu bar icon should display a friendly, circular ghost character that gives Wisp a distinctive identity. The ghost should evoke the whimsical, translucent spirit aesthetic without reproducing any copyrighted or trademarked artwork — it must be clearly original. + +**Why this priority**: The app icon is the most visible element of the product; it sets the tone and brand identity. Getting a distinctive look matters before the project is considered done. + +**Independent Test**: Can be fully tested by launching the app and inspecting the menu bar icon — it either shows an original circular ghost or it does not. + +**Acceptance Scenarios**: + +1. **Given** the app is running, **When** the user looks at the menu bar, **Then** the icon shows a circular ghost shape that is visually distinct from any Nintendo-owned artwork. +2. **Given** the ghost icon is displayed, **When** inspected at normal menu bar size, **Then** the ghost is clearly recognisable as a ghost (rounded head, wispy base or similar) and looks polished at small sizes. +3. **Given** macOS dark mode and light mode, **When** the icon is shown in either mode, **Then** it remains legible and visually appealing in both contexts. + +--- + +### User Story 2 - Fix Beep Captured in Transcript (Priority: P2) + +When a user triggers dictation, the app plays a start beep. Occasionally the beep sound itself is picked up by the microphone and transcribed as noise or garbled text at the beginning of the transcript. The recording should not begin until the beep has finished playing. + +**Why this priority**: Transcription accuracy is the core value of the product. Artefacts from the app itself appearing in the transcript are a quality defect that undermines user trust. + +**Independent Test**: Can be fully tested by triggering dictation and inspecting the resulting transcript — if the beep is never transcribed, the fix works. + +**Acceptance Scenarios**: + +1. **Given** the user triggers dictation, **When** the start beep plays, **Then** the microphone capture begins only after the beep has fully played, so the beep is never captured. +2. **Given** the user triggers dictation multiple times in succession, **When** each session starts, **Then** none of the transcripts contain audio artefacts from the start beep. +3. **Given** the user triggers dictation and speaks immediately after the beep, **When** the transcript is produced, **Then** all spoken words are captured and no beep noise appears at the start. + +--- + +### User Story 3 - Launch on Startup Option (Priority: P3) + +The app's menu should include a toggle to control whether Wisp launches automatically when the user logs in to their Mac. This removes the need to manually start the app after every reboot. + +**Why this priority**: This is a quality-of-life convenience feature expected by mature menu bar apps. Lower priority because the app functions correctly without it. + +**Independent Test**: Can be fully tested by enabling the option, restarting the Mac, and confirming Wisp is running; then disabling it, restarting again, and confirming it does not start automatically. + +**Acceptance Scenarios**: + +1. **Given** the dropdown menu is open, **When** the user views the menu, **Then** a "Launch Wisp on Startup" toggle item is present, showing its current state (enabled/disabled). +2. **Given** "Launch Wisp on Startup" is disabled, **When** the user selects it, **Then** it becomes enabled and Wisp will launch automatically after the next login. +3. **Given** "Launch Wisp on Startup" is enabled, **When** the user selects it again, **Then** it becomes disabled and Wisp will not launch automatically after the next login. +4. **Given** the startup preference has been set, **When** the app is quit and relaunched, **Then** the toggle reflects the previously saved state. + +--- + +### Edge Cases + +- What happens if the ghost icon is viewed on a non-Retina display — does it look acceptable at 1x resolution? +- What if the beep delay pushes the recording start noticeably later — does it feel laggy to the user? +- What happens if the system denies permission to register a login item — is the user informed with a helpful message? +- What if the user enables startup launch on a managed Mac where login items are restricted by policy? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The app icon displayed in the menu bar MUST be a custom, original circular ghost illustration that is visually distinct from any Nintendo-owned artwork. +- **FR-002**: The ghost icon MUST be legible and polished at standard macOS menu bar icon sizes. +- **FR-003**: The ghost icon MUST adapt appropriately to both macOS light mode and dark mode so it remains visible in both contexts. +- **FR-004**: Microphone recording MUST NOT begin until the start beep has completely finished playing, ensuring the beep cannot be captured in the audio input. +- **FR-005**: Transcripts MUST NOT contain audio artefacts from the app's own start beep under normal operating conditions. +- **FR-006**: The dropdown menu MUST include a "Launch Wisp on Startup" toggle item. +- **FR-007**: The "Launch Wisp on Startup" toggle MUST reflect the current state (on/off) each time the menu is opened. +- **FR-008**: Enabling "Launch Wisp on Startup" MUST register Wisp as a login item so it launches automatically after the user logs in. +- **FR-009**: Disabling "Launch Wisp on Startup" MUST remove Wisp from the login items so it no longer launches automatically. +- **FR-010**: The startup preference MUST persist across app restarts and system reboots. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In 10 consecutive dictation sessions, zero transcripts contain audio artefacts attributable to the app's start beep. +- **SC-002**: The ghost icon is instantly recognisable as a ghost at menu bar icon size by all users shown it without prior context. +- **SC-003**: The ghost icon passes a legal distinctness review — no identifiable elements from Nintendo's Wisp character artwork are reproduced. +- **SC-004**: After enabling "Launch on Startup" and logging out and back in, Wisp is running without any manual intervention, 100% of the time. +- **SC-005**: After disabling "Launch on Startup" and logging out and back in, Wisp does not launch automatically, 100% of the time. +- **SC-006**: The startup preference toggle state is accurately reflected in the menu every time it is opened. + +## Assumptions + +- The existing menu bar icon is a placeholder or system symbol; replacing it with a custom asset is safe and expected. +- macOS login item registration is done through the standard system API available to sandboxed apps; no special entitlements beyond what is already in place are required. +- A short fixed delay between beep completion and recording start (up to 500ms) is acceptable and will not feel laggy to users. +- The ghost illustration will be created as a vector/template asset by the developer; commissioning external designers is out of scope for this specification. +- "Legally distinct" means the ghost does not reproduce specific protected elements of Nintendo's Wisp character (exact colour palette, ear shape, expression, overall composition) even if it shares the general concept of a round ghost, which is not protected. diff --git a/specs/006-polish-and-cleanup/tasks.md b/specs/006-polish-and-cleanup/tasks.md new file mode 100644 index 0000000..1b2997f --- /dev/null +++ b/specs/006-polish-and-cleanup/tasks.md @@ -0,0 +1,168 @@ +# Tasks: Polish and Cleanup + +**Input**: Design documents from `/specs/006-polish-and-cleanup/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, quickstart.md ✅ + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: No new project setup required — all three changes are additive modifications to the existing Swift package. No dependencies to add; `ServiceManagement` is a system framework. + +- [x] T001 Verify `ServiceManagement` framework is imported in `Wisp/App/AppDelegate.swift` (system framework, no Package.swift change needed) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: No shared foundational changes are required. Each user story is independently additive. Proceed directly to user story phases. + +**Checkpoint**: Foundation ready — all three user stories can begin independently. + +--- + +## Phase 3: User Story 1 — Custom Ghost Logo (Priority: P1) 🎯 MVP + +**Goal**: Replace the placeholder SF Symbol idle-state icon with a custom original circular ghost illustration. + +**Independent Test**: Build and run the app. In idle state the menu bar icon shows a ghost shape. Toggle dark/light mode — icon remains legible. Start a recording session — icon reverts to `mic.fill` (existing behaviour). + +### Implementation for User Story 1 + +- [x] T002 [US1] Create ghost icon SVG or PDF and add to `Wisp/Resources/Assets.xcassets/StatusBarIcon.imageset/` as a Template Image asset +- [x] T003 [US1] In `Wisp/UI/MenuBarController.swift`, update `updateState(_:)` case `.idle` (and `.cancelling`) to load `NSImage(named: "StatusBarIcon")` with `isTemplate = true` instead of the `"waveform"` SF Symbol +- [ ] T004 [US1] Build and verify the ghost icon renders at menu bar size in both light and dark mode (manual visual check per quickstart.md §1) + +**Checkpoint**: User Story 1 complete — custom ghost icon visible in idle state, all other state icons unchanged. + +--- + +## Phase 4: User Story 2 — Fix Beep Captured in Transcript (Priority: P2) + +**Goal**: Ensure microphone recording only starts after the start beep has fully played, preventing the beep from being transcribed. + +**Independent Test**: Trigger dictation, say nothing for 2 seconds, stop. Transcript is empty. Repeat 5 times — beep never appears. (See quickstart.md §2.) + +### Implementation for User Story 2 + +- [x] T005 [US2] In `Wisp/UI/MenuBarController.swift`, add `NSSoundDelegate` conformance and a stored `startSoundCompletion: (() -> Void)?` property +- [x] T006 [US2] In `Wisp/UI/MenuBarController.swift`, replace `playStartSound()` with `playStartSound(completion: @escaping () -> Void)` — store the closure, set `sound.delegate = self`, call `sound.play()` +- [x] T007 [US2] In `Wisp/UI/MenuBarController.swift`, implement `sound(_:didFinishPlaying:)` — fire and clear `startSoundCompletion`; add a 1000 ms `DispatchWorkItem` fallback that fires the closure if the delegate never calls back (cancel the work item inside the delegate method) +- [x] T008 [US2] In `Wisp/App/AppDelegate.swift`, update the recording-start sequence (around line 306) to call `menuBarController?.playStartSound { [weak self] in self?.audioCaptureService?.startRecording(autoStopHandler: ...) }` instead of calling them sequentially +- [ ] T009 [US2] Build and verify beep-timing fix: trigger dictation 5 times with built-in mic + speakers, confirm beep never appears in transcripts (manual test per quickstart.md §2) + +**Checkpoint**: User Story 2 complete — beep no longer captured in transcripts. + +--- + +## Phase 5: User Story 3 — Launch on Startup (Priority: P3) + +**Goal**: Add a "Launch on Startup" toggle accessible from both the dropdown menu and Preferences, backed by `SMAppService`. + +**Independent Test**: Enable toggle → log out/in → Wisp launches automatically. Disable toggle → log out/in → Wisp does not launch. Toggle reflects correct state after relaunch and after manual System Settings change. (See quickstart.md §3.) + +### Implementation for User Story 3 + +- [x] T010 [P] [US3] In `Wisp/Models/PreferencesStore.swift`, add `launchOnStartup: Bool` property backed by `UserDefaults` key `"launchOnStartup"`, default `false` +- [x] T011 [US3] In `Wisp/Models/PreferencesStore.swift`, add `didSet` on `launchOnStartup` that calls `SMAppService.mainApp.register()` when `true` and `.unregister()` when `false`; catch `SMAppServiceError` and revert the stored value, then post a notification for the UI to show an `NSAlert` +- [x] T012 [US3] In `Wisp/App/AppDelegate.swift`, add `import ServiceManagement` and, in `applicationDidFinishLaunching`, reconcile `PreferencesStore.launchOnStartup` with `SMAppService.mainApp.status` (set stored value to `true` if `.enabled`, `false` otherwise) +- [x] T013 [P] [US3] In `Wisp/UI/PreferencesView.swift`, add a `Toggle("Launch Wisp on Startup", isOn: $preferences.launchOnStartup)` row to the preferences form +- [x] T014 [US3] In `Wisp/App/AppDelegate.swift` `setupMenuBar()`, insert a "Launch on Startup" `NSMenuItem` above the separator; bind its `.state` to `preferencesStore.launchOnStartup` (`.on`/`.off`); wire its action to toggle `preferencesStore.launchOnStartup` (depends on T010, T011) +- [x] T015 [US3] In `Wisp/App/AppDelegate.swift`, ensure the menu item state is refreshed when `NSMenu` is about to open (implement `menuWillOpen(_:)` delegate method to re-read current `SMAppService.mainApp.status`) +- [ ] T016 [US3] Build and verify the full launch-on-startup flow per quickstart.md §3: enable, logout/login, Wisp starts; disable, logout/login, Wisp does not start; manual System Settings removal reconciles correctly + +**Checkpoint**: User Story 3 complete — launch-on-startup toggle works end-to-end. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation and any cross-story tidy-up. + +- [x] T017 [P] Run a full build with strict concurrency checking and confirm zero warnings or errors introduced by this feature +- [ ] T018 Run quickstart.md validation for all three stories end-to-end in a single session +- [x] T019 [P] Confirm `NSSound` delegate object lifetime: ensure no retain cycle between `MenuBarController` and the sound delegate (review `sound.delegate = self` and stored closure) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: N/A — skipped, no shared prerequisites +- **User Stories (Phases 3–5)**: All three are **fully independent** and can proceed in parallel after T001 +- **Polish (Phase 6)**: Depends on all three user story phases being complete + +### User Story Dependencies + +- **US1 (P1)**: Independent — only touches `MenuBarController.swift` and `Assets.xcassets` +- **US2 (P2)**: Independent — touches `MenuBarController.swift` (different methods) and `AppDelegate.swift` (recording sequence only) +- **US3 (P3)**: Independent — touches `PreferencesStore.swift`, `PreferencesView.swift`, and `AppDelegate.swift` (menu setup + launch reconciliation) + +*Note*: US2 and US3 both touch `AppDelegate.swift` but in different, non-conflicting locations. They can be worked in parallel by different developers with minimal merge risk. + +### Within Each User Story + +- US1: Asset creation (T002) before code change (T003) +- US2: Delegate implementation (T005–T007) before call-site update (T008) +- US3: Model (T010–T011) before menu item (T014); reconciliation (T012) can be done in parallel with T013 + +### Parallel Opportunities + +- T010 and T013 (US3 model property + preferences UI) can run in parallel — different files +- All three user story phases can run in parallel if two developers are available +- T017 and T019 (Polish phase) can run in parallel + +--- + +## Parallel Example: User Story 3 + +``` +# Two developers can split US3: +Developer A: T010 → T011 → T012 → T014 → T015 (model + menu item + reconciliation) +Developer B: T013 (PreferencesView toggle — independent file) +# Then both: T016 (end-to-end verification) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete T001 (setup check) +2. Complete Phase 3 (T002–T004): Ghost icon +3. **STOP and VALIDATE**: Menu bar shows ghost icon in both modes +4. Ship / demo + +### Incremental Delivery + +1. T001 → US1 (T002–T004) → visual validation +2. US2 (T005–T009) → beep fix validation +3. US3 (T010–T016) → startup toggle validation +4. Phase 6 polish (T017–T019) + +### Parallel Team Strategy + +With two developers: +- Dev A: US1 (ghost icon) + US2 (beep fix) in sequence +- Dev B: US3 (launch on startup) independently +- Both join for Phase 6 polish + +--- + +## Notes + +- [P] tasks = different files, no blocking dependencies +- [Story] label maps each task to its user story for traceability +- Each user story is independently completable and testable without the others +- No test tasks generated: spec does not request TDD; verification steps are manual per quickstart.md +- Commit after each user story phase checkpoint before moving to the next