feat: add menu bar app and bundle script

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:50:14 +02:00
co-authored by Claude Sonnet 5.5
parent ee8d56c2fb
commit 835b8bb46b
6 changed files with 180 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
import AVFoundation
import ApplicationServices
import SwiftUI
import SstCore
import SstWhisper
@MainActor
final class AppModel: ObservableObject {
@Published var state: DictationState = .idle
@Published var modelStatus = "Model laden…"
@Published var modelReady = false
@Published var lastError: String?
@Published var micGranted = false
@Published var accessibilityGranted = false
@Published var inputMonitoringGranted = false
private let transcriber = WhisperKitTranscriber()
private let hotkey = HotkeyMonitor()
private let controller: DictationController
init() {
controller = DictationController(
recorder: AudioRecorder(),
transcriber: transcriber,
cleaner: PassthroughCleaner(),
inserter: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster())
)
controller.onStateChange = { [weak self] in self?.state = $0 }
controller.onError = { [weak self] in self?.lastError = $0 }
hotkey.onEvent = { [weak self] event in
guard let self, self.modelReady else { return }
Task { await self.controller.handle(event) }
}
Task { await start() }
}
var statusLine: String {
guard modelReady else { return modelStatus }
switch state {
case .idle: return "Klaar — houd Fn ingedrukt om te dicteren"
case .recording: return "Luistert…"
case .transcribing: return "Transcriberen…"
case .inserting: return "Invoegen…"
}
}
var iconName: String {
switch state {
case .idle: return modelReady ? "mic" : "mic.slash"
case .recording: return "mic.fill"
case .transcribing, .inserting: return "waveform"
}
}
func refreshPermissions() {
micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
accessibilityGranted = AXIsProcessTrusted()
inputMonitoringGranted = CGPreflightListenEventAccess()
}
func retryHotkey() {
refreshPermissions()
do {
try hotkey.start()
lastError = nil
} catch {
lastError = error.localizedDescription
}
}
private func start() async {
_ = await AVCaptureDevice.requestAccess(for: .audio)
if !CGPreflightListenEventAccess() { _ = CGRequestListenEventAccess() }
if !AXIsProcessTrusted() {
_ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary)
}
retryHotkey()
do {
try await transcriber.load()
modelReady = true
modelStatus = "Klaar"
} catch {
modelStatus = "Model laden mislukt"
lastError = error.localizedDescription
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import AppKit
import SwiftUI
struct MenuContent: View {
@ObservedObject var model: AppModel
var body: some View {
Text(model.statusLine)
if let error = model.lastError {
Text("Fout: \(error)")
}
Divider()
permissionRow("Microfoon", granted: model.micGranted, pane: "Privacy_Microphone")
permissionRow("Accessibility", granted: model.accessibilityGranted, pane: "Privacy_Accessibility")
permissionRow("Input Monitoring", granted: model.inputMonitoringGranted, pane: "Privacy_ListenEvent")
Button("Rechten opnieuw controleren") { model.retryHotkey() }
Divider()
Button("Stop") { NSApplication.shared.terminate(nil) }
.keyboardShortcut("q")
}
@ViewBuilder
private func permissionRow(_ title: String, granted: Bool, pane: String) -> some View {
if granted {
Text("✓ \(title)")
} else {
Button("⚠︎ \(title) — open Instellingen") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
NSWorkspace.shared.open(url)
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct SstApp: App {
@StateObject private var model = AppModel()
var body: some Scene {
MenuBarExtra {
MenuContent(model: model)
} label: {
Image(systemName: model.iconName)
}
}
}
+7 -1
View File
@@ -31,6 +31,7 @@ public final class AudioRecorder: AudioRecording {
}
let ratio = targetFormat.sampleRate / inputFormat.sampleRate
input.removeTap(onBus: 0)
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in
guard let self else { return }
let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1
@@ -52,7 +53,12 @@ public final class AudioRecorder: AudioRecording {
self.lock.withLock { self.buffer.append(contentsOf: samples) }
}
engine.prepare()
try engine.start()
do {
try engine.start()
} catch {
input.removeTap(onBus: 0)
throw error
}
}
public func stop() -> [Float] {