1884 lines
71 KiB
Markdown
1884 lines
71 KiB
Markdown
# 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.
|