From dcb66a9849bcb405859c1a7b70b8700431f6a759 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:13:07 +0800 Subject: [PATCH] feat: sync force-quit Flow teardown and polish macOS local ASR UX End Live Activities and release the audio session synchronously on applicationWillTerminate, and continue macOS local-model install, onboarding, and settings polish on this branch. --- .gitignore | 3 + CHANGELOG.md | 3 + OSGKeyboard/Services/AppURLHandler.swift | 8 + .../Services/FlowLiveActivityController.swift | 17 + OSGKeyboard/Services/FlowSessionManager.swift | 68 ++ .../Services/FlowTerminationCoordinator.swift | 24 + OSGKeyboardMac/DashboardView.swift | 76 +- OSGKeyboardMac/MacComponents.swift | 27 +- OSGKeyboardMac/MacContentView.swift | 11 + OSGKeyboardMac/MacDictationViewModel.swift | 28 +- OSGKeyboardMac/MacDictionaryView.swift | 11 +- OSGKeyboardMac/MacHistoryView.swift | 10 +- OSGKeyboardMac/MacLegalSettingsViews.swift | 110 +++ OSGKeyboardMac/MacLegalWebView.swift | 48 ++ .../MacLocalASRModelSettingsView.swift | 95 +-- OSGKeyboardMac/MacLocalASRService.swift | 80 +- OSGKeyboardMac/MacOnboardingView.swift | 699 ++++++++++++++++++ OSGKeyboardMac/MacQwen3LocalASR.swift | 17 +- OSGKeyboardMac/MacRootView.swift | 70 +- OSGKeyboardMac/MacSettingsView.swift | 70 +- OSGKeyboardMac/MacSherpaLocalASR.swift | 8 + OSGKeyboardMac/MacSherpaONNXRunner.swift | 27 + OSGKeyboardMac/MacTheme.swift | 117 ++- OSGKeyboardMac/OSGKeyboardMacApp.swift | 43 +- .../Models/LocalASRCapabilities.swift | 9 + .../Models/LocalASRModelCatalog.swift | 61 ++ .../Resources/LocalASR/local-asr-catalog.json | 93 ++- .../Services/LocalASRModelInstallState.swift | 9 +- .../Services/LocalASRModelManager.swift | 262 +++++-- OSGKeyboardShared/en.lproj/Shared.strings | 47 +- .../zh-Hans.lproj/Shared.strings | 47 +- .../LocalASRDownloadSourceSorterTests.swift | 47 ++ .../LocalASRModelCatalogTests.swift | 28 +- project.yml | 3 + 34 files changed, 1936 insertions(+), 340 deletions(-) create mode 100644 OSGKeyboard/Services/FlowTerminationCoordinator.swift create mode 100644 OSGKeyboardMac/MacLegalSettingsViews.swift create mode 100644 OSGKeyboardMac/MacLegalWebView.swift create mode 100644 OSGKeyboardMac/MacOnboardingView.swift create mode 100644 OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift diff --git a/.gitignore b/.gitignore index 5b038ea..e381900 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ __pycache__/ .flow-oslog-capture.log .flow-*.log .osg_*.png + +# Standalone experiment: 灵动岛录音机制验证 demo (throwaway) +AudioIntentProbe/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 637796a..2f87910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Force-quit mic release**: on termination the host app now synchronously stops `AVAudioEngine`, deactivates `AVAudioSession`, and ends Live Activities (Dynamic Island + Lock Screen) in `applicationWillTerminate`, reducing “microphone in use” errors after reopening. / **强杀麦克风释放**:进程终止时在 `applicationWillTerminate` 内同步停止 `AVAudioEngine`、释放 `AVAudioSession` 并结束 Live Activity(灵动岛 + 锁屏),降低强杀后重开提示麦克风被占用的概率。 + ## [0.5.2] - 2026-07-09 ### Added diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift index 916b2a6..50c2a2a 100644 --- a/OSGKeyboard/Services/AppURLHandler.swift +++ b/OSGKeyboard/Services/AppURLHandler.swift @@ -59,6 +59,14 @@ final class AppURLHandler: NSObject, UIApplicationDelegate { configuration.delegateClass = AppSceneDelegate.self return configuration } + + /// 后台音频会话仍 running 时,用户强杀常会进入此回调(约 5 秒清理窗口)。 + /// 同步释放麦克风 + 结束 Live Activity,避免重开后「麦克风被占用」。 + func applicationWillTerminate(_ application: UIApplication) { + MainActor.assumeIsolated { + FlowTerminationCoordinator.performSynchronousTerminationCleanup() + } + } } final class AppSceneDelegate: NSObject, UIWindowSceneDelegate { diff --git a/OSGKeyboard/Services/FlowLiveActivityController.swift b/OSGKeyboard/Services/FlowLiveActivityController.swift index a173f26..eb374d1 100644 --- a/OSGKeyboard/Services/FlowLiveActivityController.swift +++ b/OSGKeyboard/Services/FlowLiveActivityController.swift @@ -116,4 +116,21 @@ enum FlowLiveActivityController { } } } + + /// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。 + nonisolated static func endAllSynchronouslyOnTerminate() { + let semaphore = DispatchSemaphore(value: 0) + Task.detached(priority: .userInitiated) { + let activities = Activity.activities + let count = activities.count + for activity in activities { + await activity.end(activity.content, dismissalPolicy: .immediate) + } + FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))") + semaphore.signal() + } + semaphore.wait() + currentPhase = .idle + currentActivity = nil + } } diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index cc7539d..b9ad9d1 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -88,6 +88,7 @@ final class FlowSessionManager: ObservableObject { capture.onEngineLiveChanged = { [weak self] _ in self?.refreshHostReady() } + FlowTerminationCoordinator.register(self) } // MARK: - Public @@ -210,6 +211,73 @@ final class FlowSessionManager: ObservableObject { AppPermissions.openSystemSettings() } + /// 强杀专用同步 teardown:不等待 ASR/LLM;Live Activity 由 + /// `FlowTerminationCoordinator` 同步 `end`。 + func prepareForProcessTermination() { + debug("prepareForProcessTermination") + + coldStartContext = nil + isColdStartHandoff = false + coldStartRecoveryTask?.cancel() + coldStartRecoveryTask = nil + startTask?.cancel() + startTask = nil + commandObserver = nil + pollingTask?.cancel() + pollingTask = nil + heartbeatTask?.cancel() + heartbeatTask = nil + expiryTask?.cancel() + expiryTask = nil + levelTask?.cancel() + levelTask = nil + finalizeTask?.cancel() + finalizeTask = nil + utteranceSafetyTask?.cancel() + utteranceSafetyTask = nil + asrTask?.cancel() + asrTask = nil + chunkedPipeline = nil + + if isUtteranceRecording || isUtteranceProcessing { + capture.cancelUtterance() + asr.cancel() + } + + capture.cancelUtterance() + if capture.running { + capture.stop() + } + + endBackgroundKeepAlive() + ScreenWakeLock.release() + + sessionASR?.cancel() + sessionASR = nil + sessionASREngineMode = nil + sessionASRWarmedLocaleID = nil + + if isActive || FlowSessionBridge.isSessionActive() { + FlowSessionBridge.markSessionInactive() + FlowSessionDarwin.postSessionChanged() + } + + activeSessionId = nil + currentUtteranceId = nil + currentCommandSeq = 0 + lastHandledCommandSeq = 0 + isUtteranceRecording = false + isUtteranceProcessing = false + isActive = false + isStarting = false + sessionExpiresAt = nil + sessionWarning = nil + currentPartial = "" + lastFinal = "" + chunkWarnings = [] + FlowSessionBridge.setHostReady(false) + } + func endSession() { guard isActive else { return } debug("Flow session ended") diff --git a/OSGKeyboard/Services/FlowTerminationCoordinator.swift b/OSGKeyboard/Services/FlowTerminationCoordinator.swift new file mode 100644 index 0000000..ec0a3bc --- /dev/null +++ b/OSGKeyboard/Services/FlowTerminationCoordinator.swift @@ -0,0 +1,24 @@ +// FlowTerminationCoordinator.swift +// OSGKeyboard · Main App +// +// 桥接 `UIApplicationDelegate.applicationWillTerminate` 与 `FlowSessionManager`。 +// SwiftUI 里 `FlowSessionManager` 是 `@StateObject`,AppDelegate 无法直接持有; +// 此处用弱引用在进程退出窗口(约 5 秒)内同步释放麦克风与 Live Activity。 + +import Foundation + +@MainActor +enum FlowTerminationCoordinator { + private static weak var sessionManager: FlowSessionManager? + + /// `FlowSessionManager.init()` 注册当前实例。 + static func register(_ manager: FlowSessionManager) { + sessionManager = manager + } + + /// 强杀 / 系统终止时调用。必须在主线程执行(`applicationWillTerminate` 保证)。 + static func performSynchronousTerminationCleanup() { + sessionManager?.prepareForProcessTermination() + FlowLiveActivityController.endAllSynchronouslyOnTerminate() + } +} diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift index ab2f4b1..e06343a 100644 --- a/OSGKeyboardMac/DashboardView.swift +++ b/OSGKeyboardMac/DashboardView.swift @@ -34,11 +34,15 @@ struct DashboardView: View { Text(MacL10n.format("mac.foregroundApp", language: lang, appName)) .font(TypeStyle.caption) .foregroundStyle(palette.textTertiary) + .transition(.opacity) } statGrid dictationCanvas } - .padding(Spacing.lg) + .animation(Motion.soft, value: viewModel.foregroundAppName) + .padding(.horizontal, Spacing.lg) + .padding(.top, Spacing.sm) + .padding(.bottom, Spacing.lg) } BottomDictationBar(viewModel: viewModel) .padding(.horizontal, Spacing.lg) @@ -91,23 +95,30 @@ struct DashboardView: View { private var dictationCanvas: some View { MacCard(padding: Spacing.lg) { - if viewModel.transcript.isEmpty { - Text( - viewModel.isRecording - ? MacL10n.string("mac.status.listening", language: lang) - : MacL10n.string("mac.status.ready", language: lang) - ) - .font(.system(size: 26, weight: .light)) - .foregroundStyle(palette.textTertiary) - .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading) - } else { - Text(viewModel.transcript) - .font(.system(size: 22, weight: .regular)) - .foregroundStyle(palette.textPrimary) - .textSelection(.enabled) + ZStack(alignment: .topLeading) { + if viewModel.transcript.isEmpty { + Text( + viewModel.isRecording + ? MacL10n.string("mac.status.listening", language: lang) + : MacL10n.string("mac.status.ready", language: lang) + ) + .font(.system(size: 26, weight: .light)) + .foregroundStyle(palette.textTertiary) + .contentTransition(.opacity) .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading) + .transition(.opacity) + } else { + Text(viewModel.transcript) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(palette.textPrimary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading) + .transition(.opacity) + } } } + .animation(Motion.soft, value: viewModel.transcript.isEmpty) + .animation(Motion.quick, value: viewModel.isRecording) } } @@ -132,12 +143,11 @@ struct BottomDictationBar: View { } .padding(.horizontal, Spacing.md) .padding(.vertical, Spacing.sm) - .macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 0.78) + .macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1) .overlay( RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) .stroke(palette.dividerStrong, lineWidth: 0.5) ) - .shadow(color: palette.textPrimary.opacity(0.12), radius: 18, y: 8) } private var readinessChip: some View { @@ -152,10 +162,12 @@ struct BottomDictationBar: View { ) .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) + .contentTransition(.opacity) } .padding(.horizontal, Spacing.sm) .padding(.vertical, 5) .background(palette.surfaceElevated, in: Capsule()) + .animation(Motion.quick, value: viewModel.isProcessing) } private var translationPicker: some View { @@ -195,8 +207,10 @@ struct BottomDictationBar: View { .foregroundStyle(palette.textTertiary) .fixedSize() .offset(y: -22) + .transition(.opacity.combined(with: .offset(y: 6))) } } + .animation(Motion.quick, value: viewModel.isRecording) } private var recordButton: some View { @@ -205,25 +219,31 @@ struct BottomDictationBar: View { Circle() .fill(viewModel.isRecording ? palette.recordRed : palette.accent) .frame(width: 52, height: 52) - .macGlassSurface(in: Circle(), fillOpacity: 0.2) .shadow( - color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5), - radius: pulse ? 14 : 6 + color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35), + radius: pulse ? 10 : 5 ) - if viewModel.isRecording { - // 与 iOS 一致:录音时在红色按钮内部显示实时波形 - MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent) - } else { - Image(systemName: "mic.fill") - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(palette.textOnAccent) + Group { + if viewModel.isRecording { + // 与 iOS 一致:录音时在红色按钮内部显示实时波形 + MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent) + } else { + Image(systemName: "mic.fill") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(palette.textOnAccent) + } } + .transition(.opacity.combined(with: .scale(scale: 0.7))) } } .buttonStyle(.plain) .disabled(viewModel.isProcessing) + .opacity(viewModel.isProcessing ? 0.55 : 1) + .scaleEffect(viewModel.isRecording ? 1.06 : 1) + .animation(Motion.soft, value: viewModel.isRecording) + .animation(Motion.quick, value: viewModel.isProcessing) .onAppear { - withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) { + withAnimation(Motion.breath) { pulse = true } } diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift index abd509b..8daa33f 100644 --- a/OSGKeyboardMac/MacComponents.swift +++ b/OSGKeyboardMac/MacComponents.swift @@ -38,20 +38,17 @@ private struct MacGlassSurface: ViewModifier { let fillOpacity: Double func body(content: Content) -> some View { - if #available(macOS 26.0, *) { - content - .background(palette.surface.opacity(fillOpacity), in: shape) - .glassEffect(.regular, in: shape) - } else { - content - .background(palette.surface.opacity(fillOpacity), in: shape) - } + // Flat, shadowless surface fill. We deliberately avoid `glassEffect` + // here: on macOS 26 Liquid Glass adds a raised drop shadow to every + // card, which reads as visual noise for content containers. Hierarchy + // is carried by the surface colour + hairline border instead. + content + .background(palette.surface.opacity(fillOpacity), in: shape) } } extension View { - /// Applies Liquid Glass on macOS 26 while keeping the same semantic - /// surface colour on older systems. + /// Applies a flat semantic surface fill (no drop shadow) behind `content`. func macGlassSurface( in shape: S, fillOpacity: Double = 0.72 @@ -97,7 +94,7 @@ struct MacCard: View { content() .padding(padding) - .macGlassSurface(in: shape) + .macGlassSurface(in: shape, fillOpacity: 1) .overlay( shape .stroke(palette.divider, lineWidth: 0.5) @@ -135,6 +132,8 @@ struct StatCard: View { .foregroundStyle(accent ? palette.accent : palette.textPrimary) .lineLimit(1) .minimumScaleFactor(0.7) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) Text(caption) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) @@ -165,7 +164,7 @@ struct MiniWaveform: View { } } .frame(height: 22) - .animation(.easeOut(duration: 0.12), value: level) + .animation(Motion.instant, value: level) .onAppear { withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) { phase = 1 @@ -214,12 +213,14 @@ struct MacStatusFooter: View { systemImage: viewModel.isCloudMode ? "cloud" : "cpu" ) .foregroundStyle(palette.textSecondary) + .contentTransition(.opacity) Label( MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang), systemImage: "translate" ) .foregroundStyle(palette.textSecondary) + .contentTransition(.opacity) Label(MacL10n.string("mac.connected", language: lang), systemImage: "link") .foregroundStyle(palette.accent) @@ -228,5 +229,7 @@ struct MacStatusFooter: View { .labelStyle(.titleAndIcon) .padding(.horizontal, Spacing.lg) .padding(.vertical, Spacing.xs) + .animation(Motion.quick, value: viewModel.isCloudMode) + .animation(Motion.quick, value: viewModel.config.translationTargetLocaleId) } } diff --git a/OSGKeyboardMac/MacContentView.swift b/OSGKeyboardMac/MacContentView.swift index ebb3c51..ed4b6f1 100644 --- a/OSGKeyboardMac/MacContentView.swift +++ b/OSGKeyboardMac/MacContentView.swift @@ -22,6 +22,7 @@ struct MacContentView: View { Text(viewModel.statusMessage) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) + .contentTransition(.opacity) if !viewModel.transcript.isEmpty { ScrollView { @@ -34,6 +35,7 @@ struct MacContentView: View { .frame(maxHeight: 120) .padding(Spacing.xs) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous)) + .transition(.opacity.combined(with: .move(edge: .top))) } Divider().overlay(palette.divider) @@ -43,6 +45,8 @@ struct MacContentView: View { } .padding(Spacing.md) .background(palette.background) + .animation(Motion.soft, value: viewModel.transcript.isEmpty) + .animation(Motion.quick, value: viewModel.statusMessage) } private var statusRow: some View { @@ -103,20 +107,24 @@ struct MacContentView: View { Spacer() if viewModel.isRecording { MiniWaveform(level: viewModel.audioLevel, barCount: 4) + .transition(.opacity.combined(with: .scale(scale: 0.7))) } } + .animation(Motion.quick, value: viewModel.isRecording) } private var recordButton: some View { Button(action: viewModel.toggleRecording) { HStack { Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill") + .contentTransition(.symbolEffect(.replace)) Text( viewModel.isRecording ? MacL10n.string("mac.record.stop", language: lang) : MacL10n.string("mac.record.start", language: lang) ) .font(TypeStyle.bodyEmph) + .contentTransition(.opacity) } .frame(maxWidth: .infinity, minHeight: 40) .background( @@ -127,6 +135,9 @@ struct MacContentView: View { } .buttonStyle(.plain) .disabled(viewModel.isProcessing) + .opacity(viewModel.isProcessing ? 0.55 : 1) + .animation(Motion.soft, value: viewModel.isRecording) + .animation(Motion.quick, value: viewModel.isProcessing) } private var footer: some View { diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift index 09f3786..ad44d3e 100644 --- a/OSGKeyboardMac/MacDictationViewModel.swift +++ b/OSGKeyboardMac/MacDictationViewModel.swift @@ -101,25 +101,11 @@ final class MacDictationViewModel: ObservableObject { func onAppear() async { await MacICloudSyncBootstrap.pullIfEnabled() refreshForegroundAppName() - warmUpQwen3IfNeeded() - } - - /// Pre-load MLX weights + Metal shaders so the first dictation is fast. - func warmUpQwen3IfNeeded() { - guard config.engineMode == "local", - let model = MacLocalASRService.selectedModelDefinition(), - model.backend == .mlx, - MacLocalASRService.isModelInstalled(model) else { return } - let path = MacLocalASRPreferences.qwen3ModelPath - Task.detached(priority: .utility) { - _ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path) - } } func reloadConfigFromCloud() { config.reloadFromPersistedStorage() statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage) - warmUpQwen3IfNeeded() } func refreshDictionaryFromCloud() { @@ -159,10 +145,8 @@ final class MacDictationViewModel: ObservableObject { var localModelReady: Bool { _ = localModelRevision - if let model = MacLocalASRService.selectedModelDefinition() { - return MacLocalASRService.isModelInstalled(model) - } - return MacLocalASRPreferences.qwen3ModelIsInstalled() + 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. @@ -173,9 +157,6 @@ final class MacDictationViewModel: ObservableObject { guard let model = MacLocalASRService.selectedModelDefinition() else { return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage) } - if model.installKind == .manual { - return MacL10n.string("mac.settings.mlxModelMissing", language: config.uiLanguage) - } return MacL10n.format( "mac.settings.selectedModelMissing", language: config.uiLanguage, @@ -190,10 +171,6 @@ final class MacDictationViewModel: ObservableObject { objectWillChange.send() } - var qwen3ModelInstalled: Bool { - MacLocalASRPreferences.qwen3ModelIsInstalled() - } - // MARK: - Preferences func setAutoPasteEnabled(_ enabled: Bool) { @@ -210,7 +187,6 @@ final class MacDictationViewModel: ObservableObject { func setEngineMode(_ mode: String) { config.engineMode = mode - if mode == "local" { warmUpQwen3IfNeeded() } } // MARK: - Recording diff --git a/OSGKeyboardMac/MacDictionaryView.swift b/OSGKeyboardMac/MacDictionaryView.swift index f2f986b..1a3c80d 100644 --- a/OSGKeyboardMac/MacDictionaryView.swift +++ b/OSGKeyboardMac/MacDictionaryView.swift @@ -48,12 +48,15 @@ struct MacDictionaryView: View { Group { if entries.isEmpty { emptyState + .transition(.opacity) } else { form + .transition(.opacity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(palette.background) + .animation(Motion.soft, value: entries.isEmpty) .task { await MacICloudSyncBootstrap.dictionarySync.pullAndMergeIfEnabled() viewModel.refreshDictionaryFromCloud() @@ -86,6 +89,7 @@ struct MacDictionaryView: View { .formStyle(.grouped) .scrollContentBackground(.hidden) .background(palette.background) + .animation(Motion.soft, value: query) .safeAreaInset(edge: .top, spacing: 0) { centeredSearchField } .confirmationDialog( MacL10n.string("mac.dict.deleteTitle", language: lang), @@ -93,7 +97,9 @@ struct MacDictionaryView: View { titleVisibility: .visible ) { Button(MacL10n.string("mac.delete", language: lang), role: .destructive) { - if let entry = entryPendingDeletion { delete(entry) } + if let entry = entryPendingDeletion { + withAnimation(Motion.soft) { delete(entry) } + } entryPendingDeletion = nil } Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) { @@ -215,12 +221,13 @@ private struct MacDictionaryRow: View { .frame(width: 24, height: 24) } .buttonStyle(.borderless) - .foregroundStyle(palette.textTertiary) + .foregroundStyle(isHovering ? palette.danger : palette.textTertiary) .opacity(isHovering ? 1 : 0) .accessibilityLabel(MacL10n.string("mac.delete", language: language)) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) + .animation(Motion.quick, value: isHovering) .onHover { isHovering = $0 } .contextMenu { Button(action: copy) { diff --git a/OSGKeyboardMac/MacHistoryView.swift b/OSGKeyboardMac/MacHistoryView.swift index f9edc2d..e02f914 100644 --- a/OSGKeyboardMac/MacHistoryView.swift +++ b/OSGKeyboardMac/MacHistoryView.swift @@ -34,12 +34,15 @@ struct MacHistoryView: View { Group { if historyStore.entries.isEmpty { emptyState + .transition(.opacity) } else { form + .transition(.opacity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(palette.background) + .animation(Motion.soft, value: historyStore.entries.isEmpty) } // MARK: - Grouped cards @@ -64,7 +67,7 @@ struct MacHistoryView: View { titleVisibility: .visible ) { Button(MacL10n.string("mac.history.clearConfirm", language: lang), role: .destructive) { - historyStore.clearAll() + withAnimation(Motion.soft) { historyStore.clearAll() } } Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {} } message: { @@ -95,7 +98,7 @@ struct MacHistoryView: View { time: Self.timeFormatter.string(from: entry.createdAt), language: lang, copy: { viewModel.copyToClipboard(entry.text) }, - delete: { historyStore.delete(id: entry.id) } + delete: { withAnimation(Motion.soft) { historyStore.delete(id: entry.id) } } ) } @@ -144,12 +147,13 @@ private struct MacHistoryRow: View { .frame(width: 24, height: 24) } .buttonStyle(.borderless) - .foregroundStyle(palette.textTertiary) + .foregroundStyle(isHovering ? palette.danger : palette.textTertiary) .opacity(isHovering ? 1 : 0) .accessibilityLabel(MacL10n.string("mac.delete", language: language)) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) + .animation(Motion.quick, value: isHovering) .onHover { isHovering = $0 } .contextMenu { Button(action: copy) { diff --git a/OSGKeyboardMac/MacLegalSettingsViews.swift b/OSGKeyboardMac/MacLegalSettingsViews.swift new file mode 100644 index 0000000..004304c --- /dev/null +++ b/OSGKeyboardMac/MacLegalSettingsViews.swift @@ -0,0 +1,110 @@ +// MacLegalSettingsViews.swift +// OSGKeyboard · Mac +// +// Privacy policy and third-party license screens (mirrors iOS Settings footer). + +import SwiftUI + +struct MacPrivacyPolicyView: View { + let uiLanguage: AppUILanguage + @Environment(\.themePalette) private var palette + + var body: some View { + MacLegalWebView( + resourceName: "PrivacyPolicy", + scrollToAnchor: privacyScrollAnchor + ) + .background(palette.background) + .navigationTitle(MacL10n.string("mac.settings.privacyPolicy", language: uiLanguage)) + } + + private var privacyScrollAnchor: String? { + switch uiLanguage { + case .chinese: + return "zh" + case .english: + return "top" + case .auto: + return uiLanguage.resolvedLanguageCode().hasPrefix("zh") ? "zh" : "top" + } + } +} + +struct MacOpenSourceLicensesView: View { + let uiLanguage: AppUILanguage + @Environment(\.themePalette) private var palette + + var body: some View { + List { + Section { + Text(MacL10n.string("mac.settings.licenses.footer", language: uiLanguage)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .listRowBackground(Color.clear) + } + + Section { + ForEach(OpenSourceLicenseCatalog.entries) { entry in + NavigationLink { + MacOpenSourceLicenseDetailView(entry: entry, uiLanguage: uiLanguage) + } label: { + HStack { + Text(entry.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: Spacing.sm) + Text(entry.licenseName) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + } + } + } + } + .scrollContentBackground(.hidden) + .background(palette.background) + .navigationTitle(MacL10n.string("mac.settings.thirdPartyLicenses", language: uiLanguage)) + } +} + +private struct MacOpenSourceLicenseDetailView: View { + let entry: OpenSourceLicenseCatalog.Entry + let uiLanguage: AppUILanguage + @Environment(\.themePalette) private var palette + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Spacing.sm) { + if let url = entry.url { + Link(destination: url) { + HStack(spacing: Spacing.xs) { + Text(url.absoluteString) + .font(TypeStyle.caption) + .foregroundStyle(palette.accent) + .lineLimit(2) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + Image(systemName: "arrow.up.right.square") + .font(.system(size: 12)) + .foregroundStyle(palette.textTertiary) + } + } + } + + Text(entry.purpose) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + Text(entry.licenseText) + .font(TypeStyle.monoSmall) + .foregroundStyle(palette.textPrimary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(Spacing.lg) + } + .background(palette.background) + .navigationTitle(entry.name) + } +} diff --git a/OSGKeyboardMac/MacLegalWebView.swift b/OSGKeyboardMac/MacLegalWebView.swift new file mode 100644 index 0000000..287730d --- /dev/null +++ b/OSGKeyboardMac/MacLegalWebView.swift @@ -0,0 +1,48 @@ +// MacLegalWebView.swift +// OSGKeyboard · Mac +// +// In-app HTML viewer for bundled legal documents (privacy policy). + +import SwiftUI +import WebKit + +struct MacLegalWebView: NSViewRepresentable { + let resourceName: String + var scrollToAnchor: String? + + func makeCoordinator() -> Coordinator { + Coordinator(scrollToAnchor: scrollToAnchor) + } + + func makeNSView(context: Context) -> WKWebView { + let webView = WKWebView(frame: .zero) + webView.setValue(false, forKey: "drawsBackground") + webView.navigationDelegate = context.coordinator + context.coordinator.webView = webView + + guard let url = Bundle.main.url(forResource: resourceName, withExtension: "html") else { + return webView + } + webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + context.coordinator.scrollToAnchor = scrollToAnchor + } + + final class Coordinator: NSObject, WKNavigationDelegate { + var scrollToAnchor: String? + weak var webView: WKWebView? + + init(scrollToAnchor: String?) { + self.scrollToAnchor = scrollToAnchor + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let anchor = scrollToAnchor, !anchor.isEmpty else { return } + let escaped = anchor.replacingOccurrences(of: "'", with: "\\'") + webView.evaluateJavaScript("location.hash = '#\(escaped)';") { _, _ in } + } + } +} diff --git a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift index fc10bfd..dee9a3c 100644 --- a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift +++ b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift @@ -1,7 +1,7 @@ // MacLocalASRModelSettingsView.swift // OSGKeyboard · Mac // -// Local ASR model catalog, download progress, MLX path, and bias diagnostics. +// Local ASR model catalog, download progress, and bias diagnostics. import AppKit import SwiftUI @@ -29,9 +29,11 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject { catalog = try? LocalASRModelCatalog.loadBundled() if let catalog { let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId) - selectedModelId = manifest.selectedModelId.isEmpty - ? MacLocalASRPreferences.selectedModelId - : manifest.selectedModelId + selectedModelId = MacLocalASRPreferences.migratedModelId( + manifest.selectedModelId.isEmpty + ? MacLocalASRPreferences.selectedModelId + : manifest.selectedModelId + ) } diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load() onLocalModelStateChanged?() @@ -46,7 +48,7 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject { } func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? { - guard model.installKind == .archive, + guard model.installKind == .archive || model.installKind == .repository, let relative = model.installRelativePath, isInstalled(model) else { return nil } let dir = LocalASRModelInstallState.installDirectory(for: relative) @@ -140,15 +142,6 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject { NSWorkspace.shared.activateFileViewerSelecting([url]) } - /// Opens (creating if needed) the model's shared subfolder so the user can - /// drop in manually-converted weights (used by the MLX model). - func revealModelFolder(_ model: LocalASRModelDefinition) { - guard let relative = model.installRelativePath else { return } - let url = LocalASRModelInstallState.installDirectory(for: relative) - try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - NSWorkspace.shared.open(url) - } - func revealStorageRoot() { let url = LocalASRModelInstallState.rootDirectory() try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -212,7 +205,6 @@ struct MacLocalASRModelSettingsView: View { Group { if let catalog = modelVM.catalog { modelPickerSection(catalog: catalog) - runtimeSection(catalog: catalog) } else { Text(MacL10n.string("mac.localASR.catalogMissing", language: lang)) .foregroundStyle(palette.textSecondary) @@ -226,6 +218,16 @@ struct MacLocalASRModelSettingsView: View { private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View { Section { + if let runtime = modelVM.currentRuntime(in: catalog) { + LabeledContent(runtime.displayName) { + Text( + modelVM.isRuntimeInstalled(runtime) + ? MacL10n.string("mac.localASR.installed", language: lang) + : MacL10n.string("mac.localASR.notInstalled", language: lang) + ) + } + } + ForEach(catalog.models) { model in modelRow(model) } @@ -251,31 +253,6 @@ struct MacLocalASRModelSettingsView: View { } } header: { Text(MacL10n.string("mac.localASR.models", language: lang)) - } footer: { - Text(MacL10n.string("mac.localASR.modelsDesc", language: lang)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - } - - private func runtimeSection(catalog: LocalASRCatalogDocument) -> some View { - Group { - if let runtime = modelVM.currentRuntime(in: catalog) { - Section { - LabeledContent(runtime.displayName) { - Text( - modelVM.isRuntimeInstalled(runtime) - ? MacL10n.string("mac.localASR.installed", language: lang) - : MacL10n.string("mac.localASR.notInstalled", language: lang) - ) - } - Text(MacL10n.string("mac.localASR.runtimeDesc", language: lang)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } header: { - Text(MacL10n.string("mac.localASR.runtime", language: lang)) - } - } } } @@ -293,9 +270,22 @@ struct MacLocalASRModelSettingsView: View { HStack(spacing: Spacing.sm) { Image(systemName: selected ? "largecircle.fill.circle" : "circle") .foregroundStyle(selected ? palette.accent : palette.textTertiary) + .contentTransition(.symbolEffect(.replace)) + .animation(Motion.quick, value: selected) VStack(alignment: .leading, spacing: 2) { - Text(model.displayName) - .foregroundStyle(palette.textPrimary) + HStack(spacing: Spacing.xs) { + Text(model.displayName) + .foregroundStyle(palette.textPrimary) + if model.supportsHotwords { + Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang)) + .font(TypeStyle.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(palette.accent.opacity(0.15)) + .foregroundStyle(palette.accent) + .clipShape(Capsule()) + } + } Text(modelSubtitle(model, installed: installed)) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) @@ -308,6 +298,8 @@ struct MacLocalASRModelSettingsView: View { Spacer() modelRowActions(model: model, installed: installed, installing: installing) + .animation(Motion.soft, value: installing) + .animation(Motion.soft, value: installed) } } .padding(.vertical, 2) @@ -344,18 +336,13 @@ struct MacLocalASRModelSettingsView: View { ) } } - } else if model.installKind == .manual { - Button(MacL10n.string("mac.localASR.openFolder", language: lang)) { - modelVM.revealModelFolder(model) - } - .buttonStyle(.bordered) - .controlSize(.small) } else if installed { Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) { modelVM.deleteModel(model) } .buttonStyle(.bordered) .controlSize(.small) + .tint(palette.danger) } else { Button(MacL10n.string("mac.localASR.download", language: lang)) { modelVM.installModel(model) @@ -382,7 +369,7 @@ struct MacLocalASRModelSettingsView: View { .trim(from: 0, to: fraction) .stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 0.15), value: fraction) + .animation(Motion.instant, value: fraction) if modelVM.installProgress.phase == .paused { Image(systemName: "pause.fill") .font(.system(size: 10, weight: .bold)) @@ -399,16 +386,10 @@ struct MacLocalASRModelSettingsView: View { private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String { let size = modelVM.formattedSize(model.sizeBytes) - let hotword = model.supportsHotwords - ? MacL10n.string("mac.localASR.hotwordsYes", language: lang) - : MacL10n.string("mac.localASR.hotwordsNo", language: lang) - let state = installed - ? MacL10n.string("mac.localASR.installed", language: lang) - : MacL10n.string("mac.localASR.notInstalled", language: lang) if let usage = modelVM.installedDiskUsage(model) { - return "\(size) · \(hotword) · \(state) · \(usage)" + return "\(size) · \(usage)" } - return "\(size) · \(hotword) · \(state)" + return size } private var diagnosticsSection: some View { diff --git a/OSGKeyboardMac/MacLocalASRService.swift b/OSGKeyboardMac/MacLocalASRService.swift index 6d185fc..71154fc 100644 --- a/OSGKeyboardMac/MacLocalASRService.swift +++ b/OSGKeyboardMac/MacLocalASRService.swift @@ -2,13 +2,13 @@ // OSGKeyboard · Mac // // On-device ASR for macOS. Routes through the bundled local ASR catalog: -// Qwen3 MLX (default), Sherpa Qwen3 hotwords POC, SenseVoice, Apple Speech fallback. +// Sherpa Qwen3 (default), Paraformer, SenseVoice, Apple Speech fallback. import Foundation enum MacLocalASRBackend: String, Sendable, CaseIterable { - case qwen3MLX case sherpaQwen3 + case sherpaParaformer case sherpaSenseVoice case appleSpeech } @@ -42,48 +42,37 @@ enum MacLocalASRError: Error, LocalizedError { enum MacLocalASRPreferences { static let backendKey = "mac.localASR.backend" static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId - /// Shared managed subfolder for the manually-provided MLX weights. + /// Legacy MLX path key — retained for migration only. static let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx" static var selectedModelId: String { get { if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty { - return raw + return migratedModelId(raw) } - return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-1.7b" + return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "sherpa-qwen3-0.6b-int8" } set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) } } + /// Maps removed catalog entries to the current default Sherpa model. + static func migratedModelId(_ id: String) -> String { + switch id { + case "qwen3-mlx-1.7b": + return "sherpa-qwen3-0.6b-int8" + default: + return id + } + } + static var legacyBackend: MacLocalASRBackend { guard let raw = UserDefaults.standard.string(forKey: backendKey), let value = MacLocalASRBackend(rawValue: raw) else { - return .qwen3MLX + return .sherpaQwen3 } + if raw == "qwen3MLX" { return .sherpaQwen3 } return value } - - /// Fixed location inside the shared managed model storage root. All three - /// catalog models live under the same directory, so MLX no longer needs a - /// per-model folder picker — the user drops converted weights here. - static var qwen3ModelPath: String { - LocalASRModelInstallState.installDirectory(for: qwen3ModelRelativePath).path - } - - static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> Bool { - var isDir: ObjCBool = false - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { - return false - } - let fm = FileManager.default - let config = (path as NSString).appendingPathComponent("config.json") - let weights = (path as NSString).appendingPathComponent("model.safetensors") - guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else { - return false - } - let names = (try? fm.contentsOfDirectory(atPath: path)) ?? [] - return names.contains("vocab.json") && names.contains("merges.txt") - } } enum MacLocalASRService { @@ -97,7 +86,7 @@ enum MacLocalASRService { let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId) let selectedId = manifest.selectedModelId.isEmpty ? MacLocalASRPreferences.selectedModelId - : manifest.selectedModelId + : MacLocalASRPreferences.migratedModelId(manifest.selectedModelId) if selectedId == "apple-speech-fallback" { return nil } return LocalASRModelCatalog.model(selectedId, in: catalog) ?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog) @@ -116,34 +105,19 @@ enum MacLocalASRService { static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool { LocalASRModelInstallState.isInstalled( model, - manualMLXPath: MacLocalASRPreferences.qwen3ModelPath + manualMLXPath: nil, + fileManager: FileManager.default ) } - /// Transcribe using the selected catalog model, with MLX → Apple Speech fallback. + /// Transcribe using the selected catalog model, falling back to Apple Speech. static func transcribe( samples: [Float], locale: Locale, bias: LocalASRBiasPayload? = nil ) async throws -> String { if let model = selectedModelDefinition(), isModelInstalled(model) { - do { - return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias) - } catch { - if model.backend != .mlx { - throw error - } - } - } - - if MacLocalASRPreferences.qwen3ModelIsInstalled() { - return try await MacQwen3LocalASR.transcribe( - samples: samples, - sampleRate: 16_000, - locale: locale, - modelPath: MacLocalASRPreferences.qwen3ModelPath, - bias: bias - ) + return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias) } return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale) @@ -157,14 +131,8 @@ enum MacLocalASRService { ) async throws -> String { switch model.backend { case .mlx: - return try await MacQwen3LocalASR.transcribe( - samples: samples, - sampleRate: 16_000, - locale: locale, - modelPath: MacLocalASRPreferences.qwen3ModelPath, - bias: bias - ) - case .sherpaQwen3, .sherpaSenseVoice: + throw MacLocalASRError.qwen3ModelMissing + case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer: return try await MacSherpaLocalASR.transcribe( samples: samples, sampleRate: 16_000, diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift new file mode 100644 index 0000000..caacf77 --- /dev/null +++ b/OSGKeyboardMac/MacOnboardingView.swift @@ -0,0 +1,699 @@ +// MacOnboardingView.swift +// OSGKeyboard · Mac +// +// A short first-run setup for the macOS app. It is intentionally separate +// from iOS onboarding because Mac needs Accessibility and optional Sherpa setup. +// +// Visual language mirrors the iOS onboarding: an ambient top gradient, a +// glowing hero icon, a large title block, and elongated capsule progress +// dots — all carried by whitespace and a single accent colour. + +import AppKit +import AVFoundation +import SwiftUI + +enum MacOnboardingState { + static let storageKey = "mac.hasCompletedOnboarding" +} + +private enum MacOnboardingStep: Int, CaseIterable { + case welcome + case microphone + case accessibility + case engine + case cloudAPI + case localModel + + var systemImage: String { + switch self { + case .welcome: return "sparkles" + case .microphone: return "mic.fill" + case .accessibility: return "accessibility" + case .engine: return "switch.2" + case .cloudAPI: return "key.fill" + case .localModel: return "arrow.down.circle.fill" + } + } +} + +@MainActor +private final class MacOnboardingViewModel: ObservableObject { + @Published var step: MacOnboardingStep = .welcome + @Published var micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + @Published var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted + @Published var catalog: LocalASRCatalogDocument? + @Published var installProgress = LocalASRModelInstallProgress.idle + @Published var isInstalling = false + @Published var statusMessage = "" + + private let manager = LocalASRModelManager.shared + private var progressPollTask: Task? + + deinit { + progressPollTask?.cancel() + } + + var defaultModel: LocalASRModelDefinition? { + guard let catalog else { return nil } + return catalog.models.first { $0.id == catalog.defaultModelId } + } + + var isDefaultModelInstalled: Bool { + guard let defaultModel else { return false } + return MacLocalASRService.isModelInstalled(defaultModel) + } + + func reload() { + catalog = try? LocalASRModelCatalog.loadBundled() + micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted + } + + func requestMicrophone() { + AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in + Task { @MainActor in + self?.micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + } + } + } + + func openAccessibilitySettings() { + _ = MacTextInsertionService.requestAccessibilityIfNeeded() + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + refreshAccessibilitySoon() + } + + func refreshAccessibilitySoon() { + accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted + } + } + + func installDefaultModel() { + guard let catalog, let model = defaultModel, !isInstalling else { return } + statusMessage = "" + isInstalling = true + startProgressPolling() + Task { + do { + try await manager.installModel(model, catalog: catalog) + installProgress = await manager.currentProgress() + selectInstalledModel(model.id, catalog: catalog) + statusMessage = MacL10n.string("mac.onboarding.model.done") + } catch { + installProgress = await manager.currentProgress() + statusMessage = error.localizedDescription + } + isInstalling = false + stopProgressPolling() + reload() + } + } + + func progressLabel(language: AppUILanguage) -> String { + let phase: String + switch installProgress.phase { + case .idle: return installProgress.message + case .downloading: phase = MacL10n.string("mac.localASR.phase.downloading", language: language) + case .paused: phase = MacL10n.string("mac.localASR.phase.paused", language: language) + case .extracting: phase = MacL10n.string("mac.localASR.phase.extracting", language: language) + case .validating: phase = MacL10n.string("mac.localASR.phase.validating", language: language) + case .finalizing: phase = MacL10n.string("mac.localASR.phase.finalizing", language: language) + case .failed: phase = MacL10n.string("mac.localASR.phase.failed", language: language) + case .completed: phase = MacL10n.string("mac.localASR.phase.completed", language: language) + } + guard !installProgress.message.isEmpty else { return phase } + return "\(phase) · \(installProgress.message)" + } + + private func selectInstalledModel(_ modelId: String, catalog: LocalASRCatalogDocument) { + MacLocalASRPreferences.selectedModelId = modelId + var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId) + manifest.selectedModelId = modelId + manifest.updatedAt = Date() + try? LocalASRInstalledManifestIO.save(manifest) + } + + private func startProgressPolling() { + progressPollTask?.cancel() + progressPollTask = Task { [weak self] in + while !Task.isCancelled { + let current = await LocalASRModelManager.shared.currentProgress() + await MainActor.run { self?.installProgress = current } + try? await Task.sleep(nanoseconds: 120_000_000) + } + } + } + + private func stopProgressPolling() { + progressPollTask?.cancel() + progressPollTask = nil + } +} + +// MARK: - Root + +struct MacOnboardingView: View { + @ObservedObject var viewModel: MacDictationViewModel + @Binding var hasCompletedOnboarding: Bool + + @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + @StateObject private var model = MacOnboardingViewModel() + @State private var contentAppeared = false + + private var lang: AppUILanguage { viewModel.config.uiLanguage } + + private var visibleSteps: [MacOnboardingStep] { + if viewModel.config.engineMode == "cloud" { + return [.welcome, .microphone, .accessibility, .engine, .cloudAPI] + } + return [.welcome, .microphone, .accessibility, .engine, .localModel] + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .top) { + background(height: geo.size.height) + + VStack(spacing: 0) { + Spacer(minLength: Spacing.xl) + + hero + .id(model.step) + .transition(stepTransition) + + Spacer(minLength: Spacing.lg) + + progressDots + .padding(.bottom, Spacing.lg) + + bottomBar + .padding(.horizontal, Spacing.xxxl) + .padding(.bottom, Spacing.xxl) + } + .frame(maxWidth: .infinity) + } + } + .frame(minWidth: 860, minHeight: 600) + .onAppear { + applyDefaults() + model.reload() + withAnimation(.spring(response: 0.7, dampingFraction: 0.85)) { + contentAppeared = true + } + } + } + + // MARK: Background + + private func background(height: CGFloat) -> some View { + ZStack(alignment: .top) { + palette.background.ignoresSafeArea() + + LinearGradient( + colors: [ + palette.accent.opacity(0.12), + palette.accent.opacity(0.03), + palette.background.opacity(0) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: height * 0.42) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } + } + + // MARK: Hero + content + + private var hero: some View { + VStack(spacing: Spacing.lg) { + heroIcon + + VStack(spacing: Spacing.sm) { + Text(title) + .font(TypeStyle.title2) + .foregroundStyle(palette.textPrimary) + .multilineTextAlignment(.center) + + Text(subtitle) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 460) + } + + stepContent + .frame(maxWidth: 460) + .padding(.top, Spacing.xs) + } + .padding(.horizontal, Spacing.xxl) + .opacity(contentAppeared ? 1 : 0) + .offset(y: contentAppeared ? 0 : 12) + } + + @ViewBuilder + private var heroIcon: some View { + if model.step == .welcome { + Image("OSGBrandMark") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 128, height: 128) + .foregroundStyle(colorScheme == .dark ? Color.white : palette.accent) + .accessibilityLabel("OSGKeyboard") + } else { + ZStack { + Circle() + .fill(palette.accentGlow) + .frame(width: 116, height: 116) + .blur(radius: 26) + + Circle() + .fill(palette.accentMuted) + .frame(width: 92, height: 92) + .overlay(Circle().stroke(palette.accent.opacity(0.25), lineWidth: 1)) + + Image(systemName: model.step.systemImage) + .font(.system(size: 40, weight: .semibold)) + .foregroundStyle(palette.accent) + .symbolRenderingMode(.hierarchical) + } + } + } + + @ViewBuilder + private var stepContent: some View { + switch model.step { + case .welcome: + featureList + case .microphone: + permissionCard( + isGranted: model.micStatus == .authorized, + grantedText: MacL10n.string("mac.onboarding.microphone.granted", language: lang), + neededText: MacL10n.string("mac.onboarding.microphone.needed", language: lang) + ) + case .accessibility: + permissionCard( + isGranted: model.accessibilityTrusted, + grantedText: MacL10n.string("mac.onboarding.accessibility.granted", language: lang), + neededText: MacL10n.string("mac.onboarding.accessibility.needed", language: lang) + ) + case .engine: + enginePicker + case .cloudAPI: + cloudAPIFields + case .localModel: + localModelPanel + } + } + + private var featureList: some View { + VStack(spacing: Spacing.sm) { + featureRow("lock.shield.fill", MacL10n.string("mac.onboarding.welcome.privacy", language: lang)) + featureRow("option", MacL10n.string("mac.onboarding.welcome.hotkey", language: lang)) + featureRow("cpu", MacL10n.string("mac.onboarding.welcome.local", language: lang)) + } + } + + private func featureRow(_ icon: String, _ text: String) -> some View { + HStack(spacing: Spacing.md) { + Image(systemName: icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(palette.accent) + .frame(width: 26, height: 26) + .background(palette.accentMuted, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Text(text) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textPrimary) + .multilineTextAlignment(.leading) + + Spacer(minLength: 0) + } + .padding(.vertical, Spacing.xs) + .padding(.horizontal, Spacing.md) + .frame(maxWidth: .infinity) + .background(cardShape.fill(palette.surface)) + .overlay(cardShape.stroke(palette.divider, lineWidth: 0.5)) + } + + private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View { + HStack(spacing: Spacing.sm) { + Image(systemName: isGranted ? "checkmark.seal.fill" : "exclamationmark.circle.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(isGranted ? palette.accent : palette.warning) + + Text(isGranted ? grantedText : neededText) + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.textPrimary) + + Spacer(minLength: 0) + } + .padding(Spacing.md) + .frame(maxWidth: .infinity) + .background(cardShape.fill(palette.surface)) + .overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1)) + } + + private var enginePicker: some View { + VStack(spacing: Spacing.sm) { + engineRow( + title: MacL10n.string("mac.settings.localEngine", language: lang), + subtitle: MacL10n.string("mac.onboarding.engine.localDesc", language: lang), + systemImage: "cpu", + selected: viewModel.config.engineMode == "local" + ) { setEngine("local") } + + engineRow( + title: MacL10n.string("mac.settings.cloudEngine", language: lang), + subtitle: MacL10n.string("mac.onboarding.engine.cloudDesc", language: lang), + systemImage: "cloud.fill", + selected: viewModel.config.engineMode == "cloud" + ) { setEngine("cloud") } + } + } + + private func engineRow( + title: String, + subtitle: String, + systemImage: String, + selected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: Spacing.md) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(selected ? palette.accent : palette.textTertiary) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.textPrimary) + Text(subtitle) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: Spacing.sm) + + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundStyle(selected ? palette.accent : palette.textTertiary.opacity(0.6)) + } + .padding(Spacing.md) + .frame(maxWidth: .infinity) + .background(cardShape.fill(selected ? palette.accentMuted : palette.surface)) + .overlay(cardShape.stroke(selected ? palette.accent.opacity(0.5) : palette.divider, lineWidth: selected ? 1 : 0.5)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var cloudAPIFields: some View { + VStack(alignment: .leading, spacing: Spacing.md) { + Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) { + ForEach(viewModel.selectableProviders) { provider in + Text(provider.name).tag(provider.id) + } + } + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + + SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) { + Text(MacL10n.string("mac.settings.apiKey", language: lang)) + } + .labelsHidden() + .macFieldStyle() + + TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) { + Text(MacL10n.string("mac.settings.model", language: lang)) + } + .labelsHidden() + .macFieldStyle() + + Label(MacL10n.string("mac.onboarding.cloud.skipHint", language: lang), systemImage: "info.circle") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + .padding(Spacing.md) + .frame(maxWidth: .infinity) + .background(cardShape.fill(palette.surface)) + .overlay(cardShape.stroke(palette.divider, lineWidth: 0.5)) + } + + private var localModelPanel: some View { + VStack(alignment: .leading, spacing: Spacing.md) { + HStack(spacing: Spacing.sm) { + Image(systemName: model.isDefaultModelInstalled ? "checkmark.circle.fill" : "shippingbox.fill") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.textTertiary) + + VStack(alignment: .leading, spacing: 2) { + Text(model.defaultModel?.displayName ?? MacL10n.string("mac.localASR.catalogMissing", language: lang)) + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.textPrimary) + Text(localModelSubtitle) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + + Spacer(minLength: Spacing.sm) + + if !model.isDefaultModelInstalled, !model.isInstalling { + Button(MacL10n.string("mac.onboarding.model.download", language: lang)) { + model.installDefaultModel() + } + .buttonStyle(.borderedProminent) + .tint(palette.accent) + .disabled(model.defaultModel == nil) + } + } + + if model.isInstalling || model.installProgress.phase != .idle { + ProgressView(value: model.installProgress.fraction) + .tint(palette.accent) + Text(model.progressLabel(language: lang)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + + if !model.statusMessage.isEmpty { + Text(model.statusMessage) + .font(TypeStyle.caption) + .foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.warning) + } + + Label(MacL10n.string("mac.onboarding.model.skipHint", language: lang), systemImage: "info.circle") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + .padding(Spacing.md) + .frame(maxWidth: .infinity) + .background(cardShape.fill(palette.surface)) + .overlay(cardShape.stroke(palette.divider, lineWidth: 0.5)) + } + + // MARK: Progress dots + + private var progressDots: some View { + HStack(spacing: 6) { + ForEach(Array(visibleSteps.enumerated()), id: \.offset) { index, _ in + Capsule() + .fill(index == currentStepIndex ? palette.accent : palette.textTertiary.opacity(0.28)) + .frame(width: index == currentStepIndex ? 22 : 6, height: 6) + } + } + .animation(Motion.quick, value: currentStepIndex) + } + + // MARK: Bottom bar + + private var bottomBar: some View { + HStack(spacing: Spacing.sm) { + if canGoBack { + secondaryButton(MacL10n.string("mac.onboarding.back", language: lang)) { goBack() } + } + + if canSkipCurrentStep { + secondaryButton(MacL10n.string("mac.onboarding.skipForNow", language: lang)) { + if isLastStep { finish() } else { goForward() } + } + } + + Spacer(minLength: 0) + + primaryButton(primaryButtonTitle, disabled: model.isInstalling && model.step == .localModel) { + primaryAction() + } + } + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + + private func primaryButton(_ titleText: String, disabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(titleText) + .font(TypeStyle.headline) + .foregroundStyle(disabled ? palette.textSecondary : palette.textOnAccent) + .padding(.horizontal, Spacing.xxl) + .frame(minWidth: 150, minHeight: 44) + .background( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .fill(disabled ? palette.surfaceElevated : palette.accent) + ) + } + .buttonStyle(.plain) + .disabled(disabled) + } + + private func secondaryButton(_ titleText: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(titleText) + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.textSecondary) + .padding(.horizontal, Spacing.lg) + .frame(minHeight: 44) + .background( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.dividerStrong, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + + // MARK: Copy + + private var title: String { + switch model.step { + case .welcome: return MacL10n.string("mac.onboarding.welcome.title", language: lang) + case .microphone: return MacL10n.string("mac.onboarding.microphone.title", language: lang) + case .accessibility: return MacL10n.string("mac.onboarding.accessibility.title", language: lang) + case .engine: return MacL10n.string("mac.onboarding.engine.title", language: lang) + case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.title", language: lang) + case .localModel: return MacL10n.string("mac.onboarding.model.title", language: lang) + } + } + + private var subtitle: String { + switch model.step { + case .welcome: return MacL10n.string("mac.onboarding.welcome.subtitle", language: lang) + case .microphone: return MacL10n.string("mac.onboarding.microphone.subtitle", language: lang) + case .accessibility: return MacL10n.string("mac.onboarding.accessibility.subtitle", language: lang) + case .engine: return MacL10n.string("mac.onboarding.engine.subtitle", language: lang) + case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang) + case .localModel: return MacL10n.string("mac.onboarding.model.subtitle", language: lang) + } + } + + private var primaryButtonTitle: String { + switch model.step { + case .microphone where model.micStatus != .authorized: + return MacL10n.string("mac.onboarding.microphone.allow", language: lang) + case .accessibility where !model.accessibilityTrusted: + return MacL10n.string("mac.onboarding.accessibility.open", language: lang) + case .cloudAPI: + return MacL10n.string("mac.onboarding.finish", language: lang) + case .localModel: + return MacL10n.string(model.isDefaultModelInstalled ? "mac.onboarding.finish" : "mac.onboarding.skipForNow", language: lang) + default: + return isLastStep ? MacL10n.string("mac.onboarding.finish", language: lang) : MacL10n.string("mac.onboarding.next", language: lang) + } + } + + private var localModelSubtitle: String { + if model.isDefaultModelInstalled { + return MacL10n.string("mac.localASR.installed", language: lang) + } + guard let model = model.defaultModel else { return "" } + return ByteCountFormatter.string(fromByteCount: Int64(model.sizeBytes), countStyle: .file) + } + + private var providerBinding: Binding { + Binding( + get: { viewModel.config.providerId }, + set: { newId in + guard let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) else { return } + viewModel.selectProvider(provider) + } + ) + } + + // MARK: Derived + + private var cardShape: RoundedRectangle { + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + } + + private var stepTransition: AnyTransition { + .asymmetric( + insertion: .opacity.combined(with: .offset(y: 10)), + removal: .opacity.combined(with: .offset(y: -10)) + ) + } + + private var currentStepIndex: Int { + visibleSteps.firstIndex(of: model.step) ?? 0 + } + + private var canGoBack: Bool { + currentStepIndex > 0 && !model.isInstalling + } + + private var canSkipCurrentStep: Bool { + model.step != .welcome && !model.isInstalling + } + + private var isLastStep: Bool { + currentStepIndex == visibleSteps.count - 1 + } + + // MARK: Actions + + private func setEngine(_ mode: String) { + withAnimation(Motion.quick) { viewModel.setEngineMode(mode) } + } + + private func primaryAction() { + switch model.step { + case .microphone where model.micStatus != .authorized: + model.requestMicrophone() + case .accessibility where !model.accessibilityTrusted: + model.openAccessibilitySettings() + default: + if isLastStep { finish() } else { goForward() } + } + } + + private func goForward() { + let nextIndex = min(currentStepIndex + 1, visibleSteps.count - 1) + withAnimation(Motion.soft) { model.step = visibleSteps[nextIndex] } + } + + private func goBack() { + let previousIndex = max(currentStepIndex - 1, 0) + withAnimation(Motion.soft) { model.step = visibleSteps[previousIndex] } + } + + private func finish() { + viewModel.selectedSection = .dashboard + hasCompletedOnboarding = true + } + + private func applyDefaults() { + guard !hasCompletedOnboarding else { return } + if viewModel.config.apiKey.isEmpty, viewModel.config.engineMode == "cloud" { + viewModel.setEngineMode("local") + } + } +} diff --git a/OSGKeyboardMac/MacQwen3LocalASR.swift b/OSGKeyboardMac/MacQwen3LocalASR.swift index cd46822..4088df3 100644 --- a/OSGKeyboardMac/MacQwen3LocalASR.swift +++ b/OSGKeyboardMac/MacQwen3LocalASR.swift @@ -15,7 +15,7 @@ enum MacQwen3LocalASR { modelPath: String, bias: LocalASRBiasPayload? = nil ) async throws -> String { - guard MacLocalASRPreferences.qwen3ModelIsInstalled(at: modelPath) else { + guard modelDirectoryIsInstalled(at: modelPath) else { throw MacLocalASRError.qwen3ModelMissing } guard sampleRate == 16_000 else { @@ -40,4 +40,19 @@ enum MacQwen3LocalASR { throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription) } } + + private static func modelDirectoryIsInstalled(at path: String) -> Bool { + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { + return false + } + let fm = FileManager.default + let config = (path as NSString).appendingPathComponent("config.json") + let weights = (path as NSString).appendingPathComponent("model.safetensors") + guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else { + return false + } + let names = (try? fm.contentsOfDirectory(atPath: path)) ?? [] + return names.contains("vocab.json") && names.contains("merges.txt") + } } diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift index d4552d9..a959dfb 100644 --- a/OSGKeyboardMac/MacRootView.swift +++ b/OSGKeyboardMac/MacRootView.swift @@ -49,7 +49,13 @@ struct MacRootView: View { brandHeader VStack(spacing: 4) { ForEach(MacSection.allCases) { section in - sidebarRow(section) + MacSidebarRow( + section: section, + isSelected: viewModel.selectedSection == section, + language: uiLanguage + ) { + withAnimation(Motion.soft) { viewModel.selectedSection = section } + } } } .padding(.horizontal, MacMetrics.sidebarInset) @@ -58,27 +64,6 @@ struct MacRootView: View { } } - private func sidebarRow(_ section: MacSection) -> some View { - let isSelected = viewModel.selectedSection == section - - return Button { - viewModel.selectedSection = section - } label: { - Label(section.title(language: uiLanguage), systemImage: section.systemImage) - .font(.system(size: 13)) - .foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Spacing.sm) - .padding(.vertical, 7) - .background( - isSelected ? palette.accent : Color.clear, - in: RoundedRectangle(cornerRadius: 7, style: .continuous) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - /// Brand mark pinned above the nav list. Top padding clears the traffic /// lights that now float over the borderless sidebar. private var brandHeader: some View { @@ -123,8 +108,49 @@ struct MacRootView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .id(viewModel.selectedSection) + .transition(.opacity) MacStatusFooter(viewModel: viewModel) } .background(palette.background) } } + +// MARK: - Sidebar row + +/// A navigation row with an animated hover highlight and selection state, +/// matching the macOS System Settings feel. +private struct MacSidebarRow: View { + let section: MacSection + let isSelected: Bool + let language: AppUILanguage + let action: () -> Void + + @Environment(\.themePalette) private var palette + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Label(section.title(language: language), systemImage: section.systemImage) + .font(.system(size: 13)) + .foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Spacing.sm) + .padding(.vertical, 7) + .background( + rowBackground, + in: RoundedRectangle(cornerRadius: 7, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .animation(Motion.quick, value: isSelected) + .animation(Motion.quick, value: isHovering) + .onHover { isHovering = $0 } + } + + private var rowBackground: Color { + if isSelected { return palette.accent } + return isHovering ? palette.textPrimary.opacity(0.06) : .clear + } +} diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift index a4b607a..2cb8fe8 100644 --- a/OSGKeyboardMac/MacSettingsView.swift +++ b/OSGKeyboardMac/MacSettingsView.swift @@ -16,6 +16,8 @@ struct MacSettingsView: View { @AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue + @AppStorage(MacOnboardingState.storageKey) + private var hasCompletedMacOnboarding = true @State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted @State private var showProviderPicker = false @@ -30,22 +32,26 @@ struct MacSettingsView: View { ] var body: some View { - Form { - generalSection - recognitionSection - if viewModel.config.engineMode == "cloud" { - providerSection + NavigationStack { + Form { + generalSection + recognitionSection + if viewModel.config.engineMode == "cloud" { + providerSection + .transition(.opacity) + } + if viewModel.config.engineMode == "local" { + MacLocalASRModelSettingsView(viewModel: viewModel) + .transition(.opacity) + } + inputSection + legalSection } - if viewModel.config.engineMode == "local" { - MacLocalASRModelSettingsView(viewModel: viewModel) - } - inputSection - syncSection + .formStyle(.grouped) + .tint(palette.accent) + .scrollContentBackground(.hidden) + .background(palette.background) } - .formStyle(.grouped) - .tint(palette.accent) - .scrollContentBackground(.hidden) - .background(palette.background) .onAppear { refreshAccessibilityState() } } @@ -70,13 +76,7 @@ struct MacSettingsView: View { Text(localeLabel(locale)).tag(locale.id) } } - } - } - // MARK: - iCloud - - private var syncSection: some View { - Section("iCloud") { MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang) MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang) } @@ -139,14 +139,14 @@ struct MacSettingsView: View { subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang), systemImage: "cloud", selected: viewModel.config.engineMode == "cloud" - ) { viewModel.setEngineMode("cloud") } + ) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } } methodRow( title: MacL10n.string("mac.settings.localEngine", language: lang), subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang), systemImage: "cpu", selected: viewModel.config.engineMode == "local" - ) { viewModel.setEngineMode("local") } + ) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } } } } @@ -176,6 +176,8 @@ struct MacSettingsView: View { ) .font(TypeStyle.caption) .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) + .contentTransition(.opacity) + .animation(Motion.quick, value: accessibilityTrusted) Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) { openAccessibilitySettings() @@ -190,7 +192,27 @@ struct MacSettingsView: View { } } - // MARK: - Qwen3 model path (legacy — see MacLocalASRModelSettingsView) + // MARK: - Legal + + private var legalSection: some View { + Section(MacL10n.string("mac.settings.about", language: lang)) { + NavigationLink { + MacPrivacyPolicyView(uiLanguage: lang) + } label: { + Text(MacL10n.string("mac.settings.privacyPolicy", language: lang)) + } + + NavigationLink { + MacOpenSourceLicensesView(uiLanguage: lang) + } label: { + Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang)) + } + + Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) { + hasCompletedMacOnboarding = false + } + } + } // MARK: - Row helpers @@ -228,7 +250,9 @@ struct MacSettingsView: View { Spacer(minLength: Spacing.sm) Image(systemName: selected ? "checkmark.circle.fill" : "circle") .foregroundStyle(selected ? palette.accent : palette.textTertiary) + .contentTransition(.symbolEffect(.replace)) } + .animation(Motion.quick, value: selected) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/OSGKeyboardMac/MacSherpaLocalASR.swift b/OSGKeyboardMac/MacSherpaLocalASR.swift index 94b632e..6bb7762 100644 --- a/OSGKeyboardMac/MacSherpaLocalASR.swift +++ b/OSGKeyboardMac/MacSherpaLocalASR.swift @@ -49,6 +49,14 @@ enum MacSherpaLocalASR { layout: layout, runtimeBinary: binary ) + case .sherpaParaformer: + return try await MacSherpaONNXRunner.transcribeParaformer( + samples: samples, + sampleRate: sampleRate, + modelRoot: modelRoot, + layout: layout, + runtimeBinary: binary + ) default: throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend") } diff --git a/OSGKeyboardMac/MacSherpaONNXRunner.swift b/OSGKeyboardMac/MacSherpaONNXRunner.swift index d4116b8..740da76 100644 --- a/OSGKeyboardMac/MacSherpaONNXRunner.swift +++ b/OSGKeyboardMac/MacSherpaONNXRunner.swift @@ -77,6 +77,33 @@ enum MacSherpaONNXRunner { return try await run(binary: runtimeBinary, arguments: arguments) } + static func transcribeParaformer( + samples: [Float], + sampleRate: Int, + modelRoot: URL, + layout: LocalASRModelLayout, + runtimeBinary: URL + ) async throws -> String { + guard sampleRate == 16_000 else { + throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio") + } + guard let paraformer = layout.paraformerModel, + let tokens = layout.tokens else { + throw MacLocalASRError.qwen3InferenceFailed("Incomplete Paraformer layout") + } + + let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate) + defer { try? FileManager.default.removeItem(at: wavURL) } + + let arguments = [ + "--tokens=\(modelRoot.appendingPathComponent(tokens).path)", + "--paraformer=\(modelRoot.appendingPathComponent(paraformer).path)", + "--num-threads=2", + wavURL.path, + ] + return try await run(binary: runtimeBinary, arguments: arguments) + } + // MARK: - Private private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL { diff --git a/OSGKeyboardMac/MacTheme.swift b/OSGKeyboardMac/MacTheme.swift index 9728001..8dfba7e 100644 --- a/OSGKeyboardMac/MacTheme.swift +++ b/OSGKeyboardMac/MacTheme.swift @@ -3,47 +3,108 @@ // // System-native colour palette for the desktop app. Instead of the custom // near-black brand palette, the Mac app maps every design token onto AppKit -// semantic colours (`Color(nsColor:)`), which adapt to light / dark on their -// own. The brand green is kept only as the accent. This gives the app the -// same zero-colour-difference, System-Settings / Notes look on both -// appearances while reusing every existing `palette.X` call site. +// semantic colours, resolved to a concrete value for the *active* appearance. +// The brand green is kept only as the accent. Light mode uses a warm, +// iOS-matched surface set (the default `windowBackgroundColor` reads cold +// grey on macOS); Dark mode keeps the native AppKit semantic colours. import AppKit import SwiftUI enum MacSystemPalette { - /// A `ThemePalette` whose surfaces and text resolve to AppKit semantic - /// colours. Because those colours are dynamic, a single value renders - /// correctly under both light and dark (driven by `preferredColorScheme`). - static let palette = ThemePalette( - background: Color(nsColor: .windowBackgroundColor), - surface: Color(nsColor: .controlBackgroundColor), - surfaceElevated: Color(nsColor: .unemphasizedSelectedContentBackgroundColor), - surfaceMuted: Color(nsColor: .underPageBackgroundColor), + /// Returns the palette for the given colour scheme. Because the two + /// palettes hold *concrete* (already-resolved) colours, the value changes + /// identity when the scheme flips — so injecting it via `@Environment` + /// (see `macSystemPalette()`) reliably re-renders every dependent view the + /// instant the appearance changes, instead of lagging until the next + /// view rebuild. + static func palette(for scheme: ColorScheme) -> ThemePalette { + scheme == .dark ? darkPalette : lightPalette + } - accent: Palette.accent, - accentMuted: Palette.accent.opacity(0.16), - accentGlow: Palette.accent.opacity(0.35), + private static let lightPalette = makePalette(dark: false) + private static let darkPalette = makePalette(dark: true) - danger: Color(nsColor: .systemRed), - success: Palette.accent, - warning: Color(nsColor: .systemOrange), + private static func makePalette(dark: Bool) -> ThemePalette { + ThemePalette( + background: resolved(dark ? darkBackground : warmBackground, dark: dark), + surface: resolved(dark ? darkSurface : warmSurface, dark: dark), + surfaceElevated: resolved(dark ? darkElevated : warmElevated, dark: dark), + surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark), - textPrimary: Color(nsColor: .labelColor), - textSecondary: Color(nsColor: .secondaryLabelColor), - textTertiary: Color(nsColor: .tertiaryLabelColor), - textOnAccent: Color.white, + accent: Palette.accent, + accentMuted: Palette.accent.opacity(0.16), + accentGlow: Palette.accent.opacity(0.35), - divider: Color(nsColor: .separatorColor), - dividerStrong: Color(nsColor: .separatorColor), + danger: resolved(.systemRed, dark: dark), + success: Palette.accent, + warning: resolved(.systemOrange, dark: dark), - recordRed: Color(nsColor: .systemRed) - ) + textPrimary: resolved(.labelColor, dark: dark), + textSecondary: resolved(.secondaryLabelColor, dark: dark), + textTertiary: resolved(.tertiaryLabelColor, dark: dark), + textOnAccent: Color.white, + + divider: resolved(.separatorColor, dark: dark), + dividerStrong: resolved(.separatorColor, dark: dark), + + recordRed: resolved(.systemRed, dark: dark) + ) + } + + // MARK: - Warm Light-mode surfaces (matched to iOS `Palette.light`) + + /// #F2F1EE — warm gray page background. + private static let warmBackground = NSColor(srgbRed: 0.949, green: 0.945, blue: 0.933, alpha: 1) + /// #FCFBF9 — warm off-white card/control surface. + private static let warmSurface = NSColor(srgbRed: 0.988, green: 0.984, blue: 0.976, alpha: 1) + /// #EBEAE7 — slightly recessed elevated surface. + private static let warmElevated = NSColor(srgbRed: 0.922, green: 0.918, blue: 0.906, alpha: 1) + /// #EEEDE9 — muted fill between background and surface. + private static let warmMuted = NSColor(srgbRed: 0.933, green: 0.929, blue: 0.918, alpha: 1) + + // MARK: - Dark-mode surfaces (Apple standard elevated grays) + // + // AppKit's `controlBackgroundColor` is *darker* than `windowBackgroundColor` + // in Dark Aqua, so cards using it recede into the page. Instead we step the + // surfaces explicitly (systemGray6→4 equivalents) so every card reads as + // clearly elevated above the background — mirroring the iOS dark palette. + + /// #1C1C1E — page background. + private static let darkBackground = NSColor(srgbRed: 0.110, green: 0.110, blue: 0.118, alpha: 1) + /// #2C2C2E — card / control surface, clearly lighter than the background. + private static let darkSurface = NSColor(srgbRed: 0.173, green: 0.173, blue: 0.180, alpha: 1) + /// #3A3A3C — elevated fill for selected / raised chrome. + private static let darkElevated = NSColor(srgbRed: 0.227, green: 0.227, blue: 0.235, alpha: 1) + /// #242426 — muted fill between background and surface. + private static let darkMuted = NSColor(srgbRed: 0.141, green: 0.141, blue: 0.149, alpha: 1) + + /// Resolves a (possibly dynamic) AppKit colour to its concrete value under + /// the requested appearance, so the two static palettes differ by value. + private static func resolved(_ nsColor: NSColor, dark: Bool) -> Color { + guard let appearance = NSAppearance(named: dark ? .darkAqua : .aqua) else { + return Color(nsColor: nsColor) + } + var result = nsColor + appearance.performAsCurrentDrawingAppearance { + result = nsColor.usingColorSpace(.sRGB) ?? nsColor + } + return Color(nsColor: result) + } +} + +private struct MacSystemPaletteModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + content.environment(\.themePalette, MacSystemPalette.palette(for: colorScheme)) + } } extension View { - /// Injects the system-native palette used across the macOS app. + /// Injects the system-native palette used across the macOS app, refreshed + /// automatically whenever the effective colour scheme changes. func macSystemPalette() -> some View { - environment(\.themePalette, MacSystemPalette.palette) + modifier(MacSystemPaletteModifier()) } } diff --git a/OSGKeyboardMac/OSGKeyboardMacApp.swift b/OSGKeyboardMac/OSGKeyboardMacApp.swift index a87c048..5f1af5a 100644 --- a/OSGKeyboardMac/OSGKeyboardMacApp.swift +++ b/OSGKeyboardMac/OSGKeyboardMacApp.swift @@ -16,6 +16,7 @@ struct OSGKeyboardMacApp: App { // Mac-local appearance preference. Drives both the SwiftUI colour scheme // and — via `applyToApp` — the AppKit window chrome / popover. @AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue + @AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false private var appearance: MacAppearancePreference { MacAppearancePreference(rawValue: appearanceRaw) ?? .system @@ -23,7 +24,16 @@ struct OSGKeyboardMacApp: App { var body: some Scene { Window("OSGKeyboard", id: "main") { - MacRootView(viewModel: viewModel) + Group { + if hasCompletedMacOnboarding { + MacRootView(viewModel: viewModel) + } else { + MacOnboardingView( + viewModel: viewModel, + hasCompletedOnboarding: $hasCompletedMacOnboarding + ) + } + } .macSystemPalette() .environment(\.locale, viewModel.config.uiLanguage.swiftUILocale) .preferredColorScheme(appearance.colorScheme) @@ -177,12 +187,41 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate { private struct MacMenuBarPopover: View { @ObservedObject private var viewModel = MacDictationViewModel.shared @AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue + @AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false var body: some View { - MacContentView(viewModel: viewModel) + Group { + if hasCompletedMacOnboarding { + MacContentView(viewModel: viewModel) + } else { + onboardingPrompt + } + } .frame(width: 340) .macSystemPalette() .environment(\.locale, viewModel.config.uiLanguage.swiftUILocale) .preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil) } + + private var onboardingPrompt: some View { + VStack(spacing: Spacing.md) { + Image(systemName: "sparkles") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(.accent) + + Text(MacL10n.string("mac.onboarding.popover.title", language: viewModel.config.uiLanguage)) + .font(TypeStyle.headline) + + Text(MacL10n.string("mac.onboarding.popover.subtitle", language: viewModel.config.uiLanguage)) + .font(TypeStyle.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Button(MacL10n.string("mac.openWindow", language: viewModel.config.uiLanguage)) { + MacMainWindow.open() + } + .buttonStyle(.borderedProminent) + } + .padding(Spacing.lg) + } } diff --git a/OSGKeyboardShared/Models/LocalASRCapabilities.swift b/OSGKeyboardShared/Models/LocalASRCapabilities.swift index c5e33d5..37743c5 100644 --- a/OSGKeyboardShared/Models/LocalASRCapabilities.swift +++ b/OSGKeyboardShared/Models/LocalASRCapabilities.swift @@ -79,4 +79,13 @@ public struct LocalASRCapabilities: Sendable, Equatable { supportsStreaming: false, hotwordReloadCost: .none ) + + /// FunASR Paraformer (Sherpa offline) — no project hotword API. + public static let sherpaParaformer = LocalASRCapabilities( + hotwordMode: .none, + maxHotwordCount: 0, + maxPromptCharacters: 0, + supportsStreaming: false, + hotwordReloadCost: .none + ) } diff --git a/OSGKeyboardShared/Models/LocalASRModelCatalog.swift b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift index d4f2f12..beb67d1 100644 --- a/OSGKeyboardShared/Models/LocalASRModelCatalog.swift +++ b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift @@ -9,19 +9,41 @@ public enum LocalASRModelBackend: String, Codable, Sendable, Equatable { case mlx case sherpaQwen3 case sherpaSenseVoice + case sherpaParaformer case appleSpeech } public enum LocalASRInstallKind: String, Codable, Sendable, Equatable { case manual case archive + /// Multi-file install from a remote repository (ModelScope / HuggingFace file API). + case repository case runtime } +public struct LocalASRDownloadFile: Codable, Sendable, Equatable { + public let remotePath: String + public let localPath: String + public let sizeBytes: Int? +} + public struct LocalASRDownloadSource: Codable, Sendable, Equatable { public let type: String public let priority: Int + /// Full URL for a single archive download. public let url: String + /// Base URL template for repository installs; must contain `{path}`. + public let baseURL: String? + public let files: [LocalASRDownloadFile]? + + public var isRepository: Bool { + guard let files, !files.isEmpty else { return false } + return baseURL?.contains("{path}") == true + } + + public var isArchive: Bool { + !url.isEmpty && !isRepository + } } public struct LocalASRModelLayout: Codable, Sendable, Equatable { @@ -30,6 +52,7 @@ public struct LocalASRModelLayout: Codable, Sendable, Equatable { public var decoder: String? public var tokenizer: String? public var senseVoiceModel: String? + public var paraformerModel: String? public var tokens: String? } @@ -91,6 +114,8 @@ public enum LocalASRModelCatalog { return .sherpaQwen3 case .sherpaSenseVoice: return .sherpaSenseVoice + case .sherpaParaformer: + return .sherpaParaformer case .appleSpeech: return .appleSpeech } @@ -117,6 +142,42 @@ public enum LocalASRModelCatalog { #endif } +/// Region-aware ordering for local ASR model download mirrors. +public enum LocalASRDownloadSourceSorter { + + /// `true` when the system region is mainland China (`CN`). + public static func isChinaMainland(region: Locale.Region? = Locale.current.region) -> Bool { + region?.identifier == "CN" + } + + /// Lower rank = tried earlier. CN: ModelScope → HuggingFace → GitHub; elsewhere: HF → GitHub → ModelScope. + public static func typeRank(_ type: String, chinaFirst: Bool) -> Int { + switch type.lowercased() { + case "modelscope": + return chinaFirst ? 0 : 2 + case "huggingface": + return chinaFirst ? 1 : 0 + case "github": + return 1 + default: + return 3 + } + } + + public static func sorted( + _ sources: [LocalASRDownloadSource], + region: Locale.Region? = Locale.current.region + ) -> [LocalASRDownloadSource] { + let chinaFirst = isChinaMainland(region: region) + return sources.sorted { lhs, rhs in + let leftRank = typeRank(lhs.type, chinaFirst: chinaFirst) + let rightRank = typeRank(rhs.type, chinaFirst: chinaFirst) + if leftRank != rightRank { return leftRank < rightRank } + return lhs.priority < rhs.priority + } + } +} + public enum LocalASRModelCatalogError: Error, LocalizedError { case missingBundledCatalog case modelNotFound(String) diff --git a/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json index 610986b..424219f 100644 --- a/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json +++ b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "defaultModelId": "qwen3-mlx-1.7b", + "defaultModelId": "sherpa-qwen3-0.6b-int8", "runtimes": [ { "id": "sherpa-onnx-1.13.4-macos-arm64", @@ -36,21 +36,9 @@ } ], "models": [ - { - "id": "qwen3-mlx-1.7b", - "displayName": "Qwen3-ASR 1.7B (MLX)", - "backend": "mlx", - "sizeBytes": 1400000000, - "recommendedLocales": ["zh-CN", "en-US"], - "supportsHotwords": true, - "hotwordMode": "promptOnly", - "installKind": "manual", - "installRelativePath": "models/qwen3-asr-1.7b-mlx", - "requiredRelativeFiles": ["config.json", "model.safetensors", "vocab.json", "merges.txt"] - }, { "id": "sherpa-qwen3-0.6b-int8", - "displayName": "Qwen3-ASR 0.6B (Sherpa · hotwords)", + "displayName": "Qwen3-ASR 0.6B", "backend": "sherpaQwen3", "runtimePlatform": "macos", "sizeBytes": 650000000, @@ -74,9 +62,84 @@ } ] }, + { + "id": "sherpa-qwen3-1.7b-int8", + "displayName": "Qwen3-ASR 1.7B", + "backend": "sherpaQwen3", + "runtimePlatform": "macos", + "sizeBytes": 1900000000, + "recommendedLocales": ["zh-CN", "en-US"], + "supportsHotwords": true, + "hotwordMode": "recognizerScoped", + "installKind": "repository", + "installRelativePath": "models/sherpa-qwen3-1.7b-int8", + "archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8", + "layout": { + "convFrontend": "conv_frontend.onnx", + "encoder": "encoder.int8.onnx", + "decoder": "decoder.int8.onnx", + "tokenizer": "tokenizer" + }, + "sources": [ + { + "type": "modelscope", + "priority": 1, + "url": "", + "baseURL": "https://www.modelscope.cn/models/zengshuishui/Qwen3-ASR-onnx/resolve/master/{path}", + "files": [ + { "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 }, + { "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 }, + { "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 }, + { "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 }, + { "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 }, + { "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 }, + { "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 } + ] + }, + { + "type": "huggingface", + "priority": 1, + "url": "", + "baseURL": "https://huggingface.co/zengshuishui/Qwen3-ASR-onnx/resolve/main/{path}", + "files": [ + { "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 }, + { "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 }, + { "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 }, + { "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 }, + { "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 }, + { "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 }, + { "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 } + ] + } + ] + }, + { + "id": "sherpa-paraformer-zh-int8", + "displayName": "Paraformer Large", + "backend": "sherpaParaformer", + "runtimePlatform": "macos", + "sizeBytes": 220000000, + "recommendedLocales": ["zh-CN", "en-US"], + "supportsHotwords": false, + "hotwordMode": "none", + "installKind": "archive", + "installRelativePath": "models/sherpa-paraformer-zh-int8", + "archiveBaseName": "sherpa-onnx-paraformer-zh-int8-2025-10-07", + "layout": { + "paraformerModel": "model.int8.onnx", + "tokens": "tokens.txt" + }, + "sources": [ + { + "type": "github", + "priority": 1, + "url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-paraformer-zh-int8-2025-10-07.tar.bz2" + } + ] + }, { "id": "sherpa-sensevoice-small-int8", - "displayName": "SenseVoice Small (Sherpa)", + "displayName": "SenseVoice Small", "backend": "sherpaSenseVoice", "runtimePlatform": "macos", "sizeBytes": 250000000, diff --git a/OSGKeyboardShared/Services/LocalASRModelInstallState.swift b/OSGKeyboardShared/Services/LocalASRModelInstallState.swift index 5545c26..6d6bd77 100644 --- a/OSGKeyboardShared/Services/LocalASRModelInstallState.swift +++ b/OSGKeyboardShared/Services/LocalASRModelInstallState.swift @@ -25,7 +25,7 @@ public enum LocalASRModelInstallState { let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true) guard fileManager.fileExists(atPath: base.path) else { return false } return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) } - case .archive: + case .archive, .repository: guard let relative = model.installRelativePath, let layout = model.layout, let baseName = model.archiveBaseName else { return false } @@ -41,7 +41,7 @@ public enum LocalASRModelInstallState { _ model: LocalASRModelDefinition, fileManager: FileManager = .default ) -> URL? { - guard model.installKind == .archive, + guard model.installKind == .archive || model.installKind == .repository, let relative = model.installRelativePath, let baseName = model.archiveBaseName else { return nil } return installDirectory(for: relative, fileManager: fileManager) @@ -98,6 +98,11 @@ public enum LocalASRModelInstallState { let tokens = layout.tokens else { return false } return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path) && fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path) + case .sherpaParaformer: + guard let paraformer = layout.paraformerModel, + let tokens = layout.tokens else { return false } + return fileManager.fileExists(atPath: root.appendingPathComponent(paraformer).path) + && fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path) default: return false } diff --git a/OSGKeyboardShared/Services/LocalASRModelManager.swift b/OSGKeyboardShared/Services/LocalASRModelManager.swift index 022fbe4..a0580eb 100644 --- a/OSGKeyboardShared/Services/LocalASRModelManager.swift +++ b/OSGKeyboardShared/Services/LocalASRModelManager.swift @@ -184,9 +184,7 @@ public actor LocalASRModelManager { _ model: LocalASRModelDefinition, catalog: LocalASRCatalogDocument ) async throws { - guard model.installKind == .archive, - let relative = model.installRelativePath, - let baseName = model.archiveBaseName, + guard let relative = model.installRelativePath, let sources = model.sources, !sources.isEmpty else { throw LocalASRModelManagerError.validationFailed("Model is not downloadable.") @@ -198,38 +196,70 @@ public actor LocalASRModelManager { message: model.displayName, activeItemId: model.id ) - if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice { + if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice || model.backend == .sherpaParaformer { try await ensureRuntimeInstalled(catalog: catalog) } - let sortedSources = sources.sorted { $0.priority < $1.priority } + + let sortedSources = LocalASRDownloadSourceSorter.sorted(sources) var lastError: Error? - for source in sortedSources { - do { - try await installArchive( - from: source.url, - installRelativePath: relative, - archiveBaseName: baseName, - layoutModel: model, - itemId: model.id, - displayName: model.displayName - ) - var manifest = loadManifest(defaultModelId: catalog.defaultModelId) - if !manifest.installedModelIDs.contains(model.id) { - manifest.installedModelIDs.append(model.id) - } - manifest.updatedAt = Date() - try saveManifest(manifest) - progress = LocalASRModelInstallProgress( - phase: .completed, - fraction: 1, - message: model.displayName, - activeItemId: model.id - ) - return - } catch { - lastError = error + + switch model.installKind { + case .archive: + guard let baseName = model.archiveBaseName else { + throw LocalASRModelManagerError.validationFailed("Model is not downloadable.") } + for source in sortedSources where source.isArchive { + do { + try await installArchive( + from: source.url, + installRelativePath: relative, + archiveBaseName: baseName, + layoutModel: model, + itemId: model.id, + displayName: model.displayName + ) + try markModelInstalled(model, catalog: catalog) + progress = LocalASRModelInstallProgress( + phase: .completed, + fraction: 1, + message: model.displayName, + activeItemId: model.id + ) + return + } catch { + lastError = error + } + } + case .repository: + guard let baseName = model.archiveBaseName else { + throw LocalASRModelManagerError.validationFailed("Model is not downloadable.") + } + for source in sortedSources where source.isRepository { + do { + try await installRepository( + source: source, + installRelativePath: relative, + archiveBaseName: baseName, + layoutModel: model, + itemId: model.id, + displayName: model.displayName + ) + try markModelInstalled(model, catalog: catalog) + progress = LocalASRModelInstallProgress( + phase: .completed, + fraction: 1, + message: model.displayName, + activeItemId: model.id + ) + return + } catch { + lastError = error + } + } + default: + throw LocalASRModelManagerError.validationFailed("Model is not downloadable.") } + progress = LocalASRModelInstallProgress( phase: .failed, fraction: 0, @@ -238,11 +268,24 @@ public actor LocalASRModelManager { throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed") } + private func markModelInstalled( + _ model: LocalASRModelDefinition, + catalog: LocalASRCatalogDocument + ) throws { + var manifest = loadManifest(defaultModelId: catalog.defaultModelId) + if !manifest.installedModelIDs.contains(model.id) { + manifest.installedModelIDs.append(model.id) + } + manifest.updatedAt = Date() + try saveManifest(manifest) + } + public func installRuntime( _ runtime: LocalASRRuntimeDefinition, catalog: LocalASRCatalogDocument ) async throws { - guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else { + let sortedSources = LocalASRDownloadSourceSorter.sorted(runtime.sources) + guard !sortedSources.isEmpty else { throw LocalASRModelManagerError.downloadFailed("No runtime source configured.") } progress = LocalASRModelInstallProgress( @@ -251,25 +294,34 @@ public actor LocalASRModelManager { message: runtime.displayName, activeItemId: runtime.id ) - try await installArchive( - from: source.url, - installRelativePath: runtime.installRelativePath, - archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id, - layoutModel: nil, - expectedBinaryCandidates: runtime.binaryCandidates, - itemId: runtime.id, - displayName: runtime.displayName - ) - guard isRuntimeInstalled(runtime) else { - throw LocalASRModelManagerError.binaryMissing + var lastError: Error? + for source in sortedSources where source.isArchive { + do { + try await installArchive( + from: source.url, + installRelativePath: runtime.installRelativePath, + archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id, + layoutModel: nil, + expectedBinaryCandidates: runtime.binaryCandidates, + itemId: runtime.id, + displayName: runtime.displayName + ) + guard isRuntimeInstalled(runtime) else { + throw LocalASRModelManagerError.binaryMissing + } + var manifest = loadManifest(defaultModelId: catalog.defaultModelId) + if !manifest.installedRuntimeIDs.contains(runtime.id) { + manifest.installedRuntimeIDs.append(runtime.id) + } + manifest.updatedAt = Date() + try saveManifest(manifest) + progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName) + return + } catch { + lastError = error + } } - var manifest = loadManifest(defaultModelId: catalog.defaultModelId) - if !manifest.installedRuntimeIDs.contains(runtime.id) { - manifest.installedRuntimeIDs.append(runtime.id) - } - manifest.updatedAt = Date() - try saveManifest(manifest) - progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName) + throw lastError ?? LocalASRModelManagerError.downloadFailed("All runtime mirrors failed") } public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? { @@ -285,7 +337,8 @@ public actor LocalASRModelManager { _ model: LocalASRModelDefinition, catalog: LocalASRCatalogDocument ) throws { - guard model.installKind == .archive, let relative = model.installRelativePath else { return } + guard model.installKind == .archive || model.installKind == .repository, + let relative = model.installRelativePath else { return } let dir = installDirectory(for: relative) if fileManager.fileExists(atPath: dir.path) { try fileManager.removeItem(at: dir) @@ -445,6 +498,115 @@ public actor LocalASRModelManager { try? fileManager.removeItem(at: archiveURL) } + private func installRepository( + source: LocalASRDownloadSource, + installRelativePath: String, + archiveBaseName: String, + layoutModel: LocalASRModelDefinition, + itemId: String, + displayName: String + ) async throws { + guard let baseURL = source.baseURL, + let files = source.files, + !files.isEmpty else { + throw LocalASRModelManagerError.downloadFailed("Invalid repository source.") + } + + let destinationRoot = installDirectory(for: installRelativePath) + .appendingPathComponent(archiveBaseName, isDirectory: true) + let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: stagingRoot) } + + if fileManager.fileExists(atPath: destinationRoot.path) { + try fileManager.removeItem(at: destinationRoot) + } + try fileManager.createDirectory(at: destinationRoot, withIntermediateDirectories: true) + + let totalBytes = files.reduce(Int64(0)) { partial, file in + partial + Int64(file.sizeBytes ?? 0) + } + var completedBytes: Int64 = 0 + + for (index, file) in files.enumerated() { + let remoteURLString = baseURL.replacingOccurrences(of: "{path}", with: file.remotePath) + guard let remoteURL = URL(string: remoteURLString) else { + throw LocalASRModelManagerError.downloadFailed("Invalid URL for \(file.remotePath)") + } + + let localURL = destinationRoot.appendingPathComponent(file.localPath) + try fileManager.createDirectory( + at: localURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + // Snapshot the running total so the progress closure captures an + // immutable value (avoids concurrent access to `completedBytes`). + let priorBytes = completedBytes + progress = LocalASRModelInstallProgress( + phase: .downloading, + fraction: 0.10 + (Double(index) / Double(files.count)) * 0.45, + message: displayName, + bytesReceived: priorBytes, + bytesTotal: totalBytes > 0 ? totalBytes : nil, + activeItemId: itemId + ) + + do { + let controller = LocalASRModelDownloadClient.makeController(destinationURL: localURL) { update in + Task { + let aggregateReceived = priorBytes + update.bytesReceived + let aggregateTotal = totalBytes > 0 ? totalBytes : update.bytesTotal + await LocalASRModelManager.shared.updateDownloadProgress( + itemId: itemId, + displayName: displayName, + update: LocalASRDownloadProgressUpdate( + bytesReceived: aggregateReceived, + bytesTotal: max(aggregateTotal, 1) + ) + ) + } + } + activeDownloadController = controller + try await controller.download(from: remoteURL) + activeDownloadController = nil + pausedResumeData = nil + } catch { + activeDownloadController = nil + pausedResumeData = nil + throw LocalASRModelManagerError.downloadFailed(error.localizedDescription) + } + + if let size = file.sizeBytes { + completedBytes += Int64(size) + } else if let attrs = try? fileManager.attributesOfItem(atPath: localURL.path), + let size = attrs[.size] as? Int64 { + completedBytes += size + } + } + + progress = LocalASRModelInstallProgress( + phase: .validating, + fraction: 0.82, + message: displayName, + activeItemId: itemId + ) + guard LocalASRModelInstallState.isInstalled( + layoutModel, + manualMLXPath: nil, + fileManager: fileManager + ) else { + throw LocalASRModelManagerError.validationFailed("Required model files missing after download.") + } + + progress = LocalASRModelInstallProgress( + phase: .finalizing, + fraction: 0.95, + message: displayName, + activeItemId: itemId + ) + } + public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws { guard let runtime = LocalASRModelCatalog.runtime( for: LocalASRModelCatalog.currentRuntimePlatform(), diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index 218bd11..b8509ed 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -162,6 +162,10 @@ "mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)"; "mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed."; "mac.settings.about" = "About"; +"mac.settings.privacyPolicy" = "Privacy Policy"; +"mac.settings.thirdPartyLicenses" = "Third-Party Licenses"; +"mac.settings.licenses.footer" = "Open-source components used by OSGKeyboard. License texts are reproduced from upstream repositories; model weights are downloaded at runtime and cached on device. OSGKeyboard itself is source-available — commercial licensing: rocky.hk@gmail.com."; +"mac.settings.restartOnboarding" = "Restart First-Run Setup"; "mac.settings.general" = "General"; "mac.settings.input" = "Input & Shortcuts"; "mac.settings.recognitionLanguage" = "Recognition Language"; @@ -185,14 +189,48 @@ "mac.appearance.system" = "System"; "mac.appearance.light" = "Light"; "mac.appearance.dark" = "Dark"; +"mac.onboarding.progress" = "Step %lld of %lld"; +"mac.onboarding.back" = "Back"; +"mac.onboarding.next" = "Next"; +"mac.onboarding.finish" = "Finish"; +"mac.onboarding.skipForNow" = "Skip for now"; +"mac.onboarding.welcome.title" = "Welcome to OSGKeyboard"; +"mac.onboarding.welcome.subtitle" = "Set up dictation in a minute. Everything here can be changed later in Settings."; +"mac.onboarding.welcome.privacy" = "Your voice stays local unless you choose a cloud provider."; +"mac.onboarding.welcome.hotkey" = "Hold Option to dictate from any app."; +"mac.onboarding.welcome.local" = "Local Sherpa recognition can be prepared now or later."; +"mac.onboarding.microphone.title" = "Allow microphone access"; +"mac.onboarding.microphone.subtitle" = "OSGKeyboard needs the microphone to capture your dictation audio."; +"mac.onboarding.microphone.allow" = "Allow Microphone"; +"mac.onboarding.microphone.granted" = "Microphone access is granted."; +"mac.onboarding.microphone.needed" = "Microphone access is needed for recording."; +"mac.onboarding.accessibility.title" = "Enable Accessibility"; +"mac.onboarding.accessibility.subtitle" = "Accessibility lets OSGKeyboard listen for the global shortcut and paste text into the front app. You can skip it and grant it later."; +"mac.onboarding.accessibility.open" = "Open System Settings"; +"mac.onboarding.accessibility.granted" = "Accessibility permission is granted."; +"mac.onboarding.accessibility.needed" = "Accessibility is not enabled yet."; +"mac.onboarding.engine.title" = "Choose a recognition path"; +"mac.onboarding.engine.subtitle" = "Start locally for privacy, or use your own cloud API for provider-based recognition and polish."; +"mac.onboarding.engine.localDesc" = "Private on-device recognition with Sherpa models. Falls back to Apple Speech until a model is installed."; +"mac.onboarding.engine.cloudDesc" = "Use your API key for cloud speech recognition and AI polishing."; +"mac.onboarding.cloud.title" = "Configure API access"; +"mac.onboarding.cloud.subtitle" = "Pick a provider and add your key. This step is optional, so you can finish now and fill it later in Settings."; +"mac.onboarding.cloud.skipHint" = "You can leave these blank and finish setup; cloud mode will ask for a key before use."; +"mac.onboarding.model.title" = "Prepare local recognition"; +"mac.onboarding.model.subtitle" = "Download the basic Sherpa runtime and default model now, or skip and continue with Apple Speech fallback."; +"mac.onboarding.model.download" = "Download Default Model"; +"mac.onboarding.model.done" = "Local model is ready."; +"mac.onboarding.model.skipHint" = "Skipping is safe: Settings keeps the same download controls for later."; +"mac.onboarding.popover.title" = "Finish setup"; +"mac.onboarding.popover.subtitle" = "Open the main window to choose permissions, API settings, and local model download."; "mac.error.noAudio" = "No audio captured"; "mac.error.noCloudASR" = "Selected provider has no cloud ASR"; "mac.error.emptyTranscript" = "No speech recognized"; "mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed"; "mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@"; "mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@"; -"mac.localASR.models" = "Local ASR Models"; -"mac.localASR.modelsDesc" = "Sherpa models download directly. For MLX Qwen3, drop your converted weights into the folder opened by “Open folder”. All three share one storage directory."; +"mac.localASR.models" = "Local ASR Engine & Models"; +"mac.localASR.modelsDesc" = "Models download directly. China uses ModelScope first; elsewhere Hugging Face first, then GitHub. Qwen3 1.7B downloads as multiple files."; "mac.localASR.download" = "Download"; "mac.localASR.selectFolder" = "Choose folder"; "mac.localASR.openFolder" = "Open folder"; @@ -202,6 +240,7 @@ "mac.localASR.installDone" = "Install completed."; "mac.localASR.installed" = "Installed"; "mac.localASR.notInstalled" = "Not installed"; +"mac.localASR.personalDictionaryTag" = "Personal dictionary"; "mac.localASR.hotwordsYes" = "Hotwords"; "mac.localASR.hotwordsNo" = "No hotwords"; "mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle."; @@ -219,8 +258,8 @@ "mac.localASR.redownload" = "Re-download"; "mac.localASR.revealInFinder" = "Reveal in Finder"; "mac.localASR.openStorage" = "Open model storage folder"; -"mac.localASR.runtime" = "Sherpa Runtime"; -"mac.localASR.runtimeDesc" = "Required for Sherpa Qwen3 and SenseVoice models. Installed automatically with those models."; +"mac.localASR.runtime" = "Local ASR runtime"; +"mac.localASR.runtimeDesc" = "Installed automatically with the models above; required for Qwen3, SenseVoice, and similar models."; "mac.localASR.phase.downloading" = "Downloading"; "mac.localASR.phase.paused" = "Paused"; "mac.localASR.phase.extracting" = "Extracting"; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index 5562931..771ca39 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -162,6 +162,10 @@ "mac.settings.localSpeechFallback" = "本地识别(Apple Speech)"; "mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。"; "mac.settings.about" = "关于"; +"mac.settings.privacyPolicy" = "隐私政策"; +"mac.settings.thirdPartyLicenses" = "第三方许可"; +"mac.settings.licenses.footer" = "以下为 OSGKeyboard 使用的开源组件。许可正文摘自上游仓库;模型权重在运行时下载并缓存在本机。OSGKeyboard 本身采用源码可见许可,商业授权请联系 rocky.hk@gmail.com。"; +"mac.settings.restartOnboarding" = "重新开始首次引导"; "mac.settings.general" = "通用"; "mac.settings.input" = "输入与快捷键"; "mac.settings.recognitionLanguage" = "识别语言"; @@ -185,14 +189,48 @@ "mac.appearance.system" = "跟随系统"; "mac.appearance.light" = "浅色"; "mac.appearance.dark" = "深色"; +"mac.onboarding.progress" = "第 %lld 步,共 %lld 步"; +"mac.onboarding.back" = "上一步"; +"mac.onboarding.next" = "下一步"; +"mac.onboarding.finish" = "完成"; +"mac.onboarding.skipForNow" = "暂时跳过"; +"mac.onboarding.welcome.title" = "欢迎使用 OSGKeyboard"; +"mac.onboarding.welcome.subtitle" = "用一分钟完成听写基础配置。这里的选项之后都可以在设置里修改。"; +"mac.onboarding.welcome.privacy" = "除非你选择云端服务商,语音会优先留在本机处理。"; +"mac.onboarding.welcome.hotkey" = "按住 Option 键即可在任意应用开始听写。"; +"mac.onboarding.welcome.local" = "Sherpa 本地识别可以现在准备,也可以稍后下载。"; +"mac.onboarding.microphone.title" = "允许麦克风访问"; +"mac.onboarding.microphone.subtitle" = "OSGKeyboard 需要麦克风来录制你的听写音频。"; +"mac.onboarding.microphone.allow" = "允许麦克风"; +"mac.onboarding.microphone.granted" = "麦克风权限已授权。"; +"mac.onboarding.microphone.needed" = "录音需要麦克风权限。"; +"mac.onboarding.accessibility.title" = "开启辅助功能"; +"mac.onboarding.accessibility.subtitle" = "辅助功能用于全局快捷键和向前台应用粘贴文本。你可以先跳过,之后再授权。"; +"mac.onboarding.accessibility.open" = "打开系统设置"; +"mac.onboarding.accessibility.granted" = "辅助功能权限已授权。"; +"mac.onboarding.accessibility.needed" = "尚未开启辅助功能权限。"; +"mac.onboarding.engine.title" = "选择识别方式"; +"mac.onboarding.engine.subtitle" = "默认可用本地识别以保护隐私,也可以配置自己的云端 API。"; +"mac.onboarding.engine.localDesc" = "使用 Sherpa 本地模型进行私密转写;模型未安装前会回退到 Apple 语音识别。"; +"mac.onboarding.engine.cloudDesc" = "使用你的 API Key 进行云端语音识别和 AI 润色。"; +"mac.onboarding.cloud.title" = "配置 API 访问"; +"mac.onboarding.cloud.subtitle" = "选择服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。"; +"mac.onboarding.cloud.skipHint" = "可以先留空并完成引导;云端模式使用前会提示需要 API Key。"; +"mac.onboarding.model.title" = "准备本地识别"; +"mac.onboarding.model.subtitle" = "现在下载基础 Sherpa runtime 和默认模型,或先跳过并使用 Apple Speech 兜底。"; +"mac.onboarding.model.download" = "下载默认模型"; +"mac.onboarding.model.done" = "本地模型已准备好。"; +"mac.onboarding.model.skipHint" = "跳过不会影响使用:设置页保留同样的下载入口。"; +"mac.onboarding.popover.title" = "完成首次配置"; +"mac.onboarding.popover.subtitle" = "打开主窗口选择权限、API 设置和本地模型下载。"; "mac.error.noAudio" = "没有捕获到音频"; "mac.error.noCloudASR" = "当前服务商不支持云端语音识别"; "mac.error.emptyTranscript" = "没有识别到语音"; "mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型"; "mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@"; "mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@"; -"mac.localASR.models" = "本地 ASR 模型"; -"mac.localASR.modelsDesc" = "Sherpa 模型可直接下载;MLX Qwen3 请将转换好的权重放入「打开目录」指向的文件夹。三个模型共用同一存储目录。"; +"mac.localASR.models" = "本地 ASR 引擎与模型"; +"mac.localASR.modelsDesc" = "模型可直接下载。中国大陆优先 ModelScope,其他地区优先 Hugging Face,再回退 GitHub。Qwen3 1.7B 以多文件方式下载。"; "mac.localASR.download" = "下载"; "mac.localASR.selectFolder" = "选择目录"; "mac.localASR.openFolder" = "打开目录"; @@ -202,6 +240,7 @@ "mac.localASR.installDone" = "安装完成。"; "mac.localASR.installed" = "已安装"; "mac.localASR.notInstalled" = "未安装"; +"mac.localASR.personalDictionaryTag" = "个性词库"; "mac.localASR.hotwordsYes" = "支持热词"; "mac.localASR.hotwordsNo" = "无热词"; "mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。"; @@ -219,8 +258,8 @@ "mac.localASR.redownload" = "重新下载"; "mac.localASR.revealInFinder" = "在 Finder 中显示"; "mac.localASR.openStorage" = "打开模型存储目录"; -"mac.localASR.runtime" = "Sherpa 运行时"; -"mac.localASR.runtimeDesc" = "Sherpa Qwen3 与 SenseVoice 模型需要此运行时;下载上述模型时会自动安装。"; +"mac.localASR.runtime" = "本地识别运行时"; +"mac.localASR.runtimeDesc" = "下载上述模型时会自动安装;Qwen3 与 SenseVoice 等模型需要此组件。"; "mac.localASR.phase.downloading" = "下载中"; "mac.localASR.phase.paused" = "已暂停"; "mac.localASR.phase.extracting" = "解压中"; diff --git a/OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift b/OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift new file mode 100644 index 0000000..0376c1c --- /dev/null +++ b/OSGKeyboardTests/LocalASRDownloadSourceSorterTests.swift @@ -0,0 +1,47 @@ +// LocalASRDownloadSourceSorterTests.swift +// OSGKeyboardTests + +import XCTest +@testable import OSGKeyboardShared + +final class LocalASRDownloadSourceSorterTests: XCTestCase { + + private func source(_ type: String, priority: Int = 1) -> LocalASRDownloadSource { + LocalASRDownloadSource( + type: type, + priority: priority, + url: "https://example.com/\(type).tar.bz2", + baseURL: nil, + files: nil + ) + } + + func testChinaMainlandPrefersModelScope() { + let sources = [ + source("github"), + source("huggingface"), + source("modelscope"), + ] + let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("CN")) + XCTAssertEqual(sorted.map(\.type), ["modelscope", "huggingface", "github"]) + } + + func testGlobalPrefersHuggingFace() { + let sources = [ + source("github"), + source("huggingface"), + source("modelscope"), + ] + let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("US")) + XCTAssertEqual(sorted.map(\.type), ["huggingface", "github", "modelscope"]) + } + + func testSameTypeUsesPriority() { + let sources = [ + source("github", priority: 2), + source("github", priority: 1), + ] + let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("US")) + XCTAssertEqual(sorted.map(\.priority), [1, 2]) + } +} diff --git a/OSGKeyboardTests/LocalASRModelCatalogTests.swift b/OSGKeyboardTests/LocalASRModelCatalogTests.swift index 4e13d7a..8a913cc 100644 --- a/OSGKeyboardTests/LocalASRModelCatalogTests.swift +++ b/OSGKeyboardTests/LocalASRModelCatalogTests.swift @@ -9,9 +9,19 @@ final class LocalASRModelCatalogTests: XCTestCase { func testBundledCatalogLoads() throws { let catalog = try LocalASRModelCatalog.loadBundled() XCTAssertEqual(catalog.schemaVersion, 1) - XCTAssertFalse(catalog.models.isEmpty) - XCTAssertTrue(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" }) + XCTAssertEqual(catalog.defaultModelId, "sherpa-qwen3-0.6b-int8") + XCTAssertFalse(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" }) XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" }) + XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-1.7b-int8" }) + XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-paraformer-zh-int8" }) + } + + func testSherpaQwen317BUsesRepositoryInstall() throws { + let catalog = try LocalASRModelCatalog.loadBundled() + let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-qwen3-1.7b-int8", in: catalog)) + XCTAssertEqual(model.installKind, .repository) + XCTAssertTrue(model.sources?.contains(where: { $0.type == "modelscope" && $0.isRepository }) == true) + XCTAssertTrue(model.sources?.contains(where: { $0.type == "huggingface" && $0.isRepository }) == true) } func testCapabilitiesForSherpaQwen3() throws { @@ -22,6 +32,14 @@ final class LocalASRModelCatalogTests: XCTestCase { XCTAssertTrue(model.supportsHotwords) } + func testCapabilitiesForParaformer() throws { + let catalog = try LocalASRModelCatalog.loadBundled() + let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-paraformer-zh-int8", in: catalog)) + let caps = LocalASRModelCatalog.capabilities(for: model) + XCTAssertEqual(caps.hotwordMode, .none) + XCTAssertFalse(model.supportsHotwords) + } + func testManifestRoundTrip() throws { let manifest = LocalASRInstalledManifest( selectedModelId: "sherpa-qwen3-0.6b-int8", @@ -54,11 +72,11 @@ final class LocalASRModelCatalogTests: XCTestCase { ) LocalASRBiasDiagnosticsStore.save( payload: payload, - modelId: "qwen3-mlx-1.7b", - backendLabel: "MLX" + modelId: "sherpa-qwen3-0.6b-int8", + backendLabel: "Sherpa Qwen3" ) let snapshot = LocalASRBiasDiagnosticsStore.load() - XCTAssertEqual(snapshot?.modelId, "qwen3-mlx-1.7b") + XCTAssertEqual(snapshot?.modelId, "sherpa-qwen3-0.6b-int8") XCTAssertEqual(snapshot?.diagnostics.userTermCount, 2) XCTAssertEqual(snapshot?.hotwordCount, 1) LocalASRBiasDiagnosticsStore.clear() diff --git a/project.yml b/project.yml index 70b8ead..30685ed 100644 --- a/project.yml +++ b/project.yml @@ -407,6 +407,7 @@ targets: excludes: - "AppIcon.appiconset" - path: OSGKeyboardMac + - path: OSGKeyboard/Services/OpenSourceLicenseCatalog.swift - path: OSGKeyboardShared excludes: - "en.lproj" @@ -432,6 +433,8 @@ targets: buildPhase: resources - path: OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv buildPhase: resources + - path: OSGKeyboard/Resources/PrivacyPolicy.html + buildPhase: resources - path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json buildPhase: resources entitlements: