import AppKit import AVFoundation 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 { enum Section: String, CaseIterable, Identifiable { case transcripts, dictionary 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() // 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() { 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: GatedInserter( inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), shouldInsert: { policy.allowed() }), corrector: corrector, vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) wire() 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.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 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 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] id in guard let self else { return } self.hotkey.binding = HotkeyBinding.binding(id: id) ?? .fn self.objectWillChange.send() } .store(in: &cancellables) permissions.$inputMonitoring .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) 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 { 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() activateModel(settings.modelVariant) } func retryHotkey() { permissions.refresh() do { try hotkey.start() } catch { 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) if state != .recording { policy.windowSession = false } } } 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() } }