feat: add menu bar app and bundle script
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ let package = Package(
|
|||||||
.product(name: "WhisperKit", package: "WhisperKit"),
|
.product(name: "WhisperKit", package: "WhisperKit"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]),
|
||||||
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
||||||
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ public final class AudioRecorder: AudioRecording {
|
|||||||
}
|
}
|
||||||
let ratio = targetFormat.sampleRate / inputFormat.sampleRate
|
let ratio = targetFormat.sampleRate / inputFormat.sampleRate
|
||||||
|
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in
|
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1
|
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) }
|
self.lock.withLock { self.buffer.append(contentsOf: samples) }
|
||||||
}
|
}
|
||||||
engine.prepare()
|
engine.prepare()
|
||||||
try engine.start()
|
do {
|
||||||
|
try engine.start()
|
||||||
|
} catch {
|
||||||
|
input.removeTap(onBus: 0)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func stop() -> [Float] {
|
public func stop() -> [Float] {
|
||||||
|
|||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
swift build -c release --product Sst
|
||||||
|
BIN=".build/release/Sst"
|
||||||
|
APP="build/Sst.app"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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>local.sst.app</string>
|
||||||
|
<key>CFBundleName</key><string>Sst</string>
|
||||||
|
<key>CFBundleExecutable</key><string>Sst</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||||
|
<key>LSUIElement</key><true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Sst neemt je stem op zolang je Fn ingedrukt houdt, om die lokaal naar tekst om te zetten.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
|
codesign --force --sign - --identifier local.sst.app "$APP"
|
||||||
|
echo "Gebouwd: $APP"
|
||||||
Reference in New Issue
Block a user