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

1884 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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 <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`:
```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 <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`:
```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`:
```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:
```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 <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):
```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 <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
```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<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`:
```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 <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:
```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 <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`:
```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" <<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:
```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 <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.