Files
sst/docs/superpowers/plans/2026-10-01-app-4-shell.md
T

71 KiB
Raw Blame History

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 <noreply@anthropic.com> (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:

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:

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
git add Sources/SstCore Tests/SstCoreTests
git commit -m "feat: add GatedInserter, SwappableTranscriber and LevelSource" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

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:

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:

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
git add Sources/SstData Tests/SstDataTests
git commit -m "feat: add AppSettings" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

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-<variant>)
    • WhisperKitTranscriber(model:storage:) met variant, load(), onProgress
  • Step 1: Schrijf de falende tests

Tests/SstWhisperTests/ModelStorageTests.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 `<repo>/<folder>/<model>.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:

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: `<base>/models/argmaxinc/whisperkit-coreml/openai_whisper-<variant>`.
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:

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
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 <noreply@anthropic.com>"

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):

/// 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
git add Sources/SstUI
git commit -m "feat: add rocker toggle style" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

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

        .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]),

door:

        .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:

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:

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:

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):

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<String> = []
    @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<Void, Never>?
    private var loadingVariant: String?
    private var cancellables = Set<AnyCancellable>()

    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:

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:

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:

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:

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:

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)
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 <noreply@anthropic.com>"

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:

        .executableTarget(name: "SstIcon", dependencies: ["SstUI"]),
  • Step 1: Icoon-generator

Sources/SstIcon/IconMaker.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 <output-folder.iconset>")
            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:

#!/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:

#!/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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleIdentifier</key><string>$IDENTIFIER</string>
  <key>CFBundleName</key><string>Sst</string>
  <key>CFBundleDisplayName</key><string>Sst</string>
  <key>CFBundleExecutable</key><string>Sst</string>
  <key>CFBundlePackageType</key><string>APPL</string>
  <key>CFBundleShortVersionString</key><string>$VERSION</string>
  <key>CFBundleVersion</key><string>1</string>
  <key>CFBundleIconFile</key><string>AppIcon</string>
  <key>LSMinimumSystemVersion</key><string>14.0</string>
  <key>LSApplicationCategoryType</key><string>public.app-category.productivity</string>
  <key>NSHighResolutionCapable</key><true/>
  <key>NSMicrophoneUsageDescription</key>
  <string>Sst neemt je stem op zolang je de sneltoets ingedrukt houdt, om die lokaal naar tekst om te zetten.</string>
</dict>
</plist>
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:

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
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 <noreply@anthropic.com>"

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.