# App fase 4 — App-shell Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Van menubalk-hulpprogramma naar een echte Mac-app: Dock-icoon, app-menu, hoofdvenster (voorlopig een schil), Instellingen (⌘,) met sneltoets, model en privacy, een secundair menubalkitem, en een dubbelklikbaar `Sst.app` dat ondertekend is met jouw certificaat en in Programma's kan staan. De dicteer-keten gebruikt nu ook het woordenboek en bewaart de geschiedenis. **Architecture:** `AppModel` bezit alle services (instellingen, rechten, modelbeheer, woordenboek, geschiedenis, controller, sneltoets) en is de enige brug naar SwiftUI. Testbare lijmcode (`GatedInserter`, `SwappableTranscriber`, `LevelSource`, `AppSettings`, `ModelStorage`) staat in `SstCore`, `SstData` en `SstWhisper`. De views gebruiken alleen tokens; de token-scan uit fase 3 bewaakt dat ook voor `Sources/Sst`. **Tech Stack:** Swift 6.4 (language mode 5), SwiftUI (`Window`, `Settings`, `MenuBarExtra`, `Commands`), AppKit, Combine, WhisperKit, `codesign`/`iconutil`. **Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "App-shell", "Instellingen", "Rechten en eerste start") **Voorwaarde:** fase 1, 2 en 3 zijn afgerond (`DictationController` met corrector/`onResult`, `HotkeyBinding`, `SstData`, `SstUI` met tokens en componenten). ## Global Constraints - Echte app: Dock-icoon, geen `LSUIElement`; bundle-id blijft `local.sst.app`; niet gesandboxed. - Sluit je het hoofdvenster, dan draait de app door (Dock-icoon en menubalkitem blijven; dicteren werkt). ⌘Q sluit af. Klik op het Dock-icoon opent het venster weer. - Menubalkitem is secundair: statusregel, sneltoets, "Open Sst", "Instellingen…", "Stop Sst". Geen geschiedenis, geen meter. - Sneltoetsen alleen: Fn (standaard), Rechter Option, Rechter Command, Rechter Control, Linker Control; alleen vasthouden. Vastleggen door erop te drukken. - Modellen (exacte WhisperKit-varianten, groottes gemeten bij Hugging Face): `large-v3-v20240930_turbo` (standaard, 1638 MB), `large-v3-v20240930_turbo_632MB` (646 MB), `small` (486 MB). Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het, dan blijft het vorige actief. - Tekst wordt niet geplakt wanneer Sst zelf voorop staat of de opname via het venster/⌥⌘R is gestart; hij gaat dan alleen naar de geschiedenis. - Rechten worden opnieuw gelezen zodra de app actief wordt en elke 2 s zolang een weergave met de lampjes zichtbaar is. - Ondertekening met de eerste "Apple Development"-identiteit (of `SST_SIGN_IDENTITY`); geen identiteit in de repo; zonder identiteit ad-hoc met een duidelijke waarschuwing. - UI-teksten Nederlands, allemaal in `Strings.swift`; alleen tokens in views. - Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). ## Bestandsstructuur ``` Package.swift (wijzig) Sst-afhankelijkheden, SstIcon Sources/SstCore/AppGlue.swift (nieuw) GatedInserter, SwappableTranscriber, LevelSource Sources/SstData/AppSettings.swift (nieuw) Sources/SstWhisper/ModelStorage.swift (nieuw) ModelOption, ModelCatalog, ModelStorage Sources/SstWhisper/WhisperKitTranscriber.swift (vervang) gebruikt ModelStorage Sources/SstUI/Controls.swift (aanvullen) RockerToggleStyle Sources/Sst/AppIdentity.swift, Strings.swift, PermissionMonitor.swift, AppModel.swift (nieuw) Sources/Sst/SstApp.swift, MainWindow.swift, AppCommands.swift, MenuBarContent.swift, SettingsView.swift (nieuw) Sources/Sst/AppModel.swift (oud), MenuContent.swift (verwijderd) Sources/SstIcon/IconMaker.swift (nieuw) rendert het icoon uit de tokens scripts/make-icon.sh, scripts/make-app.sh (nieuw/vervang) Design/AppIcon.icns (gegenereerd, gecommit) Tests/SstCoreTests/AppGlueTests.swift, Tests/SstDataTests/AppSettingsTests.swift, Tests/SstWhisperTests/ModelStorageTests.swift (nieuw) ``` --- ### Task 1: Lijmcode in `SstCore` **Files:** - Create: `Sources/SstCore/AppGlue.swift` - Test: `Tests/SstCoreTests/AppGlueTests.swift` **Interfaces:** - Produces: - `GatedInserter(inner: TextInserting, shouldInsert: @escaping () -> Bool)` — vraagt `shouldInsert()` op het moment van invoegen - `SwappableTranscriber(_ initial: Transcriber? = nil)` met `swap(to:)`, `hasTranscriber`, beide `transcribe`-varianten; werpt `SwapError.noTranscriber` zonder model - `LevelSource` met `dbfs`, `update(_:)`, `reset()` (thread-safe; start op `AudioLevel.silenceFloorDbfs`) - [ ] **Step 1: Schrijf de falende tests** `Tests/SstCoreTests/AppGlueTests.swift`: ```swift import Foundation import Testing @testable import SstCore struct GatedInserterTests { @Test func insertsWhenAllowed() async throws { let inner = FakeInserter() try await GatedInserter(inner: inner, shouldInsert: { true }).insert("hallo") #expect(inner.inserted == ["hallo"]) } @Test func staysSilentWhenNotAllowed() async throws { let inner = FakeInserter() try await GatedInserter(inner: inner, shouldInsert: { false }).insert("hallo") #expect(inner.inserted.isEmpty) } @Test func askedAtInsertionTimeNotAtCreation() async throws { let inner = FakeInserter() var allowed = false let gated = GatedInserter(inner: inner, shouldInsert: { allowed }) allowed = true try await gated.insert("nu wel") #expect(inner.inserted == ["nu wel"]) } } struct SwappableTranscriberTests { @Test func throwsWithoutATranscriber() async { let swappable = SwappableTranscriber() #expect(!swappable.hasTranscriber) await #expect(throws: SwappableTranscriber.SwapError.self) { _ = try await swappable.transcribe([0]) } } @Test func delegatesIncludingTheVocabulary() async throws { let inner = FakeVocabularyTranscriber() inner.result = "klaar" let swappable = SwappableTranscriber(inner) #expect(try await swappable.transcribe([0], vocabulary: ["Anthropic"]) == "klaar") #expect(inner.vocabularies == [["Anthropic"]]) #expect(try await swappable.transcribe([0]) == "klaar") #expect(inner.vocabularies == [["Anthropic"], []]) } @Test func swapReplacesTheTranscriber() async throws { let first = FakeVocabularyTranscriber(), second = FakeVocabularyTranscriber() first.result = "een"; second.result = "twee" let swappable = SwappableTranscriber(first) #expect(try await swappable.transcribe([0]) == "een") swappable.swap(to: second) #expect(try await swappable.transcribe([0]) == "twee") swappable.swap(to: nil) #expect(!swappable.hasTranscriber) } } struct LevelSourceTests { @Test func startsSilentAndKeepsTheLatestValue() { let source = LevelSource() #expect(source.dbfs == AudioLevel.silenceFloorDbfs) source.update(-23) source.update(-18) #expect(source.dbfs == -18) source.reset() #expect(source.dbfs == AudioLevel.silenceFloorDbfs) } } ``` - [ ] **Step 2: Draai de tests, moeten falen** Run: `swift test --filter "GatedInserterTests|SwappableTranscriberTests|LevelSourceTests"` Expected: compilatiefouten (`cannot find 'GatedInserter' in scope`). - [ ] **Step 3: Implementatie** `Sources/SstCore/AppGlue.swift`: ```swift import Foundation /// Inserts text only while `shouldInsert()` says so (not when Sst itself is in front, nor for window recordings). public struct GatedInserter: TextInserting { private let inner: TextInserting private let shouldInsert: () -> Bool public init(inner: TextInserting, shouldInsert: @escaping () -> Bool) { self.inner = inner self.shouldInsert = shouldInsert } public func insert(_ text: String) async throws { guard shouldInsert() else { return } try await inner.insert(text) } } /// Lets the app replace the speech model (for example after a model switch) without rebuilding the controller. public final class SwappableTranscriber: Transcriber, @unchecked Sendable { public enum SwapError: Error, LocalizedError { case noTranscriber public var errorDescription: String? { "Spraakmodel is nog niet geladen." } } private let lock = NSLock() private var current: Transcriber? public init(_ initial: Transcriber? = nil) { current = initial } public func swap(to transcriber: Transcriber?) { lock.withLock { current = transcriber } } public var hasTranscriber: Bool { lock.withLock { current != nil } } public func transcribe(_ samples: [Float]) async throws -> String { try await transcribe(samples, vocabulary: []) } public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { guard let transcriber = lock.withLock({ current }) else { throw SwapError.noTranscriber } return try await transcriber.transcribe(samples, vocabulary: vocabulary) } } /// The latest input level, written from the audio thread and read by the meter once per frame. public final class LevelSource: @unchecked Sendable { private let lock = NSLock() private var value = AudioLevel.silenceFloorDbfs public init() {} public var dbfs: Float { lock.withLock { value } } public func update(_ dbfs: Float) { lock.withLock { value = dbfs } } public func reset() { update(AudioLevel.silenceFloorDbfs) } } ``` - [ ] **Step 4: Draai de tests** Run: `swift test --filter "GatedInserterTests|SwappableTranscriberTests|LevelSourceTests"` Expected: 7 tests PASS. - [ ] **Step 5: Commit** ```bash git add Sources/SstCore Tests/SstCoreTests git commit -m "feat: add GatedInserter, SwappableTranscriber and LevelSource" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 2: `AppSettings` **Files:** - Create: `Sources/SstData/AppSettings.swift` - Test: `Tests/SstDataTests/AppSettingsTests.swift` **Interfaces:** - Produces: `final class AppSettings: ObservableObject` met `init(defaults: UserDefaults = .standard)`, `@Published` `hotkeyID` ("fn"), `modelVariant` ("large-v3-v20240930_turbo"), `keepHistory` (true), `usePriming` (true), `useCorrection` (true); elke wijziging wordt direct in `UserDefaults` bewaard. `AppSettings.defaultModelVariant` is bewust gelijk aan `ModelCatalog.defaultVariant` (SstData kent SstWhisper niet). - [ ] **Step 1: Schrijf de falende tests** `Tests/SstDataTests/AppSettingsTests.swift`: ```swift import Foundation import Testing @testable import SstData struct AppSettingsTests { func freshDefaults() -> UserDefaults { let name = "sst-tests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! defaults.removePersistentDomain(forName: name) return defaults } @Test func defaultsMatchTheSpec() { let settings = AppSettings(defaults: freshDefaults()) #expect(settings.hotkeyID == "fn") #expect(settings.modelVariant == "large-v3-v20240930_turbo") #expect(settings.keepHistory) #expect(settings.usePriming) #expect(settings.useCorrection) } @Test func changesArePersisted() { let defaults = freshDefaults() let settings = AppSettings(defaults: defaults) settings.hotkeyID = "rightOption" settings.modelVariant = "small" settings.keepHistory = false settings.usePriming = false settings.useCorrection = false let reloaded = AppSettings(defaults: defaults) #expect(reloaded.hotkeyID == "rightOption") #expect(reloaded.modelVariant == "small") #expect(!reloaded.keepHistory) #expect(!reloaded.usePriming) #expect(!reloaded.useCorrection) } } ``` - [ ] **Step 2: Draai de tests, moeten falen** Run: `swift test --filter AppSettingsTests` Expected: compilatiefout `cannot find 'AppSettings' in scope`. - [ ] **Step 3: Implementatie** `Sources/SstData/AppSettings.swift`: ```swift import Combine import Foundation /// User preferences, persisted in UserDefaults as soon as a value changes. public final class AppSettings: ObservableObject { public static let defaultModelVariant = "large-v3-v20240930_turbo" enum Key { static let hotkey = "hotkeyID" static let model = "modelVariant" static let keepHistory = "keepHistory" static let usePriming = "usePriming" static let useCorrection = "useCorrection" } private let defaults: UserDefaults /// `HotkeyBinding.id` of the key that has to be held. @Published public var hotkeyID: String { didSet { defaults.set(hotkeyID, forKey: Key.hotkey) } } @Published public var modelVariant: String { didSet { defaults.set(modelVariant, forKey: Key.model) } } @Published public var keepHistory: Bool { didSet { defaults.set(keepHistory, forKey: Key.keepHistory) } } /// Give the speech engine the dictionary words as context. @Published public var usePriming: Bool { didSet { defaults.set(usePriming, forKey: Key.usePriming) } } /// Apply the dictionary corrections after transcription. @Published public var useCorrection: Bool { didSet { defaults.set(useCorrection, forKey: Key.useCorrection) } } public init(defaults: UserDefaults = .standard) { self.defaults = defaults hotkeyID = defaults.string(forKey: Key.hotkey) ?? "fn" modelVariant = defaults.string(forKey: Key.model) ?? Self.defaultModelVariant keepHistory = defaults.object(forKey: Key.keepHistory) as? Bool ?? true usePriming = defaults.object(forKey: Key.usePriming) as? Bool ?? true useCorrection = defaults.object(forKey: Key.useCorrection) as? Bool ?? true } } ``` - [ ] **Step 4: Draai de tests** Run: `swift test --filter AppSettingsTests` Expected: 2 tests PASS. - [ ] **Step 5: Commit** ```bash git add Sources/SstData Tests/SstDataTests git commit -m "feat: add AppSettings" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 3: `ModelStorage`, `ModelCatalog` en de transcriber **Files:** - Create: `Sources/SstWhisper/ModelStorage.swift` - Replace: `Sources/SstWhisper/WhisperKitTranscriber.swift` - Test: `Tests/SstWhisperTests/ModelStorageTests.swift` **Interfaces:** - Consumes: bestaande `WhisperKitTranscriber` (fase 1: `transcribe(_:vocabulary:)`, `promptTokens`). - Produces: - `ModelOption` (`id`, `title`, `detail`, `approxSizeMB`), `ModelCatalog.all`, `ModelCatalog.defaultVariant` - `ModelStorage(base:)` met `base`, `folder(for:) -> URL?`, `isInstalled(_:)`, `delete(_:) throws`, `defaultBase` (`~/Library/Application Support/Sst/models`); varianten worden exact gematcht (`openai_whisper-`) - `WhisperKitTranscriber(model:storage:)` met `variant`, `load()`, `onProgress` - [ ] **Step 1: Schrijf de falende tests** `Tests/SstWhisperTests/ModelStorageTests.swift`: ```swift import Foundation import Testing @testable import SstWhisper struct ModelStorageTests { func makeStorage() -> ModelStorage { ModelStorage(base: FileManager.default.temporaryDirectory.appending(path: "sst-models-\(UUID().uuidString)")) } /// Creates `//.mlmodelc/coremldata.bin` for the given models. func install(_ storage: ModelStorage, folder: String, models: [String] = ModelStorage.requiredModels) throws { for model in models { let dir = storage.repoDirectory.appending(path: "\(folder)/\(model).mlmodelc", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try Data([1]).write(to: dir.appending(path: "coremldata.bin")) } } @Test func emptyStorageHasNothingInstalled() { let storage = makeStorage() #expect(storage.folder(for: "small") == nil) #expect(!storage.isInstalled("small")) } @Test func completeInstallIsFound() throws { let storage = makeStorage() try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo") #expect(storage.isInstalled("large-v3-v20240930_turbo")) #expect(storage.folder(for: "large-v3-v20240930_turbo")?.lastPathComponent == "openai_whisper-large-v3-v20240930_turbo") } @Test func incompleteInstallDoesNotCount() throws { let storage = makeStorage() try install(storage, folder: "openai_whisper-small", models: ["MelSpectrogram", "AudioEncoder"]) #expect(!storage.isInstalled("small")) } @Test func variantsAreMatchedExactly() throws { let storage = makeStorage() try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo_632MB") #expect(!storage.isInstalled("large-v3-v20240930_turbo")) #expect(storage.isInstalled("large-v3-v20240930_turbo_632MB")) } @Test func deleteRemovesOnlyThatVariant() throws { let storage = makeStorage() try install(storage, folder: "openai_whisper-small") try install(storage, folder: "openai_whisper-base") try storage.delete("small") #expect(!storage.isInstalled("small")) #expect(storage.isInstalled("base")) } @Test func deletingSomethingNotInstalledIsHarmless() throws { try makeStorage().delete("small") } @Test func catalogStartsWithTheDefaultAndHasUniqueIds() { #expect(ModelCatalog.all.first?.id == ModelCatalog.defaultVariant) #expect(Set(ModelCatalog.all.map(\.id)).count == ModelCatalog.all.count) #expect(ModelCatalog.all.allSatisfy { $0.approxSizeMB > 0 }) } } ``` - [ ] **Step 2: Draai de tests, moeten falen** Run: `swift test --filter ModelStorageTests` Expected: compilatiefout `cannot find 'ModelStorage' in scope`. - [ ] **Step 3: Implementatie** `Sources/SstWhisper/ModelStorage.swift`: ```swift import Foundation /// A speech model the user can pick in Settings. public struct ModelOption: Identifiable, Equatable, Sendable { public let id: String // WhisperKit variant name public let title: String public let detail: String public let approxSizeMB: Int public init(id: String, title: String, detail: String, approxSizeMB: Int) { self.id = id self.title = title self.detail = detail self.approxSizeMB = approxSizeMB } } public enum ModelCatalog { public static let defaultVariant = "large-v3-v20240930_turbo" /// Sizes measured from the Hugging Face repository `argmaxinc/whisperkit-coreml`. public static let all: [ModelOption] = [ ModelOption(id: defaultVariant, title: "Whisper Large v3 Turbo", detail: "Aanbevolen · nauwkeurigst", approxSizeMB: 1638), ModelOption(id: "large-v3-v20240930_turbo_632MB", title: "Whisper Large v3 Turbo (compact)", detail: "Kleiner, iets minder nauwkeurig", approxSizeMB: 646), ModelOption(id: "small", title: "Whisper Small", detail: "Snel en licht, minder nauwkeurig", approxSizeMB: 486), ] } /// Where models live on disk: `/models/argmaxinc/whisperkit-coreml/openai_whisper-`. public struct ModelStorage: Sendable { public static let repo = "argmaxinc/whisperkit-coreml" public static let requiredModels = ["MelSpectrogram", "AudioEncoder", "TextDecoder"] public let base: URL public init(base: URL = ModelStorage.defaultBase) { self.base = base } /// Application Support, so models are neither in ~/Documents (privacy prompt, iCloud) nor re-fetched. public static var defaultBase: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appending(path: "Sst/models", directoryHint: .isDirectory) } var repoDirectory: URL { base.appending(path: "models/\(Self.repo)", directoryHint: .isDirectory) } private func folders(for variant: String) -> [URL] { let names = (try? FileManager.default.contentsOfDirectory(atPath: repoDirectory.path)) ?? [] return names .filter { $0 == variant || $0 == "openai_whisper-\(variant)" } .map { repoDirectory.appending(path: $0, directoryHint: .isDirectory) } } /// The folder of a completely downloaded variant, or nil. public func folder(for variant: String) -> URL? { folders(for: variant).first { folder in Self.requiredModels.allSatisfy { FileManager.default.fileExists(atPath: folder.appending(path: "\($0).mlmodelc/coremldata.bin").path) } } } public func isInstalled(_ variant: String) -> Bool { folder(for: variant) != nil } public func delete(_ variant: String) throws { for folder in folders(for: variant) { try FileManager.default.removeItem(at: folder) } } } ``` Vervang `Sources/SstWhisper/WhisperKitTranscriber.swift` volledig door: ```swift import Foundation import SstCore import WhisperKit public final class WhisperKitTranscriber: Transcriber { public enum TranscriberError: Error, LocalizedError { case notLoaded public var errorDescription: String? { "Spraakmodel is nog niet geladen." } } private let modelName: String private let storage: ModelStorage private var pipeline: WhisperKit? /// Download progress (0...1) during the one-time model download. May be called from any thread. public var onProgress: ((Double) -> Void)? public init(model: String = ModelCatalog.defaultVariant, storage: ModelStorage = ModelStorage()) { self.modelName = model self.storage = storage } public var variant: String { modelName } private func loadPipeline(from folder: URL) async throws -> WhisperKit { try await WhisperKit(WhisperKitConfig( downloadBase: storage.base, modelFolder: folder.path, tokenizerFolder: storage.base, prewarm: true, load: true, download: false )) } /// Loads the model into memory. Downloads it (≈1.5 GB) only if it is not stored locally yet; /// once downloaded, launching needs no network. public func load() async throws { guard pipeline == nil else { return } try FileManager.default.createDirectory(at: storage.base, withIntermediateDirectories: true) if let folder = storage.folder(for: modelName), let loaded = try? await loadPipeline(from: folder) { pipeline = loaded return } let folder = try await WhisperKit.download( variant: modelName, downloadBase: storage.base, from: ModelStorage.repo ) { [onProgress] progress in onProgress?(progress.fractionCompleted) } pipeline = try await loadPipeline(from: folder) } public func transcribe(_ samples: [Float]) async throws -> String { try await transcribe(samples, vocabulary: []) } /// Terms are added one by one while the encoded prompt stays within `maxTokens`. /// Returns nil when nothing fits or the list is empty. static func promptTokens( vocabulary: [String], maxTokens: Int = 64, specialTokenBegin: Int, encode: (String) -> [Int] ) -> [Int]? { var kept: [String] = [] var tokens: [Int] = [] for term in vocabulary { let candidate = kept + [term] let encoded = encode(" " + candidate.joined(separator: ", ")).filter { $0 < specialTokenBegin } if encoded.count > maxTokens { break } kept = candidate tokens = encoded } return tokens.isEmpty ? nil : tokens } public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { guard let pipeline else { throw TranscriberError.notLoaded } // language nil + detectLanguage: Whisper picks NL or EN per utterance. var options = DecodingOptions( task: .transcribe, language: nil, detectLanguage: true, skipSpecialTokens: true, withoutTimestamps: true ) if !vocabulary.isEmpty, let tokenizer = pipeline.tokenizer { options.promptTokens = Self.promptTokens( vocabulary: vocabulary, specialTokenBegin: tokenizer.specialTokens.specialTokenBegin, encode: { tokenizer.encode(text: $0) } ) } let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options) return results .map(\.text) .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) } } ``` - [ ] **Step 4: Draai alle tests** Run: `swift test` Expected: alle tests PASS (7 nieuwe `ModelStorageTests`; `PromptTokensTests` uit fase 1 blijft groen). - [ ] **Step 5: Commit** ```bash git add Sources/SstWhisper Tests/SstWhisperTests git commit -m "feat: add ModelStorage and ModelCatalog; transcriber uses ModelStorage" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 4: `RockerToggleStyle` in `SstUI` **Files:** - Modify: `Sources/SstUI/Controls.swift` (onderaan aanvullen) **Interfaces:** - Consumes: `WellBackground`, `PhysicalSurface`, tokens (`Size.switchWidth/Height`, `Shadows.keyRaised`, `Motion.durationFast`). - Produces: `RockerToggleStyle()` (`ToggleStyle`): label links, tuimelschakelaar rechts; toegankelijkheidswaarde "aan"/"uit". - [ ] **Step 1: Implementatie** Voeg onderaan `Sources/SstUI/Controls.swift` toe (een lege regel ertussen): ```swift /// A two-position rocker switch: a key cap sliding in a recessed track. public struct RockerToggleStyle: ToggleStyle { public init() {} public func makeBody(configuration: Configuration) -> some View { HStack(spacing: Space.s4) { configuration.label Button { configuration.isOn.toggle() } label: { RockerTrack(isOn: configuration.isOn) } .buttonStyle(.plain) .accessibilityValue(configuration.isOn ? "aan" : "uit") } } private struct RockerTrack: View { let isOn: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack(alignment: isOn ? .trailing : .leading) { WellBackground() PhysicalSurface( fill: AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)), cornerRadius: Radius.sm, shadows: Shadows.keyRaised) .frame(width: Size.switchWidth / 2, height: Size.switchHeight - Space.s2) .padding(Space.s1) } .frame(width: Size.switchWidth, height: Size.switchHeight) .animation(reduceMotion ? nil : .easeOut(duration: Motion.durationFast), value: isOn) } } } ``` - [ ] **Step 2: Bouwen en scannen** Run: `swift build && swift test --filter SstUITests` Expected: build zonder fouten of waarschuwingen; alle SstUI-tests PASS (geen losse waarden). - [ ] **Step 3: Commit** ```bash git add Sources/SstUI git commit -m "feat: add rocker toggle style" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 5: App-kern — identiteit, teksten, rechten en `AppModel` **Files:** - Create: `Sources/Sst/AppIdentity.swift`, `Sources/Sst/Strings.swift`, `Sources/Sst/PermissionMonitor.swift` - Replace: `Sources/Sst/AppModel.swift` - Modify: `Package.swift` **Interfaces:** - Consumes: Task 1–3, fase 1 (`DictationController` met `corrector`, `vocabularyProvider`, `onResult`; `HotkeyMonitor.binding`; `AudioRecorder.onLevel`), fase 2 (`DictionaryStore`, `DictionaryCorrector`, `HistoryStore`, `VocabularyBuilder`, `SystemWordKnowledge`). - Produces (gebruikt door de views en door fase 5): - `AppIdentity.displayName`, `.brandCaption`; `AppPaths.support/dictionary/history` - `Strings` (alle UI-teksten) - `PermissionMonitor` met `microphone/accessibility/inputMonitoring`, `allGranted`, `isGranted(_:)`, `refresh()`, `requestMissing()`, `openSettings(for:)`, `startPolling()/stopPolling()`; `PermissionMonitor.Kind` - `AppModel` (`@MainActor ObservableObject`): `settings`, `permissions`, `levelSource`, `history`, `dictionary`, `storage`, `state`, `modelStatus` (`.loading/.downloading/.ready/.failed`), `activeVariant`, `installedVariants`, `recordingStartedAt`, `lastDuration`, `lastError`, `section` (`.transcripts/.dictionary`), `searchFocusTick`, `confirmingClearHistory`, `isRecording`, `modelReady`, `hotkeyBinding`, `recordingBlocker`, `statusLine`, `menuIconName`, `title(for:)`, `retryHotkey()`, `toggleWindowRecording()`, `startWindowRecording()`, `stopWindowRecording()`, `refreshInstalled()`, `activateModel(_:)`, `deleteModel(_:)`, `show(_:)`, `focusSearch()`, `clearHistory()` - [ ] **Step 1: Package.swift** Vervang in `Package.swift` de regel ```swift .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]), ``` door: ```swift .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper", "SstData", "SstUI"]), ``` (Het `SstIcon`-target volgt in Task 7, samen met zijn bronbestanden; een target zonder bronnen laat SwiftPM elke build weigeren.) - [ ] **Step 2: Oude app-bestanden opruimen** Run: `git rm Sources/Sst/MenuContent.swift` (`Sources/Sst/AppModel.swift` en `SstApp.swift` worden in deze en de volgende taak vervangen.) - [ ] **Step 3: Bestanden schrijven** `Sources/Sst/AppIdentity.swift`: ```swift import Foundation /// The one place that knows the app's name, so renaming it later is a one-file change. enum AppIdentity { static let displayName = "Sst" static let supportFolderName = "Sst" static let brandCaption = "SST · FIELD RECORDER" } enum AppPaths { static var support: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appending(path: AppIdentity.supportFolderName, directoryHint: .isDirectory) } static var dictionary: URL { support.appending(path: "dictionary.txt") } static var history: URL { support.appending(path: "history.json") } } ``` `Sources/Sst/Strings.swift`: ```swift import Foundation /// All user-facing text, in one place (Dutch), so translating later is a single-file job. enum Strings { // sections static let transcripts = "Transcripties" static let dictionary = "Woordenboek" static let controls = "Controles" // deck static let record = "Rec" static let stop = "Stop" static let time = "Tijd" static let level = "Niveau" // menus static let recordingMenu = "Opname" static let startRecording = "Start opname" static let stopRecording = "Stop opname" static let search = "Zoek" static let clearHistory = "Wis geschiedenis…" static let openApp = "Open Sst" static let settings = "Instellingen…" static let quit = "Stop Sst" // status static let statusIdle = "Klaar" static let statusListening = "Luistert…" static let statusTranscribing = "Transcriberen…" static let statusInserting = "Invoegen…" static func modelLoading(_ title: String) -> String { "Model laden: \(title)…" } static func modelDownloading(_ percent: Int) -> String { "Model downloaden… \(percent)%" } static let modelFailed = "Model laden mislukt" static func holdToDictate(_ keyName: String) -> String { "Houd \(keyName) ingedrukt om te dicteren" } // permissions static let microphone = "Microfoon" static let accessibility = "Accessibility" static let inputMonitoring = "Input Monitoring" static let modelLamp = "Model" static let microphoneMissing = "Microfoon-toegang ontbreekt" static let openSystemSettings = "Open Instellingen" // settings static let tabHotkey = "Sneltoets" static let tabModel = "Model" static let tabPrivacy = "Privacy" static let captureHotkey = "Leg nieuwe toets vast" static let capturing = "Druk op de toets die je wilt gebruiken…" static let hotkeyRejected = "Die toets kan niet. Kies Fn, Rechter Option, Rechter Command of Control." static let globeHint = "Zet bij Fn in Systeeminstellingen → Toetsenbord ‘Druk op 🌐 voor’ op ‘Doe niets’." static let useModel = "Gebruik" static let download = "Download" static let remove = "Verwijder" static let showInFinder = "Toon in Finder" static let keepHistory = "Transcripties bewaren" static let clearHistoryConfirm = "Alle transcripties wissen?" static let clearHistoryDetail = "Dit kan niet ongedaan worden gemaakt. Audio wordt nooit bewaard." static let cancel = "Annuleer" static let erase = "Wis" } ``` `Sources/Sst/PermissionMonitor.swift`: ```swift import AppKit import ApplicationServices import AVFoundation import Combine /// Reads the three macOS permissions and keeps them current: on every app activation and while polling is on. @MainActor final class PermissionMonitor: ObservableObject { enum Kind: CaseIterable, Identifiable { case microphone, accessibility, inputMonitoring var id: Self { self } var title: String { switch self { case .microphone: Strings.microphone case .accessibility: Strings.accessibility case .inputMonitoring: Strings.inputMonitoring } } fileprivate var pane: String { switch self { case .microphone: "Privacy_Microphone" case .accessibility: "Privacy_Accessibility" case .inputMonitoring: "Privacy_ListenEvent" } } } @Published private(set) var microphone = false @Published private(set) var accessibility = false @Published private(set) var inputMonitoring = false private var timer: Timer? private var activation: AnyCancellable? init() { refresh() activation = NotificationCenter.default .publisher(for: NSApplication.didBecomeActiveNotification) .sink { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } } } var allGranted: Bool { microphone && accessibility && inputMonitoring } func isGranted(_ kind: Kind) -> Bool { switch kind { case .microphone: microphone case .accessibility: accessibility case .inputMonitoring: inputMonitoring } } func refresh() { microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized accessibility = AXIsProcessTrusted() inputMonitoring = CGPreflightListenEventAccess() } /// Asks macOS for every permission that is still missing (each shows its own system prompt). func requestMissing() async { if !microphone { _ = await AVCaptureDevice.requestAccess(for: .audio) } if !inputMonitoring { _ = CGRequestListenEventAccess() } if !accessibility { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) } refresh() } func openSettings(for kind: Kind) { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(kind.pane)") { NSWorkspace.shared.open(url) } } /// Polls while a view that shows the lamps is visible, so granting a permission shows up without a click. func startPolling(every interval: TimeInterval = 2) { guard timer == nil else { return } timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } } } func stopPolling() { timer?.invalidate() timer = nil } } ``` `Sources/Sst/AppModel.swift` (vervang volledig): ```swift import AppKit import AVFoundation import Combine import SstCore import SstData import SstWhisper import SwiftUI /// Decides at insertion time whether dictated text may be pasted. /// Not when Sst itself is in front (it would land in Sst's own search field) and not for recordings started in the window. final class InsertionPolicy { var windowSession = false func allowed() -> Bool { !windowSession && NSWorkspace.shared.frontmostApplication?.bundleIdentifier != Bundle.main.bundleIdentifier } } @MainActor final class AppModel: ObservableObject { enum Section: String, CaseIterable, Identifiable { case transcripts, dictionary var id: String { rawValue } } enum ModelStatus: Equatable { case loading(String) case downloading(String, Double) case ready case failed(String) } // Services let settings: AppSettings let permissions = PermissionMonitor() let levelSource = LevelSource() let history: HistoryStore let dictionary: DictionaryStore let storage = ModelStorage() // Observable state @Published private(set) var state: DictationState = .idle @Published private(set) var modelStatus: ModelStatus = .loading(ModelCatalog.defaultVariant) @Published private(set) var activeVariant: String? @Published private(set) var installedVariants: Set = [] @Published private(set) var recordingStartedAt: Date? @Published private(set) var lastDuration: TimeInterval? /// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh. @Published private(set) var historyRevision = 0 @Published private(set) var dictionaryRevision = 0 @Published private(set) var searchFocusTick = 0 @Published var lastError: String? @Published var section: Section = .transcripts @Published var confirmingClearHistory = false private let transcriber = SwappableTranscriber() private let corrector: DictionaryCorrector private let hotkey = HotkeyMonitor() private let policy: InsertionPolicy private let controller: DictationController private var modelTask: Task? private var loadingVariant: String? private var cancellables = Set() init() { let settings = AppSettings() let policy = InsertionPolicy() let knowledge = SystemWordKnowledge() let corrector = DictionaryCorrector(knowledge: knowledge) let dictionary = DictionaryStore(fileURL: AppPaths.dictionary) let history = HistoryStore(fileURL: AppPaths.history) let levelSource = self.levelSource let recorder = AudioRecorder() recorder.onLevel = { levelSource.update($0) } self.settings = settings self.policy = policy self.corrector = corrector self.dictionary = dictionary self.history = history self.controller = DictationController( recorder: recorder, transcriber: transcriber, cleaner: PassthroughCleaner(), inserter: GatedInserter( inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), shouldInsert: { policy.allowed() }), corrector: corrector, vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) wire() Task { await start() } } // MARK: derived var isRecording: Bool { state == .recording } var hotkeyBinding: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } var modelReady: Bool { modelStatus == .ready } /// Why recording is not possible right now, or nil when it is. var recordingBlocker: String? { if !permissions.allGranted { return Strings.openSystemSettings } switch modelStatus { case .ready: return nil case .loading(let variant): return Strings.modelLoading(title(for: variant)) case .downloading(_, let fraction): return Strings.modelDownloading(Int(fraction * 100)) case .failed: return Strings.modelFailed } } var statusLine: String { if let blocker = recordingBlocker, state == .idle { return blocker } switch state { case .idle: return Strings.holdToDictate(hotkeyBinding.displayName) case .recording: return Strings.statusListening case .transcribing: return Strings.statusTranscribing case .inserting: return Strings.statusInserting } } var menuIconName: String { switch state { case .idle: if lastError != nil { return "exclamationmark.triangle" } return modelReady ? "mic" : "mic.slash" case .recording: return "mic.fill" case .transcribing, .inserting: return "waveform" } } func title(for variant: String) -> String { ModelCatalog.all.first { $0.id == variant }?.title ?? variant } // MARK: lifecycle private func wire() { controller.onStateChange = { [weak self] state in guard let self else { return } self.state = state switch state { case .recording: self.lastError = nil self.recordingStartedAt = Date() self.levelSource.reset() case .idle: if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) } self.recordingStartedAt = nil self.levelSource.reset() default: break } } controller.onError = { [weak self] in self?.lastError = $0 } controller.onResult = { [weak self] result in guard let self, self.settings.keepHistory else { return } self.history.add(HistoryEntry(result: result)) } hotkey.onEvent = { [weak self] event in self?.handleHotkey(event) } hotkey.binding = hotkeyBinding settings.$hotkeyID .sink { [weak self] _ in guard let self else { return } self.hotkey.binding = self.hotkeyBinding self.objectWillChange.send() } .store(in: &cancellables) settings.$useCorrection .sink { [corrector] in corrector.setEnabled($0) } .store(in: &cancellables) permissions.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) dictionary.onChange = { [weak self] in guard let self else { return } self.corrector.update(entries: self.dictionary.entries) self.dictionaryRevision += 1 } history.onChange = { [weak self] in self?.historyRevision += 1 } NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in MainActor.assumeIsolated { history.flush() } } } private func start() async { do { try dictionary.load() } catch { lastError = error.localizedDescription } dictionary.startWatching() if case .recoveredCorrupt = history.load() { lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet." } refreshInstalled() await permissions.requestMissing() retryHotkey() activateModel(settings.modelVariant) } func retryHotkey() { permissions.refresh() do { try hotkey.start() } catch { lastError = error.localizedDescription } } // MARK: recording private func handleHotkey(_ event: HotkeyEvent) { guard modelReady else { return } if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { lastError = Strings.microphoneMissing return } Task { await controller.handle(event) } } /// Start/stop from the window or ⌥⌘R. The result goes to the history only; nothing is pasted. func toggleWindowRecording() { if isRecording { stopWindowRecording() } else { startWindowRecording() } } func startWindowRecording() { guard recordingBlocker == nil, state == .idle else { return } policy.windowSession = true Task { await controller.handle(.pressed) } } func stopWindowRecording() { guard policy.windowSession else { return } Task { await controller.handle(.released) policy.windowSession = false } } // MARK: model func refreshInstalled() { installedVariants = Set(ModelCatalog.all.map(\.id).filter { storage.isInstalled($0) }) } /// Downloads (if needed) and loads `variant`. Dictation is off meanwhile; if it fails the previous model stays. func activateModel(_ variant: String) { modelTask?.cancel() let previous = activeVariant loadingVariant = variant modelStatus = .loading(variant) modelTask = Task { [weak self] in guard let self else { return } let candidate = WhisperKitTranscriber(model: variant, storage: storage) candidate.onProgress = { [weak self] fraction in Task { @MainActor in guard let self, self.loadingVariant == variant else { return } self.modelStatus = fraction < 1 ? .downloading(variant, fraction) : .loading(variant) } } do { try await candidate.load() guard !Task.isCancelled else { return } transcriber.swap(to: candidate) activeVariant = variant settings.modelVariant = variant modelStatus = .ready } catch { guard !Task.isCancelled else { return } lastError = error.localizedDescription modelStatus = previous == nil ? .failed(error.localizedDescription) : .ready } if loadingVariant == variant { loadingVariant = nil } refreshInstalled() } } func deleteModel(_ variant: String) { guard variant != activeVariant else { return } do { try storage.delete(variant) } catch { lastError = error.localizedDescription } refreshInstalled() } // MARK: navigation and history func show(_ section: Section) { self.section = section NSApp.activate(ignoringOtherApps: true) } func focusSearch() { searchFocusTick += 1 } func clearHistory() { history.removeAll() history.flush() } } ``` - [ ] **Step 4: Bouwen** De app bouwt pas na Task 6 (de scenes ontbreken nog); controleer hier alleen de bibliotheken: Run: `swift build --target SstCore --target SstData --target SstWhisper --target SstUI` Expected: build zonder fouten. - [ ] **Step 5: Commit (samen met Task 6)** Er wordt pas gecommit na Task 6, want de app-target bouwt pas dan. --- ### Task 6: Scenes, menu's, instellingen en menubalkitem **Files:** - Create: `Sources/Sst/SstApp.swift`, `Sources/Sst/MainWindow.swift`, `Sources/Sst/AppCommands.swift`, `Sources/Sst/MenuBarContent.swift`, `Sources/Sst/SettingsView.swift` - Test: de token-scan (`Tests/SstUITests/TokenIntegrityTests.swift`) bewaakt ook `Sources/Sst` **Interfaces:** - Consumes: Task 5 (`AppModel`, `Strings`), `SstUI`. - Produces: scenes `Window("Sst", id: "main")`, `Settings`, `MenuBarExtra`; menu "Opname" (⌥⌘R start/stop, ⌘1 Transcripties, ⌘2 Woordenboek, ⌘F zoek) en "Wis geschiedenis…" in Bewerken; `SettingsView` met drie toetsen-tabs; `MainWindow` is voorlopig een schil (fase 5 vervangt hem). - [ ] **Step 1: Bestanden schrijven** `Sources/Sst/SstApp.swift`: ```swift import SwiftUI import SstUI @main struct SstApp: App { @StateObject private var model = AppModel() var body: some Scene { Window(AppIdentity.displayName, id: "main") { MainWindow() .environmentObject(model) .environmentObject(model.settings) .frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight) } .defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) .commands { AppCommands(model: model) } Settings { SettingsView() .environmentObject(model) .environmentObject(model.settings) } MenuBarExtra { MenuBarContent() .environmentObject(model) } label: { Image(systemName: model.menuIconName) } } } ``` `Sources/Sst/MainWindow.swift`: ```swift import SwiftUI import SstUI /// Placeholder shell; phase 5 replaces the content with the deck, history and dictionary. struct MainWindow: View { @EnvironmentObject private var model: AppModel var body: some View { ZStack { BrushedBackground().ignoresSafeArea() VStack(alignment: .leading, spacing: Layout.panelGap) { SilkLabel(AppIdentity.brandCaption) Text(model.statusLine) .textStyle(Typography.reading) .foregroundStyle(Palette.inkPrimary) .padding(Layout.panelPadding) .frame(maxWidth: .infinity, alignment: .leading) .background(PanelBackground()) } .padding(Layout.panelPadding) } } } ``` `Sources/Sst/AppCommands.swift`: ```swift import SwiftUI struct AppCommands: Commands { @ObservedObject var model: AppModel @Environment(\.openWindow) private var openWindow var body: some Commands { CommandGroup(after: .pasteboard) { Divider() Button(Strings.clearHistory) { openWindow(id: "main") model.confirmingClearHistory = true } } CommandMenu(Strings.recordingMenu) { Button(model.isRecording ? Strings.stopRecording : Strings.startRecording) { model.toggleWindowRecording() } .keyboardShortcut("r", modifiers: [.command, .option]) Divider() Button(Strings.transcripts) { show(.transcripts) } .keyboardShortcut("1", modifiers: .command) Button(Strings.dictionary) { show(.dictionary) } .keyboardShortcut("2", modifiers: .command) Button(Strings.search) { show(.transcripts) model.focusSearch() } .keyboardShortcut("f", modifiers: .command) } } private func show(_ section: AppModel.Section) { openWindow(id: "main") model.show(section) } } ``` `Sources/Sst/MenuBarContent.swift`: ```swift import SwiftUI /// The secondary interface: status and shortcut while working in another app. struct MenuBarContent: View { @EnvironmentObject private var model: AppModel @Environment(\.openWindow) private var openWindow var body: some View { Text(model.statusLine) if let error = model.lastError { Text(error) } Divider() Button(Strings.openApp) { openWindow(id: "main") model.show(model.section) } SettingsLink { Text(Strings.settings) } Divider() Button(Strings.quit) { NSApplication.shared.terminate(nil) } .keyboardShortcut("q") } } ``` `Sources/Sst/SettingsView.swift`: ```swift import AppKit import SstCore import SstData import SstUI import SstWhisper import SwiftUI struct SettingsView: View { private enum Tab: CaseIterable { case hotkey, model, privacy var title: String { switch self { case .hotkey: Strings.tabHotkey case .model: Strings.tabModel case .privacy: Strings.tabPrivacy } } } @State private var tab: Tab = .hotkey var body: some View { ZStack { BrushedBackground() VStack(alignment: .leading, spacing: Layout.panelGap) { HStack(spacing: Space.s3) { ForEach(Tab.allCases, id: \.self) { candidate in Button(candidate.title) { tab = candidate } .buttonStyle(KeyButtonStyle(latched: tab == candidate)) } } Group { switch tab { case .hotkey: HotkeySettingsView() case .model: ModelSettingsView() case .privacy: PrivacySettingsView() } } .padding(Layout.panelPadding) .frame(maxWidth: .infinity, alignment: .topLeading) .background(PanelBackground()) } .padding(Layout.panelPadding) } .frame(width: Layout.settingsWidth) .fixedSize(horizontal: false, vertical: true) } } // MARK: hotkey extension HotkeyBinding { /// The matching flag in an AppKit event, used while capturing a new key. fileprivate var eventFlag: NSEvent.ModifierFlags { switch id { case "fn": .function case "rightOption": .option case "rightCommand": .command default: .control } } } @MainActor private final class HotkeyCapture: ObservableObject { @Published var isCapturing = false @Published var message: String? private var monitor: Any? func start(onCapture: @escaping (HotkeyBinding) -> Void) { stop() isCapturing = true message = Strings.capturing monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in guard let self else { return event } if event.type == .keyDown { if event.keyCode == 53 { self.stop() } // Escape cancels return nil } guard let binding = HotkeyBinding.binding(keyCode: Int(event.keyCode)) else { self.message = Strings.hotkeyRejected return nil } if event.modifierFlags.contains(binding.eventFlag) { self.stop() onCapture(binding) } return nil } } func stop() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil isCapturing = false message = nil } } private struct HotkeySettingsView: View { @EnvironmentObject private var settings: AppSettings @StateObject private var capture = HotkeyCapture() private var current: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } var body: some View { VStack(alignment: .leading, spacing: Layout.panelGap) { SilkLabel(Strings.tabHotkey, style: Typography.heading) HStack(spacing: Layout.panelGap) { Text(current.displayName) .textStyle(Typography.heading) .foregroundStyle(Palette.inkPrimary) .padding(.horizontal, Space.s4) .frame(minHeight: Size.controlHeight) .background(WellBackground()) Button(Strings.captureHotkey) { capture.start { settings.hotkeyID = $0.id } } .buttonStyle(KeyButtonStyle(latched: capture.isCapturing)) } if let message = capture.message { Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) } SeamDivider() Text(HotkeyBinding.allowed.map(\.displayName).joined(separator: " · ")) .textStyle(Typography.meta) .foregroundStyle(Palette.inkSecondary) if current == .fn { Text(Strings.globeHint).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) } } .onDisappear { capture.stop() } } } // MARK: model private struct ModelSettingsView: View { @EnvironmentObject private var model: AppModel var body: some View { VStack(alignment: .leading, spacing: Layout.panelGap) { SilkLabel(Strings.tabModel, style: Typography.heading) ForEach(ModelCatalog.all) { option in row(option) SeamDivider() } HStack { Text(model.storage.base.path) .textStyle(Typography.code) .foregroundStyle(Palette.inkSecondary) .lineLimit(1) .truncationMode(.middle) Spacer() Button(Strings.showInFinder) { NSWorkspace.shared.activateFileViewerSelecting([model.storage.base]) } .buttonStyle(KeyButtonStyle()) } } .onAppear { model.refreshInstalled() } } private func row(_ option: ModelOption) -> some View { let isActive = model.activeVariant == option.id let installed = model.installedVariants.contains(option.id) let progress: Double? = { if case .downloading(option.id, let fraction) = model.modelStatus { return fraction } return nil }() return HStack(alignment: .center, spacing: Layout.panelGap) { LampView(isOn: isActive, tint: .amber) VStack(alignment: .leading, spacing: Space.s1) { Text(option.title).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) Text("\(option.detail) · \(option.approxSizeMB) MB") .textStyle(Typography.meta) .foregroundStyle(Palette.inkSecondary) if let progress { LEDBarView(fraction: progress) } else if case .loading(option.id) = model.modelStatus { SilkLabel(Strings.modelLoading(option.title), style: Typography.labelSmall) } } Spacer() if installed && !isActive { Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle()) } else if !installed { Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) } } } } // MARK: privacy private struct PrivacySettingsView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings @State private var confirming = false var body: some View { VStack(alignment: .leading, spacing: Layout.panelGap) { SilkLabel(Strings.tabPrivacy, style: Typography.heading) Toggle(isOn: $settings.keepHistory) { Text(Strings.keepHistory).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) } .toggleStyle(RockerToggleStyle()) SeamDivider() Button(Strings.clearHistory) { confirming = true } .buttonStyle(KeyButtonStyle()) } .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $confirming) { Button(Strings.erase, role: .destructive) { model.clearHistory() } Button(Strings.cancel, role: .cancel) {} } message: { Text(Strings.clearHistoryDetail) } } } ``` - [ ] **Step 2: Bouwen en alle tests** Run: `swift build --product Sst && swift test` Expected: build zonder fouten of waarschuwingen; alle tests PASS, inclusief `viewsContainNoLooseDesignValues` over `Sources/Sst`. - [ ] **Step 3: Commit (Task 5 + 6 samen)** ```bash git add Package.swift Sources/Sst git commit -m "feat: turn Sst into a regular Mac app with settings, menus and menu bar item" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 7: Icoon, bundel en ondertekening **Files:** - Create: `Sources/SstIcon/IconMaker.swift`, `scripts/make-icon.sh`, `Design/AppIcon.icns` (gegenereerd) - Replace: `scripts/make-app.sh` **Interfaces:** - Produces: `build/Sst.app` — een gewone Dock-app (geen `LSUIElement`), met icoon, versie 0.2.0, categorie productiviteit, Nederlandse microfoon-uitleg, ondertekend met je Apple Development-identiteit; `scripts/make-app.sh --install` kopieert naar `/Applications`. - [ ] **Step 0: Target toevoegen** In `Package.swift`, voeg onder de `Sst`-regel toe: ```swift .executableTarget(name: "SstIcon", dependencies: ["SstUI"]), ``` - [ ] **Step 1: Icoon-generator** `Sources/SstIcon/IconMaker.swift`: ```swift import AppKit import SstUI import SwiftUI /// The app icon: a brushed-aluminium plate with a VU meter and the red record lamp, drawn with the design tokens. /// Canvas is 256 pt; the macOS icon grid puts the plate at 206 pt with a 46 pt corner radius. struct IconView: View { static let canvas: CGFloat = 256 private let plate: CGFloat = 206 private let corner: CGFloat = 46 var body: some View { ZStack { BrushedBackground() .frame(width: plate, height: plate) .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: corner, style: .continuous) .strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline)) .shadow(color: .black.opacity(Opacity.mark), radius: Space.s3, y: Space.s2) VStack(spacing: Space.s5) { VUMeterView(levelDbfs: -26, isActive: true, brand: "", initialFraction: 0.62) .frame(width: 150) HStack(spacing: Space.s4) { LampView(isOn: true, large: true).scaleEffect(1.5) SilkLabel("REC", style: Typography.heading) } } .offset(y: Space.s2) } .frame(width: Self.canvas, height: Self.canvas) .environment(\.colorScheme, .light) } } @main @MainActor struct IconMaker { static func main() { guard CommandLine.arguments.count > 1 else { print("usage: SstIcon ") exit(2) } let folder = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) // (file name, pixel size) as iconutil expects them let outputs: [(String, CGFloat)] = [ ("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64), ("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), ("icon_256x256@2x", 512), ("icon_512x512", 512), ("icon_512x512@2x", 1024), ] for (name, pixels) in outputs { let renderer = ImageRenderer(content: IconView()) renderer.scale = pixels / IconView.canvas guard let tiff = renderer.nsImage?.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) else { print("kon \(name) niet renderen") exit(1) } try? png.write(to: folder.appending(path: "\(name).png")) } print("iconset geschreven naar \(folder.path)") } } ``` `scripts/make-icon.sh`: ```bash #!/usr/bin/env bash # Renders the app icon from the design tokens and stores it as Design/AppIcon.icns (committed). set -euo pipefail cd "$(dirname "$0")/.." swift build --product SstIcon rm -rf build/AppIcon.iconset .build/debug/SstIcon build/AppIcon.iconset iconutil -c icns build/AppIcon.iconset -o Design/AppIcon.icns echo "Icoon geschreven: Design/AppIcon.icns" ``` - [ ] **Step 2: Icoon genereren en bekijken** Run: `chmod +x scripts/make-icon.sh && ./scripts/make-icon.sh && open build/AppIcon.iconset/icon_512x512@2x.png` Expected: `Icoon geschreven: Design/AppIcon.icns`. Het icoon toont een afgeronde aluminium plaat met een VU-meter (cream wijzerplaat, rode band, wijzer rond −5 VU) en een rood lampje met "REC". - [ ] **Step 3: Bundelscript** Vervang `scripts/make-app.sh` volledig door: ```bash #!/usr/bin/env bash # Builds build/Sst.app (a normal Dock app, no LSUIElement). Pass --install to copy it to /Applications. set -euo pipefail cd "$(dirname "$0")/.." APP="build/Sst.app" IDENTIFIER="local.sst.app" VERSION="0.2.0" swift build -c release --product Sst BIN=".build/release/Sst" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$BIN" "$APP/Contents/MacOS/Sst" # SwiftPM resource bundles (if any dependency ships them) live next to the binary. for b in .build/release/*.bundle; do [ -e "$b" ] && cp -R "$b" "$APP/Contents/Resources/" done if [ -f Design/AppIcon.icns ]; then cp Design/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" else echo "Let op: Design/AppIcon.icns ontbreekt; run scripts/make-icon.sh." fi cat > "$APP/Contents/Info.plist" < CFBundleIdentifier$IDENTIFIER CFBundleNameSst CFBundleDisplayNameSst CFBundleExecutableSst CFBundlePackageTypeAPPL CFBundleShortVersionString$VERSION CFBundleVersion1 CFBundleIconFileAppIcon LSMinimumSystemVersion14.0 LSApplicationCategoryTypepublic.app-category.productivity NSHighResolutionCapable NSMicrophoneUsageDescription Sst neemt je stem op zolang je de sneltoets ingedrukt houdt, om die lokaal naar tekst om te zetten. PLIST # A stable signing identity keeps the macOS permissions (Microphone, Accessibility, Input Monitoring) across rebuilds. IDENTITY="${SST_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | sed -n 's/.*"\(Apple Development: [^"]*\)".*/\1/p' | head -1)}" if [ -n "$IDENTITY" ]; then codesign --force --sign "$IDENTITY" --identifier "$IDENTIFIER" "$APP" echo "Ondertekend met: $IDENTITY" else codesign --force --sign - --identifier "$IDENTIFIER" "$APP" echo "WAARSCHUWING: geen Apple Development-certificaat gevonden; ad-hoc ondertekend (rechten gaan na elke herbouw verloren)." fi codesign --verify --strict "$APP" echo "Gebouwd: $APP" echo "Eenmalig na het wisselen van ondertekening: tccutil reset Accessibility $IDENTIFIER; tccutil reset ListenEvent $IDENTIFIER; tccutil reset Microphone $IDENTIFIER (en geef de rechten opnieuw)." if [ "${1:-}" = "--install" ]; then rm -rf /Applications/Sst.app cp -R "$APP" /Applications/Sst.app echo "Geïnstalleerd: /Applications/Sst.app" fi ``` - [ ] **Step 4: Bundel bouwen en controleren** Run: ```bash chmod +x scripts/make-app.sh && ./scripts/make-app.sh plutil -lint build/Sst.app/Contents/Info.plist /usr/libexec/PlistBuddy -c "Print :LSUIElement" build/Sst.app/Contents/Info.plist codesign -dv build/Sst.app 2>&1 | grep -E "Identifier|TeamIdentifier" ls build/Sst.app/Contents/Resources ``` Expected: `Ondertekend met: Apple Development: …`; `plutil … OK`; PlistBuddy meldt `:LSUIElement … Does Not Exist`; `Identifier=local.sst.app` en een `TeamIdentifier`; `AppIcon.icns` staat in Resources. - [ ] **Step 5: Commit** ```bash git add Sources/SstIcon scripts Design/AppIcon.icns git commit -m "feat: app icon generator, signed app bundle script with install option" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### Task 8: Handmatige acceptatie met de gebruiker Dit vraagt rechten, het toetsenbord en het scherm van de gebruiker; subagents voeren dit niet uit. - [ ] **Step 1: Voorbereiden** 1. Sluit de oude `Sst` (de menubalk-app) als die nog draait: `pkill -f "Sst.app/Contents/MacOS/Sst"`. 2. De ondertekening is veranderd van ad-hoc naar je certificaat, dus eenmalig: `tccutil reset Accessibility local.sst.app; tccutil reset ListenEvent local.sst.app; tccutil reset Microphone local.sst.app` (door de gebruiker zelf uit te voeren). 3. `open build/Sst.app` en sta Microfoon, Accessibility en Input Monitoring toe (bij Input Monitoring zo nodig de app herstarten). - [ ] **Step 2: Controleer samen** 1. **Dock en menu:** Sst heeft een Dock-icoon met het nieuwe icoon; het menu "Sst" toont Instellingen… (⌘,), Verberg en Stop Sst; er is een menu "Opname". 2. **Venster:** het hoofdvenster opent, is vrij te vergroten en niet kleiner te maken dan 800×560; de statusregel zegt "Houd Fn ingedrukt om te dicteren" zodra het model geladen is (of "Geef eerst de rechten onder Controles" zolang rechten ontbreken). 3. **Doorlopen na sluiten:** sluit het venster (⌘W). Dock-icoon en menubalkitem blijven; dicteer in TextEdit met Fn: de tekst verschijnt. Klik op het Dock-icoon: het venster komt terug. 4. **⌘Q** sluit Sst af (Dock-icoon weg, menubalkitem weg). 5. **Instellingen (⌘,):** drie toetsen Sneltoets / Model / Privacy. Sneltoets: kies "Leg nieuwe toets vast" en druk op Rechter Option; dicteer in TextEdit met Rechter Option (Fn doet dan niets). Zet terug op Fn. Druk bij het vastleggen op Shift of A: de melding legt uit dat dat niet kan. 6. **Model:** drie modellen; het standaardmodel heeft een brandend lampje. (Optioneel: download het compacte model, kijk naar de voortgangsbalk, schakel erheen en terug.) 7. **Voorop-regel:** zet Sst voorop (klik in het venster) en dicteer met Fn: er wordt niets geplakt. 8. **Menubalkitem:** toont de statusregel; "Open Sst" opent het venster; "Instellingen…" opent Instellingen; "Stop Sst" sluit af. 9. **Rechten:** trek Microfoon in via Systeeminstellingen; binnen enkele seconden of bij terugkeren naar Sst meldt de statusregel de ontbrekende rechten; geef ze terug. 10. **Installeren:** `./scripts/make-app.sh --install`, start `/Applications/Sst.app` vanuit Launchpad of Finder: werkt, en de rechten blijven na een herbouw behouden (herhaal `make-app.sh` en start opnieuw: geen nieuwe vragen). Noteer afwijkingen; kleine visuele bijstellingen horen bij deze taak. - [ ] **Step 3: Alle tests en afronden** Run: `swift test` Expected: alle tests PASS. --- ## Self-review - **Spec-dekking:** Dock-app zonder `LSUIElement`, vrij schaalbaar venster met minimumgrootte, Instellingen (⌘,) met sneltoets/model/privacy, secundair menubalkitem, menu's (Opname, Wis geschiedenis), doorlopen na sluiten, bundel met icoon, stabiele ondertekening en `--install`, rechten opnieuw lezen bij activeren en polling, voorop-regel en window-opname alleen opslaan, woordenboek en geschiedenis aan de keten gekoppeld (`corrector`, `vocabularyProvider`, `onResult`). Hoofdvenster-inhoud (deck, geschiedenis, woordenboek, zijbalk, CONTROLES) komt in fase 5; hier is `MainWindow` een schil. - **Placeholders:** geen; alle code is overgenomen uit een gecompileerde en geteste versie (130 tests groen in de toestand na deze fase). - **Typeconsistentie:** `AppModel.Section`, `ModelStatus`, `PermissionMonitor.Kind`, `Strings.*` en de tokenformaten komen overeen met fase 3 en worden in fase 5 hergebruikt. - **Afwijkingen van de spec (bewust):** de modelvarianten en groottes zijn nu bij de bron geverifieerd; `AppSettings.defaultModelVariant` dupliceert `ModelCatalog.defaultVariant` omdat `SstData` niet van `SstWhisper` afhangt. - **Risico's:** het exacte gedrag van `Window`-heropenen via het Dock en `SettingsLink` is alleen handmatig te controleren (Task 8 stap 3 en 8); valt het Dock-heropenen tegen, voeg dan een `NSApplicationDelegateAdaptor` toe met `applicationShouldHandleReopen` en meld dit als afwijking.