import AppKit import AVFoundation import Combine import SstCore import SstData import SstWhisper import SwiftUI /// Decides at insertion time where dictated text goes. /// Sst in front: into the Notitie editor if (and only if) it has the cursor, otherwise nowhere (it would land in Sst's own /// search field). Another app in front: pasted there, except for recordings started in the window. final class InsertionPolicy { var windowSession = false @MainActor func route() -> InsertionRoute { let sstInFront = NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier if sstInFront { return NoteEditorRegistry.shared.active != nil ? .editor : .none } return windowSession ? .none : .system } } @MainActor final class AppModel: ObservableObject { enum Section: String, CaseIterable, Identifiable { case transcripts, dictionary, note 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() let note: NoteStore let historyList: HistoryViewModel let dictionaryList: DictionaryViewModel // 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? @Published private(set) var loadingVariant: String? /// 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 cancellables = Set() convenience init() { self.init(preview: false) } /// `preview: true` is for snapshots: sample data in a temporary folder, no permission prompts, no model, no hotkey. init(preview: Bool) { let settings = preview ? AppSettings(defaults: UserDefaults(suiteName: "sst-preview")!) : AppSettings() let policy = InsertionPolicy() let knowledge = SystemWordKnowledge() let corrector = DictionaryCorrector(knowledge: knowledge) let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\(UUID().uuidString)", directoryHint: .isDirectory) let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary) let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history) let note = NoteStore(fileURL: preview ? folder.appending(path: "note.txt") : AppPaths.note) 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.note = note self.historyList = HistoryViewModel(store: history) self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge) self.controller = DictationController( recorder: recorder, transcriber: transcriber, cleaner: PassthroughCleaner(), inserter: RoutingInserter( system: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), editor: EditorInserter(), route: { await policy.route() }), corrector: corrector, vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) wire() if preview { loadPreviewData() } else { 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.permissionsMissing } 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 } } /// Like `recordingBlocker`, plus: a window recording is only kept when the history is on. var windowRecordingBlocker: String? { recordingBlocker ?? (settings.keepHistory ? nil : Strings.historyNeededForWindow) } 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 if state != .recording, let started = self.recordingStartedAt { // The counter shows the recording length, not the time spent transcribing and pasting. self.lastDuration = Date().timeIntervalSince(started) self.recordingStartedAt = nil } switch state { case .recording: self.lastError = nil self.recordingStartedAt = Date() self.levelSource.reset() case .idle: // Also covers a window recording that was ended with the hotkey; the paste decision is already made. self.policy.windowSession = false 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] id in guard let self else { return } self.hotkey.binding = HotkeyBinding.binding(id: id) ?? .fn self.objectWillChange.send() } .store(in: &cancellables) permissions.$inputMonitoring .dropFirst() .removeDuplicates() .filter { $0 } .sink { [weak self] _ in self?.retryHotkey() } .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) settings.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.dictionaryList.reload() self.dictionaryRevision += 1 } dictionary.onExternalChange = { [weak self] in self?.dictionaryList.externalChangeDetected() } history.onChange = { [weak self] in self?.historyRevision += 1 self?.historyList.refresh() } // Backstop for editors that write in place, which the folder watcher can miss. NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() } } NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history, note] _ in MainActor.assumeIsolated { history.flush() note.flush() } } } private func start() async { do { try dictionary.load() } catch { lastError = error.localizedDescription } dictionary.startWatching() note.load() switch history.load() { case .recoveredCorrupt: lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet." case .unreadable: lastError = Strings.historyUnreadable case .empty, .loaded: break } refreshInstalled() if !ModelCatalog.all.contains(where: { $0.id == settings.modelVariant }) { settings.modelVariant = ModelCatalog.defaultVariant } activateModel(settings.modelVariant) // the download must not wait for the microphone prompt await permissions.requestMissing() retryHotkey() } func retryHotkey() { permissions.refresh() do { try hotkey.start() if lastError == HotkeyMonitor.HotkeyError.tapUnavailable.errorDescription { lastError = nil } } catch { lastError = error.localizedDescription } } // MARK: recording private func handleHotkey(_ event: HotkeyEvent) { guard event == .released || 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 windowRecordingBlocker == nil, state == .idle else { return } policy.windowSession = true Task { await controller.handle(.pressed) if state != .recording { policy.windowSession = false } } } func stopWindowRecording() { guard isRecording 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, variant != loadingVariant 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() } func openDictionaryFile() { NSWorkspace.shared.open(dictionary.fileURL) } // MARK: preview private func loadPreviewData() { note.text = "Notities voor vrijdag: Supabase-migratie plannen en Claude Code uitproberen." try? dictionary.load() try? dictionary.apply { file in file.append(.term("Anthropic")) file.append(.term("Supabase")) file.append(.correction(heard: "cloud code", wrote: "Claude Code")) file.append(.correction(heard: "cloud", wrote: "Claude")) } let now = Date() history.add(HistoryEntry(date: now.addingTimeInterval(-86_400 * 2), duration: 5.1, rawText: "een oudere notitie over de planning", finalText: "Een oudere notitie over de planning.")) history.add(HistoryEntry(date: now.addingTimeInterval(-3_600), duration: 7.2, rawText: "ik wil de meeting verplaatsen naar tomorrow morning", finalText: "Ik wil de meeting verplaatsen naar tomorrow morning.")) history.add(HistoryEntry( date: now.addingTimeInterval(-300), duration: 4.4, rawText: "ik gebruik cloud code met supabase", finalText: "Ik gebruik Claude Code met Supabase.", corrections: [ AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 11, length: 11), AppliedCorrection(heard: "supabase", wrote: "Supabase", rule: "Supabase", start: 27, length: 8), ])) activeVariant = ModelCatalog.defaultVariant modelStatus = .ready lastDuration = 4.4 historyList.selection = history.entries.first?.id } }