dcb66a9849
End Live Activities and release the audio session synchronously on applicationWillTerminate, and continue macOS local-model install, onboarding, and settings polish on this branch.
311 lines
10 KiB
Swift
311 lines
10 KiB
Swift
// MacDictationViewModel.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// Drives the whole macOS window: navigation, recording, hotkey, iCloud-backed
|
|
// settings, foreground-app context, and text insertion.
|
|
|
|
import AppKit
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
/// Top-level navigation destinations, mirroring the iOS app's tabs.
|
|
enum MacSection: String, CaseIterable, Identifiable {
|
|
case dashboard
|
|
case history
|
|
case dictionary
|
|
case settings
|
|
|
|
var id: String { rawValue }
|
|
|
|
func title(language: AppUILanguage) -> String {
|
|
switch self {
|
|
case .dashboard: return MacL10n.string("mac.section.dashboard", language: language)
|
|
case .history: return MacL10n.string("mac.section.history", language: language)
|
|
case .dictionary: return MacL10n.string("mac.section.dictionary", language: language)
|
|
case .settings: return MacL10n.string("mac.section.settings", language: language)
|
|
}
|
|
}
|
|
|
|
var systemImage: String {
|
|
switch self {
|
|
case .dashboard: return "square.grid.2x2"
|
|
case .history: return "clock.arrow.circlepath"
|
|
case .dictionary: return "character.book.closed"
|
|
case .settings: return "gearshape"
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MacDictationViewModel: ObservableObject {
|
|
/// Shared instance so the SwiftUI window and the AppKit menu-bar popover
|
|
/// (see `MacAppDelegate`) drive the exact same recording / settings state.
|
|
static let shared = MacDictationViewModel()
|
|
|
|
@Published var selectedSection: MacSection = .dashboard
|
|
|
|
@Published var isRecording = false
|
|
@Published var isProcessing = false
|
|
@Published var transcript = ""
|
|
@Published var statusMessage = ""
|
|
@Published var audioLevel: Float = 0
|
|
@Published var sessionSeconds: Int = 0
|
|
@Published var foregroundAppName: String?
|
|
@Published var dictionaryRevision = 0
|
|
|
|
@Published var autoPasteEnabled: Bool
|
|
@Published var hotkeyEnabled: Bool
|
|
|
|
@Published var config: ProviderConfig
|
|
|
|
let defaults: UserDefaults
|
|
private let recorder = MacAudioRecorder()
|
|
private let hotkeyService = MacHotkeyService()
|
|
private var levelTimer: Timer?
|
|
private var sessionTimer: Timer?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
let usageStatistics: UsageStatisticsStore
|
|
let speechHistory = SpeechHistoryStore.shared
|
|
|
|
private enum StoredKeys {
|
|
static let autoPaste = "mac.autoPasteEnabled"
|
|
static let hotkey = "mac.hotkeyEnabled"
|
|
}
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
self.config = ProviderConfig(defaults: defaults)
|
|
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
|
|
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
|
|
self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true
|
|
|
|
MacICloudSyncBootstrap.configure(defaults: defaults)
|
|
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
|
wireHotkeyService()
|
|
forwardNestedObjectChanges()
|
|
}
|
|
|
|
/// `config` is a nested `ObservableObject`; without forwarding its
|
|
/// `objectWillChange`, SwiftUI views that observe only the view model
|
|
/// won't refresh when settings (e.g. engine mode / provider) change.
|
|
private func forwardNestedObjectChanges() {
|
|
config.objectWillChange
|
|
.sink { [weak self] in self?.objectWillChange.send() }
|
|
.store(in: &cancellables)
|
|
usageStatistics.objectWillChange
|
|
.sink { [weak self] in self?.objectWillChange.send() }
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
func onAppear() async {
|
|
await MacICloudSyncBootstrap.pullIfEnabled()
|
|
refreshForegroundAppName()
|
|
}
|
|
|
|
func reloadConfigFromCloud() {
|
|
config.reloadFromPersistedStorage()
|
|
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
|
}
|
|
|
|
func refreshDictionaryFromCloud() {
|
|
dictionaryRevision += 1
|
|
}
|
|
|
|
// MARK: - Derived
|
|
|
|
var selectableProviders: [LLMProvider] {
|
|
LLMProvider.presets.filter {
|
|
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
|
|
}
|
|
}
|
|
|
|
var dictionaryTermCount: Int {
|
|
_ = dictionaryRevision
|
|
return AppGroupStore(defaults: defaults).personalDictionary.entries.count
|
|
}
|
|
|
|
var currentWordCount: Int {
|
|
transcript.split { $0 == " " || $0 == "\n" || $0 == "\t" }.count
|
|
}
|
|
|
|
var isCloudMode: Bool { config.engineMode == "cloud" }
|
|
|
|
var languageLabel: String {
|
|
let id = config.localeId.isEmpty ? "zh-CN" : config.localeId
|
|
return Locale.current.localizedString(forIdentifier: id) ?? id
|
|
}
|
|
|
|
var sessionTimeLabel: String {
|
|
let minutes = sessionSeconds / 60
|
|
let seconds = sessionSeconds % 60
|
|
if minutes > 0 { return "\(minutes)m \(seconds)s" }
|
|
return "\(seconds)s"
|
|
}
|
|
|
|
var localModelReady: Bool {
|
|
_ = localModelRevision
|
|
guard let model = MacLocalASRService.selectedModelDefinition() else { return false }
|
|
return MacLocalASRService.isModelInstalled(model)
|
|
}
|
|
|
|
/// Context-aware warning when local engine is selected but the active model is not ready.
|
|
var localModelWarningMessage: String? {
|
|
_ = localModelRevision
|
|
guard config.engineMode == "local" else { return nil }
|
|
if localModelReady { return nil }
|
|
guard let model = MacLocalASRService.selectedModelDefinition() else {
|
|
return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage)
|
|
}
|
|
return MacL10n.format(
|
|
"mac.settings.selectedModelMissing",
|
|
language: config.uiLanguage,
|
|
model.displayName
|
|
)
|
|
}
|
|
|
|
@Published private(set) var localModelRevision = 0
|
|
|
|
func bumpLocalModelRevision() {
|
|
localModelRevision += 1
|
|
objectWillChange.send()
|
|
}
|
|
|
|
// MARK: - Preferences
|
|
|
|
func setAutoPasteEnabled(_ enabled: Bool) {
|
|
autoPasteEnabled = enabled
|
|
defaults.set(enabled, forKey: StoredKeys.autoPaste)
|
|
}
|
|
|
|
func setHotkeyEnabled(_ enabled: Bool) {
|
|
hotkeyEnabled = enabled
|
|
defaults.set(enabled, forKey: StoredKeys.hotkey)
|
|
hotkeyService.setEnabled(enabled)
|
|
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
|
|
}
|
|
|
|
func setEngineMode(_ mode: String) {
|
|
config.engineMode = mode
|
|
}
|
|
|
|
// MARK: - Recording
|
|
|
|
func toggleRecording() {
|
|
if isRecording { finishRecording() } else { beginRecording() }
|
|
}
|
|
|
|
func beginRecording() {
|
|
guard !isProcessing else { return }
|
|
let store = AppGroupStore(defaults: defaults)
|
|
MacAppContextService.captureAndPersist(to: store)
|
|
refreshForegroundAppName()
|
|
|
|
do {
|
|
try recorder.start()
|
|
isRecording = true
|
|
transcript = ""
|
|
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
|
|
startTimers()
|
|
} catch {
|
|
statusMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func finishRecording() {
|
|
guard isRecording else { return }
|
|
isRecording = false
|
|
isProcessing = true
|
|
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
|
|
stopTimers()
|
|
audioLevel = 0
|
|
let samples = recorder.stop()
|
|
let store = AppGroupStore(defaults: defaults)
|
|
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let text = try await MacDictationPipeline.run(samples: samples, store: store)
|
|
self.transcript = text
|
|
let pasted = try self.deliver(text)
|
|
self.recordUsage(for: text)
|
|
self.speechHistory.append(text: text)
|
|
self.statusMessage = self.statusAfterDelivery(pasted: pasted)
|
|
} catch {
|
|
self.statusMessage = error.localizedDescription
|
|
}
|
|
self.isProcessing = false
|
|
}
|
|
}
|
|
|
|
private func deliver(_ text: String) throws -> Bool {
|
|
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
|
|
}
|
|
|
|
private func statusAfterDelivery(pasted: Bool) -> String {
|
|
let lang = config.uiLanguage
|
|
if autoPasteEnabled, pasted {
|
|
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
|
|
}
|
|
if autoPasteEnabled, !pasted {
|
|
return MacL10n.string("mac.status.copied", language: lang)
|
|
}
|
|
return MacL10n.string("mac.status.copied", language: lang)
|
|
}
|
|
|
|
private func wireHotkeyService() {
|
|
hotkeyService.onPressBegan = { [weak self] in
|
|
self?.beginRecording()
|
|
}
|
|
hotkeyService.onPressEnded = { [weak self] in
|
|
self?.finishRecording()
|
|
}
|
|
if hotkeyEnabled { hotkeyService.start() }
|
|
}
|
|
|
|
private func startTimers() {
|
|
sessionSeconds = 0
|
|
let levelTimer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor in self.audioLevel = self.recorder.level() }
|
|
}
|
|
let sessionTimer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor in self.sessionSeconds += 1 }
|
|
}
|
|
RunLoop.main.add(levelTimer, forMode: .common)
|
|
RunLoop.main.add(sessionTimer, forMode: .common)
|
|
self.levelTimer = levelTimer
|
|
self.sessionTimer = sessionTimer
|
|
}
|
|
|
|
private func stopTimers() {
|
|
levelTimer?.invalidate()
|
|
sessionTimer?.invalidate()
|
|
levelTimer = nil
|
|
sessionTimer = nil
|
|
}
|
|
|
|
private func recordUsage(for text: String) {
|
|
usageStatistics.recordUtterance(
|
|
text: text,
|
|
duration: TimeInterval(sessionSeconds),
|
|
wasTranslation: config.isTranslationEffective
|
|
)
|
|
}
|
|
|
|
func copyToClipboard(_ text: String) {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
}
|
|
|
|
func selectProvider(_ provider: LLMProvider) {
|
|
config.apply(preset: provider)
|
|
}
|
|
|
|
func refreshForegroundAppName() {
|
|
foregroundAppName = MacAppContextService.frontmostApplicationName()
|
|
}
|
|
}
|