feat: turn Sst into a regular Mac app with settings, menus and menu bar item
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ let package = Package(
|
|||||||
.product(name: "WhisperKit", package: "WhisperKit"),
|
.product(name: "WhisperKit", package: "WhisperKit"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]),
|
.executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper", "SstData", "SstUI"]),
|
||||||
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
.executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]),
|
||||||
.executableTarget(name: "SstGallery", dependencies: ["SstUI"]),
|
.executableTarget(name: "SstGallery", dependencies: ["SstUI"]),
|
||||||
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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") }
|
||||||
|
}
|
||||||
+262
-60
@@ -1,57 +1,125 @@
|
|||||||
|
import AppKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import ApplicationServices
|
import Combine
|
||||||
import SwiftUI
|
|
||||||
import SstCore
|
import SstCore
|
||||||
|
import SstData
|
||||||
import SstWhisper
|
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
|
@MainActor
|
||||||
final class AppModel: ObservableObject {
|
final class AppModel: ObservableObject {
|
||||||
@Published var state: DictationState = .idle
|
enum Section: String, CaseIterable, Identifiable {
|
||||||
@Published var modelStatus = "Model laden…"
|
case transcripts, dictionary
|
||||||
@Published var modelReady = false
|
var id: String { rawValue }
|
||||||
@Published var lastError: String?
|
}
|
||||||
@Published var micGranted = false
|
|
||||||
@Published var accessibilityGranted = false
|
|
||||||
@Published var inputMonitoringGranted = false
|
|
||||||
|
|
||||||
private let transcriber = WhisperKitTranscriber()
|
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 hotkey = HotkeyMonitor()
|
||||||
|
private let policy: InsertionPolicy
|
||||||
private let controller: DictationController
|
private let controller: DictationController
|
||||||
|
private var modelTask: Task<Void, Never>?
|
||||||
|
private var loadingVariant: String?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
controller = DictationController(
|
let settings = AppSettings()
|
||||||
recorder: AudioRecorder(),
|
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,
|
transcriber: transcriber,
|
||||||
cleaner: PassthroughCleaner(),
|
cleaner: PassthroughCleaner(),
|
||||||
inserter: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster())
|
inserter: GatedInserter(
|
||||||
)
|
inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()),
|
||||||
controller.onStateChange = { [weak self] in
|
shouldInsert: { policy.allowed() }),
|
||||||
self?.state = $0
|
corrector: corrector,
|
||||||
if $0 == .recording { self?.lastError = nil }
|
vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] })
|
||||||
}
|
|
||||||
controller.onError = { [weak self] in self?.lastError = $0 }
|
wire()
|
||||||
hotkey.onEvent = { [weak self] event in
|
|
||||||
guard let self, self.modelReady else { return }
|
|
||||||
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
|
||||||
self.lastError = "Microfoon-toegang ontbreekt"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Task { await self.controller.handle(event) }
|
|
||||||
}
|
|
||||||
Task { await start() }
|
Task { await start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusLine: String {
|
// MARK: derived
|
||||||
guard modelReady else { return modelStatus }
|
|
||||||
switch state {
|
var isRecording: Bool { state == .recording }
|
||||||
case .idle: return "Klaar — houd Fn ingedrukt om te dicteren"
|
var hotkeyBinding: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn }
|
||||||
case .recording: return "Luistert…"
|
var modelReady: Bool { modelStatus == .ready }
|
||||||
case .transcribing: return "Transcriberen…"
|
|
||||||
case .inserting: return "Invoegen…"
|
/// 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 iconName: String {
|
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 {
|
switch state {
|
||||||
case .idle:
|
case .idle:
|
||||||
if lastError != nil { return "exclamationmark.triangle" }
|
if lastError != nil { return "exclamationmark.triangle" }
|
||||||
@@ -61,43 +129,177 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshPermissions() {
|
func title(for variant: String) -> String {
|
||||||
micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
ModelCatalog.all.first { $0.id == variant }?.title ?? variant
|
||||||
accessibilityGranted = AXIsProcessTrusted()
|
|
||||||
inputMonitoringGranted = CGPreflightListenEventAccess()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func retryHotkey() {
|
// MARK: lifecycle
|
||||||
refreshPermissions()
|
|
||||||
do {
|
private func wire() {
|
||||||
try hotkey.start()
|
controller.onStateChange = { [weak self] state in
|
||||||
lastError = nil
|
guard let self else { return }
|
||||||
} catch {
|
self.state = state
|
||||||
lastError = error.localizedDescription
|
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 {
|
private func start() async {
|
||||||
_ = await AVCaptureDevice.requestAccess(for: .audio)
|
do { try dictionary.load() } catch { lastError = error.localizedDescription }
|
||||||
if !CGPreflightListenEventAccess() { _ = CGRequestListenEventAccess() }
|
dictionary.startWatching()
|
||||||
if !AXIsProcessTrusted() {
|
if case .recoveredCorrupt = history.load() {
|
||||||
_ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary)
|
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
|
||||||
}
|
}
|
||||||
|
refreshInstalled()
|
||||||
|
await permissions.requestMissing()
|
||||||
retryHotkey()
|
retryHotkey()
|
||||||
transcriber.onProgress = { [weak self] fraction in
|
activateModel(settings.modelVariant)
|
||||||
Task { @MainActor in
|
|
||||||
self?.modelStatus = fraction < 1
|
|
||||||
? "Model downloaden… \(Int(fraction * 100))%"
|
|
||||||
: "Model laden…"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retryHotkey() {
|
||||||
|
permissions.refresh()
|
||||||
do {
|
do {
|
||||||
try await transcriber.load()
|
try hotkey.start()
|
||||||
modelReady = true
|
|
||||||
modelStatus = "Klaar"
|
|
||||||
} catch {
|
} catch {
|
||||||
modelStatus = "Model laden mislukt"
|
|
||||||
lastError = error.localizedDescription
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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,87 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SstUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct SstApp: App {
|
struct SstApp: App {
|
||||||
@StateObject private var model = AppModel()
|
@StateObject private var model = AppModel()
|
||||||
|
|
||||||
var body: some Scene {
|
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 {
|
MenuBarExtra {
|
||||||
MenuContent(model: model)
|
MenuBarContent()
|
||||||
|
.environmentObject(model)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: model.iconName)
|
Image(systemName: model.menuIconName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user