From f510f02945b05c7275a103a9d89c0b9367571f7b Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:36:45 +0200 Subject: [PATCH] feat: turn Sst into a regular Mac app with settings, menus and menu bar item Co-Authored-By: Claude Sonnet 5.5 --- Package.swift | 2 +- Sources/Sst/AppCommands.swift | 37 ++++ Sources/Sst/AppIdentity.swift | 17 ++ Sources/Sst/AppModel.swift | 324 ++++++++++++++++++++++------ Sources/Sst/MainWindow.swift | 23 ++ Sources/Sst/MenuBarContent.swift | 23 ++ Sources/Sst/MenuContent.swift | 34 --- Sources/Sst/PermissionMonitor.swift | 87 ++++++++ Sources/Sst/SettingsView.swift | 221 +++++++++++++++++++ Sources/Sst/SstApp.swift | 21 +- Sources/Sst/Strings.swift | 61 ++++++ 11 files changed, 752 insertions(+), 98 deletions(-) create mode 100644 Sources/Sst/AppCommands.swift create mode 100644 Sources/Sst/AppIdentity.swift create mode 100644 Sources/Sst/MainWindow.swift create mode 100644 Sources/Sst/MenuBarContent.swift delete mode 100644 Sources/Sst/MenuContent.swift create mode 100644 Sources/Sst/PermissionMonitor.swift create mode 100644 Sources/Sst/SettingsView.swift create mode 100644 Sources/Sst/Strings.swift diff --git a/Package.swift b/Package.swift index 098a8bb..4cb6621 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ let package = Package( .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: "SstGallery", dependencies: ["SstUI"]), .testTarget(name: "SstCoreTests", dependencies: ["SstCore"]), diff --git a/Sources/Sst/AppCommands.swift b/Sources/Sst/AppCommands.swift new file mode 100644 index 0000000..2b42c4e --- /dev/null +++ b/Sources/Sst/AppCommands.swift @@ -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) + } +} diff --git a/Sources/Sst/AppIdentity.swift b/Sources/Sst/AppIdentity.swift new file mode 100644 index 0000000..f683c88 --- /dev/null +++ b/Sources/Sst/AppIdentity.swift @@ -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") } +} diff --git a/Sources/Sst/AppModel.swift b/Sources/Sst/AppModel.swift index 64a56b7..a223e3d 100644 --- a/Sources/Sst/AppModel.swift +++ b/Sources/Sst/AppModel.swift @@ -1,57 +1,125 @@ +import AppKit import AVFoundation -import ApplicationServices -import SwiftUI +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 { - @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 + enum Section: String, CaseIterable, Identifiable { + case transcripts, dictionary + var id: String { rawValue } + } - 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 = [] + @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? + private var loadingVariant: String? + private var cancellables = Set() init() { - controller = DictationController( - recorder: AudioRecorder(), + 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: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()) - ) - controller.onStateChange = { [weak self] in - self?.state = $0 - if $0 == .recording { self?.lastError = nil } - } - controller.onError = { [weak self] in self?.lastError = $0 } - 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) } - } + 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() } } - 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…" + // 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 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 { case .idle: if lastError != nil { return "exclamationmark.triangle" } @@ -61,43 +129,177 @@ final class AppModel: ObservableObject { } } - func refreshPermissions() { - micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - accessibilityGranted = AXIsProcessTrusted() - inputMonitoringGranted = CGPreflightListenEventAccess() + func title(for variant: String) -> String { + ModelCatalog.all.first { $0.id == variant }?.title ?? variant } - func retryHotkey() { - refreshPermissions() - do { - try hotkey.start() - lastError = nil - } catch { - lastError = error.localizedDescription + // 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 { - _ = await AVCaptureDevice.requestAccess(for: .audio) - if !CGPreflightListenEventAccess() { _ = CGRequestListenEventAccess() } - if !AXIsProcessTrusted() { - _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) + 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() - transcriber.onProgress = { [weak self] fraction in - Task { @MainActor in - self?.modelStatus = fraction < 1 - ? "Model downloaden… \(Int(fraction * 100))%" - : "Model laden…" - } - } + activateModel(settings.modelVariant) + } + + func retryHotkey() { + permissions.refresh() do { - try await transcriber.load() - modelReady = true - modelStatus = "Klaar" + try hotkey.start() } catch { - modelStatus = "Model laden mislukt" 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() + } } diff --git a/Sources/Sst/MainWindow.swift b/Sources/Sst/MainWindow.swift new file mode 100644 index 0000000..ef1550c --- /dev/null +++ b/Sources/Sst/MainWindow.swift @@ -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) + } + } +} diff --git a/Sources/Sst/MenuBarContent.swift b/Sources/Sst/MenuBarContent.swift new file mode 100644 index 0000000..3132fff --- /dev/null +++ b/Sources/Sst/MenuBarContent.swift @@ -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") + } +} diff --git a/Sources/Sst/MenuContent.swift b/Sources/Sst/MenuContent.swift deleted file mode 100644 index 5d9b15f..0000000 --- a/Sources/Sst/MenuContent.swift +++ /dev/null @@ -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) - } - } - } - } -} diff --git a/Sources/Sst/PermissionMonitor.swift b/Sources/Sst/PermissionMonitor.swift new file mode 100644 index 0000000..d44f327 --- /dev/null +++ b/Sources/Sst/PermissionMonitor.swift @@ -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 + } +} diff --git a/Sources/Sst/SettingsView.swift b/Sources/Sst/SettingsView.swift new file mode 100644 index 0000000..b9249dd --- /dev/null +++ b/Sources/Sst/SettingsView.swift @@ -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) + } + } +} diff --git a/Sources/Sst/SstApp.swift b/Sources/Sst/SstApp.swift index e4d86be..6799290 100644 --- a/Sources/Sst/SstApp.swift +++ b/Sources/Sst/SstApp.swift @@ -1,14 +1,31 @@ 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 { - MenuContent(model: model) + MenuBarContent() + .environmentObject(model) } label: { - Image(systemName: model.iconName) + Image(systemName: model.menuIconName) } } } diff --git a/Sources/Sst/Strings.swift b/Sources/Sst/Strings.swift new file mode 100644 index 0000000..cac1d72 --- /dev/null +++ b/Sources/Sst/Strings.swift @@ -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" +}