From cdf833935a7781747cfdc2a823f2b75e7800d7c0 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:39:41 +0800 Subject: [PATCH 1/2] feat: harden Flow cold-start/force-quit and polish macOS dictation UX Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening. --- AUDIT_APPSTORE.md | 2 +- CHANGELOG.md | 14 + OSGKeyboard/Info.plist | 7 + OSGKeyboard/PrivacyInfo.xcprivacy | 15 + OSGKeyboard/Services/AppURLHandler.swift | 7 +- .../Services/FlowLiveActivityController.swift | 20 +- OSGKeyboard/Services/FlowSessionManager.swift | 431 ++++++++++++++---- OSGKeyboard/Views/ASRSettingsCard.swift | 204 +++++++++ .../Views/Components/MinimalTabBar.swift | 12 + .../Components/WideLayoutComponents.swift | 198 ++++++++ OSGKeyboard/Views/HomeView.swift | 140 +++++- OSGKeyboard/Views/MainAppRoot.swift | 14 +- OSGKeyboard/Views/MainSplitView.swift | 276 +++++++++++ OSGKeyboard/Views/MainTabContent.swift | 25 + OSGKeyboard/Views/MainTabView.swift | 55 ++- OSGKeyboard/Views/ProviderPickerSection.swift | 20 +- OSGKeyboard/Views/SettingsView.swift | 47 +- OSGKeyboard/en.lproj/InfoPlist.strings | 2 +- OSGKeyboard/en.lproj/Localizable.strings | 25 +- OSGKeyboard/zh-Hans.lproj/InfoPlist.strings | 2 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 25 +- OSGKeyboardExt/KeyboardViewController.swift | 8 +- OSGKeyboardExt/Services/HostAppLauncher.swift | 61 ++- .../Services/KeyboardFlowCoordinator.swift | 112 ++++- .../Services/KeyboardTextInserter.swift | 11 +- OSGKeyboardExt/Views/KeyboardRootView.swift | 7 + .../FlowLiveActivityWidget.swift | 67 ++- .../en.lproj/Localizable.strings | 7 + .../zh-Hans.lproj/Localizable.strings | 6 + OSGKeyboardMac/DashboardView.swift | 178 +++++--- OSGKeyboardMac/MacAudioRecorder.swift | 73 ++- OSGKeyboardMac/MacCloudASRChunkAdapter.swift | 55 +++ OSGKeyboardMac/MacComponents.swift | 223 +++++++-- OSGKeyboardMac/MacContentView.swift | 2 +- .../MacDictationOverlayController.swift | 222 +++++++++ OSGKeyboardMac/MacDictationOverlayView.swift | 170 +++++++ OSGKeyboardMac/MacDictationPipeline.swift | 279 ++++++++++-- OSGKeyboardMac/MacDictationResult.swift | 12 + OSGKeyboardMac/MacDictationViewModel.swift | 222 ++++++++- OSGKeyboardMac/MacDictionaryView.swift | 150 +++--- OSGKeyboardMac/MacHistoryView.swift | 118 +++-- OSGKeyboardMac/MacHotkeyService.swift | 131 +++++- OSGKeyboardMac/MacLocalASRChunkAdapter.swift | 43 ++ .../MacLocalASRModelSettingsView.swift | 31 +- OSGKeyboardMac/MacLocalASRService.swift | 6 +- OSGKeyboardMac/MacOnboardingView.swift | 2 +- OSGKeyboardMac/MacRootView.swift | 31 +- OSGKeyboardMac/MacSettingsView.swift | 231 ++++++++-- OSGKeyboardMac/MacSherpaONNXRunner.swift | 65 ++- OSGKeyboardMac/MacSpeechLocalASR.swift | 104 ++++- OSGKeyboardMac/MacTextInsertionService.swift | 154 ++++++- OSGKeyboardMac/MacTheme.swift | 15 +- OSGKeyboardMac/OSGKeyboardMacApp.swift | 7 +- .../Configuration/ConfigurationStore.swift | 8 + OSGKeyboardShared/DesignSystem/Theme.swift | 2 + .../Models/AppGroupConfiguration.swift | 127 +++++- .../Models/CloudProviderRole.swift | 12 + .../Models/FlowInactivityDuration.swift | 7 +- OSGKeyboardShared/Models/LLMProvider.swift | 7 + .../Models/LocalASRModelCatalog.swift | 3 + .../Models/PersonalDictionary+Merging.swift | 21 +- OSGKeyboardShared/Models/ProviderConfig.swift | 115 ++++- .../Models/SyncedAppSettingsV2.swift | 107 ++++- OSGKeyboardShared/Models/SyncedField.swift | 31 +- .../Models/SyncedSpeechHistory.swift | 29 +- .../Resources/LocalASR/local-asr-catalog.json | 27 +- .../Services/ASRChunkTranscribing.swift | 25 + OSGKeyboardShared/Services/ASRService.swift | 8 +- .../Services/AppGroupStore.swift | 10 + .../Services/ChunkedUtterancePipeline.swift | 4 +- .../Services/CloudASR/CloudASRClients.swift | 24 +- .../Services/CloudASR/CloudASRService.swift | 2 +- .../Services/CustomLanguageModelManager.swift | 77 +++- .../Services/FlowContinuousCapture.swift | 161 +++++-- .../Services/FlowSessionBridge.swift | 86 +++- .../Services/FlowSessionKeys.swift | 28 +- .../Services/ICloudSync/AppCloudSync.swift | 49 +- .../ICloudSync/SpeechHistoryCloudSync.swift | 45 +- .../ICloudSync/UsageStatisticsCloudSync.swift | 10 +- .../Services/KeyboardState.swift | 47 +- OSGKeyboardShared/Services/Keychain.swift | 200 +++++++- .../PersonalDictionaryCloudSync.swift | 17 +- .../Services/PolishingService.swift | 53 ++- .../Services/SpeechHistoryStore.swift | 14 + .../TranscriptionPolishFallback.swift | 47 ++ .../Utilities/DictationTextComposer.swift | 21 + OSGKeyboardShared/Views/FlowDebugPanel.swift | 174 +++++++ OSGKeyboardShared/en.lproj/Shared.strings | 41 +- .../zh-Hans.lproj/Shared.strings | 43 +- .../AppGroupConfigurationTests.swift | 51 ++- .../ConfigurationStoreTests.swift | 26 ++ .../FlowBudgetAndMergeTests.swift | 212 +++++++++ OSGKeyboardTests/FlowSessionBridgeTests.swift | 136 ++++++ OSGKeyboardTests/FlowSessionPolicyTests.swift | 6 +- OSGKeyboardTests/IntelligentPolishTests.swift | 16 +- .../LocalASRModelCatalogTests.swift | 19 +- OSGKeyboardTests/SettingsCloudSyncTests.swift | 7 + README.md | 23 +- README.zh.md | 10 +- docs/APPSTORE_METADATA.md | 22 +- docs/index.html | 28 +- docs/privacy.html | 16 +- docs/privacy/index.html | 22 +- project.yml | 27 +- 104 files changed, 5794 insertions(+), 853 deletions(-) create mode 100644 OSGKeyboard/Views/ASRSettingsCard.swift create mode 100644 OSGKeyboard/Views/Components/WideLayoutComponents.swift create mode 100644 OSGKeyboard/Views/MainSplitView.swift create mode 100644 OSGKeyboard/Views/MainTabContent.swift create mode 100644 OSGKeyboardLiveActivity/en.lproj/Localizable.strings create mode 100644 OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings create mode 100644 OSGKeyboardMac/MacCloudASRChunkAdapter.swift create mode 100644 OSGKeyboardMac/MacDictationOverlayController.swift create mode 100644 OSGKeyboardMac/MacDictationOverlayView.swift create mode 100644 OSGKeyboardMac/MacDictationResult.swift create mode 100644 OSGKeyboardMac/MacLocalASRChunkAdapter.swift create mode 100644 OSGKeyboardShared/Models/CloudProviderRole.swift create mode 100644 OSGKeyboardShared/Services/ASRChunkTranscribing.swift create mode 100644 OSGKeyboardShared/Services/TranscriptionPolishFallback.swift create mode 100644 OSGKeyboardShared/Views/FlowDebugPanel.swift create mode 100644 OSGKeyboardTests/FlowBudgetAndMergeTests.swift diff --git a/AUDIT_APPSTORE.md b/AUDIT_APPSTORE.md index 4479d10..758aafa 100644 --- a/AUDIT_APPSTORE.md +++ b/AUDIT_APPSTORE.md @@ -116,7 +116,7 @@ - **6.7"** (1290×2796) — 必需 - **6.1"** (1179×2556) — 必需(iPhone 17 Pro) - 5.5" 已被苹果官方文档降级为"可选"(iPhone 8 Plus 等已停产机型) -- iPad 截图 — 项目声明 iPhone only,无需提供 +- iPad 截图 — **现已必需**:项目自 TARGETED_DEVICE_FAMILY "1,2" 起支持 iPad,App Store Connect 要求提供 13″ iPad Pro 截图套组(本条为后续更新覆盖原「iPhone only 无需提供」的结论) - 每套至少 3 张、最多 10 张 **本审计交付**: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f87910..b1fbab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **macOS dictation overlay**: a bottom-centered floating pill appears for any recording path (global hotkey, menu bar, or main window) — shows listening / transcribing state, a stronger live waveform, a one-line live transcript preview (partials when chunked ASR runs), front-app name, and a stop control, then briefly confirms success before fading out without stealing focus. / **macOS 听写浮层**:任意录音路径(全局热键、菜单栏或主窗口)都会在屏幕底部居中出现胶囊浮层——显示聆听 / 识别状态、更强的实时波形、单行转写预览(分块识别时显示 partial)、前台应用名与停止按钮,成功后短暂确认再淡出,且不抢前台焦点。 +- **macOS Option-key picker**: Settings → Input lets you choose Left / Right / Either Option as the hold-to-talk key, so the shortcut can avoid conflicts with other apps. / **macOS Option 键选择**:设置 → 输入与快捷键可选择左 / 右 / 任一 Option 作为按住听写键,避免与其他应用冲突。 + +### Changed +- **Removed temporary Flow DEBUG panels**: the on-screen App Group / session debug text boxes on Home and the keyboard extension are gone now that the orange-mic investigation is closed. / **移除临时 Flow DEBUG 面板**:橙色麦克风排查结束后,首页与键盘扩展上的 App Group / 会话调试文本框已去掉。 +- **macOS local ASR catalog**: removed offline Paraformer; SenseVoice / Qwen3 0.6B / Qwen3 1.7B now show Fastest / Most balanced / Best quality badges (users still on Paraformer migrate to Qwen3 0.6B). / **macOS 本地 ASR 目录**:移除 offline Paraformer;SenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。 +- **macOS visual system**: full-app redesign around the brand line “Speak it. It’s typed.” / 「开口即文字。」— grouped sidebar, restrained accent selection, asymmetric Home stats (chars as hero), unified page headers, quieter status footer, and clearer dark-mode card elevation. / **macOS 视觉体系**:围绕品牌句「开口即文字。」/ “Speak it. It’s typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。 +- **macOS Home stat cards**: the hero word-count card is now a full-width horizontal bar sized to its content (icon + label + big number) instead of a tall card stretched to match its neighbors, removing the large dead space below the number; the "Recent" list was removed from Home since it duplicated the History page. / **macOS 首页统计卡**:字数主卡改为按内容自适应高度的满宽横条(图标+标题+大数字),不再被拉伸到与相邻卡片等高、留出大片空白;首页底部与历史页重复的「最近」列表已移除。 +- **macOS page margins**: Home / History / Dictionary / Settings share `pageHorizontalInset` on titles and scroll *content*; ScrollViews / Forms stay full-bleed so the scrollbar sits on the window edge, while cards stay aligned with the page title. Settings keeps native grouped Form for control layout. / **macOS 页边距**:首页 / 历史 / 词库 / 设置在标题与滚动*内容*上共用 `pageHorizontalInset`;ScrollView / Form 通栏使滚动条贴窗口右缘,卡片仍与页标题对齐。设置保留原生分组 Form 以保证控件排版。 + ### Fixed +- **Cold-start overlay recursion crash**: dismissing the ready overlay while an utterance is already recording no longer recurses `refreshHostReady` → `reconcile` → `dismiss` on the main thread until stack overflow (`EXC_BAD_ACCESS`). Handoff flags are cleared before any refresh. / **冷启动浮层递归崩溃**:就绪浮层仍在时若已开始录音,不再在主线程上递归 `refreshHostReady` → `reconcile` → `dismiss` 直至栈溢出(`EXC_BAD_ACCESS`);交接标志会在任何 refresh 之前先清除。 +- **macOS Qwen3 “language” garbage transcript**: Sherpa Qwen3 results that still include the model scaffold (`language Chinese…`) are now stripped to the spoken text; incomplete outputs that stop at the bare word `language` are treated as empty instead of being inserted. / **macOS Qwen3「language」乱码转写**:Sherpa Qwen3 结果若仍带模型脚手架(`language Chinese…`)会剥到真实口语文案;不完整输出停在单词 `language` 时按空结果处理,不再插入。 +- **macOS local ASR silence garbage output**: dictating with no speech (silence) on a Sherpa-backed local model (SenseVoice/Qwen3/Paraformer) no longer inserts the raw JSON result line (`{"lang": "", "emotion": "", ...}`) as the transcript — it now correctly reports "no speech recognized". / **macOS 本地识别静音乱码**:使用 Sherpa 本地模型(SenseVoice/Qwen3/Paraformer)听写时若未检测到语音,不再把原始 JSON 结果行(`{"lang": "", "emotion": "", ...}`)当作转写文本插入,现在会正确提示「没有识别到语音」。 - **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 diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist index 98aa4cb..a373c36 100644 --- a/OSGKeyboard/Info.plist +++ b/OSGKeyboard/Info.plist @@ -117,5 +117,12 @@ UIInterfaceOrientationPortrait + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + diff --git a/OSGKeyboard/PrivacyInfo.xcprivacy b/OSGKeyboard/PrivacyInfo.xcprivacy index 765b862..20edd9b 100644 --- a/OSGKeyboard/PrivacyInfo.xcprivacy +++ b/OSGKeyboard/PrivacyInfo.xcprivacy @@ -8,6 +8,21 @@ NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeAudioData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeOtherUserContent diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift index 50c2a2a..d0bfd38 100644 --- a/OSGKeyboard/Services/AppURLHandler.swift +++ b/OSGKeyboard/Services/AppURLHandler.swift @@ -97,8 +97,11 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate { for item in items { // `sourceApplication` is only non-nil when the caller belongs to // the same Apple Developer Team (our own keyboard extension) — - // exactly what the host-return whitelist relies on. - if let source = item.source { + // exactly what the host-return whitelist relies on. Record it + // ONLY for the `startflow` handoff: overwriting it for every + // deep link (e.g. `osgkeyboard://settings`) could point a later + // cold-start "return to host" at the wrong app. + if let source = item.source, item.url.host == "startflow" { FlowSessionBridge.setPendingHostBundleId(source) } AppOpenURLRouter.shared.route(item.url) diff --git a/OSGKeyboard/Services/FlowLiveActivityController.swift b/OSGKeyboard/Services/FlowLiveActivityController.swift index eb374d1..cc8cc5c 100644 --- a/OSGKeyboard/Services/FlowLiveActivityController.swift +++ b/OSGKeyboard/Services/FlowLiveActivityController.swift @@ -15,11 +15,17 @@ enum FlowLiveActivityController { nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle /// If the host app is force-quit its `endSession()` never runs, orphaning - /// the Live Activity. A short `staleDate` lets the system grey it out and - /// reclaim it on its own within ~45s of the process dying. While the host is - /// alive the heartbeat calls `keepAlive()` well inside this window, so a - /// genuinely active session never looks stale. - private static let staleWindow: TimeInterval = 45 + /// the Live Activity. `staleDate` semantics (verified against ActivityKit + /// behaviour, not folklore): the *Dynamic Island* presentation is reliably + /// removed shortly after the stale date passes, but the *lock-screen* + /// banner may linger greyed-out depending on the iOS version — it is NOT + /// guaranteed to be dismissed. Treating staleDate as "auto-cleanup" is + /// therefore wrong on its own; the full zombie defence is this short + /// window + launch-time reconciliation (`clearOrphanedActivities`) + the + /// widget rendering an explicit "disconnected" state via + /// `context.isStale`. While the host is alive the heartbeat calls + /// `keepAlive()` every ~10 s, well inside this window. + private static let staleWindow: TimeInterval = 30 private static func freshContent( phase: FlowActivityAttributes.ContentState.Phase @@ -118,6 +124,8 @@ enum FlowLiveActivityController { } /// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。 + /// 等待必须带超时:ActivityKit 的 `end` 走异步 XPC,若在 watchdog 杀进程前 + /// 没有返回,无限期 `wait()` 会吞掉整个 ~5 秒终止窗口,反而让后续清理全部没跑。 nonisolated static func endAllSynchronouslyOnTerminate() { let semaphore = DispatchSemaphore(value: 0) Task.detached(priority: .userInitiated) { @@ -129,7 +137,7 @@ enum FlowLiveActivityController { FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))") semaphore.signal() } - semaphore.wait() + _ = semaphore.wait(timeout: .now() + 2) currentPhase = .idle currentActivity = nil } diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index b9ad9d1..a19cba6 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -54,9 +54,10 @@ final class FlowSessionManager: ObservableObject { private var currentUtteranceId: UUID? private var currentCommandSeq: Int64 = 0 private var lastHandledCommandSeq: Int64 = 0 - private var isUtteranceRecording = false + /// Published so Home / debug UI can show "recording" instead of a false "ready". + @Published private(set) var isUtteranceRecording = false /// True from `stopped` until the result/error is written back to App Group. - private var isUtteranceProcessing = false + @Published private(set) var isUtteranceProcessing = false private var finalizeTask: Task? private var asrTask: Task? private var utteranceSafetyTask: Task? @@ -77,17 +78,50 @@ final class FlowSessionManager: ObservableObject { private var coldStartRecoveryTask: Task? /// Initial proof window — cold mic sessions often need >2.5s after app switch. private static let coldStartAudioProofTimeout: TimeInterval = 6 - /// Extra window after the first timeout while the overlay shows a failure hint. - private static let coldStartRecoveryProofTimeout: TimeInterval = 12 + /// Guards the once-per-process launch reconciliation (scene reconnects + /// recreate the `@StateObject`-owned manager within the same process). + private static var didRunLaunchReconciliation = false init() { // Sessions are (re)started explicitly on app foreground via // `activateOnForeground()`. We deliberately do NOT silently reattach a // stored session here — after a force-quit that would resurrect capture // (and keep a stale Live Activity alive) without the user re-opening. + // + // Launch reconciliation: a brand-new process can never own an + // in-flight session, so whatever the previous generation persisted + // (force-quit skips `applicationWillTerminate` entirely when the app + // was suspended) is void. Rotating the generation token also lets the + // keyboard invalidate stale ready snapshots instantly instead of + // waiting out the 60 s heartbeat-zombie window. + // + // Once per PROCESS, not per manager: iOS can disconnect and later + // reconnect the sole scene without killing the process, which + // recreates the `@StateObject` (and thus this init). Re-rotating then + // would wipe live state that belongs to this very process. + if AppGroup.isAvailable, !Self.didRunLaunchReconciliation { + Self.didRunLaunchReconciliation = true + let previous = FlowSessionBridge.rotateHostGeneration() + if previous != nil || FlowSessionBridge.isSessionActive() { + FlowSessionBridge.clearFlowStateOnHostLaunch() + FlowLiveActivityController.clearOrphanedActivities() + FlowSessionDarwin.postSessionChanged() + debug("launch reconciliation: voided previous-generation Flow state") + } + } + capture.onEngineLiveChanged = { [weak self] _ in self?.refreshHostReady() } + // A system interruption (call / Siri) stops audio frames mid-utterance; + // fail fast so the user is not silently recording into a gap. + capture.onInterruptionBegan = { [weak self] in + guard let self, self.isUtteranceRecording else { return } + self.failUtterance( + message: AppL10n.string("flow.error.recognitionInterrupted"), + kind: .recognitionInterrupted + ) + } FlowTerminationCoordinator.register(self) } @@ -126,6 +160,10 @@ final class FlowSessionManager: ObservableObject { traceState("startSession.ignored", extra: "reason=alreadyStarting") return } + // Claim the flag synchronously: on a cold start the URL router and + // `activateOnForeground()` both fire in the same runloop turn, and + // setting it inside the async body let two start bodies interleave. + isStarting = true startTask?.cancel() startTask = Task { @MainActor [weak self] in @@ -190,11 +228,15 @@ final class FlowSessionManager: ObservableObject { func dismissColdStartOverlay() { coldStartRecoveryTask?.cancel() coldStartRecoveryTask = nil + // Clear handoff flags BEFORE any refreshHostReady call. Otherwise + // refresh → reconcileColdStartOverlayIfRecovered → dismiss → refresh + // recurses until the main-thread stack overflows (EXC_BAD_ACCESS, + // "Thread stack size exceeded due to excessive recursion"). + coldStartContext = nil + isColdStartHandoff = false if isActive { refreshHostReady() } - coldStartContext = nil - isColdStartHandoff = false } func returnToPendingHostFromColdStart() { @@ -204,6 +246,21 @@ final class FlowSessionManager: ObservableObject { func retryColdStartReadiness() { guard AppGroup.isAvailable else { return } + // A failed cold start leaves capture in a running-but-dead state on + // purpose (the recovery loop keeps probing it). A user-initiated + // retry must instead begin from a clean pipeline: tear down capture + // and the cached ASR instance so `startSession` rebuilds both — + // otherwise the retry reuses the zombie engine and is guaranteed to + // hit the same audio-proof timeout. + coldStartRecoveryTask?.cancel() + coldStartRecoveryTask = nil + if capture.running { + capture.stop() + } + sessionASR?.cancel() + sessionASR = nil + sessionASREngineMode = nil + sessionASRWarmedLocaleID = nil startSession(coldStart: true) } @@ -241,7 +298,10 @@ final class FlowSessionManager: ObservableObject { if isUtteranceRecording || isUtteranceProcessing { capture.cancelUtterance() - asr.cancel() + // `asr` is a computed property that ALLOCATES a fresh ASRService + // when `sessionASR` is nil — never do that inside the ~5 s + // termination window; only cancel an instance that exists. + sessionASR?.cancel() } capture.cancelUtterance() @@ -407,16 +467,42 @@ final class FlowSessionManager: ObservableObject { private func reactivateCaptureIfNeeded() async { guard isActive else { return } + // A system interruption (call / Siri) may be in progress. Probe it: + // `setActive(true)` inside `reassertIfRunning` fails while the + // interruption is live and succeeds once it ends — which also covers + // the documented case where iOS never delivers `.ended` (the latch + // must not depend on that notification, or the session is dead until + // its TTL). While the probe fails we deliberately do NOT stop or + // rebuild: tearing the engine down would remove the observers the + // `.ended` rebuild relies on and churn the shared session mid-call. + if capture.isInterrupted { + guard capture.reassertIfRunning(), !capture.isInterrupted else { return } + } if capture.running { let reasserted = capture.reassertIfRunning() - if reasserted, capture.engineHasRecentAudio() { + if reasserted, await capture.awaitAudioFlowing(timeout: 2) { sessionWarning = nil - } else if !reasserted { - sessionWarning = AppL10n.string("flow.error.audioUnavailable") + refreshHostReady() + return } - refreshHostReady() - return + // The await above is a suspension point: the session may have + // ended (expiry, user, teardown) while we waited. Never restart + // the microphone for a session that no longer exists. + guard isActive, !Task.isCancelled else { return } + // Never tear capture down underneath a live utterance either — a + // stalled route transition mid-recording must surface through the + // utterance pipeline (safety timer / empty-transcript error), not + // as a silent stop that truncates the take with no error at all. + guard !isUtteranceRecording, !isUtteranceProcessing else { + refreshHostReady() + return + } + // Reassert failed, or the engine reports running yet produces no + // frames (zombie after suspend / mediaserverd reset) — fall + // through to a full rebuild instead of leaving it half-dead. + capture.stop() + debug("capture zombie after foreground — rebuilding") } do { @@ -442,7 +528,8 @@ final class FlowSessionManager: ObservableObject { reason: .noSession, engineMode: store.engineMode, localeId: store.localeId, - sessionExpiresAt: FlowSessionBridge.sessionExpiresAt() + sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(), + hostGeneration: FlowSessionBridge.currentHostGeneration() ) ) return @@ -486,7 +573,8 @@ final class FlowSessionManager: ObservableObject { engineMode: store.engineMode, localeId: store.localeId, busyUtteranceId: isUtteranceRecording || isUtteranceProcessing ? currentUtteranceId : nil, - sessionExpiresAt: FlowSessionBridge.sessionExpiresAt() + sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(), + hostGeneration: FlowSessionBridge.currentHostGeneration() ) ) let signature = [ @@ -509,9 +597,19 @@ final class FlowSessionManager: ObservableObject { /// shows a stale preparing/failed snapshot, heal automatically. private func reconcileColdStartOverlayIfRecovered() { guard isColdStartHandoff, isActive else { return } - guard FlowSessionBridge.isHostReady() else { return } guard let context = coldStartContext else { return } + // Mid-utterance is not "ready" — dismiss the ready overlay so Home + // does not keep advertising "语音已就绪" while utt.rec=1. + if isUtteranceRecording || isUtteranceProcessing { + if case .ready = context.state { + dismissColdStartOverlay() + } + return + } + + guard FlowSessionBridge.isHostReady() else { return } + switch context.state { case .preparing: presentColdStartReadyOverlay() @@ -556,10 +654,11 @@ final class FlowSessionManager: ObservableObject { // MARK: - Session start private func startSessionAsync(duration: TimeInterval?) async { - traceState("startSessionAsync.begin") - isStarting = true - sessionWarning = nil + // `isStarting` was claimed synchronously in `startSession()`. defer { isStarting = false } + guard !Task.isCancelled else { return } + traceState("startSessionAsync.begin") + sessionWarning = nil guard AppPermissions.flowRequirementsMet else { sessionWarning = permissionWarningMessage() @@ -586,7 +685,9 @@ final class FlowSessionManager: ObservableObject { return } - guard await waitForAudioProof() else { + let audioProved = await waitForAudioProof() + guard !Task.isCancelled else { return } + guard audioProved else { let message = AppL10n.string("flow.coldStart.error.audioTimeout") sessionWarning = message traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout") @@ -658,6 +759,16 @@ final class FlowSessionManager: ObservableObject { refreshHostReady() guard FlowSessionBridge.isHostReady() else { + // Busy ≠ broken: a startflow arriving mid-utterance (e.g. tapping + // the Live Activity while dictating) finds a healthy session that + // is simply recording/processing. Showing the audio-failure + // overlay here would be a lie — and its recovery loop could even + // stop capture and kill the live utterance. + if isUtteranceRecording || isUtteranceProcessing { + dismissColdStartOverlay() + debug("cold-start handoff ignored: session busy with an utterance") + return + } let message = AppL10n.string("flow.coldStart.error.audioTimeout") sessionWarning = message showColdStartAudioFailure(message: message) @@ -687,18 +798,54 @@ final class FlowSessionManager: ObservableObject { } } - /// Keeps proving mic readiness after the first timeout instead of tearing - /// capture down — many handoffs become ready a few seconds later. + /// Actively rebuilds the audio pipeline after a failed cold start instead + /// of passively waiting for frames that a dead engine will never produce. + /// Escalates per attempt: reassert the session → full engine rebuild → + /// bounce the audio session and rebuild. Force-quit relaunches routinely + /// inherit stale mediaserverd state that only a rebuild clears. private func scheduleColdStartRecovery(duration: TimeInterval?) { coldStartRecoveryTask?.cancel() coldStartRecoveryTask = Task { @MainActor [weak self] in guard let self else { return } - let recovered = await self.capture.awaitAudioFlowing( - timeout: Self.coldStartRecoveryProofTimeout - ) + var recovered = false + for attempt in 1...3 { + guard !Task.isCancelled, self.isColdStartHandoff else { return } + switch attempt { + case 1: + _ = self.capture.reassertIfRunning() + case 2: + self.capture.stop() + try? self.capture.start() + default: + self.capture.stop() + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + try? self.capture.start() + } + recovered = await self.capture.awaitAudioFlowing( + timeout: TimeInterval(attempt + 1) + ) + self.traceState( + "coldStartRecovery.attempt", + extra: "attempt=\(attempt) recovered=\(recovered)" + ) + if recovered { break } + } guard !Task.isCancelled else { return } guard self.isColdStartHandoff else { return } - guard recovered else { return } + guard recovered else { + // Out of attempts — leave the failure overlay up; its retry + // button now performs a full teardown so the user always has + // a working escape hatch (no more force-quit loops). Only + // tear capture down when no session owns it: for an active + // session the 1 Hz heartbeat keeps self-healing, and a stop + // here would just fight it. + if !self.isActive { + self.capture.stop() + } + self.traceState("coldStartRecovery.exhausted") + return + } self.sessionWarning = nil self.traceState("coldStartRecovery.recovered") @@ -1027,12 +1174,22 @@ final class FlowSessionManager: ObservableObject { // Do NOT cancel `asrTask` or `asr` — drain trailing PCM, then finalize. + // Capture ids now: a cancelled finalize must still clear *this* + // utterance's processing gate even if currentUtteranceId was cleared + // by a racing fail/abort path. + let drainingSessionId = activeSessionId + let drainingUtteranceId = currentUtteranceId + let drainingCommandSeq = currentCommandSeq finalizeTask?.cancel() finalizeTask = Task { @MainActor [weak self] in guard let self else { return } let drainReport = await self.capture.endUtteranceAndDrain() FlowDiagnostics.logDrain(drainReport) - await self.finalizeUtterance() + await self.finalizeUtterance( + sessionId: drainingSessionId, + utteranceId: drainingUtteranceId, + commandSeq: drainingCommandSeq + ) } debug("utterance stopped, draining tail") } @@ -1109,20 +1266,22 @@ final class FlowSessionManager: ObservableObject { debug("utterance processing failed: \(message)") } - private func finalizeUtterance() async { - let finalizeSessionId = activeSessionId - let finalizeUtteranceId = currentUtteranceId + private func finalizeUtterance( + sessionId finalizeSessionId: UUID?, + utteranceId finalizeUtteranceId: UUID?, + commandSeq finalizeCommandSeq: Int64 + ) async { let pipelineStarted = Date() + // ALWAYS clear the processing gate for this utterance. The previous + // guard required currentUtteranceId to still match; a racing + // fail/abort/cancel path could nil the id (or leave processing stuck) + // and then skip refreshHostReady — keyboard stayed white forever + // while host logs still said "utterance finalized". defer { - if activeSessionId == finalizeSessionId, - currentUtteranceId == finalizeUtteranceId { - isUtteranceProcessing = false - FlowLiveActivityController.update(phase: .idle) - touchSessionActivity() - currentUtteranceId = nil - currentCommandSeq = 0 - refreshHostReady() - } + completeFinalizeCleanup( + sessionId: finalizeSessionId, + utteranceId: finalizeUtteranceId + ) } let asrWait = asrWaitTimeout() @@ -1134,6 +1293,10 @@ final class FlowSessionManager: ObservableObject { while Date() < asrDeadline { if !lastFinal.isEmpty { break } if asrTask?.isCancelled == true { break } + // Honour cooperative cancel so a replaced finalize exits promptly, + // but still run defer cleanup (unlike an early `return` mid-polish + // that used to leave processing=true when ids no longer matched). + if Task.isCancelled { break } try? await Task.sleep(nanoseconds: 100_000_000) } @@ -1151,14 +1314,21 @@ final class FlowSessionManager: ObservableObject { text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines) } guard !text.isEmpty else { - let key = (asrTask?.isCancelled == true) + let key = (asrTask?.isCancelled == true || Task.isCancelled) ? "flow.error.recognitionInterrupted" : "flow.error.noSpeech" let kind: FlowSessionKeys.TranscriptionErrorKind = - (asrTask?.isCancelled == true) ? .recognitionInterrupted : .noSpeech + (asrTask?.isCancelled == true || Task.isCancelled) + ? .recognitionInterrupted : .noSpeech FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s") utteranceRecordingStartedAt = nil - storeCurrentError(AppL10n.string(key), kind: kind) + storeFinalizedError( + AppL10n.string(key), + kind: kind, + sessionId: finalizeSessionId, + utteranceId: finalizeUtteranceId, + commandSeq: finalizeCommandSeq + ) return } @@ -1178,23 +1348,33 @@ final class FlowSessionManager: ObservableObject { "translationTarget=\(pipelineStore.translationTargetLocaleId)" ) do { + // If the finalize task was cancelled (cold-start churn / abort), + // skip the LLM round-trip and deliver the raw transcript so the + // keyboard is not left waiting on a result that never arrives. + if Task.isCancelled { + throw CancellationError() + } let polished = try await polisher.polish( text, mode: polishMode, providerIdOverride: pipelineStore.polishProviderIdOverride ) delivered = polished - storeCurrentFinal(polished, warning: chunkNote) + storeFinalizedResult( + polished, + warning: chunkNote, + sessionId: finalizeSessionId, + utteranceId: finalizeUtteranceId, + commandSeq: finalizeCommandSeq + ) FlowDiagnostics.log( "polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " + "total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s" ) } catch { - // v0.2.0: local + cloud-polish-on + no API key surfaces - // `.missingAPIKey`. We translate it into a polishWarning - // so the keyboard can show the "fill in your key" hint - // inline rather than a generic failure message. The raw - // transcript is still delivered — no data loss. + // CancellationError is common when the user jumps back via + // startflow mid-polish; still deliver raw text. Other errors + // keep the existing polish-warning fallback. let fallback = Self.makeFallbackDelivery( rawText: text, error: error, @@ -1206,7 +1386,13 @@ final class FlowSessionManager: ObservableObject { "\(error.localizedDescription)" ) delivered = fallback.text - storeCurrentFinal(fallback.text, warning: fallback.polishWarning) + storeFinalizedResult( + fallback.text, + warning: fallback.polishWarning, + sessionId: finalizeSessionId, + utteranceId: finalizeUtteranceId, + commandSeq: finalizeCommandSeq + ) } SpeechHistoryStore.shared.recordUtterance( @@ -1223,6 +1409,92 @@ final class FlowSessionManager: ObservableObject { debug("utterance finalized length=\(text.count)") } + /// Drop the processing gate and republish hostReady after finalize. + /// Must not depend on a perfect id match — a racing fail/abort/cancel + /// used to skip this block and leave the keyboard stuck on white「识别中」 + /// even after "utterance finalized" was logged. + private func completeFinalizeCleanup(sessionId: UUID?, utteranceId: UUID?) { + // A newer utterance may have started; never clobber its gate. + if let current = currentUtteranceId, + let finished = utteranceId, + current != finished { + debug( + "finalize cleanup skipped — newer utterance live " + + "finished=\(finished.uuidString.prefix(8)) current=\(current.uuidString.prefix(8))" + ) + return + } + + let wasProcessing = isUtteranceProcessing + isUtteranceProcessing = false + FlowLiveActivityController.update(phase: .idle) + if isActive { + touchSessionActivity() + } + if currentUtteranceId == utteranceId || currentUtteranceId == nil { + currentUtteranceId = nil + currentCommandSeq = 0 + } + refreshHostReady() + debug( + "finalize cleanup done wasProcessing=\(wasProcessing ? 1 : 0) " + + "utterance=\(utteranceId?.uuidString.prefix(8) ?? "nil") " + + "session=\(sessionId?.uuidString.prefix(8) ?? "nil")" + ) + } + + private func storeFinalizedResult( + _ text: String, + warning: String?, + sessionId: UUID?, + utteranceId: UUID?, + commandSeq: Int64 + ) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + storeFinalizedError( + AppL10n.string("flow.error.noSpeech"), + kind: .noSpeech, + sessionId: sessionId, + utteranceId: utteranceId, + commandSeq: commandSeq + ) + return + } + guard let sessionId, let utteranceId else { return } + FlowSessionBridge.writeResult( + FlowResult( + sessionId: sessionId, + utteranceId: utteranceId, + commandSeq: commandSeq, + status: .final, + text: trimmed, + warning: warning + ) + ) + } + + private func storeFinalizedError( + _ message: String, + kind: FlowSessionKeys.TranscriptionErrorKind, + sessionId: UUID?, + utteranceId: UUID?, + commandSeq: Int64, + status: FlowResult.Status = .error + ) { + guard let sessionId, let utteranceId else { return } + FlowSessionBridge.writeResult( + FlowResult( + sessionId: sessionId, + utteranceId: utteranceId, + commandSeq: commandSeq, + status: status, + text: message, + errorKind: kind + ) + ) + } + private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String { switch mode { case .polish: @@ -1252,35 +1524,12 @@ final class FlowSessionManager: ObservableObject { engineMode: String, chunkWarning: String? ) -> TranscriptionDelivery { - let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText) - let warning = warningFromPolishError(error, engineMode: engineMode) - ?? polishDegradedWarning() - ?? chunkWarning - return TranscriptionDelivery(text: fallbackText, polishWarning: warning) - } - - private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? { - if let polishError = error as? PolishingService.PolishError { - switch polishError { - case .missingAPIKey: - if engineMode == "local" { - return SharedL10n.string("flow.warning.localPolishUnavailable") - } - return SharedL10n.string("flow.warning.cloudPolishMissingKey") - case .timeout: - return polishDegradedWarning() - case .noTranscript: - return nil - } - } - if error is LLMError { - return polishDegradedWarning() - } - return nil - } - - private static func polishDegradedWarning() -> String? { - SharedL10n.string("flow.warning.polishDegraded") + TranscriptionPolishFallback.makeDelivery( + rawText: rawText, + error: error, + engineMode: engineMode, + chunkWarning: chunkWarning + ) } private func asrWaitTimeout() -> TimeInterval { @@ -1318,8 +1567,8 @@ final class FlowSessionManager: ObservableObject { // Refresh the Live Activity `staleDate` every N heartbeat ticks // (1 Hz) — well inside `FlowLiveActivityController.staleWindow` so a // live session never looks stale, while a force-quit stops these - // refreshes and lets the system reclaim the orphaned island. - let liveActivityKeepAliveEveryTicks = 15 + // refreshes and lets the island go stale within ~30 s. + let liveActivityKeepAliveEveryTicks = 10 var tick = 0 while !Task.isCancelled { guard let self else { break } @@ -1351,6 +1600,30 @@ final class FlowSessionManager: ObservableObject { FlowDiagnostics.log(message) } + // MARK: - Temporary Flow debug panel (remove after orange-mic investigation) + + /// Snapshot for the on-screen debug panel. Safe to call from the main actor. + func makeDebugRows() -> [FlowDebugRow] { + let snapshot = FlowSessionBridge.readySnapshot() + let hostRows = FlowDebugAppGroupSnapshot.rows() + let memRows: [FlowDebugRow] = [ + FlowDebugRow("isActive", isActive ? "1" : "0"), + FlowDebugRow("isStarting", isStarting ? "1" : "0"), + FlowDebugRow("coldStart", isColdStartHandoff ? "1" : "0"), + FlowDebugRow("engineLive", capture.engineIsLive ? "1" : "0"), + FlowDebugRow("audioFresh", capture.engineHasRecentAudio(maxAge: 2) ? "1" : "0"), + FlowDebugRow("mem.reason", snapshot?.reason.rawValue ?? "nil"), + FlowDebugRow("utt.rec", isUtteranceRecording ? "1" : "0"), + FlowDebugRow("utt.proc", isUtteranceProcessing ? "1" : "0"), + FlowDebugRow("sessionId", activeSessionId.map { String($0.uuidString.prefix(8)) } ?? "nil"), + FlowDebugRow("warning", sessionWarning == nil ? "0" : "1"), + FlowDebugRow("overlay", coldStartContext.map { String(describing: $0.state) } ?? "nil"), + FlowDebugRow("bridgeReady", FlowSessionBridge.isHostReady() ? "1" : "0") + ] + // Prefer App Group snap.reason near the top of the shared block. + return memRows + hostRows + } + private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) { let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)" guard signature != lastIgnoredCommandSignature else { return } diff --git a/OSGKeyboard/Views/ASRSettingsCard.swift b/OSGKeyboard/Views/ASRSettingsCard.swift new file mode 100644 index 0000000..7989c2e --- /dev/null +++ b/OSGKeyboard/Views/ASRSettingsCard.swift @@ -0,0 +1,204 @@ +// ASRSettingsCard.swift +// OSGKeyboard · Main App +// +// Cloud ASR credentials — independent from the polish LLM card. + +import SwiftUI +import OSGKeyboardShared + +struct ASRSettingsCard: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var config: ProviderConfig + @State private var showKey: Bool = false + @State private var testStatus: TestStatus = .idle + + private enum TestStatus: Equatable { + case idle + case running + case success + case failure(String) + } + + var body: some View { + VStack(spacing: 0) { + if CloudASRModelCatalog.strategy(for: config.asrProviderId) == .prompt { + field( + title: AppL10n.string("api.baseUrl"), + placeholder: "https://api.openai.com/v1", + text: $config.asrBaseURL, + autocap: false + ) + Divider().background(palette.divider) + } + keyField + Divider().background(palette.divider) + field( + title: AppL10n.string("settings.asr.model"), + placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId), + text: $config.asrModel, + autocap: false + ) + if let url = LLMProvider.provider(id: config.asrProviderId).apiKeyURL { + Divider().background(palette.divider) + Button { + UIApplication.shared.open(url) + } label: { + HStack { + Text("api.getKey") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(palette.textSecondary) + } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + Divider().background(palette.divider) + testConnectionRow + } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + private var keyField: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("api.key") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + Spacer() + Button(action: { showKey.toggle() }) { + Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") + .foregroundStyle(palette.textSecondary) + } + .buttonStyle(.plain) + } + Group { + if showKey { + TextField("sk-…", text: $config.asrApiKey) + } else { + SecureField("sk-…", text: $config.asrApiKey) + } + } + .keyboardType(.asciiCapable) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + } + + @ViewBuilder + private func field( + title: String, + placeholder: String, + text: Binding, + autocap: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + TextField(placeholder, text: text) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .textInputAutocapitalization(autocap ? .sentences : .never) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + } + + private var testConnectionRow: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("settings.asr.testConnection") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Button(action: runTest) { + Group { + if testStatus == .running { + ProgressView().controlSize(.mini) + } else { + Text(testButtonLabel) + .font(TypeStyle.body) + .foregroundStyle(testTint) + } + } + } + .buttonStyle(.plain) + .disabled(testStatus == .running) + } + if let detail = testDetail { + Text(detail) + .font(TypeStyle.caption2) + .foregroundStyle(testTint) + .lineLimit(3) + } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.xs) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center) + } + + private var testButtonLabel: String { + switch testStatus { + case .idle: return AppL10n.string("api.test.idle") + case .running: return AppL10n.string("api.test.running") + case .success: return AppL10n.string("api.test.success") + case .failure: return AppL10n.string("api.test.failure") + } + } + + private var testTint: Color { + switch testStatus { + case .idle, .running: return palette.accent + case .success: return palette.accent + case .failure: return palette.danger + } + } + + private var testDetail: String? { + switch testStatus { + case .idle, .running, .success: return nil + case .failure(let message): return message + } + } + + private func runTest() { + testStatus = .running + let store = AppGroupStore() + let client = CloudASRClientFactory.make(store: store) + Task { + do { + try await client.prepare(dictionary: store.personalDictionary) + let samples = [Float](repeating: 0.01, count: 16_000) + _ = try await client.transcribe( + samples: samples, + sampleRate: 16_000, + locale: Locale(identifier: store.localeId == "auto" ? "zh-CN" : store.localeId), + dictionary: store.personalDictionary + ) + testStatus = .success + } catch CloudASRError.noAPIKey { + testStatus = .failure(AppL10n.string("api.test.missing")) + } catch let error as CloudASRError { + testStatus = .failure(error.localizedDescription ?? "\(error)") + } catch { + testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)") + } + } + } +} diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift index d013ff3..f3abcb9 100644 --- a/OSGKeyboard/Views/Components/MinimalTabBar.swift +++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift @@ -39,6 +39,18 @@ enum AppTab: Int, CaseIterable { case .settings: return "tab.settings" } } + + /// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title). + var sidebarTitle: LocalizedStringKey { accessibilityKey } + + var sidebarSystemImage: String { + switch self { + case .keyboard: return "house" + case .history: return "clock.arrow.circlepath" + case .dictionary: return "character.book.closed" + case .settings: return "gearshape" + } + } } struct MinimalTabBar: View { diff --git a/OSGKeyboard/Views/Components/WideLayoutComponents.swift b/OSGKeyboard/Views/Components/WideLayoutComponents.swift new file mode 100644 index 0000000..261ebbd --- /dev/null +++ b/OSGKeyboard/Views/Components/WideLayoutComponents.swift @@ -0,0 +1,198 @@ +// WideLayoutComponents.swift +// OSGKeyboard · Main App +// +// Reusable layout pieces for iPad / regular-width surfaces. Styled with the +// shared design tokens so the wide Home dashboard can mirror the macOS shell +// without pulling in AppKit-only types from OSGKeyboardMac. + +import SwiftUI +import OSGKeyboardShared + +// MARK: - Layout metrics + +/// Fixed metrics that keep wide surfaces on the same grid as the macOS app. +enum WideLayoutMetrics { + static let sidebarWidth: CGFloat = 240 + static let sidebarInset: CGFloat = Spacing.md + static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm + static let pageHorizontalInset: CGFloat = 40 + static let dictationCanvasMinHeight: CGFloat = 120 +} + +// MARK: - Card container + +/// Elevated surface used for stat tiles and the dictation canvas. +struct WideCard: View { + @Environment(\.themePalette) private var palette + var padding: CGFloat = Spacing.md + var cornerRadius: CGFloat = Radius.medium + @ViewBuilder var content: () -> Content + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + content() + .padding(padding) + .background(palette.surface, in: shape) + .overlay( + shape.stroke(palette.divider, lineWidth: 0.5) + ) + } +} + +// MARK: - Stat tile + +struct WideStatCard: View { + @Environment(\.themePalette) private var palette + let title: String + let value: String + let caption: String + var systemImage: String? + var accent: Bool = false + /// Hero metric: wide horizontal layout for the primary word count. + var prominent: Bool = false + + var body: some View { + WideCard(padding: Spacing.md) { + if prominent { + prominentBody + } else { + compactBody + } + } + } + + private var compactBody: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Spacer() + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(accent ? palette.accent : palette.textTertiary) + .symbolRenderingMode(.hierarchical) + } + } + Text(value) + .font(TypeStyle.title2) + .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) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var prominentBody: some View { + HStack(spacing: Spacing.md) { + if let systemImage { + ZStack { + Circle() + .fill(palette.accentMuted) + .frame(width: 44, height: 44) + Image(systemName: systemImage) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(palette.accent) + .symbolRenderingMode(.hierarchical) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Text(caption) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + Spacer(minLength: Spacing.md) + Text(value) + .font(.system(size: 34, weight: .bold)) + .foregroundStyle(accent ? palette.accent : palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) + } + } +} + +// MARK: - Home stats cluster + +/// Dashboard-style stat cluster for the wide Home layout. +struct WideHomeStatsCluster: View { + @ObservedObject private var stats = UsageStatisticsStore.shared + @ObservedObject private var config = ProviderConfig.shared + + @State private var dictionaryCount = 0 + + private var language: AppUILanguage { config.uiLanguage } + + var body: some View { + VStack(spacing: Spacing.md) { + WideStatCard( + title: AppL10n.string("home.stats.dictationCharacters", language: language), + value: UsageStatisticsStore.formatCount( + stats.dictationCharacterCount, + language: language + ), + caption: AppL10n.string("home.wide.stat.transcribed", language: language), + systemImage: "text.alignleft", + accent: true, + prominent: true + ) + + HStack(spacing: Spacing.md) { + WideStatCard( + title: AppL10n.string("home.stats.dictationDuration", language: language), + value: UsageStatisticsStore.formatDuration( + stats.dictationDurationSeconds, + language: language + ), + caption: AppL10n.string("home.wide.stat.cumulativeDuration", language: language), + systemImage: "waveform" + ) + WideStatCard( + title: AppL10n.string("home.stats.translationCharacters", language: language), + value: UsageStatisticsStore.formatCount( + stats.translationCharacterCount, + language: language + ), + caption: AppL10n.string("home.wide.stat.cumulativeTranslation", language: language), + systemImage: "character.bubble" + ) + WideStatCard( + title: AppL10n.string("home.stats.dictionaryEntries", language: language), + value: UsageStatisticsStore.formatCount( + dictionaryCount, + language: language + ), + caption: AppL10n.string("home.wide.stat.customTerms", language: language), + systemImage: "character.book.closed" + ) + } + } + .onAppear(perform: refreshDictionaryCount) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + refreshDictionaryCount() + } + .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in + refreshDictionaryCount() + } + .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in + stats.reloadFromDisk() + } + } + + private func refreshDictionaryCount() { + dictionaryCount = AppGroupStore().personalDictionary.entries.count + } +} diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index 7cf4623..ff0d257 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -15,6 +15,7 @@ import UIKit struct HomeView: View { @Environment(\.themePalette) private var palette: ThemePalette @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @ObservedObject private var config = ProviderConfig.shared @EnvironmentObject private var flowManager: FlowSessionManager @@ -24,6 +25,10 @@ struct HomeView: View { @State private var micStatus = AppPermissions.micStatus @State private var speechStatus = AppPermissions.speechStatus + private var usesWideLayout: Bool { + horizontalSizeClass == .regular + } + private var sessionIsLive: Bool { flowManager.isActive || flowManager.isStarting } @@ -52,6 +57,32 @@ struct HomeView: View { } var body: some View { + Group { + if usesWideLayout { + wideBody + } else { + phoneBody + } + } + .onAppear { + refreshPermissionStatuses() + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + refreshPermissionStatuses() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + refreshPermissionStatuses() + } + .onChange(of: previewFocused) { _, focused in + guard focused else { return } + Task { await flowManager.refreshForInlineKeyboardFocus() } + } + } + + // MARK: - Phone layout + + private var phoneBody: some View { GeometryReader { geo in let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top @@ -98,20 +129,63 @@ struct HomeView: View { } } } - .onAppear { - refreshPermissionStatuses() + } + + // MARK: - Wide layout (iPad / regular width) + + private var wideBody: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: Spacing.lg) { + wideHeroHeader + + WideHomeStatsCluster() + + if showsFlowSessionExtras { + flowSessionExtras + } + + widePreviewStage + } + .padding(.horizontal, WideLayoutMetrics.pageHorizontalInset) + .padding(.top, Spacing.sm) + .padding(.bottom, Spacing.md) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .onChange(of: scenePhase) { _, phase in - guard phase == .active else { return } - refreshPermissionStatuses() + .background(palette.background) + .contentShape(Rectangle()) + .onTapGesture { + if previewFocused { + previewFocused = false + } } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - refreshPermissionStatuses() + } + + private var wideHeroHeader: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + Text("home.wide.tagline") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .lineLimit(2) + .minimumScaleFactor(0.85) + + Text("home.wide.tagline.subtitle") + .font(TypeStyle.footnote) + .foregroundStyle(palette.textTertiary) } - .onChange(of: previewFocused) { _, focused in - guard focused else { return } - Task { await flowManager.refreshForInlineKeyboardFocus() } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var widePreviewStage: some View { + WideCard(padding: Spacing.md, cornerRadius: Radius.large) { + previewFieldContent + .frame( + maxWidth: .infinity, + minHeight: WideLayoutMetrics.dictationCanvasMinHeight, + maxHeight: .infinity, + alignment: .topLeading + ) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } private func refreshPermissionStatuses() { @@ -187,7 +261,18 @@ struct HomeView: View { .foregroundStyle(palette.warning) .lineLimit(1) .minimumScaleFactor(0.85) + } else if flowManager.isUtteranceRecording { + Text("home.flow.recording") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + } else if flowManager.isUtteranceProcessing { + Text("home.flow.processing") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) } else if flowManager.isActive, + FlowSessionBridge.isHostReady(), let expires = flowManager.sessionExpiresAt { Text("home.flow.label") .font(TypeStyle.caption2) @@ -322,10 +407,14 @@ struct HomeView: View { private var flowStatusColor: Color { if needsCloudSetup { return palette.warning } - if flowManager.isActive { return palette.accent } + if flowManager.isUtteranceRecording { return palette.accent } + if flowManager.isUtteranceProcessing { return palette.accent } + if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent } if flowManager.isStarting { return palette.accent } if needsPermissionSetup { return palette.warning } if flowManager.sessionWarning != nil { return palette.warning } + // Active but not host-ready (e.g. mid-utterance / audio proof) — amber. + if flowManager.isActive { return palette.warning } return palette.textTertiary } @@ -337,21 +426,26 @@ struct HomeView: View { if flowManager.isStarting { return AppL10n.string("home.flow.starting") } - if flowManager.isActive { + if flowManager.isUtteranceRecording { + return AppL10n.string("home.flow.recording") + } + if flowManager.isUtteranceProcessing { + return AppL10n.string("home.flow.processing") + } + if flowManager.isActive, FlowSessionBridge.isHostReady() { return AppL10n.string("home.flow.label") } + if flowManager.isActive { + // Session flag is up but the ready contract is not — do not lie. + return AppL10n.string("home.flow.notReady") + } return AppL10n.string("home.flow.inactive") } // MARK: - Preview field private var previewField: some View { - TextField("home.preview.placeholder", text: $previewText, axis: .vertical) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - .tint(palette.accent) - .focused($previewFocused) - .lineLimit(1...100) + previewFieldContent .frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading) .padding(Spacing.md) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) @@ -359,11 +453,19 @@ struct HomeView: View { RoundedRectangle(cornerRadius: Radius.large, style: .continuous) .stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1) ) - // TextField only hit-tests the text line(s); expand taps to the full card. .contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .onTapGesture { previewFocused = true } } + private var previewFieldContent: some View { + TextField("home.preview.placeholder", text: $previewText, axis: .vertical) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .tint(palette.accent) + .focused($previewFocused) + .lineLimit(1...100) + } + private var engineStatusLine: some View { Text( EngineServiceLabel.summary( diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index 1be9f85..b165628 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -39,14 +39,18 @@ struct MainAppRoot: View { .animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil) .onAppear { flowManager.setAppForeground(scenePhase == .active) - flowManager.activateOnForeground() - AppCloudSync.shared.startObservingExternalChanges() - // Registering here also flushes any URL buffered during a cold - // launch (the keyboard → app `startflow` handoff arrives via the - // scene delegate before this view is on screen). + // Register the URL handler BEFORE the foreground auto-start. + // Registering flushes any URL buffered during a cold launch (the + // keyboard → app `startflow` handoff arrives via the scene + // delegate before this view is on screen), so a cold start takes + // the cold-start path first and `activateOnForeground()`'s plain + // start then no-ops on the isStarting guard — instead of two + // start bodies racing each other on the main actor. AppOpenURLRouter.shared.register { url in handleIncomingURL(url) } + flowManager.activateOnForeground() + AppCloudSync.shared.startObservingExternalChanges() Task { await AppCloudSync.shared.pullAllIfEnabled() } diff --git a/OSGKeyboard/Views/MainSplitView.swift b/OSGKeyboard/Views/MainSplitView.swift new file mode 100644 index 0000000..0e5cf08 --- /dev/null +++ b/OSGKeyboard/Views/MainSplitView.swift @@ -0,0 +1,276 @@ +// MainSplitView.swift +// OSGKeyboard · Main App +// +// iPad / regular-width shell: sidebar navigation + detail workspace. +// Mirrors the macOS `NavigationSplitView` structure while keeping iOS tabs +// and Flow session behaviour unchanged underneath. + +import SwiftUI +import OSGKeyboardShared +import UIKit + +struct MainSplitView: View { + @Binding var selection: AppTab + + @Environment(\.themePalette) private var palette + + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + sidebar + .navigationSplitViewColumnWidth(WideLayoutMetrics.sidebarWidth) + } detail: { + detail + } + .navigationSplitViewStyle(.balanced) + .background(palette.background) + // No floating dock in split mode — child scroll views should not + // reserve bottom clearance for the phone tab bar. + .environment(\.isTabBarVisible, false) + } + + // MARK: - Sidebar + + private var sidebar: some View { + VStack(spacing: 0) { + brandHeader + VStack(spacing: 4) { + ForEach(AppTab.allCases, id: \.rawValue) { tab in + WideSidebarRow( + tab: tab, + isSelected: selection == tab + ) { + withAnimation(Motion.soft) { selection = tab } + } + } + } + .padding(.horizontal, WideLayoutMetrics.sidebarInset) + Spacer() + devicesFooter + } + .background(palette.background) + } + + private var brandHeader: some View { + HStack { + Image("osglogo") + .resizable() + .scaledToFit() + .frame(height: 28) + .accessibilityLabel("OSGKeyboard") + Spacer() + } + .padding(.leading, WideLayoutMetrics.sidebarContentInset) + .padding(.trailing, WideLayoutMetrics.sidebarInset) + .padding(.top, Spacing.lg) + .padding(.bottom, Spacing.md) + } + + private var devicesFooter: some View { + Label("home.wide.devices", systemImage: "ipad.and.iphone") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, WideLayoutMetrics.sidebarInset + Spacing.sm) + .padding(.vertical, Spacing.sm) + } + + // MARK: - Detail + + private var detail: some View { + VStack(spacing: 0) { + MainTabContent(tab: selection) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .id(selection) + .transition(.opacity) + WideStatusFooter() + } + .background(palette.background) + } +} + +// MARK: - Sidebar row + +private struct WideSidebarRow: View { + let tab: AppTab + let isSelected: Bool + let action: () -> Void + + @Environment(\.themePalette) private var palette + + var body: some View { + Button(action: action) { + Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? palette.accent : 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) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private var rowBackground: Color { + isSelected ? palette.accentMuted : .clear + } +} + +// MARK: - Status footer + +/// Quiet bottom strip: engine mode + translation target + Flow readiness. +private struct WideStatusFooter: View { + @Environment(\.themePalette) private var palette + @ObservedObject private var config = ProviderConfig.shared + @EnvironmentObject private var flowManager: FlowSessionManager + + @State private var micStatus = AppPermissions.micStatus + @State private var speechStatus = AppPermissions.speechStatus + + private var needsCloudSetup: Bool { + !config.isLocalEngine && !config.isConfigured + } + + private var needsPermissionSetup: Bool { + micStatus != .granted || speechStatus != .granted + } + + private var canManuallyStartSession: Bool { + !flowManager.isActive && !flowManager.isStarting && !needsPermissionSetup + } + + var body: some View { + HStack(spacing: Spacing.sm) { + Spacer() + Label( + config.engineMode == "cloud" ? "home.wide.mode.cloud" : "home.wide.mode.local", + systemImage: config.engineMode == "cloud" ? "cloud" : "cpu" + ) + .contentTransition(.opacity) + + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + + Label( + translationLabel, + systemImage: "translate" + ) + .contentTransition(.opacity) + + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + + flowStatusControl + } + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .labelStyle(.titleAndIcon) + .padding(.horizontal, WideLayoutMetrics.pageHorizontalInset) + .padding(.vertical, Spacing.sm) + .animation(Motion.quick, value: config.engineMode) + .animation(Motion.quick, value: config.translationTargetLocaleId) + .animation(Motion.soft, value: flowManager.isActive) + .onAppear { refreshPermissionStatuses() } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + refreshPermissionStatuses() + } + } + + @ViewBuilder + private var flowStatusControl: some View { + HStack(spacing: Spacing.xs) { + Circle() + .fill(flowStatusColor) + .frame(width: 6, height: 6) + + if needsCloudSetup { + Text("home.flow.notReady") + .foregroundStyle(palette.warning) + } else if flowManager.isUtteranceRecording { + Text("home.flow.recording") + } else if flowManager.isUtteranceProcessing { + Text("home.flow.processing") + } else if flowManager.isActive, + FlowSessionBridge.isHostReady(), + let expires = flowManager.sessionExpiresAt { + Text("home.flow.label") + Text(":") + Text(expires, style: .timer) + .monospacedDigit() + } else { + Text(flowStatusLabel) + } + + if flowManager.isActive { + Button { + flowManager.endSession() + } label: { + Text("home.flow.endShort") + .foregroundStyle(palette.accent) + } + .buttonStyle(.plain) + } else if canManuallyStartSession && !needsCloudSetup { + Button { + flowManager.activateOnForeground() + } label: { + Text("home.flow.startShort") + .foregroundStyle(palette.accent) + } + .buttonStyle(.plain) + } + } + } + + private func refreshPermissionStatuses() { + micStatus = AppPermissions.micStatus + speechStatus = AppPermissions.speechStatus + } + + private var translationLabel: String { + let resolved = TranslationLanguageCatalog.resolve(config.translationTargetLocaleId) + if TranslationLanguageCatalog.isOff(resolved.id) { + return AppL10n.string("keyboard.translation.offMenu", language: config.uiLanguage) + } + return resolved.nativeName + } + + private var flowStatusColor: Color { + if !config.isLocalEngine && !config.isConfigured { return palette.warning } + if flowManager.isUtteranceRecording || flowManager.isUtteranceProcessing { + return palette.accent + } + if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent } + if flowManager.isStarting { return palette.accent } + if flowManager.isActive { return palette.warning } + return palette.textTertiary + } + + private var flowStatusLabel: LocalizedStringKey { + if !config.isLocalEngine && !config.isConfigured { + return "home.flow.notReady" + } + if flowManager.isStarting { + return "home.flow.starting" + } + if flowManager.isUtteranceRecording { + return "home.flow.recording" + } + if flowManager.isUtteranceProcessing { + return "home.flow.processing" + } + if flowManager.isActive, FlowSessionBridge.isHostReady() { + return "home.flow.label" + } + if flowManager.isActive { + return "home.flow.notReady" + } + return "home.flow.inactive" + } +} diff --git a/OSGKeyboard/Views/MainTabContent.swift b/OSGKeyboard/Views/MainTabContent.swift new file mode 100644 index 0000000..3bc5188 --- /dev/null +++ b/OSGKeyboard/Views/MainTabContent.swift @@ -0,0 +1,25 @@ +// MainTabContent.swift +// OSGKeyboard · Main App +// +// Shared tab destination switcher used by both the phone dock and the iPad +// split-view detail column. + +import SwiftUI +import OSGKeyboardShared + +struct MainTabContent: View { + let tab: AppTab + + var body: some View { + switch tab { + case .keyboard: + HomeView() + case .history: + HistoryView() + case .dictionary: + PersonalDictionaryView() + case .settings: + SettingsView(presentation: .tab) + } + } +} diff --git a/OSGKeyboard/Views/MainTabView.swift b/OSGKeyboard/Views/MainTabView.swift index 9fdfe3e..2d58fba 100644 --- a/OSGKeyboard/Views/MainTabView.swift +++ b/OSGKeyboard/Views/MainTabView.swift @@ -6,47 +6,52 @@ import OSGKeyboardShared struct MainTabView: View { @Environment(\.themePalette) private var palette: ThemePalette + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject private var flowManager: FlowSessionManager @State private var tab: AppTab = .keyboard @State private var isTabBarHidden = false + private var usesSplitLayout: Bool { + horizontalSizeClass == .regular + } + var body: some View { + Group { + if usesSplitLayout { + MainSplitView(selection: $tab) + } else { + phoneTabLayout + } + } + .background(palette.background) + .ignoresSafeArea(.keyboard, edges: .bottom) + } + + // MARK: - Phone layout + + private var phoneTabLayout: some View { ZStack(alignment: .bottom) { palette.background.ignoresSafeArea() - Group { - switch tab { - case .keyboard: - HomeView() - case .history: - HistoryView() - case .dictionary: - PersonalDictionaryView() - case .settings: - SettingsView(presentation: .tab) + MainTabContent(tab: tab) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.isTabBarVisible, !isTabBarHidden) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !isTabBarHidden { + Color.clear.frame(height: 88) + } } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.isTabBarVisible, !isTabBarHidden) - .safeAreaInset(edge: .bottom, spacing: 0) { - if !isTabBarHidden { - Color.clear.frame(height: 88) + .onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in + withAnimation(Motion.quick) { + isTabBarHidden = hidden + } } - } - .onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in - withAnimation(Motion.quick) { - isTabBarHidden = hidden - } - } if !isTabBarHidden { MinimalTabBar(selection: $tab) .transition(.move(edge: .bottom).combined(with: .opacity)) } } - // Keep home card/input/tab layout fixed when system keyboard appears. - // Let the keyboard overlay the content instead of pushing it. - .ignoresSafeArea(.keyboard, edges: .bottom) } } diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift index 22934eb..93c35c2 100644 --- a/OSGKeyboard/Views/ProviderPickerSection.swift +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -8,17 +8,22 @@ struct ProviderPickerSection: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig + var role: CloudProviderRole = .polish + + private var selectedProviderId: String { + role == .asr ? config.asrProviderId : config.providerId + } var body: some View { - // v0.2.1 follow-up: filter out presets marked as - // `isUserSelectable == false` (DeepSeek is local-engine only). - let visiblePresets = LLMProvider.userSelectablePresets + let visiblePresets = role == .asr + ? LLMProvider.asrSelectablePresets + : LLMProvider.userSelectablePresets VStack(spacing: 0) { ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in Button { select(provider) } label: { - row(provider, selected: provider.id == config.providerId) + row(provider, selected: provider.id == selectedProviderId) } .buttonStyle(.plain) if index < visiblePresets.count - 1 { @@ -35,7 +40,12 @@ struct ProviderPickerSection: View { private func select(_ provider: LLMProvider) { withAnimation(Motion.quick) { - config.apply(preset: provider) + switch role { + case .polish: + config.apply(preset: provider) + case .asr: + config.applyAsr(preset: provider) + } } } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 0675d74..15faaa9 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -43,16 +43,11 @@ struct SettingsView: View { dictionaryAndPolishSection flowSessionSection engineSection - // v0.2.1: hide provider/api card when the - // local engine is active regardless of the - // cloud-polish toggle. Local mode is - // contractually ASR-only, so provider/model/ - // base URL/API key controls have no use — - // and exposing them invites the user to fill - // out a DeepSeek key they can't use. + polishProviderSection + polishApiSection if config.engineMode == "cloud" { - providerSection - apiSection + asrProviderSection + asrApiSection } if config.engineMode == "local" { localEngineSettingsSection @@ -253,22 +248,42 @@ struct SettingsView: View { } } - private var providerSection: some View { + private var polishProviderSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.provider.title") - ProviderPickerSection(config: config) + sectionHeader("settings.polishProvider.title") + Text("settings.polishProvider.subtitle") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + ProviderPickerSection(config: config, role: .polish) } } - // MARK: - API - - private var apiSection: some View { + private var asrProviderSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.api.title") + sectionHeader("settings.asrProvider.title") + Text("settings.asrProvider.subtitle") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + ProviderPickerSection(config: config, role: .asr) + } + } + + private var polishApiSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + sectionHeader("settings.polishApi.title") APISettingsCard(config: config) } } + private var asrApiSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + sectionHeader("settings.asrApi.title") + ASRSettingsCard(config: config) + } + } + // MARK: - Language helpers /// Falls back to a static list while dynamic locales are loading. diff --git a/OSGKeyboard/en.lproj/InfoPlist.strings b/OSGKeyboard/en.lproj/InfoPlist.strings index 4ff7086..d4670f3 100644 --- a/OSGKeyboard/en.lproj/InfoPlist.strings +++ b/OSGKeyboard/en.lproj/InfoPlist.strings @@ -1,4 +1,4 @@ /* Permission prompts — English */ "NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running."; -"NSSpeechRecognitionUsageDescription" = "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription."; +"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure."; diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index abff350..70ad5f0 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -98,11 +98,19 @@ "settings.engine.local.ios26" = "Always on-device, no network."; "settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; "settings.engine.cloud.title" = "Cloud recognition & polish"; -"settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider."; +"settings.engine.cloud.subtitle" = "Cloud ASR and polish LLM are configured separately. Audio goes to your ASR provider."; "settings.engine.cloud.badge" = "Cloud engine"; "settings.provider.title" = "Provider"; "settings.provider.personalDictionaryBadge" = "Personal dictionary"; "settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; +"settings.polishProvider.title" = "Text polish (LLM)"; +"settings.polishProvider.subtitle" = "Cleans up the transcript after recognition. Independent from the ASR provider."; +"settings.polishApi.title" = "Polish API"; +"settings.asrProvider.title" = "Speech recognition (ASR)"; +"settings.asrProvider.subtitle" = "Transcribes your audio in cloud mode. Can differ from the polish provider."; +"settings.asrApi.title" = "ASR API"; +"settings.asr.model" = "ASR model"; +"settings.asr.testConnection" = "Test ASR"; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "Qwen (DashScope)"; @@ -161,8 +169,8 @@ "settings.privacy.fullAccess.title" = "About Full Access"; "settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard."; "settings.privacy.cloud.body" = "In Cloud polish mode, transcribed text is sent to the API endpoint you configure (e.g. OpenAI or your own server). OSGKeyboard does not operate servers and does not store transcripts in the cloud."; -"settings.privacy.cloud.alert.title" = "Third-party API"; -"settings.privacy.cloud.alert.message" = "Cloud polish sends transcribed text to the third-party API you configure. OSGKeyboard never stores data on our servers. Continue?"; +"settings.privacy.cloud.alert.title" = "Audio leaves your device"; +"settings.privacy.cloud.alert.message" = "The cloud engine uploads your voice recordings to the third-party ASR provider you configure, and sends the transcript to its API for polish. That provider's privacy policy applies. OSGKeyboard never stores data on our servers. Continue?"; "settings.link.support" = "Help & Feedback"; "settings.link.github" = "GitHub"; "settings.support.footer" = "OSGKeyboard is open source. Report bugs and share ideas on GitHub."; @@ -287,6 +295,8 @@ "home.flow.label" = "Ready"; "home.flow.inactive" = "Voice session inactive"; "home.flow.notReady" = "Not ready"; +"home.flow.recording" = "Recording…"; +"home.flow.processing" = "Processing…"; "home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate."; "home.setup.permission.mic" = "Microphone access is off — voice input won't work."; "home.setup.permission.speech" = "Speech recognition is off — voice input won't work."; @@ -307,6 +317,15 @@ "home.stats.dictationCharacters" = "Dictation chars"; "home.stats.translationCharacters" = "Translation chars"; "home.stats.dictionaryEntries" = "Dictionary"; +"home.wide.tagline" = "Voice dictation, anywhere."; +"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate."; +"home.wide.stat.transcribed" = "Total dictated"; +"home.wide.stat.cumulativeDuration" = "Cumulative duration"; +"home.wide.stat.cumulativeTranslation" = "Cumulative translation"; +"home.wide.stat.customTerms" = "Custom terms"; +"home.wide.mode.cloud" = "Cloud"; +"home.wide.mode.local" = "On-device"; +"home.wide.devices" = "iPhone & iPad"; "home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+"; "home.engine.warming" = "Loading ASR model into memory…"; "home.engine.downloading" = "Downloading ASR model…"; diff --git a/OSGKeyboard/zh-Hans.lproj/InfoPlist.strings b/OSGKeyboard/zh-Hans.lproj/InfoPlist.strings index 8da774d..a70affe 100644 --- a/OSGKeyboard/zh-Hans.lproj/InfoPlist.strings +++ b/OSGKeyboard/zh-Hans.lproj/InfoPlist.strings @@ -1,4 +1,4 @@ /* 权限说明 — 简体中文 */ "NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,并在语音会话运行期间保持后台音频会话。"; -"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 使用设备端语音识别将你的语音转为文字。音频仅在设备上处理,不会上传用于转写。"; +"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index c0bb312..2afcbe0 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -98,11 +98,19 @@ "settings.engine.local.ios26" = "全程在手机本地,不用联网"; "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.cloud.title" = "云端识别与润色"; -"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务"; +"settings.engine.cloud.subtitle" = "云端 ASR 与润色 LLM 分开配置;音频发送至转写服务商。"; "settings.engine.cloud.badge" = "云端引擎"; "settings.provider.title" = "云端引擎"; "settings.provider.personalDictionaryBadge" = "个性词库"; "settings.provider.subtitle" = "选择 LLM 提供商。"; +"settings.polishProvider.title" = "文本润色(LLM)"; +"settings.polishProvider.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。"; +"settings.polishApi.title" = "润色接口"; +"settings.asrProvider.title" = "语音转写(ASR)"; +"settings.asrProvider.subtitle" = "云端模式下负责听写转文字,可与润色模型分开配置。"; +"settings.asrApi.title" = "转写接口"; +"settings.asr.model" = "ASR 模型"; +"settings.asr.testConnection" = "测试转写"; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "通义千问"; @@ -161,8 +169,8 @@ "settings.privacy.fullAccess.title" = "关于完全访问"; "settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。"; "settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。"; -"settings.privacy.cloud.alert.title" = "第三方 API"; -"settings.privacy.cloud.alert.message" = "云端润色会把转写文字发到你配置的第三方 API。OSGKeyboard 不会在自有服务器上存储数据。是否继续?"; +"settings.privacy.cloud.alert.title" = "音频将离开你的设备"; +"settings.privacy.cloud.alert.message" = "云端引擎会把你的语音录音上传到你配置的第三方识别服务,并把转写文字发送到其 API 进行润色,适用该服务商的隐私政策。OSGKeyboard 不会在自有服务器上存储数据。是否继续?"; "settings.link.support" = "帮助与反馈"; "settings.link.github" = "GitHub"; "settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。"; @@ -286,6 +294,8 @@ "home.flow.label" = "就绪"; "home.flow.inactive" = "语音会话未启动"; "home.flow.notReady" = "未就绪"; +"home.flow.recording" = "录音中…"; +"home.flow.processing" = "处理中…"; "home.flow.hint" = "切到别的 App,点键盘麦克风就能说。"; "home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。"; "home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。"; @@ -306,6 +316,15 @@ "home.stats.dictationCharacters" = "听写字数"; "home.stats.translationCharacters" = "翻译字数"; "home.stats.dictionaryEntries" = "个性词库"; +"home.wide.tagline" = "随处语音听写"; +"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。"; +"home.wide.stat.transcribed" = "累计听写字数"; +"home.wide.stat.cumulativeDuration" = "累计听写时长"; +"home.wide.stat.cumulativeTranslation" = "累计翻译字数"; +"home.wide.stat.customTerms" = "自定义词条"; +"home.wide.mode.cloud" = "云端"; +"home.wide.mode.local" = "本机"; +"home.wide.devices" = "iPhone 与 iPad"; "home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本"; "home.engine.warming" = "正在加载语音识别模型…"; "home.engine.downloading" = "正在下载语音识别模型…"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index ab5b6f4..99847f4 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -57,7 +57,11 @@ public final class KeyboardViewController: UIInputViewController { // Voice-first keyboard — hide the misleading "English" subtitle in Settings. primaryLanguage = "mis" OSGLog.keyboardExt.info("viewDidLoad — extension booted") - CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded() + // Deliberately NO CustomLanguageModelManager prewarm here: the + // extension never runs ASR (the host app owns the microphone and + // the SpeechAnalyzer pipeline), and compiling/caching an LM inside + // the keyboard's ~60 MB jetsam budget risks the system killing the + // keyboard outright. The host app prewarms it on session start. setNeedsUpdateOfScreenEdgesDeferringSystemGestures() installKeyboardHeight() configureDictationBehavior() @@ -84,6 +88,7 @@ public final class KeyboardViewController: UIInputViewController { setNeedsUpdateOfScreenEdgesDeferringSystemGestures() configureDictationBehavior() KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) + state.debugHasFullAccess = hasFullAccess flowCoordinator.refreshSessionState() flowCoordinator.startSessionMonitor() configSync.syncOnboardingStateFromAppGroup() @@ -126,6 +131,7 @@ public final class KeyboardViewController: UIInputViewController { textInserter = KeyboardTextInserter( state: state, insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) }, + contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput }, scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() } ) diff --git a/OSGKeyboardExt/Services/HostAppLauncher.swift b/OSGKeyboardExt/Services/HostAppLauncher.swift index 57e53b7..ccdbeb2 100644 --- a/OSGKeyboardExt/Services/HostAppLauncher.swift +++ b/OSGKeyboardExt/Services/HostAppLauncher.swift @@ -4,15 +4,17 @@ // Opens the host app from the keyboard extension. // // Reality check (verified against iOS 18–26 behaviour): -// • `extensionContext.open` is documented for Today widgets only; for a -// keyboard extension it resolves `false`, so we do not use it. // • The deprecated `openURL:` selector hack was disabled in iOS 18 // ("BUG IN CLIENT OF UIKIT … migrate to open(_:options:completionHandler:)"). -// • The still-working path is: walk the responder chain to `UIApplication` -// and call the non-deprecated `open(_:options:completionHandler:)`. This -// requires Full Access and grows less reliable on newer iOS, so we report -// the *real* success from the completion handler instead of assuming it -// worked — callers degrade to on-keyboard guidance when it returns false. +// • Primary path: walk the responder chain to `UIApplication` and call the +// non-deprecated `open(_:options:completionHandler:)`. This requires Full +// Access and grows less reliable on newer iOS, so we report the *real* +// success from the completion handler instead of assuming it worked. +// • Fallback: `extensionContext.open`. Historically documented for Today +// widgets only (and it used to resolve `false` for keyboards), but it is +// the Apple-documented API for extensions to open URLs and ships in +// production keyboards on current iOS — worth trying before giving up. +// When both paths fail, callers degrade to on-keyboard guidance. import UIKit @@ -27,13 +29,52 @@ enum HostAppLauncher { while let current = responder { if let application = current as? UIApplication { application.open(url, options: [:]) { success in - Task { @MainActor in completion(success) } + Task { @MainActor in + if success { + completion(true) + } else { + openViaExtensionContext(url: url, from: controller, completion: completion) + } + } } return } responder = current.next } - // No `UIApplication` in the responder chain — cannot open the host app. - completion(false) + // No `UIApplication` in the responder chain — try the extension context. + openViaExtensionContext(url: url, from: controller, completion: completion) + } + + @MainActor + private static func openViaExtensionContext( + url: URL, + from controller: KeyboardViewController, + completion: @escaping @MainActor (Bool) -> Void + ) { + guard let context = controller.extensionContext else { + completion(false) + return + } + // `NSExtensionContext.open` from keyboards has historically been + // flaky about ever invoking its completion on some iOS versions. + // Callers rely on a real answer to fail fast (instead of spinning + // the 30 s start watchdog), so race the callback against a timeout + // and report the first result only. + var didComplete = false + let finish: @MainActor (Bool) -> Void = { success in + guard !didComplete else { return } + didComplete = true + completion(success) + } + Task { @MainActor in + // 1.5 s: long enough for a real open to call back, short enough + // that a dead completion degrades to on-keyboard guidance before + // the user gives up staring at nothing. + try? await Task.sleep(nanoseconds: 1_500_000_000) + finish(false) + } + context.open(url) { success in + Task { @MainActor in finish(success) } + } } } diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 1286a5e..0899e3b 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -44,6 +44,10 @@ final class KeyboardFlowCoordinator { private var isAwaitingFlowResult = false private var activeSessionId: UUID? private var currentUtteranceId: UUID? + /// Utterance whose final result we already inserted (or failed). Prevents + /// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a + /// stale App Group snapshot still says `reason=processing`. + private var lastConsumedUtteranceId: UUID? private var currentCommandSeq: Int64 = 0 private var lastAvailabilityTraceSignature = "" @@ -139,6 +143,12 @@ final class KeyboardFlowCoordinator { FlowSessionBridge.reloadFromDisk() let readySnapshot = FlowSessionBridge.readySnapshot() activeSessionId = readySnapshot?.sessionId ?? activeSessionId + + // If the host is mid-utterance but this extension process lost local + // ownership (jetsam / recreate after app switch), re-adopt it so we + // show red/white instead of a fake orange "starting" state. + adoptHostBusyStateIfNeeded(snapshot: readySnapshot) + let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady() let now = Date().timeIntervalSince1970 if hostReady { lastHostReadyAt = now } @@ -148,10 +158,20 @@ final class KeyboardFlowCoordinator { // across cross-process read jitter and anchors this smoothing. let withinReadyGrace = lastHostReadyAt > 0 && (now - lastHostReadyAt) <= Self.hostReadyGrace + // Host busy (recording/processing) is NOT "still starting". Treating + // it as preparingSession was the orange-stuck bug after cold start: + // host utt.rec=1 → ready=false → keyboard forever "正在启动…". + let hostBusy = readySnapshot?.reason == .recording + || readySnapshot?.reason == .processing let hostWarming = !hostReady + && !hostBusy && FlowSessionBridge.isSessionActive() && (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace) state.flowSessionActive = FlowSessionBridge.isSessionActive() + state.debugPendingFlowStart = isPendingFlowStart + state.debugFlowRecording = isFlowRecording + state.debugAwaitingFlowResult = isAwaitingFlowResult + state.debugHasFullAccess = hasFullAccess() state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve( phase: state.phase, micDisabled: state.micDisabled, @@ -176,6 +196,84 @@ final class KeyboardFlowCoordinator { } } + /// Re-attach to a host utterance this keyboard process no longer owns. + private func adoptHostBusyStateIfNeeded(snapshot: FlowReadySnapshot?) { + guard let snapshot, let sessionId = snapshot.sessionId else { return } + // Ignore snapshots from a dead host generation. + if let snapGen = snapshot.hostGeneration, + let liveGen = FlowSessionBridge.currentHostGeneration(), + snapGen != liveGen { + return + } + + // Host already finished — never re-adopt a consumed utterance, and + // clear sticky local processing left behind by a stale busy snapshot. + if snapshot.reason != .recording, snapshot.reason != .processing { + clearStickyProcessingIfNeeded(hostReady: snapshot.ready) + return + } + + switch snapshot.reason { + case .recording: + guard !isFlowRecording else { return } + guard !isAwaitingFlowResult else { return } + // Require the host's utterance id — inventing one makes matchingResult + // forever miss the real delivery and leaves the mic white forever. + guard let busyId = snapshot.busyUtteranceId else { return } + guard busyId != lastConsumedUtteranceId else { return } + activeSessionId = sessionId + currentUtteranceId = busyId + isPendingFlowStart = false + flowStartDeadline = 0 + stopHostReadyWait() + isFlowRecording = true + state.phase = .recording + if state.lastTranscript.isEmpty { + state.lastTranscript = "" + } + if let view = wakeLockView() { + ExtensionScreenWakeLock.acquire(from: view) + } + startUtteranceCountdown() + startFlowLevelWatchdog() + traceState("adoptHostBusy.recording", extra: "session=\(sessionId)") + case .processing: + guard !isAwaitingFlowResult else { return } + guard let busyId = snapshot.busyUtteranceId else { return } + guard busyId != lastConsumedUtteranceId else { return } + activeSessionId = sessionId + currentUtteranceId = busyId + isPendingFlowStart = false + flowStartDeadline = 0 + isFlowRecording = false + stopUtteranceCountdown() + ExtensionScreenWakeLock.release() + state.phase = .processing + if state.lastTranscript.isEmpty { + state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") + } + startFlowResultWatchdog() + traceState("adoptHostBusy.processing", extra: "session=\(sessionId)") + default: + break + } + } + + /// After insert, a stale `reason=processing` snapshot can bounce the mic + /// back to white loading. When the host is no longer busy, force idle. + private func clearStickyProcessingIfNeeded(hostReady: Bool) { + guard !isAwaitingFlowResult, !isFlowRecording else { return } + guard case .processing = state.phase else { return } + state.phase = .idle + state.lastTranscript = "" + stopFlowWatchdog() + currentUtteranceId = nil + traceState( + "stickyProcessing.cleared", + extra: hostReady ? "hostReady=1" : "hostReady=0" + ) + } + /// Session is live but the ready contract has not landed yet — poll /// quickly instead of sticking on "session inactive" orange. private func startHostReadyWaitIfNeeded() { @@ -184,6 +282,12 @@ final class KeyboardFlowCoordinator { stopHostReadyWait() return } + // Host busy ≠ waiting for ready. Do not spin the ready-wait poll. + if let reason = FlowSessionBridge.readySnapshot()?.reason, + reason == .recording || reason == .processing { + stopHostReadyWait() + return + } guard !FlowSessionBridge.isHostReady() else { stopHostReadyWait() return @@ -196,7 +300,9 @@ final class KeyboardFlowCoordinator { guard let self, !Task.isCancelled else { return } FlowSessionBridge.reloadFromDisk() self.recomputeMicVoiceAvailability() - if self.state.micVoiceAvailability.isReady { + if self.state.micVoiceAvailability.isReady + || self.state.micVoiceAvailability == .recording + || self.state.micVoiceAvailability == .processing { return } try? await Task.sleep(nanoseconds: 150_000_000) @@ -365,6 +471,7 @@ final class KeyboardFlowCoordinator { ) ) FlowSessionBridge.clearResult() + lastConsumedUtteranceId = result.utteranceId currentUtteranceId = nil textInserter.handleFlowTranscript( TranscriptionDelivery(text: text, polishWarning: result.warning) @@ -375,6 +482,7 @@ final class KeyboardFlowCoordinator { isAwaitingFlowResult = false stopFlowWatchdog() FlowSessionBridge.clearResult() + lastConsumedUtteranceId = result.utteranceId currentUtteranceId = nil let error = FlowTranscriptionError( message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), @@ -626,6 +734,7 @@ final class KeyboardFlowCoordinator { ) ) FlowSessionBridge.clearResult() + self.lastConsumedUtteranceId = result.utteranceId self.currentUtteranceId = nil self.debug("resultWatchdog consumed delivery len=\(text.count)") self.textInserter.handleFlowTranscript( @@ -637,6 +746,7 @@ final class KeyboardFlowCoordinator { self.isAwaitingFlowResult = false self.stopFlowWatchdog() FlowSessionBridge.clearResult() + self.lastConsumedUtteranceId = result.utteranceId self.currentUtteranceId = nil let error = FlowTranscriptionError( message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), diff --git a/OSGKeyboardExt/Services/KeyboardTextInserter.swift b/OSGKeyboardExt/Services/KeyboardTextInserter.swift index 69c38b7..d5cd4ab 100644 --- a/OSGKeyboardExt/Services/KeyboardTextInserter.swift +++ b/OSGKeyboardExt/Services/KeyboardTextInserter.swift @@ -10,15 +10,18 @@ import OSGKeyboardShared final class KeyboardTextInserter { private let state: KeyboardState private let insertText: (String) -> Void + private let contextBeforeInput: () -> String? private let scheduleAutoClearError: () -> Void init( state: KeyboardState, insertText: @escaping (String) -> Void, + contextBeforeInput: @escaping () -> String?, scheduleAutoClearError: @escaping () -> Void ) { self.state = state self.insertText = insertText + self.contextBeforeInput = contextBeforeInput self.scheduleAutoClearError = scheduleAutoClearError } @@ -30,7 +33,13 @@ final class KeyboardTextInserter { return } // Host app already polished when configured; keyboard only inserts. - insertText(trimmed) + // Word-boundary hygiene: dictating "world" with the cursor right + // after "Hello" must yield "Hello world", not "Helloworld". + let separator = DictationTextComposer.insertionSeparator( + previousContext: contextBeforeInput(), + insertion: trimmed + ) + insertText(separator + trimmed) state.lastTranscript = "" state.level = 0 if let warning = delivery.polishWarning { diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 04cc0b1..f4c0bb5 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -33,6 +33,11 @@ private enum KeyboardLayoutMetrics { static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2 /// Outer inset for the bottom action row from screen edges (8 pt → 24 pt, +200%). static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3 + /// iPad: cap the content column. A full-width (~1180 pt) keyboard would + /// park delete/return at the far screen edges and turn each cursor-drag + /// pad into a ~450 pt runway — capping keeps the reach ergonomics of the + /// phone layout. iPhone widths are all below this, so it is a no-op there. + static let contentMaxWidth: CGFloat = 700 // MARK: - Content-driven keyboard height (single source of truth) static let outerPaddingTop: CGFloat = 2 @@ -109,6 +114,8 @@ public struct KeyboardRootView: View { .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) // 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。 .background(Color.clear) + .frame(maxWidth: KeyboardLayoutMetrics.contentMaxWidth) + .frame(maxWidth: .infinity) .frame(height: Self.totalHeight) // Feed the resolved palette to all nested chips/buttons. .environment(\.themePalette, palette) diff --git a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift index f8a3f74..f4eb6e6 100644 --- a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift +++ b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift @@ -9,37 +9,50 @@ import SwiftUI import WidgetKit struct FlowLiveActivityWidget: Widget { + /// Deep link that restarts the Flow session. When the host process is + /// dead the activity goes stale; tapping it must take the *cold-start* + /// path (same as the keyboard's mic button), not just open the app. + private static let reconnectURL = URL(string: "osgkeyboard://startflow") + var body: some WidgetConfiguration { ActivityConfiguration(for: FlowActivityAttributes.self) { context in - FlowLiveActivityLockScreenView(phase: context.state.phase) - .activityBackgroundTint(Color.black.opacity(0.82)) - .activitySystemActionForegroundColor(.white) + FlowLiveActivityLockScreenView( + phase: context.state.phase, + isStale: context.isStale + ) + .activityBackgroundTint(Color.black.opacity(0.82)) + .activitySystemActionForegroundColor(.white) + // Deep-link to a session restart only when the host is dead — + // tapping a HEALTHY activity should just open the app, not + // force a cold-start handoff into a running session. + .widgetURL(context.isStale ? Self.reconnectURL : nil) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { FlowLiveActivityBrandMark(height: 16) } DynamicIslandExpandedRegion(.trailing) { - FlowLiveActivityPhaseLabel(phase: context.state.phase) + FlowLiveActivityPhaseLabel(phase: context.state.phase, isStale: context.isStale) } DynamicIslandExpandedRegion(.center) { Text("OSGKeyboard") .font(.headline) } DynamicIslandExpandedRegion(.bottom) { - FlowLiveActivityPhaseCaption(phase: context.state.phase) + FlowLiveActivityPhaseCaption(phase: context.state.phase, isStale: context.isStale) .font(.caption) .foregroundStyle(.secondary) } } compactLeading: { FlowLiveActivityBrandMark(height: 12) } compactTrailing: { - FlowLiveActivityTrailingGlyph(phase: context.state.phase) + FlowLiveActivityTrailingGlyph(phase: context.state.phase, isStale: context.isStale) } minimal: { // The minimal slot is a tiny circle; a short wordmark keeps // the natural ratio without overflowing its bounds. FlowLiveActivityBrandMark(height: 6) } + .widgetURL(context.isStale ? Self.reconnectURL : nil) .keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0)) } } @@ -49,6 +62,7 @@ struct FlowLiveActivityWidget: Widget { private struct FlowLiveActivityLockScreenView: View { let phase: FlowActivityAttributes.ContentState.Phase + let isStale: Bool var body: some View { HStack(spacing: 12) { @@ -56,13 +70,16 @@ private struct FlowLiveActivityLockScreenView: View { VStack(alignment: .leading, spacing: 4) { Text("OSGKeyboard") .font(.headline) - FlowLiveActivityPhaseCaption(phase: phase) + FlowLiveActivityPhaseCaption(phase: phase, isStale: isStale) .font(.subheadline) .foregroundStyle(.secondary) } Spacer(minLength: 0) - FlowLiveActivityTrailingGlyph(phase: phase) + FlowLiveActivityTrailingGlyph(phase: phase, isStale: isStale) } + // A stale activity means the host process is gone — never advertise + // "Voice session active" for a dead session; grey the card instead. + .opacity(isStale ? 0.55 : 1) // iOS Live Activity lock-screen content needs margins so the leading // logo and trailing glyph don't touch the card edges. .padding(.horizontal, 16) @@ -91,8 +108,19 @@ private struct FlowLiveActivityBrandMark: View { private struct FlowLiveActivityTrailingGlyph: View { let phase: FlowActivityAttributes.ContentState.Phase + var isStale: Bool = false var body: some View { + if isStale { + Image(systemName: "bolt.slash.circle") + .foregroundStyle(.secondary) + } else { + phaseGlyph + } + } + + @ViewBuilder + private var phaseGlyph: some View { switch phase { case .recording: Image(systemName: "waveform") @@ -114,8 +142,19 @@ private struct FlowLiveActivityTrailingGlyph: View { private struct FlowLiveActivityPhaseLabel: View { let phase: FlowActivityAttributes.ContentState.Phase + var isStale: Bool = false var body: some View { + if isStale { + Image(systemName: "bolt.slash.circle") + .foregroundStyle(.secondary) + } else { + phaseLabel + } + } + + @ViewBuilder + private var phaseLabel: some View { switch phase { case .recording: Text("REC") @@ -133,8 +172,20 @@ private struct FlowLiveActivityPhaseLabel: View { private struct FlowLiveActivityPhaseCaption: View { let phase: FlowActivityAttributes.ContentState.Phase + var isStale: Bool = false var body: some View { + if isStale { + // Host process is gone — be honest about it and turn the card + // into a recovery entry point (tap deep-links to startflow). + Text("Session disconnected · tap to reconnect") + } else { + phaseCaption + } + } + + @ViewBuilder + private var phaseCaption: some View { switch phase { case .idle: Text("Voice session active") diff --git a/OSGKeyboardLiveActivity/en.lproj/Localizable.strings b/OSGKeyboardLiveActivity/en.lproj/Localizable.strings new file mode 100644 index 0000000..db8d765 --- /dev/null +++ b/OSGKeyboardLiveActivity/en.lproj/Localizable.strings @@ -0,0 +1,7 @@ +/* Live Activity captions. Keys are the English literals used in + FlowLiveActivityWidget — SwiftUI Text(_:) resolves string literals as + LocalizedStringKey against this table automatically. */ +"Voice session active" = "Voice session active"; +"Listening…" = "Listening…"; +"Transcribing…" = "Transcribing…"; +"Session disconnected · tap to reconnect" = "Session disconnected · tap to reconnect"; diff --git a/OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings b/OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..c7a037b --- /dev/null +++ b/OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,6 @@ +/* Live Activity 文案。键为 FlowLiveActivityWidget 中的英文字面量 — + SwiftUI Text(_:) 会把字符串字面量按 LocalizedStringKey 在本表解析。 */ +"Voice session active" = "语音会话进行中"; +"Listening…" = "正在聆听…"; +"Transcribing…" = "正在转写…"; +"Session disconnected · tap to reconnect" = "会话已断开 · 点按重连"; diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift index e06343a..a7ff65e 100644 --- a/OSGKeyboardMac/DashboardView.swift +++ b/OSGKeyboardMac/DashboardView.swift @@ -1,7 +1,8 @@ // DashboardView.swift // OSGKeyboard · Mac // -// Primary workspace: session stats, dictation canvas, floating record bar. +// Primary workspace: brand voice, asymmetric stats, dictation stage, and +// the record bar. History lives on its own page — no duplicate list here. import SwiftUI @@ -20,33 +21,24 @@ struct DashboardView: View { self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics) } - // Four equal-width columns — same metrics as the iOS home stats card. - private let columns = Array( - repeating: GridItem(.flexible(minimum: 120), spacing: Spacing.md), - count: 4 - ) - var body: some View { VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: Spacing.lg) { - if let appName = viewModel.foregroundAppName { - Text(MacL10n.format("mac.foregroundApp", language: lang, appName)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textTertiary) - .transition(.opacity) - } - statGrid - dictationCanvas - } - .animation(Motion.soft, value: viewModel.foregroundAppName) - .padding(.horizontal, Spacing.lg) - .padding(.top, Spacing.sm) - .padding(.bottom, Spacing.lg) + VStack(alignment: .leading, spacing: Spacing.lg) { + heroHeader + statCluster + dictationStage } + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.top, Spacing.sm) + + // Leftover window height splits evenly above / below the mic bar + // so spacing stays balanced at any window size. + Spacer(minLength: Spacing.xs) + BottomDictationBar(viewModel: viewModel) - .padding(.horizontal, Spacing.lg) - .padding(.bottom, Spacing.sm) + .padding(.horizontal, MacMetrics.pageHorizontalInset) + + Spacer(minLength: Spacing.xs) } .onAppear { stats.reloadFromDisk() } .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in @@ -54,18 +46,41 @@ struct DashboardView: View { } } - private var statGrid: some View { - LazyVGrid(columns: columns, spacing: Spacing.md) { - StatCard( - title: MacL10n.string("mac.stat.dictationTime", language: lang), - value: UsageStatisticsStore.formatDuration( - stats.dictationDurationSeconds, - language: lang - ), - caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang), - systemImage: "waveform", - accent: true - ) + // MARK: - Hero + + private var heroHeader: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + Text(MacL10n.string("mac.brand.tagline", language: lang)) + .font(TypeStyle.pageTitle) + .foregroundStyle(palette.textPrimary) + .lineLimit(2) + .minimumScaleFactor(0.85) + + HStack(spacing: Spacing.sm) { + Text(MacL10n.string("mac.brand.tagline.subtitle", language: lang)) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textTertiary) + + if let appName = viewModel.foregroundAppName { + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.45)) + Text(MacL10n.format("mac.foregroundApp", language: lang, appName)) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textTertiary) + .transition(.opacity) + .lineLimit(1) + } + } + .animation(Motion.soft, value: viewModel.foregroundAppName) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Stats (hero word count, full width but content-height — + // never stretched to match a taller sibling and left with dead air) + + private var statCluster: some View { + VStack(spacing: Spacing.md) { StatCard( title: MacL10n.string("mac.stat.words", language: lang), value: UsageStatisticsStore.formatCount( @@ -73,28 +88,44 @@ struct DashboardView: View { language: lang ), caption: MacL10n.string("mac.stat.transcribed", language: lang), - systemImage: "text.alignleft" - ) - StatCard( - title: MacL10n.string("mac.stat.translation", language: lang), - value: UsageStatisticsStore.formatCount( - stats.translationCharacterCount, - language: lang - ), - caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang), - systemImage: "character.bubble" - ) - StatCard( - title: MacL10n.string("mac.stat.dictionary", language: lang), - value: "\(viewModel.dictionaryTermCount)", - caption: MacL10n.string("mac.stat.customTerms", language: lang), - systemImage: "character.book.closed" + systemImage: "text.alignleft", + accent: true, + prominent: true ) + + HStack(spacing: Spacing.md) { + StatCard( + title: MacL10n.string("mac.stat.dictationTime", language: lang), + value: UsageStatisticsStore.formatDuration( + stats.dictationDurationSeconds, + language: lang + ), + caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang), + systemImage: "waveform" + ) + StatCard( + title: MacL10n.string("mac.stat.translation", language: lang), + value: UsageStatisticsStore.formatCount( + stats.translationCharacterCount, + language: lang + ), + caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang), + systemImage: "character.bubble" + ) + StatCard( + title: MacL10n.string("mac.stat.dictionary", language: lang), + value: "\(viewModel.dictionaryTermCount)", + caption: MacL10n.string("mac.stat.customTerms", language: lang), + systemImage: "character.book.closed" + ) + } } } - private var dictationCanvas: some View { - MacCard(padding: Spacing.lg) { + // MARK: - Dictation stage + + private var dictationStage: some View { + MacCard(padding: Spacing.md, cornerRadius: Radius.large) { ZStack(alignment: .topLeading) { if viewModel.transcript.isEmpty { Text( @@ -102,24 +133,37 @@ struct DashboardView: View { ? MacL10n.string("mac.status.listening", language: lang) : MacL10n.string("mac.status.ready", language: lang) ) - .font(.system(size: 26, weight: .light)) + .font(.system(size: 22, weight: .light)) .foregroundStyle(palette.textTertiary) .contentTransition(.opacity) - .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading) + .frame( + maxWidth: .infinity, + minHeight: MacMetrics.dictationCanvasMinHeight, + maxHeight: .infinity, + alignment: .topLeading + ) .transition(.opacity) } else { Text(viewModel.transcript) - .font(.system(size: 22, weight: .regular)) + .font(.system(size: 20, weight: .regular)) .foregroundStyle(palette.textPrimary) + .lineSpacing(4) .textSelection(.enabled) - .frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading) + .frame( + maxWidth: .infinity, + minHeight: MacMetrics.dictationCanvasMinHeight, + maxHeight: .infinity, + alignment: .topLeading + ) .transition(.opacity) } } + .frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: 160) } .animation(Motion.soft, value: viewModel.transcript.isEmpty) .animation(Motion.quick, value: viewModel.isRecording) } + } // MARK: - Floating record bar @@ -142,12 +186,10 @@ struct BottomDictationBar: View { recordControl } .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.dividerStrong, lineWidth: 0.5) - ) + .padding(.vertical, Spacing.xs) + // No surface fill — the mic bar sits on the page background so Home + // stays flat and the canvas above can stay shorter without a second + // floating card competing for height. } private var readinessChip: some View { @@ -189,15 +231,14 @@ struct BottomDictationBar: View { .foregroundStyle(palette.textSecondary) .padding(.horizontal, Spacing.sm) .padding(.vertical, 7) - .macGlassSurface(in: Capsule(), fillOpacity: 0.66) + .background(palette.surfaceElevated, in: Capsule()) } .menuStyle(.borderlessButton) .fixedSize() } - // 麦克风按钮始终居中固定:录音时的波形放进按钮内部, - // “按停止”提示作为浮层显示在按钮上方,二者均不参与布局, - // 因此按下 Option 触发录音时按钮位置不会发生偏移。 + // Mic stays geometrically centred: waveform lives inside the button, + // “press stop” floats above — neither participates in layout. private var recordControl: some View { recordButton .overlay(alignment: .top) { @@ -225,7 +266,6 @@ struct BottomDictationBar: View { ) Group { if viewModel.isRecording { - // 与 iOS 一致:录音时在红色按钮内部显示实时波形 MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent) } else { Image(systemName: "mic.fill") diff --git a/OSGKeyboardMac/MacAudioRecorder.swift b/OSGKeyboardMac/MacAudioRecorder.swift index 135a3b2..91c83f9 100644 --- a/OSGKeyboardMac/MacAudioRecorder.swift +++ b/OSGKeyboardMac/MacAudioRecorder.swift @@ -11,11 +11,16 @@ final class MacAudioRecorder: @unchecked Sendable { enum RecorderError: Error, LocalizedError { case converterUnavailable + case microphoneAccessDenied var errorDescription: String? { switch self { case .converterUnavailable: return "无法初始化音频转换器 / Failed to initialize audio converter" + case .microphoneAccessDenied: + return "麦克风权限被拒绝——请在「系统设置 → 隐私与安全性 → 麦克风」中启用" + + " / Microphone access denied — enable it in System Settings" + + " → Privacy & Security → Microphone" } } } @@ -30,7 +35,20 @@ final class MacAudioRecorder: @unchecked Sendable { private var converter: AVAudioConverter? private let lock = NSLock() private var samples: [Float] = [] + private var snapshotContinuation: AsyncStream.Continuation? private var isRunning = false + /// Hard cap on accumulated audio: 10 minutes @16 kHz ≈ 38 MB of Float32. + /// Recording is push-to-talk, but a stuck hotkey (or a latched Option + /// key) would otherwise grow this buffer without bound; past the cap we + /// keep the newest audio (drop from the front) so the take still ends + /// with what the user last said. + private static let maxSampleCount = 10 * 60 * 16_000 + /// Trim hysteresis: dropping from the front is an O(n) memmove of the + /// whole ~38 MB buffer, done under the same lock the UI's level poll + /// takes — doing it on EVERY tap callback once capped would stall the + /// render thread ~12×/s. Let the buffer overshoot by 30 s and trim the + /// whole excess in one move instead. + private static let trimHysteresisSamples = 30 * 16_000 private var smoothedLevel: Float = 0 /// One-shot flag for the converter pull block. Taps are serialized per /// bus, so a plain instance property (not a captured local) is safe here. @@ -42,8 +60,51 @@ final class MacAudioRecorder: @unchecked Sendable { lock.withLock { smoothedLevel } } - func start() throws { - lock.withLock { samples.removeAll(keepingCapacity: true) } + /// Resolves microphone authorization before capture. Prompts on first + /// use; throws `microphoneAccessDenied` once the user has declined so + /// failures surface as a permission problem, not an empty transcription. + private static func ensureMicrophoneAccess() async throws { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return + case .notDetermined: + guard await AVCaptureDevice.requestAccess(for: .audio) else { + throw RecorderError.microphoneAccessDenied + } + case .denied, .restricted: + throw RecorderError.microphoneAccessDenied + @unknown default: + throw RecorderError.microphoneAccessDenied + } + } + + func start() async throws { + try await Self.ensureMicrophoneAccess() + try startEngine() + } + + /// Live 16 kHz mono snapshots for streaming ASR while the mic is open. + /// The stream is finished automatically in `stop()`. + func makeSnapshotStream() -> AsyncStream { + AsyncStream { continuation in + lock.withLock { + snapshotContinuation?.finish() + snapshotContinuation = continuation + } + continuation.onTermination = { [weak self] _ in + self?.lock.withLock { + self?.snapshotContinuation = nil + } + } + } + } + + private func startEngine() throws { + lock.withLock { + samples.removeAll(keepingCapacity: true) + snapshotContinuation?.finish() + snapshotContinuation = nil + } let input = engine.inputNode let inputFormat = input.outputFormat(forBus: 0) @@ -67,6 +128,8 @@ final class MacAudioRecorder: @unchecked Sendable { engine.stop() isRunning = false return lock.withLock { + snapshotContinuation?.finish() + snapshotContinuation = nil let out = samples samples.removeAll(keepingCapacity: false) return out @@ -105,8 +168,14 @@ final class MacAudioRecorder: @unchecked Sendable { lock.withLock { samples.append(contentsOf: chunk) + if samples.count > Self.maxSampleCount + Self.trimHysteresisSamples { + samples.removeFirst(samples.count - Self.maxSampleCount) + } let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15 smoothedLevel += (normalized - smoothedLevel) * factor + snapshotContinuation?.yield( + AudioBufferSnapshot(samples: chunk, sampleRate: 16_000) + ) } } } diff --git a/OSGKeyboardMac/MacCloudASRChunkAdapter.swift b/OSGKeyboardMac/MacCloudASRChunkAdapter.swift new file mode 100644 index 0000000..273ae90 --- /dev/null +++ b/OSGKeyboardMac/MacCloudASRChunkAdapter.swift @@ -0,0 +1,55 @@ +// MacCloudASRChunkAdapter.swift +// OSGKeyboard · Mac +// +// Adapts configured cloud ASR clients to the shared chunked utterance pipeline. + +import Foundation +import os + +final class MacCloudASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable { + private let store: AppGroupStore + private let client: CloudASRTranscribing + private let cancelled = OSAllocatedUnfairLock(initialState: false) + + init(store: AppGroupStore) throws { + let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId) + guard strategy != .localFallback else { + throw MacDictationError.providerHasNoCloudASR + } + self.store = store + self.client = CloudASRClientFactory.make(store: store) + } + + func prepare() async throws { + try await client.prepare(dictionary: store.personalDictionary) + } + + func resetForNewUtterance() { + cancelled.withLock { $0 = false } + } + + func cancel() { + cancelled.withLock { $0 = true } + } + + func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult { + let isCancelled = cancelled.withLock { $0 } + if isCancelled || Task.isCancelled { return .cancelled } + guard !samples.isEmpty else { return .success("") } + + do { + let text = try await client.transcribe( + samples: samples, + sampleRate: 16_000, + locale: locale, + dictionary: store.personalDictionary + ) + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return .success(trimmed) + } catch is CancellationError { + return .cancelled + } catch { + return .failure(error.localizedDescription) + } + } +} diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift index 8daa33f..6a81bff 100644 --- a/OSGKeyboardMac/MacComponents.swift +++ b/OSGKeyboardMac/MacComponents.swift @@ -25,6 +25,22 @@ enum MacMetrics { static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm /// Reading width for single-column content. static let contentMaxWidth: CGFloat = 720 + /// Horizontal inset for page titles and scroll *content* (cards). + /// ScrollViews / Forms stay full-bleed so the scrollbar sits on the + /// window edge; only the content inside is inset. + /// Doubled from `Spacing.lg` so title + cards breathe from the edges. + static let pageHorizontalInset: CGFloat = Spacing.lg * 2 + /// Built-in horizontal inset macOS grouped `Form` adds around its section + /// cards, on top of any padding we apply. Subtracted from + /// `pageHorizontalInset` on the Settings Form so its card outer edge lands + /// on `pageHorizontalInset` — matching the History page and the page title. + static let groupedFormSectionInset: CGFloat = Spacing.lg + /// Default (= minimum) main-window size. Opening the app uses this size; + /// the window cannot shrink below it. + static let windowMinWidth: CGFloat = 860 + static let windowMinHeight: CGFloat = 600 + /// Compact dictation-canvas height so Home fits the min window without scrolling. + static let dictationCanvasMinHeight: CGFloat = 120 /// Top inset that clears the window traffic-light buttons now that the /// title bar is hidden. static let trafficLightInset: CGFloat = 28 @@ -81,16 +97,67 @@ extension View { func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) } } +// MARK: - Page header + +/// Page title for History / Dictionary / Settings. Applies the shared +/// `pageHorizontalInset` so its left edge matches inset card content below. +/// Type size matches Home's brand line (`TypeStyle.pageTitle`). +struct MacPageHeader: View { + @Environment(\.themePalette) private var palette + let title: String + var subtitle: String? + @ViewBuilder var trailing: () -> Trailing + + init( + title: String, + subtitle: String? = nil, + @ViewBuilder trailing: @escaping () -> Trailing + ) { + self.title = title + self.subtitle = subtitle + self.trailing = trailing + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(title) + .font(TypeStyle.pageTitle) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.85) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textTertiary) + } + } + Spacer(minLength: 0) + trailing() + } + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.top, Spacing.sm) + .padding(.bottom, Spacing.sm) + } +} + +extension MacPageHeader where Trailing == EmptyView { + init(title: String, subtitle: String? = nil) { + self.init(title: title, subtitle: subtitle) { EmptyView() } + } +} + // MARK: - Card container /// Elevated surface used for stat tiles and the dictation canvas. struct MacCard: View { @Environment(\.themePalette) private var palette var padding: CGFloat = Spacing.md + var cornerRadius: CGFloat = Radius.medium @ViewBuilder var content: () -> Content var body: some View { - let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) content() .padding(padding) @@ -111,34 +178,82 @@ struct StatCard: View { let caption: String var systemImage: String? var accent: Bool = false + /// Hero metric: wide horizontal layout that uses full-card width without + /// stretching to fill dead vertical space — used for the primary word count. + var prominent: Bool = false var body: some View { - MacCard { - VStack(alignment: .leading, spacing: Spacing.xs) { - HStack { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Spacer() - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(accent ? palette.accent : palette.textTertiary) - } + MacCard(padding: prominent ? Spacing.md : Spacing.md) { + if prominent { + prominentBody + } else { + compactBody + } + } + } + + private var compactBody: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Spacer() + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(accent ? palette.accent : palette.textTertiary) + .symbolRenderingMode(.hierarchical) } - Text(value) - .font(TypeStyle.title2) - .foregroundStyle(accent ? palette.accent : palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.7) - .contentTransition(.numericText()) - .animation(Motion.soft, value: value) + } + Text(value) + .font(TypeStyle.title2) + .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) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Wide "hero bar" layout: icon badge + title/caption on the left, the + /// big number anchored right — fills the full card width edge-to-edge + /// instead of a tall card with empty space below a small number. + private var prominentBody: some View { + HStack(spacing: Spacing.md) { + if let systemImage { + ZStack { + Circle() + .fill(palette.accentMuted) + .frame(width: 44, height: 44) + Image(systemName: systemImage) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(palette.accent) + .symbolRenderingMode(.hierarchical) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) Text(caption) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } - .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: Spacing.md) + Text(value) + .font(.system(size: 34, weight: .bold)) + .foregroundStyle(accent ? palette.accent : palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) } } } @@ -152,30 +267,39 @@ struct MiniWaveform: View { var barCount: Int = 5 /// Pass nil to inherit the palette accent automatically. var tint: Color? + /// Peak bar height; overlay HUD uses a taller meter than the mic button. + var maxBarHeight: CGFloat = 22 + var barWidth: CGFloat = 3 + var barSpacing: CGFloat = 3 @State private var phase: CGFloat = 0 var body: some View { - HStack(spacing: 3) { + HStack(spacing: barSpacing) { ForEach(0.. CGFloat { - let base = CGFloat(level) * 22 - let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4 - return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble)) + // Stronger level coupling + staggered phase so the meter reads as + // "alive" even at modest mic levels. + let boosted = min(1, CGFloat(level) * 1.35 + 0.08) + let base = boosted * maxBarHeight + let wobble = sin((phase * .pi * 2) + CGFloat(index) * 0.85) * (maxBarHeight * 0.22) + + (maxBarHeight * 0.12) + let parity = 0.55 + CGFloat(index % 3) * 0.2 + return max(maxBarHeight * 0.18, min(maxBarHeight, base * parity + wobble)) } } @@ -195,8 +319,8 @@ enum MacTranslationDisplay { // MARK: - Status footer -/// Bottom status strip: engine mode (cloud/local), translation target, and -/// the connection state — icons and wording mirror the dashboard record bar. +/// Bottom status strip: engine mode, translation target, connection — +/// kept visually quiet so it never competes with the record bar. struct MacStatusFooter: View { @ObservedObject var viewModel: MacDictationViewModel @Environment(\.themePalette) private var palette @@ -204,7 +328,7 @@ struct MacStatusFooter: View { private var lang: AppUILanguage { viewModel.config.uiLanguage } var body: some View { - HStack(spacing: Spacing.md) { + HStack(spacing: Spacing.sm) { Spacer() Label( viewModel.isCloudMode @@ -212,24 +336,47 @@ struct MacStatusFooter: View { : MacL10n.string("mac.mode.local", language: lang), systemImage: viewModel.isCloudMode ? "cloud" : "cpu" ) - .foregroundStyle(palette.textSecondary) .contentTransition(.opacity) + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + Label( MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang), systemImage: "translate" ) - .foregroundStyle(palette.textSecondary) .contentTransition(.opacity) + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + Label(MacL10n.string("mac.connected", language: lang), systemImage: "link") - .foregroundStyle(palette.accent) + .foregroundStyle(palette.accent.opacity(0.85)) } - .font(TypeStyle.caption) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) .labelStyle(.titleAndIcon) - .padding(.horizontal, Spacing.lg) - .padding(.vertical, Spacing.xs) + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.vertical, Spacing.sm) .animation(Motion.quick, value: viewModel.isCloudMode) .animation(Motion.quick, value: viewModel.config.translationTargetLocaleId) } } + +// MARK: - Form alignment + +private struct MacFormPageAlignModifier: ViewModifier { + func body(content: Content) -> some View { + // Form stays full-bleed (scrollbar on the window edge). Section + // cards are inset to match `MacPageHeader`. + content + .contentMargins(.horizontal, MacMetrics.pageHorizontalInset, for: .scrollContent) + } +} + +extension View { + /// Insets grouped-`Form` section cards to `pageHorizontalInset`. + func macFormPageAligned() -> some View { + modifier(MacFormPageAlignModifier()) + } +} diff --git a/OSGKeyboardMac/MacContentView.swift b/OSGKeyboardMac/MacContentView.swift index ed4b6f1..c80085a 100644 --- a/OSGKeyboardMac/MacContentView.swift +++ b/OSGKeyboardMac/MacContentView.swift @@ -15,7 +15,7 @@ struct MacContentView: View { VStack(alignment: .leading, spacing: Spacing.sm) { header recordButton - Text(MacL10n.string("mac.hint.holdOption", language: lang)) + Text(MacL10n.string(viewModel.hotkeyTrigger.hintKey, language: lang)) .font(TypeStyle.caption) .foregroundStyle(palette.textTertiary) .frame(maxWidth: .infinity, alignment: .center) diff --git a/OSGKeyboardMac/MacDictationOverlayController.swift b/OSGKeyboardMac/MacDictationOverlayController.swift new file mode 100644 index 0000000..8cd895d --- /dev/null +++ b/OSGKeyboardMac/MacDictationOverlayController.swift @@ -0,0 +1,222 @@ +// MacDictationOverlayController.swift +// OSGKeyboard · Mac +// +// Owns a borderless, non-activating floating NSPanel that hosts the +// dictation HUD. Shown for any recording path (hotkey, menu bar, main +// window) and dismissed after a short success beat when processing ends. + +import AppKit +import Combine +import SwiftUI + +@MainActor +final class MacDictationOverlayController { + static let shared = MacDictationOverlayController() + + private var panel: NSPanel? + private var hosting: NSHostingView? + private var cancellables = Set() + private var hideWorkItem: DispatchWorkItem? + /// Keeps the pill visible briefly after a successful delivery. + private var showingCompletion = false + private var wasBusy = false + + private let bottomMargin: CGFloat = 36 + private let fallbackSize = NSSize(width: 400, height: 52) + + private init() {} + + func start(observing viewModel: MacDictationViewModel) { + guard cancellables.isEmpty else { return } + + Publishers.CombineLatest3( + viewModel.$isRecording, + viewModel.$isPreparingToRecord, + viewModel.$isProcessing + ) + .receive(on: RunLoop.main) + .sink { [weak self] recording, preparing, processing in + self?.handleBusyChange( + recording: recording, + preparing: preparing, + processing: processing, + viewModel: viewModel + ) + } + .store(in: &cancellables) + + // Keep waveform / app name / copy fresh while visible. + viewModel.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self, self.panel?.isVisible == true else { return } + self.refreshContent(viewModel: viewModel) + self.resizeToFit() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.reposition() } + .store(in: &cancellables) + } + + private func handleBusyChange( + recording: Bool, + preparing: Bool, + processing: Bool, + viewModel: MacDictationViewModel + ) { + let busy = recording || preparing || processing + + if busy { + hideWorkItem?.cancel() + hideWorkItem = nil + showingCompletion = false + wasBusy = true + present(viewModel: viewModel) + return + } + + // Transition: busy → idle. Flash a short "done" state, then hide. + if wasBusy { + wasBusy = false + showingCompletion = true + present(viewModel: viewModel) + scheduleHide() + return + } + + if !showingCompletion { + hideImmediately() + } + } + + private func present(viewModel: MacDictationViewModel) { + ensurePanel(viewModel: viewModel) + refreshContent(viewModel: viewModel) + resizeToFit() + reposition() + + guard let panel else { return } + if panel.isVisible { + // Already up — still bump to front in case another space stole it. + panel.orderFrontRegardless() + return + } + + panel.alphaValue = 0 + panel.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.22 + panel.animator().alphaValue = 1 + } + } + + private func ensurePanel(viewModel: MacDictationViewModel) { + if panel != nil { return } + + let host = NSHostingView(rootView: makeRoot(viewModel: viewModel)) + host.frame = NSRect(origin: .zero, size: fallbackSize) + hosting = host + + let panel = NSPanel( + contentRect: NSRect(origin: .zero, size: fallbackSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.contentView = host + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + // Above normal floating windows so the HUD stays visible over browsers / + // full-screen apps, without going as high as the screen saver. + panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1) + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.isMovableByWindowBackground = false + panel.hidesOnDeactivate = false + panel.ignoresMouseEvents = false + panel.becomesKeyOnlyIfNeeded = true + self.panel = panel + } + + private func refreshContent(viewModel: MacDictationViewModel) { + hosting?.rootView = makeRoot(viewModel: viewModel) + } + + private func makeRoot(viewModel: MacDictationViewModel) -> AnyView { + AnyView( + MacDictationOverlayView(viewModel: viewModel) + .macSystemPalette() + .environment(\.locale, viewModel.config.uiLanguage.swiftUILocale) + .preferredColorScheme(MacAppearancePreference.current.colorScheme) + ) + } + + private func resizeToFit() { + guard let panel, let hosting else { return } + hosting.layoutSubtreeIfNeeded() + let fitting = hosting.fittingSize + let width = fitting.width.isFinite && fitting.width > 1 + ? min(max(fitting.width, 300), 520) + : fallbackSize.width + let height = fitting.height.isFinite && fitting.height > 1 + ? max(fitting.height, fallbackSize.height) + : fallbackSize.height + var frame = panel.frame + let midX = frame.midX + frame.size = NSSize(width: width, height: height) + if midX.isFinite { + frame.origin.x = midX - width / 2 + } + panel.setFrame(frame, display: true) + hosting.frame = NSRect(origin: .zero, size: frame.size) + } + + private func reposition() { + guard let panel else { return } + let screen = NSScreen.main ?? NSScreen.screens.first + guard let visible = screen?.visibleFrame else { return } + let size = panel.frame.size + let origin = NSPoint( + x: visible.midX - size.width / 2, + y: visible.minY + bottomMargin + ) + panel.setFrameOrigin(origin) + } + + private func scheduleHide() { + hideWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.showingCompletion = false + self?.hideAnimated() + } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 1.15, execute: work) + } + + private func hideImmediately() { + hideWorkItem?.cancel() + hideWorkItem = nil + showingCompletion = false + panel?.orderOut(nil) + panel?.alphaValue = 1 + } + + private func hideAnimated() { + guard let panel, panel.isVisible else { + hideImmediately() + return + } + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.2 + panel.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + Task { @MainActor in + self?.panel?.orderOut(nil) + self?.panel?.alphaValue = 1 + } + }) + } +} diff --git a/OSGKeyboardMac/MacDictationOverlayView.swift b/OSGKeyboardMac/MacDictationOverlayView.swift new file mode 100644 index 0000000..bc24c40 --- /dev/null +++ b/OSGKeyboardMac/MacDictationOverlayView.swift @@ -0,0 +1,170 @@ +// MacDictationOverlayView.swift +// OSGKeyboard · Mac +// +// Compact bottom-of-screen HUD shown while dictating. Lives inside a +// non-activating NSPanel so it never steals focus from the front app. +// One-line layout: status / live transcript preview + waveform + stop. + +import SwiftUI + +struct MacDictationOverlayView: View { + @ObservedObject var viewModel: MacDictationViewModel + @Environment(\.themePalette) private var palette + + private var lang: AppUILanguage { viewModel.config.uiLanguage } + + private var isBusy: Bool { + viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing + } + + /// Trimmed live / final transcript for the single-line preview. + private var previewText: String { + viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var hasPreview: Bool { !previewText.isEmpty } + + private var showsLiveBadge: Bool { + viewModel.isRecording && viewModel.isStreamingPartial + } + + var body: some View { + HStack(spacing: Spacing.sm) { + statusDot + primaryLine + Spacer(minLength: Spacing.xs) + trailingControl + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, 11) + .frame(minWidth: 300, idealWidth: 400, maxWidth: 520) + .fixedSize(horizontal: true, vertical: true) + .background(palette.surface, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(palette.dividerStrong, lineWidth: 0.5) + ) + .shadow(color: Color.black.opacity(0.22), radius: 14, y: 5) + .padding(2) + .animation(Motion.soft, value: hasPreview) + .animation(Motion.quick, value: viewModel.isRecording) + .animation(Motion.quick, value: viewModel.isStreamingPartial) + } + + // MARK: - Primary line (status or one-line transcript) + + @ViewBuilder + private var primaryLine: some View { + if hasPreview { + HStack(spacing: 6) { + if showsLiveBadge { + liveBadge + } + Text(previewText) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .truncationMode(.head) + .frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading) + .contentTransition(.opacity) + .animation(Motion.quick, value: previewText) + .accessibilityLabel(previewText) + } + } else { + HStack(spacing: 6) { + Text(statusText) + .font(TypeStyle.caption) + .foregroundStyle(palette.textPrimary) + .contentTransition(.opacity) + if let appName = viewModel.foregroundAppName, isBusy { + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.45)) + Text(appName) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .lineLimit(1) + } + } + .animation(Motion.quick, value: statusText) + } + } + + private var liveBadge: some View { + Text(MacL10n.string("mac.overlay.live", language: lang)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(palette.recordRed) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous)) + .accessibilityHidden(true) + } + + private var statusDot: some View { + Circle() + .fill(dotColor) + .frame(width: 8, height: 8) + .animation(Motion.quick, value: viewModel.isRecording) + .animation(Motion.quick, value: viewModel.isProcessing) + .animation(Motion.quick, value: viewModel.isPreparingToRecord) + .animation(Motion.quick, value: viewModel.isStreamingPartial) + } + + private var dotColor: Color { + if viewModel.isRecording { + return viewModel.isStreamingPartial ? palette.accent : palette.recordRed + } + if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning } + return palette.accent + } + + private var statusText: String { + if viewModel.isRecording { + return MacL10n.string("mac.overlay.listening", language: lang) + } + if viewModel.isPreparingToRecord { + return MacL10n.string("mac.overlay.preparing", language: lang) + } + if viewModel.isProcessing { + if viewModel.isStreamingPartial { + return MacL10n.string("mac.overlay.polishing", language: lang) + } + return MacL10n.string("mac.overlay.transcribing", language: lang) + } + return MacL10n.string("mac.overlay.done", language: lang) + } + + @ViewBuilder + private var trailingControl: some View { + if viewModel.isRecording { + MiniWaveform( + level: viewModel.audioLevel, + barCount: 7, + tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed) + .opacity(0.9), + maxBarHeight: 28, + barWidth: 3.5, + barSpacing: 2.5 + ) + stopButton + } else if viewModel.isPreparingToRecord || viewModel.isProcessing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(palette.accent) + .symbolRenderingMode(.hierarchical) + } + } + + private var stopButton: some View { + Button(action: viewModel.toggleRecording) { + Image(systemName: "stop.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(palette.textOnAccent) + .frame(width: 28, height: 28) + .background(palette.recordRed, in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(MacL10n.string("mac.record.stop", language: lang)) + } +} diff --git a/OSGKeyboardMac/MacDictationPipeline.swift b/OSGKeyboardMac/MacDictationPipeline.swift index 6693584..8cc1671 100644 --- a/OSGKeyboardMac/MacDictationPipeline.swift +++ b/OSGKeyboardMac/MacDictationPipeline.swift @@ -1,9 +1,7 @@ // MacDictationPipeline.swift // OSGKeyboard · Mac // -// Dictation pipeline: samples → ASR (cloud or local) → polish. -// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX) -// with Apple Speech fallback when weights are missing. +// Dictation pipeline: samples → ASR (cloud or local, chunked when long) → polish. import Foundation @@ -24,39 +22,60 @@ enum MacDictationError: Error, LocalizedError { } } +/// Outcome of ASR that ran while the microphone was still open. +struct MacLiveASRCaptureResult: Sendable { + let raw: String + let chunkWarning: String? + let localBias: LocalASRBiasPayload? + /// When true, callers should fall back to batch ASR on the recorded samples. + let shouldFallbackToBatch: Bool +} + enum MacDictationPipeline { - /// Runs ASR then best-effort polish. Polish failures fall back to raw text. - static func run(samples: [Float], store: AppGroupStore) async throws -> String { + /// First-chunk threshold: longer local utterances use pipelined chunk ASR. + private static let chunkedLocalThresholdSamples = Int( + FlowUtteranceChunkConfig.flowDefault.maxChunkDurationSeconds(forChunkIndex: 0) * 16_000 + ) + + /// Whether the active engine can surface `onPartial` text while recording. + static func supportsLivePartials(store: AppGroupStore) -> Bool { + if store.engineMode == "local" { return true } + return CloudASRModelCatalog.strategy(for: store.asrProviderId) != .localFallback + } + + /// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning. + static func run( + samples: [Float], + store: AppGroupStore, + onPartial: (@Sendable (String) -> Void)? = nil + ) async throws -> MacDictationResult { guard !samples.isEmpty else { throw MacDictationError.noAudio } - let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId) + let locale = resolvedLocale(store: store) + var chunkWarning: String? let raw: String var localBias: LocalASRBiasPayload? if store.engineMode == "local" { - MacAppContextService.captureAndPersist(to: store) - let capabilities = MacLocalASRService.currentCapabilities() - let bias = LocalASRBiasAdapter.adapt( - LocalASRBiasRequest( - dictionary: store.personalDictionary, + localBias = resolveLocalBias(store: store, locale: locale) + if samples.count > chunkedLocalThresholdSamples { + let chunked = try await transcribeLocalChunked( + samples: samples, locale: locale, - frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(), - capabilities: capabilities + bias: localBias, + onPartial: onPartial ) - ) - localBias = bias - LocalASRBiasDiagnosticsStore.save( - payload: bias, - modelId: MacLocalASRService.selectedModelDefinition()?.id, - backendLabel: MacLocalASRService.currentBackendLabel() - ) - raw = try await MacLocalASRService.transcribe( - samples: samples, - locale: locale, - bias: bias - ) + raw = chunked.text + chunkWarning = chunked.chunkWarning + } else { + raw = try await MacLocalASRService.transcribe( + samples: samples, + locale: locale, + bias: localBias + ) + } } else { - let strategy = CloudASRModelCatalog.strategy(for: store.providerId) + let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId) guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR } let client = CloudASRClientFactory.make(store: store) @@ -69,6 +88,85 @@ enum MacDictationPipeline { ) } + return try await polishCapturedASR( + raw: raw, + store: store, + localBias: localBias, + chunkWarning: chunkWarning + ) + } + + /// Consumes a live mic snapshot stream until finished; yields stitched partials. + static func captureLive( + stream: AsyncStream, + store: AppGroupStore, + onPartial: @escaping @Sendable (String) -> Void + ) async -> MacLiveASRCaptureResult { + let locale = resolvedLocale(store: store) + let localBias: LocalASRBiasPayload? + if store.engineMode == "local" { + localBias = resolveLocalBias(store: store, locale: locale) + } else { + localBias = nil + } + + do { + let adapter = try makeChunkASRAdapter( + store: store, + locale: locale, + bias: localBias + ) + if let cloudAdapter = adapter as? MacCloudASRChunkAdapter { + try? await cloudAdapter.prepare() + } + + let pipeline = ChunkedUtterancePipeline( + asr: adapter, + locale: locale, + config: .flowDefault + ) + let outcome = await pipeline.transcribe(stream: stream, onPartial: onPartial) + + switch outcome { + case .success(let success): + return MacLiveASRCaptureResult( + raw: success.text, + chunkWarning: success.chunkWarnings.first, + localBias: localBias, + shouldFallbackToBatch: false + ) + case .failure: + return MacLiveASRCaptureResult( + raw: "", + chunkWarning: nil, + localBias: localBias, + shouldFallbackToBatch: true + ) + case .cancelled: + return MacLiveASRCaptureResult( + raw: "", + chunkWarning: nil, + localBias: localBias, + shouldFallbackToBatch: true + ) + } + } catch { + return MacLiveASRCaptureResult( + raw: "", + chunkWarning: nil, + localBias: localBias, + shouldFallbackToBatch: true + ) + } + } + + /// Polish-only step after live or batch ASR has produced raw text. + static func polishCapturedASR( + raw: String, + store: AppGroupStore, + localBias: LocalASRBiasPayload?, + chunkWarning: String? + ) async throws -> MacDictationResult { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript } @@ -91,14 +189,127 @@ enum MacDictationPipeline { polishContext = nil } - if let polished = try? await PolishingService(store: store).polish( - postASR, - mode: store.polishModeForPipeline, - context: polishContext - ), - !polished.isEmpty { - return polished + do { + let polished = try await PolishingService(store: store).polish( + postASR, + mode: store.polishModeForPipeline, + context: polishContext + ) + guard !polished.isEmpty else { + throw PolishingService.PolishError.noTranscript + } + return MacDictationResult( + text: polished, + polishWarning: nil, + chunkWarning: chunkWarning + ) + } catch { + let delivery = TranscriptionPolishFallback.makeDelivery( + rawText: postASR, + error: error, + engineMode: store.engineMode, + chunkWarning: chunkWarning + ) + return MacDictationResult( + text: delivery.text, + polishWarning: delivery.polishWarning, + chunkWarning: nil + ) + } + } + + // MARK: - Chunked local ASR + + private struct ChunkedLocalResult { + let text: String + let chunkWarning: String? + } + + private static func resolvedLocale(store: AppGroupStore) -> Locale { + Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId) + } + + private static func resolveLocalBias( + store: AppGroupStore, + locale: Locale + ) -> LocalASRBiasPayload? { + MacAppContextService.captureAndPersist(to: store) + let capabilities = MacLocalASRService.currentCapabilities() + let bias = LocalASRBiasAdapter.adapt( + LocalASRBiasRequest( + dictionary: store.personalDictionary, + locale: locale, + frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(), + capabilities: capabilities + ) + ) + LocalASRBiasDiagnosticsStore.save( + payload: bias, + modelId: MacLocalASRService.selectedModelDefinition()?.id, + backendLabel: MacLocalASRService.currentBackendLabel() + ) + return bias + } + + private static func makeChunkASRAdapter( + store: AppGroupStore, + locale: Locale, + bias: LocalASRBiasPayload? + ) throws -> any ASRChunkTranscribing { + if store.engineMode == "local" { + return MacLocalASRChunkAdapter(locale: locale, bias: bias) + } + return try MacCloudASRChunkAdapter(store: store) + } + + private static func transcribeLocalChunked( + samples: [Float], + locale: Locale, + bias: LocalASRBiasPayload?, + onPartial: (@Sendable (String) -> Void)? + ) async throws -> ChunkedLocalResult { + let adapter = MacLocalASRChunkAdapter(locale: locale, bias: bias) + let pipeline = ChunkedUtterancePipeline( + asr: adapter, + locale: locale, + config: .flowDefault + ) + let outcome = await pipeline.transcribe( + stream: audioStream(from: samples), + onPartial: { partial in + onPartial?(partial) + } + ) + + switch outcome { + case .success(let success): + let warning = success.chunkWarnings.first + return ChunkedLocalResult(text: success.text, chunkWarning: warning) + case .failure(let message): + throw MacLocalASRError.qwen3InferenceFailed(message) + case .cancelled: + throw MacLocalASRError.qwen3InferenceFailed("Cancelled") + } + } + + /// Feeds recorded PCM into the chunker as if it arrived incrementally. + private static func audioStream( + from samples: [Float], + sliceSamples: Int = 8_000 + ) -> AsyncStream { + AsyncStream { continuation in + var offset = 0 + while offset < samples.count { + let end = min(offset + sliceSamples, samples.count) + continuation.yield( + AudioBufferSnapshot( + samples: Array(samples[offset..() + /// In-flight `beginRecording` started by the hotkey — cancelled if the + /// key is released before the engine is ready (avoids a stuck session). + private var hotkeyBeginTask: Task? + /// Live chunked ASR while recording (cloud / supported local paths). + /// Finished in `finishRecording` so partials can become the final draft. + private var liveCaptureTask: Task? let usageStatistics: UsageStatisticsStore let speechHistory = SpeechHistoryStore.shared @@ -71,6 +84,7 @@ final class MacDictationViewModel: ObservableObject { private enum StoredKeys { static let autoPaste = "mac.autoPasteEnabled" static let hotkey = "mac.hotkeyEnabled" + static let hotkeyTrigger = MacHotkeyTrigger.storageKey } init(defaults: UserDefaults = .standard) { @@ -79,6 +93,9 @@ final class MacDictationViewModel: ObservableObject { self.usageStatistics = UsageStatisticsStore(defaults: defaults) self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true + self.hotkeyTrigger = MacHotkeyTrigger( + rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? "" + ) ?? .rightOption MacICloudSyncBootstrap.configure(defaults: defaults) statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage) @@ -114,10 +131,16 @@ final class MacDictationViewModel: ObservableObject { // MARK: - Derived + var polishSelectableProviders: [LLMProvider] { + LLMProvider.userSelectablePresets + } + + var asrSelectableProviders: [LLMProvider] { + LLMProvider.asrSelectablePresets + } + var selectableProviders: [LLMProvider] { - LLMProvider.presets.filter { - $0.isUserSelectable && $0.cloudASRStrategy != .localFallback - } + asrSelectableProviders } var dictionaryTermCount: Int { @@ -185,6 +208,12 @@ final class MacDictationViewModel: ObservableObject { if enabled { hotkeyService.start() } else { hotkeyService.stop() } } + func setHotkeyTrigger(_ trigger: MacHotkeyTrigger) { + hotkeyTrigger = trigger + defaults.set(trigger.rawValue, forKey: StoredKeys.hotkeyTrigger) + hotkeyService.trigger = trigger + } + func setEngineMode(_ mode: String) { config.engineMode = mode } @@ -192,23 +221,45 @@ final class MacDictationViewModel: ObservableObject { // MARK: - Recording func toggleRecording() { - if isRecording { finishRecording() } else { beginRecording() } + if isRecording || isPreparingToRecord { + cancelOrFinishRecording() + } else { + Task { await beginRecording() } + } } - func beginRecording() { - guard !isProcessing else { return } + func beginRecording() async { + guard !isProcessing, !isRecording, !isPreparingToRecord else { return } + isPreparingToRecord = true let store = AppGroupStore(defaults: defaults) MacAppContextService.captureAndPersist(to: store) refreshForegroundAppName() do { - try recorder.start() + try await recorder.start() + // Hotkey may have been released while we awaited mic permission / + // engine start — abandon cleanly instead of latching a stuck session. + isPreparingToRecord = false + if Task.isCancelled { + _ = recorder.stop() + return + } isRecording = true transcript = "" + isStreamingPartial = false statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage) startTimers() + startLiveCaptureIfSupported(store: store) + // Tiny race: Option released between the cancel check and + // `isRecording = true`. Treat it as end-of-hold and finish. + if Task.isCancelled { + finishRecording() + } } catch { - statusMessage = error.localizedDescription + isPreparingToRecord = false + if !Task.isCancelled { + statusMessage = error.localizedDescription + } } } @@ -216,49 +267,166 @@ final class MacDictationViewModel: ObservableObject { guard isRecording else { return } isRecording = false isProcessing = true - statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage) + let hadLivePartial = isStreamingPartial + statusMessage = MacL10n.string( + hadLivePartial ? "mac.status.polishing" : "mac.status.transcribing", + language: config.uiLanguage + ) stopTimers() audioLevel = 0 let samples = recorder.stop() let store = AppGroupStore(defaults: defaults) + let liveTask = liveCaptureTask + liveCaptureTask = nil Task { [weak self] in guard let self else { return } do { - let text = try await MacDictationPipeline.run(samples: samples, store: store) - self.transcript = text - let pasted = try self.deliver(text) - self.recordUsage(for: text) - self.speechHistory.append(text: text) - self.statusMessage = self.statusAfterDelivery(pasted: pasted) + let result: MacDictationResult + if let liveTask { + let capture = await liveTask.value + let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty { + if self.transcript.isEmpty { + self.transcript = trimmedLive + } + result = try await MacDictationPipeline.polishCapturedASR( + raw: capture.raw, + store: store, + localBias: capture.localBias, + chunkWarning: capture.chunkWarning + ) + } else { + result = try await MacDictationPipeline.run( + samples: samples, + store: store, + onPartial: { [weak self] partial in + Task { @MainActor in + self?.transcript = partial + } + } + ) + } + } else { + result = try await MacDictationPipeline.run( + samples: samples, + store: store, + onPartial: { [weak self] partial in + Task { @MainActor in + self?.transcript = partial + } + } + ) + } + self.transcript = result.text + let pasted = try await self.deliver(result.text) + self.recordUsage(for: result.text) + self.speechHistory.append(text: result.text) + self.statusMessage = self.statusAfterDelivery( + pasted: pasted, + polishWarning: result.polishWarning, + chunkWarning: result.chunkWarning + ) } catch { self.statusMessage = error.localizedDescription } + self.isStreamingPartial = false self.isProcessing = false } } - private func deliver(_ text: String) throws -> Bool { - try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled) + private func startLiveCaptureIfSupported(store: AppGroupStore) { + guard MacDictationPipeline.supportsLivePartials(store: store) else { return } + let stream = recorder.makeSnapshotStream() + liveCaptureTask = Task { [weak self] in + await MacDictationPipeline.captureLive( + stream: stream, + store: store, + onPartial: { [weak self] partial in + Task { @MainActor in + guard let self else { return } + guard self.isRecording || self.isProcessing else { return } + let trimmed = partial.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.transcript = partial + self.isStreamingPartial = true + } + } + ) + } } - private func statusAfterDelivery(pasted: Bool) -> String { + private func cancelLiveCapture() { + liveCaptureTask?.cancel() + liveCaptureTask = nil + isStreamingPartial = false + } + + /// Stops an in-flight prepare, or finishes an active recording. + private func cancelOrFinishRecording() { + if isRecording { + finishRecording() + return + } + if isPreparingToRecord { + hotkeyBeginTask?.cancel() + hotkeyBeginTask = nil + // If the button-triggered prepare wasn't tracked by hotkeyBeginTask, + // still clear the preparing flag and stop any engine that raced in. + isPreparingToRecord = false + cancelLiveCapture() + _ = recorder.stop() + } + } + + private func deliver(_ text: String) async throws -> Bool { + try await MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled) + } + + private func statusAfterDelivery( + pasted: Bool, + polishWarning: String? = nil, + chunkWarning: String? = nil + ) -> String { let lang = config.uiLanguage + let base: String if autoPasteEnabled, pasted { - return MacL10n.string("mac.status.copiedAndPasted", language: lang) + base = MacL10n.string("mac.status.copiedAndPasted", language: lang) + } else if autoPasteEnabled, !pasted { + base = MacL10n.string("mac.status.copied", language: lang) + } else { + base = MacL10n.string("mac.status.copied", language: lang) } - if autoPasteEnabled, !pasted { - return MacL10n.string("mac.status.copied", language: lang) + + if let polishWarning, !polishWarning.isEmpty { + return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, polishWarning) } - return MacL10n.string("mac.status.copied", language: lang) + if let chunkWarning, !chunkWarning.isEmpty { + return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, chunkWarning) + } + return base } private func wireHotkeyService() { + hotkeyService.trigger = hotkeyTrigger hotkeyService.onPressBegan = { [weak self] in - self?.beginRecording() + guard let self else { return } + self.hotkeyBeginTask?.cancel() + self.hotkeyBeginTask = Task { [weak self] in + await self?.beginRecording() + } } hotkeyService.onPressEnded = { [weak self] in - self?.finishRecording() + guard let self else { return } + // Cancel a still-preparing start so a quick Option tap never + // latches recording. If recording already began, finish it. + if self.isRecording { + self.hotkeyBeginTask = nil + self.finishRecording() + } else { + self.hotkeyBeginTask?.cancel() + self.hotkeyBeginTask = nil + } } if hotkeyEnabled { hotkeyService.start() } } @@ -304,6 +472,10 @@ final class MacDictationViewModel: ObservableObject { config.apply(preset: provider) } + func selectAsrProvider(_ provider: LLMProvider) { + config.applyAsr(preset: provider) + } + func refreshForegroundAppName() { foregroundAppName = MacAppContextService.frontmostApplicationName() } diff --git a/OSGKeyboardMac/MacDictionaryView.swift b/OSGKeyboardMac/MacDictionaryView.swift index 1a3c80d..4656ff4 100644 --- a/OSGKeyboardMac/MacDictionaryView.swift +++ b/OSGKeyboardMac/MacDictionaryView.swift @@ -1,9 +1,8 @@ // MacDictionaryView.swift // OSGKeyboard · Mac // -// Personal dictionary synced via iCloud KVS with the iOS app. Read-only on -// the desktop (words are authored on iPhone / iPad): grouped cards (native -// `Form`) with search, matching the Settings and History card style. +// Personal dictionary synced via iCloud KVS. ScrollView is full-bleed +// (scrollbar on the window edge); title + cards share `pageHorizontalInset`. import SwiftUI @@ -45,16 +44,27 @@ struct MacDictionaryView: View { } var body: some View { - Group { - if entries.isEmpty { - emptyState - .transition(.opacity) - } else { - form - .transition(.opacity) + VStack(spacing: 0) { + MacPageHeader( + title: MacL10n.string("mac.section.dictionary", language: lang), + subtitle: MacL10n.string("mac.page.dictionary.subtitle", language: lang) + ) { + if !entries.isEmpty { + searchField + } } + + Group { + if entries.isEmpty { + emptyState + .transition(.opacity) + } else { + list + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) .background(palette.background) .animation(Motion.soft, value: entries.isEmpty) .task { @@ -64,33 +74,6 @@ struct MacDictionaryView: View { .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in viewModel.refreshDictionaryFromCloud() } - } - - // MARK: - Grouped cards - - private var form: some View { - Form { - if sections.isEmpty { - Section { - Text(MacL10n.string("mac.dict.noMatch", language: lang)) - .foregroundStyle(palette.textTertiary) - .frame(maxWidth: .infinity, alignment: .center) - } - } else { - ForEach(sections, id: \.category) { section in - Section(MacL10n.string(section.category.labelKey, language: lang)) { - ForEach(section.items) { entry in - row(entry) - } - } - } - } - } - .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), isPresented: deletionDialogBinding, @@ -110,6 +93,55 @@ struct MacDictionaryView: View { } } + // MARK: - List + + private var list: some View { + // Full-bleed ScrollView → scrollbar on the detail pane's right edge. + ScrollView { + LazyVStack(alignment: .leading, spacing: Spacing.md) { + if sections.isEmpty { + MacCard { + Text(MacL10n.string("mac.dict.noMatch", language: lang)) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, alignment: .center) + } + } else { + ForEach(sections, id: \.category) { section in + categorySection(section) + } + } + } + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.bottom, Spacing.md) + .animation(Motion.soft, value: query) + } + } + + private func categorySection( + _ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) + ) -> some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + Text(MacL10n.string(section.category.labelKey, language: lang)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + + MacCard(padding: 0) { + VStack(spacing: 0) { + ForEach(Array(section.items.enumerated()), id: \.element.id) { index, entry in + row(entry) + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + if index < section.items.count - 1 { + // Full-bleed like macOS list rows (not iOS inset separators). + Divider() + .overlay(palette.divider) + } + } + } + } + } + } + private var deletionDialogBinding: Binding { Binding( get: { entryPendingDeletion != nil }, @@ -117,25 +149,19 @@ struct MacDictionaryView: View { ) } - private var centeredSearchField: some View { - HStack { - Spacer() - HStack(spacing: Spacing.xs) { - Image(systemName: "magnifyingglass") - .foregroundStyle(palette.textTertiary) - TextField(MacL10n.string("mac.dict.search", language: lang), text: $query) - .textFieldStyle(.plain) - } - .padding(.horizontal, Spacing.sm) - .padding(.vertical, 7) - .frame(width: 240) - .macGlassSurface(in: Capsule(), fillOpacity: 0.72) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - Spacer() + private var searchField: some View { + HStack(spacing: Spacing.xs) { + Image(systemName: "magnifyingglass") + .foregroundStyle(palette.textTertiary) + TextField(MacL10n.string("mac.dict.search", language: lang), text: $query) + .textFieldStyle(.plain) + .font(TypeStyle.footnote) } - .padding(.horizontal, Spacing.lg) - .padding(.vertical, Spacing.xs) - .background(palette.background) + .padding(.horizontal, Spacing.sm) + .padding(.vertical, 6) + .frame(width: 220) + .background(palette.surface, in: Capsule()) + .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) } private func row(_ entry: PersonalDictionary.Entry) -> some View { @@ -165,19 +191,19 @@ struct MacDictionaryView: View { private var emptyState: some View { VStack(spacing: Spacing.sm) { Image(systemName: "character.book.closed") - .font(.system(size: 34)) - .foregroundStyle(palette.textTertiary.opacity(0.6)) + .font(.system(size: 34, weight: .light)) + .foregroundStyle(palette.textTertiary.opacity(0.55)) + .symbolRenderingMode(.hierarchical) Text(MacL10n.string("mac.dict.empty", language: lang)) - .font(TypeStyle.body) + .font(TypeStyle.headline) .foregroundStyle(palette.textSecondary) Text(MacL10n.string("mac.dict.emptyBody", language: lang)) - .font(TypeStyle.caption) + .font(TypeStyle.footnote) .foregroundStyle(palette.textTertiary) .multilineTextAlignment(.center) - .frame(maxWidth: 360) + .frame(maxWidth: 320) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, Spacing.xl) } private func delete(_ entry: PersonalDictionary.Entry) { diff --git a/OSGKeyboardMac/MacHistoryView.swift b/OSGKeyboardMac/MacHistoryView.swift index e02f914..bbb5dbe 100644 --- a/OSGKeyboardMac/MacHistoryView.swift +++ b/OSGKeyboardMac/MacHistoryView.swift @@ -1,9 +1,8 @@ // MacHistoryView.swift // OSGKeyboard · Mac // -// Single-column, day-grouped transcript log rendered as grouped cards (the -// same native `Form` container as Settings). Every entry shows its full text -// inline — no master/detail split, so content never pushes the sidebar out. +// Day-grouped transcript log. ScrollView is full-bleed (scrollbar on the +// window edge); title + cards share `pageHorizontalInset` on their content. import SwiftUI @@ -31,36 +30,39 @@ struct MacHistoryView: View { }() var body: some 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 - - private var form: some View { - Form { - ForEach(historyStore.groupedByDay, id: \.day) { group in - Section(Self.dayFormatter.string(from: group.day)) { - ForEach(group.items) { entry in - row(entry) + VStack(spacing: 0) { + MacPageHeader( + title: MacL10n.string("mac.section.history", language: lang), + subtitle: MacL10n.string("mac.page.history.subtitle", language: lang) + ) { + if !historyStore.entries.isEmpty { + Button { + showClearConfirmation = true + } label: { + Label( + MacL10n.string("mac.history.clearConfirm", language: lang), + systemImage: "trash" + ) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) } + .buttonStyle(.plain) } } + + Group { + if historyStore.entries.isEmpty { + emptyState + .transition(.opacity) + } else { + list + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .formStyle(.grouped) - .scrollContentBackground(.hidden) .background(palette.background) - .safeAreaInset(edge: .top, spacing: 0) { toolbar } + .animation(Motion.soft, value: historyStore.entries.isEmpty) .confirmationDialog( MacL10n.string("mac.history.clearTitle", language: lang), isPresented: $showClearConfirmation, @@ -75,21 +77,43 @@ struct MacHistoryView: View { } } - private var toolbar: some View { - HStack { - Spacer() - Button { - showClearConfirmation = true - } label: { - Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash") - .font(TypeStyle.caption) + // MARK: - List + + private var list: some View { + // Full-bleed ScrollView → scrollbar on the detail pane's right edge. + // Horizontal inset lives on the content so cards align with the title. + ScrollView { + LazyVStack(alignment: .leading, spacing: Spacing.md) { + ForEach(historyStore.groupedByDay, id: \.day) { group in + daySection(group) + } + } + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.bottom, Spacing.md) + } + } + + private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + Text(Self.dayFormatter.string(from: group.day)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + + MacCard(padding: 0) { + VStack(spacing: 0) { + ForEach(Array(group.items.enumerated()), id: \.element.id) { index, entry in + row(entry) + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + if index < group.items.count - 1 { + // Full-bleed like macOS list rows (not iOS inset separators). + Divider() + .overlay(palette.divider) + } + } + } } - .buttonStyle(.borderless) - .foregroundStyle(palette.textSecondary) } - .padding(.horizontal, Spacing.lg) - .padding(.vertical, Spacing.xs) - .background(palette.background) } private func row(_ entry: SpeechHistoryEntry) -> some View { @@ -107,11 +131,17 @@ struct MacHistoryView: View { private var emptyState: some View { VStack(spacing: Spacing.sm) { Image(systemName: "text.bubble") - .font(.system(size: 34)) - .foregroundStyle(palette.textTertiary.opacity(0.6)) + .font(.system(size: 34, weight: .light)) + .foregroundStyle(palette.textTertiary.opacity(0.55)) + .symbolRenderingMode(.hierarchical) Text(MacL10n.string("mac.history.empty", language: lang)) - .font(TypeStyle.body) + .font(TypeStyle.headline) .foregroundStyle(palette.textSecondary) + Text(MacL10n.string("mac.history.emptyBody", language: lang)) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 320) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/OSGKeyboardMac/MacHotkeyService.swift b/OSGKeyboardMac/MacHotkeyService.swift index 304c201..0554cf3 100644 --- a/OSGKeyboardMac/MacHotkeyService.swift +++ b/OSGKeyboardMac/MacHotkeyService.swift @@ -1,33 +1,93 @@ // MacHotkeyService.swift // OSGKeyboard · Mac // -// Global hold-to-talk: while Option (⌥) is held, dictation runs. Mirrors -// Typeless / SayIt push-to-talk from any foreground app. +// Global hold-to-talk: while the configured Option (⌥) key is held, dictation +// runs. Mirrors Typeless / SayIt push-to-talk from any foreground app. import AppKit import Foundation +/// Which physical Option (⌥) key triggers global hold-to-talk. +/// +/// Right Option is the default: the left key is a routine typing modifier +/// (special characters, app shortcuts), so firing on any Option press +/// constantly misfires during normal typing. +enum MacHotkeyTrigger: String, CaseIterable, Identifiable { + case rightOption + case leftOption + case eitherOption + + var id: String { rawValue } + + var labelKey: String { + switch self { + case .rightOption: return "mac.hotkeyTrigger.rightOption" + case .leftOption: return "mac.hotkeyTrigger.leftOption" + case .eitherOption: return "mac.hotkeyTrigger.eitherOption" + } + } + + /// Main-window hint under the record button — must follow the picker, + /// or the UI tells left-Option users to hold the right key. + var hintKey: String { + switch self { + case .rightOption: return "mac.hint.hold.rightOption" + case .leftOption: return "mac.hint.hold.leftOption" + case .eitherOption: return "mac.hint.hold.eitherOption" + } + } + + /// `@AppStorage`-compatible key; persisted via the view model's defaults. + static let storageKey = "mac.hotkeyTrigger" + + /// Device-dependent modifier bits (IOKit `NX_DEVICELALTKEYMASK` / + /// `NX_DEVICERALTKEYMASK`) that `.flagsChanged` events carry alongside the + /// device-independent `.option` flag, telling left and right apart. + private static let leftOptionMask: UInt = 0x20 + private static let rightOptionMask: UInt = 0x40 + + /// Whether this trigger's key is currently down in a `.flagsChanged` event. + func isPressed(in event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.option) else { return false } + let raw = event.modifierFlags.rawValue + switch self { + case .rightOption: return raw & Self.rightOptionMask != 0 + case .leftOption: return raw & Self.leftOptionMask != 0 + case .eitherOption: return true + } + } +} + @MainActor final class MacHotkeyService { + /// How long the trigger key must stay held before recording begins. + /// Filters out quick ⌥-taps and ⌥+key combos (special characters, app + /// shortcuts) that would otherwise start and immediately abort dictation. + private static let holdDebounce: Duration = .milliseconds(150) + var onPressBegan: (() -> Void)? var onPressEnded: (() -> Void)? + var trigger: MacHotkeyTrigger = .rightOption private var globalFlagsMonitor: Any? private var localFlagsMonitor: Any? - private var optionHeld = false + /// The trigger key is physically down (debounce may still be pending). + private var triggerKeyDown = false + /// `onPressBegan` has fired and `onPressEnded` is owed. + private var pressActive = false + private var pendingBegin: Task? private var isEnabled = true func setEnabled(_ enabled: Bool) { isEnabled = enabled - if !enabled, optionHeld { - optionHeld = false - onPressEnded?() - } + if !enabled { cancelPress() } } func start() { - guard globalFlagsMonitor == nil else { return } - _ = MacTextInsertionService.requestAccessibilityIfNeeded() + guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return } + // Global monitors require Accessibility; without it the call returns + // nil and Option-hold never fires outside our own windows. + let trusted = MacTextInsertionService.requestAccessibilityIfNeeded() globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in Task { @MainActor in self?.handleFlagsChanged(event) } @@ -36,6 +96,12 @@ final class MacHotkeyService { Task { @MainActor in self?.handleFlagsChanged(event) } return event } + + #if DEBUG + if !trusted || globalFlagsMonitor == nil { + NSLog("[OSGKeyboard] Hotkey global monitor unavailable — grant Accessibility in System Settings") + } + #endif } func stop() { @@ -47,20 +113,47 @@ final class MacHotkeyService { NSEvent.removeMonitor(localFlagsMonitor) self.localFlagsMonitor = nil } - if optionHeld { - optionHeld = false - onPressEnded?() - } + cancelPress() } private func handleFlagsChanged(_ event: NSEvent) { guard isEnabled else { return } - let optionDown = event.modifierFlags.contains(.option) - if optionDown, !optionHeld { - optionHeld = true - onPressBegan?() - } else if !optionDown, optionHeld { - optionHeld = false + let triggerDown = trigger.isPressed(in: event) + if triggerDown, !triggerKeyDown { + triggerKeyDown = true + scheduleBegin() + } else if !triggerDown, triggerKeyDown { + triggerKeyDown = false + pendingBegin?.cancel() + pendingBegin = nil + if pressActive { + pressActive = false + onPressEnded?() + } + } + } + + /// Debounce: begin only after the key has stayed held for `holdDebounce`. + /// Releasing the key first cancels the pending start, so a quick + /// Option+key combo never triggers recording. + private func scheduleBegin() { + pendingBegin?.cancel() + pendingBegin = Task { [weak self] in + try? await Task.sleep(for: Self.holdDebounce) + guard let self, !Task.isCancelled else { return } + self.pendingBegin = nil + guard self.isEnabled, self.triggerKeyDown, !self.pressActive else { return } + self.pressActive = true + self.onPressBegan?() + } + } + + private func cancelPress() { + pendingBegin?.cancel() + pendingBegin = nil + triggerKeyDown = false + if pressActive { + pressActive = false onPressEnded?() } } diff --git a/OSGKeyboardMac/MacLocalASRChunkAdapter.swift b/OSGKeyboardMac/MacLocalASRChunkAdapter.swift new file mode 100644 index 0000000..a3b3f04 --- /dev/null +++ b/OSGKeyboardMac/MacLocalASRChunkAdapter.swift @@ -0,0 +1,43 @@ +// MacLocalASRChunkAdapter.swift +// OSGKeyboard · Mac +// +// Adapts macOS local ASR to the shared chunked utterance pipeline. + +import Foundation +import os + +final class MacLocalASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable { + private let locale: Locale + private let bias: LocalASRBiasPayload? + private let cancelled = OSAllocatedUnfairLock(initialState: false) + + init(locale: Locale, bias: LocalASRBiasPayload?) { + self.locale = locale + self.bias = bias + } + + func resetForNewUtterance() { + cancelled.withLock { $0 = false } + } + + func cancel() { + cancelled.withLock { $0 = true } + } + + func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult { + let isCancelled = cancelled.withLock { $0 } + if isCancelled || Task.isCancelled { return .cancelled } + guard !samples.isEmpty else { return .success("") } + + do { + let text = try await MacLocalASRService.transcribe( + samples: samples, + locale: locale, + bias: bias + ) + return .success(text) + } catch { + return .failure(error.localizedDescription) + } + } +} diff --git a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift index dee9a3c..c8bf8e4 100644 --- a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift +++ b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift @@ -276,14 +276,17 @@ struct MacLocalASRModelSettingsView: View { HStack(spacing: Spacing.xs) { Text(model.displayName) .foregroundStyle(palette.textPrimary) + if let badgeKey = model.badgeKey { + modelBadge( + MacL10n.string(badgeKey, language: lang), + emphasized: true + ) + } 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()) + modelBadge( + MacL10n.string("mac.localASR.personalDictionaryTag", language: lang), + emphasized: false + ) } } Text(modelSubtitle(model, installed: installed)) @@ -305,6 +308,20 @@ struct MacLocalASRModelSettingsView: View { .padding(.vertical, 2) } + private func modelBadge(_ title: String, emphasized: Bool) -> some View { + Text(title) + .font(TypeStyle.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + emphasized + ? palette.accent.opacity(0.18) + : palette.textTertiary.opacity(0.12) + ) + .foregroundStyle(emphasized ? palette.accent : palette.textSecondary) + .clipShape(Capsule()) + } + @ViewBuilder private func modelRowActions( model: LocalASRModelDefinition, diff --git a/OSGKeyboardMac/MacLocalASRService.swift b/OSGKeyboardMac/MacLocalASRService.swift index 71154fc..7b11dd2 100644 --- a/OSGKeyboardMac/MacLocalASRService.swift +++ b/OSGKeyboardMac/MacLocalASRService.swift @@ -58,7 +58,7 @@ enum MacLocalASRPreferences { /// Maps removed catalog entries to the current default Sherpa model. static func migratedModelId(_ id: String) -> String { switch id { - case "qwen3-mlx-1.7b": + case "qwen3-mlx-1.7b", "sherpa-paraformer-zh-int8": return "sherpa-qwen3-0.6b-int8" default: return id @@ -120,7 +120,7 @@ enum MacLocalASRService { return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias) } - return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale) + return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias) } private static func transcribeWithModel( @@ -141,7 +141,7 @@ enum MacLocalASRService { bias: bias ) case .appleSpeech: - return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale) + return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias) } } } diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift index caacf77..32c3362 100644 --- a/OSGKeyboardMac/MacOnboardingView.swift +++ b/OSGKeyboardMac/MacOnboardingView.swift @@ -198,7 +198,7 @@ struct MacOnboardingView: View { .frame(maxWidth: .infinity) } } - .frame(minWidth: 860, minHeight: 600) + .frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight) .onAppear { applyDefaults() model.reload() diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift index a959dfb..fdef0aa 100644 --- a/OSGKeyboardMac/MacRootView.swift +++ b/OSGKeyboardMac/MacRootView.swift @@ -18,15 +18,6 @@ struct MacRootView: View { private var uiLanguage: AppUILanguage { viewModel.config.uiLanguage } - /// `List` selection is optional; keep the view model's non-optional section - /// in sync without letting a nil selection blank the detail pane. - private var selection: Binding { - Binding( - get: { viewModel.selectedSection }, - set: { if let new = $0 { viewModel.selectedSection = new } } - ) - } - var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { sidebar @@ -35,7 +26,7 @@ struct MacRootView: View { detail } .navigationSplitViewStyle(.balanced) - .frame(minWidth: 860, minHeight: 600) + .frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight) .onAppear { // Let the AppKit status-bar popover reopen this window on demand. MacWindowBridge.shared.open = { openWindow(id: "main") } @@ -72,7 +63,7 @@ struct MacRootView: View { .renderingMode(.template) .resizable() .scaledToFit() - .frame(height: 30) + .frame(height: 28) .foregroundStyle(palette.accent) .accessibilityLabel("OSGKeyboard") Spacer() @@ -80,7 +71,7 @@ struct MacRootView: View { .padding(.leading, MacMetrics.sidebarContentInset) .padding(.trailing, MacMetrics.sidebarInset) .padding(.top, Spacing.lg) - .padding(.bottom, Spacing.lg) + .padding(.bottom, Spacing.md) } private var devicesFooter: some View { @@ -88,10 +79,10 @@ struct MacRootView: View { MacL10n.string("mac.devices", language: uiLanguage), systemImage: "laptopcomputer.and.iphone" ) - .font(TypeStyle.caption) + .font(TypeStyle.caption2) .foregroundStyle(palette.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, MacMetrics.sidebarInset) + .padding(.horizontal, MacMetrics.sidebarInset + Spacing.sm) .padding(.vertical, Spacing.sm) } @@ -118,8 +109,8 @@ struct MacRootView: View { // MARK: - Sidebar row -/// A navigation row with an animated hover highlight and selection state, -/// matching the macOS System Settings feel. +/// Navigation row with a restrained selected state: muted accent fill + +/// accent label (not a solid green pill), matching System Settings polish. private struct MacSidebarRow: View { let section: MacSection let isSelected: Bool @@ -132,8 +123,8 @@ private struct MacSidebarRow: View { 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) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, Spacing.sm) .padding(.vertical, 7) @@ -150,7 +141,7 @@ private struct MacSidebarRow: View { } private var rowBackground: Color { - if isSelected { return palette.accent } - return isHovering ? palette.textPrimary.opacity(0.06) : .clear + if isSelected { return palette.accentMuted } + return isHovering ? palette.textPrimary.opacity(0.05) : .clear } } diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift index 2cb8fe8..808c66d 100644 --- a/OSGKeyboardMac/MacSettingsView.swift +++ b/OSGKeyboardMac/MacSettingsView.swift @@ -1,9 +1,10 @@ // MacSettingsView.swift // OSGKeyboard · Mac // -// Settings built on the native grouped `Form` — the same container macOS -// System Settings uses. This gives system-accurate cards, dividers, insets -// and right-aligned controls for free, on both light and dark. +// Settings uses native grouped `Form` for correct control layout (Picker / +// Toggle / LabeledContent). Title and Form share the same plain +// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so +// card chrome lines up with the page title. import SwiftUI #if os(macOS) @@ -20,6 +21,7 @@ struct MacSettingsView: View { private var hasCompletedMacOnboarding = true @State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted @State private var showProviderPicker = false + @State private var showAsrProviderPicker = false private var lang: AppUILanguage { viewModel.config.uiLanguage } private let recognitionLocales: [(id: String, key: String, fallback: String)] = [ @@ -33,23 +35,43 @@ struct MacSettingsView: View { var body: some View { NavigationStack { - Form { - generalSection - recognitionSection - if viewModel.config.engineMode == "cloud" { - providerSection - .transition(.opacity) + VStack(spacing: 0) { + MacPageHeader( + title: MacL10n.string("mac.section.settings", language: lang), + subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang) + ) + + Form { + generalSection + recognitionSection + polishProviderSection + if viewModel.config.engineMode == "cloud" { + asrProviderSection + .transition(.opacity) + } + if viewModel.config.engineMode == "local" { + MacLocalASRModelSettingsView(viewModel: viewModel) + .transition(.opacity) + } + inputSection + legalSection } - if viewModel.config.engineMode == "local" { - MacLocalASRModelSettingsView(viewModel: viewModel) - .transition(.opacity) - } - inputSection - legalSection + .formStyle(.grouped) + // Zero Form's own scroll margins, then inset via padding so the + // section cards line up with MacPageHeader (contentMargins alone + // does not match plain padding on macOS). + // + // grouped Form adds its own built-in section inset on top of our + // padding, so cards sat ~`groupedFormSectionInset` wider than the + // History page. Subtract that inset here so the card OUTER edge + // lands on `pageHorizontalInset` (40pt), matching History and the + // page title's left edge. + .contentMargins(.horizontal, 0, for: .scrollContent) + .padding(.horizontal, MacMetrics.pageHorizontalInset - MacMetrics.groupedFormSectionInset) + .tint(palette.accent) + .scrollContentBackground(.hidden) + .background(palette.background) } - .formStyle(.grouped) - .tint(palette.accent) - .scrollContentBackground(.hidden) .background(palette.background) } .onAppear { refreshAccessibilityState() } @@ -82,27 +104,21 @@ struct MacSettingsView: View { } } - // MARK: - Cloud provider + // MARK: - Polish LLM - private var providerSection: some View { - Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) { - LabeledContent(MacL10n.string("mac.settings.service", language: lang)) { - Button { - showProviderPicker = true - } label: { - HStack(spacing: 6) { - providerLogo(currentProvider.id) - Text(currentProvider.name) - .foregroundStyle(palette.textPrimary) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(palette.textTertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .popover(isPresented: $showProviderPicker, arrowEdge: .bottom) { - providerPickerList + private var polishProviderSection: some View { + Section(MacL10n.string("mac.settings.polishProvider", language: lang)) { + providerPickerRow( + title: MacL10n.string("mac.settings.service", language: lang), + provider: currentPolishProvider, + isPresented: $showProviderPicker + ) { + providerPickerList( + providers: viewModel.polishSelectableProviders, + selectedId: viewModel.config.providerId + ) { provider in + viewModel.selectProvider(provider) + showProviderPicker = false } } @@ -117,6 +133,17 @@ struct MacSettingsView: View { Text(MacL10n.string("mac.settings.apiKey", language: lang)) } + LabeledContent { + TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) { + Text(MacL10n.string("mac.settings.baseURL", language: lang)) + } + .labelsHidden() + .macFieldStyle() + .frame(maxWidth: MacMetrics.controlWidth) + } label: { + Text(MacL10n.string("mac.settings.baseURL", language: lang)) + } + LabeledContent { TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) { Text(MacL10n.string("mac.settings.model", language: lang)) @@ -130,6 +157,86 @@ struct MacSettingsView: View { } } + // MARK: - Cloud ASR + + private var asrProviderSection: some View { + Section(MacL10n.string("mac.settings.asrProvider", language: lang)) { + providerPickerRow( + title: MacL10n.string("mac.settings.asrService", language: lang), + provider: currentAsrProvider, + isPresented: $showAsrProviderPicker + ) { + providerPickerList( + providers: viewModel.asrSelectableProviders, + selectedId: viewModel.config.asrProviderId + ) { provider in + viewModel.selectAsrProvider(provider) + showAsrProviderPicker = false + } + } + + LabeledContent { + SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) { + Text(MacL10n.string("mac.settings.apiKey", language: lang)) + } + .labelsHidden() + .macFieldStyle() + .frame(maxWidth: MacMetrics.controlWidth) + } label: { + Text(MacL10n.string("mac.settings.asrApiKey", language: lang)) + } + + if CloudASRModelCatalog.strategy(for: viewModel.config.asrProviderId) == .prompt { + LabeledContent { + TextField(text: $viewModel.config.asrBaseURL, prompt: Text(verbatim: "")) { + Text(MacL10n.string("mac.settings.baseURL", language: lang)) + } + .labelsHidden() + .macFieldStyle() + .frame(maxWidth: MacMetrics.controlWidth) + } label: { + Text(MacL10n.string("mac.settings.baseURL", language: lang)) + } + } + + LabeledContent { + TextField(text: $viewModel.config.asrModel, prompt: Text(verbatim: "")) { + Text(MacL10n.string("mac.settings.asrModel", language: lang)) + } + .labelsHidden() + .macFieldStyle() + .frame(maxWidth: MacMetrics.controlWidth) + } label: { + Text(MacL10n.string("mac.settings.asrModel", language: lang)) + } + } + } + + private func providerPickerRow( + title: String, + provider: LLMProvider, + isPresented: Binding, + @ViewBuilder picker: @escaping () -> Content + ) -> some View { + LabeledContent(title) { + Button { + isPresented.wrappedValue = true + } label: { + HStack(spacing: 6) { + providerLogo(provider.id) + Text(provider.name) + .foregroundStyle(palette.textPrimary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .popover(isPresented: isPresented, arrowEdge: .bottom, content: picker) + } + } + // MARK: - Recognition method private var recognitionSection: some View { @@ -161,6 +268,19 @@ struct MacSettingsView: View { ) } + Picker(selection: hotkeyTriggerBinding) { + ForEach(MacHotkeyTrigger.allCases) { trigger in + Text(MacL10n.string(trigger.labelKey, language: lang)) + .tag(trigger.rawValue) + } + } label: { + rowLabel( + MacL10n.string("mac.settings.hotkeyTrigger", language: lang), + subtitle: MacL10n.string("mac.settings.hotkeyTriggerDesc", language: lang) + ) + } + .disabled(!viewModel.hotkeyEnabled) + Toggle(isOn: autoPasteBinding) { rowLabel( MacL10n.string("mac.settings.autoPaste", language: lang), @@ -258,28 +378,34 @@ struct MacSettingsView: View { .buttonStyle(.plain) } - private var currentProvider: LLMProvider { - viewModel.selectableProviders.first { $0.id == viewModel.config.providerId } - ?? viewModel.selectableProviders.first + private var currentPolishProvider: LLMProvider { + viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId } + ?? viewModel.polishSelectableProviders.first ?? LLMProvider.presets[0] } - /// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items - /// silently drop bundled (non-SF-Symbol) images on macOS, so we render the - /// brand marks in a plain view stack instead. - private var providerPickerList: some View { + private var currentAsrProvider: LLMProvider { + viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId } + ?? viewModel.asrSelectableProviders.first + ?? LLMProvider.presets[0] + } + + private func providerPickerList( + providers: [LLMProvider], + selectedId: String, + onSelect: @escaping (LLMProvider) -> Void + ) -> some View { VStack(spacing: 0) { - ForEach(viewModel.selectableProviders) { provider in + ForEach(providers) { provider in Button { - viewModel.selectProvider(provider) - showProviderPicker = false + onSelect(provider) } label: { HStack(spacing: Spacing.sm) { providerLogo(provider.id) Text(provider.name) .foregroundStyle(palette.textPrimary) Spacer(minLength: Spacing.md) - if provider.id == currentProvider.id { + if provider.id == selectedId { Image(systemName: "checkmark") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(palette.accent) @@ -359,6 +485,13 @@ struct MacSettingsView: View { ) } + private var hotkeyTriggerBinding: Binding { + Binding( + get: { viewModel.hotkeyTrigger.rawValue }, + set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) } + ) + } + private var autoPasteBinding: Binding { Binding( get: { viewModel.autoPasteEnabled }, diff --git a/OSGKeyboardMac/MacSherpaONNXRunner.swift b/OSGKeyboardMac/MacSherpaONNXRunner.swift index 740da76..c49744c 100644 --- a/OSGKeyboardMac/MacSherpaONNXRunner.swift +++ b/OSGKeyboardMac/MacSherpaONNXRunner.swift @@ -165,16 +165,69 @@ enum MacSherpaONNXRunner { .filter { !$0.isEmpty } for line in lines.reversed() { - if line.hasPrefix("{"), let data = line.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let text = object["text"] as? String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } + if line.hasPrefix("{") { + // Sherpa's JSON result line (`{"text": ..., "lang": ..., ...}`). + // Trust only its `text` field — including when it's empty + // (silence/no-speech) — and never fall through to the raw + // JSON below, or the JSON blob itself gets inserted as text. + if let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let text = object["text"] as? String { + return sanitizeTranscript(text) + } + continue } + if isMetadataNoiseLine(line) { continue } if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 { - return line + let cleaned = sanitizeTranscript(line) + if !cleaned.isEmpty { return cleaned } } } return "" } + + /// Qwen3-ASR (via sherpa-onnx) often prefixes the transcript with a + /// scaffold such as `language Chinese…`. Older runtimes leave + /// that intact in `result.text`; incomplete generations can even stop at + /// the bare word `language`. Strip the scaffold so only spoken text remains. + private static func sanitizeTranscript(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { return "" } + + // Prefer the payload after the last `` marker. + if let marker = text.range(of: "", options: .backwards) { + text = String(text[marker.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } else if let match = text.range( + of: #"^language\s+\S+\s*"#, + options: [.regularExpression, .caseInsensitive] + ) { + // Fallback when the marker token was lost but the language prefix remains. + text = String(text[match.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Drop leftover control tokens / bare scaffold words. + if isMetadataNoiseLine(text) { return "" } + return text + } + + /// Lines that are sherpa/Qwen metadata rather than spoken content. + private static func isMetadataNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + switch lowered { + case "language", "emotion", "event", "text", + "", "", "<|im_end|>": + return true + default: + // Exact scaffold with no spoken payload, e.g. "language Chinese". + if lowered.range( + of: #"^language(\s+\S+)?$"#, + options: .regularExpression + ) != nil { + return true + } + return false + } + } } diff --git a/OSGKeyboardMac/MacSpeechLocalASR.swift b/OSGKeyboardMac/MacSpeechLocalASR.swift index b30f5e3..5444a96 100644 --- a/OSGKeyboardMac/MacSpeechLocalASR.swift +++ b/OSGKeyboardMac/MacSpeechLocalASR.swift @@ -9,10 +9,65 @@ import Foundation import Speech enum MacSpeechLocalASR { - static func transcribe(samples: [Float], locale: Locale) async throws -> String { + /// Shared resume-once state for one recognition run. The recognizer + /// callback (delivered on an arbitrary Speech queue) and the timeout task + /// race to finish, and a `CheckedContinuation` must resume exactly once, + /// so both go through this lock-guarded gate. It also retains the + /// `SFSpeechRecognitionTask` so the losing/failing path can cancel it. + private final class RecognitionSession: @unchecked Sendable { + private let lock = NSLock() + private var isResumed = false + private var task: SFSpeechRecognitionTask? + private var timeoutTask: Task? + + func retain(_ task: SFSpeechRecognitionTask) { + lock.lock() + self.task = task + let alreadyResumed = isResumed + lock.unlock() + // Timeout won the race before the task handle was stored. + if alreadyResumed { task.cancel() } + } + + func retainTimeout(_ task: Task) { + lock.lock() + timeoutTask = task + let alreadyResumed = isResumed + lock.unlock() + // Recognition finished before the handle landed — stop the timer. + if alreadyResumed { task.cancel() } + } + + /// Returns `true` exactly once across all callers; the winner may + /// resume the continuation. Pass `cancellingTask: true` on failure + /// paths so the in-flight recognition stops doing work. The winner + /// also cancels the timeout task so it doesn't keep the session (and + /// continuation captures) alive for the rest of its sleep. + func claimResume(cancellingTask: Bool) -> Bool { + lock.lock() + guard !isResumed else { + lock.unlock() + return false + } + isResumed = true + let task = self.task + let timeout = timeoutTask + lock.unlock() + if cancellingTask { task?.cancel() } + timeout?.cancel() + return true + } + } + + static func transcribe(samples: [Float], locale: Locale, bias: LocalASRBiasPayload? = nil) async throws -> String { let auth = await requestAuthorization() guard auth == .authorized else { throw MacLocalASRError.speechDenied } + if Self.isChineseLocale(locale) { + CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded() + _ = try? await CustomLanguageModelManager.shared.prepareIfNeeded() + } + let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000) defer { try? FileManager.default.removeItem(at: wavURL) } @@ -20,18 +75,43 @@ enum MacSpeechLocalASR { guard let recognizer, recognizer.isAvailable else { throw MacLocalASRError.speechFailed("Speech recognizer unavailable") } + // The request below sets `requiresOnDeviceRecognition = true`, which + // fails (or worse, never produces a final result) when the on-device + // model for the locale is missing — fail fast with a clear error. + guard recognizer.supportsOnDeviceRecognition else { + throw MacLocalASRError.speechFailed( + "On-device speech recognition is not available for \(recognizer.locale.identifier). Download the language in System Settings → Keyboard → Dictation." + ) + } - return try await withCheckedThrowingContinuation { continuation in + // Overall deadline: recognition of a file is normally much faster than + // realtime, so 2× audio length with a 30 s floor is generous. Without + // it, empty audio / cancellation / a missing model can leave the + // callback silent forever and the continuation never resumes. + let audioSeconds = Double(samples.count) / 16_000 + let timeoutSeconds = max(30.0, audioSeconds * 2) + let session = RecognitionSession() + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let request = SFSpeechURLRecognitionRequest(url: wavURL) request.shouldReportPartialResults = false - request.requiresOnDeviceRecognition = true + CustomLanguageModelManager.applyCustomLanguageModel( + to: request, + locale: locale, + bias: bias + ) - recognizer.recognitionTask(with: request) { result, error in + let task = recognizer.recognitionTask(with: request) { result, error in if let error { - continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription)) + if session.claimResume(cancellingTask: true) { + continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription)) + } return } + // Non-final callbacks carry no usable transcript yet; if a + // final result never arrives, the timeout below resumes us. guard let result, result.isFinal else { return } + guard session.claimResume(cancellingTask: false) else { return } let text = result.bestTranscription.formattedString .trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { @@ -40,6 +120,16 @@ enum MacSpeechLocalASR { continuation.resume(returning: text) } } + session.retain(task) + + let timeout = Task { + try? await Task.sleep(for: .seconds(timeoutSeconds)) + guard !Task.isCancelled else { return } + if session.claimResume(cancellingTask: true) { + continuation.resume(throwing: MacLocalASRError.speechFailed("Speech recognition timed out")) + } + } + session.retainTimeout(timeout) } } @@ -58,4 +148,8 @@ enum MacSpeechLocalASR { try wav.write(to: url) return url } + + private static func isChineseLocale(_ locale: Locale) -> Bool { + locale.identifier(.bcp47).lowercased().hasPrefix("zh") + } } diff --git a/OSGKeyboardMac/MacTextInsertionService.swift b/OSGKeyboardMac/MacTextInsertionService.swift index e8d837d..b338ad8 100644 --- a/OSGKeyboardMac/MacTextInsertionService.swift +++ b/OSGKeyboardMac/MacTextInsertionService.swift @@ -1,8 +1,10 @@ // MacTextInsertionService.swift // OSGKeyboard · Mac // -// Inserts transcribed text into the frontmost app: clipboard first, then +// Inserts transcribed text into the target app: clipboard first, then // a synthetic ⌘V (SayIt / Typeless-style). Requires Accessibility trust. +// Re-activates the app the user was dictating into (the popover steals +// focus) and restores the original clipboard once the paste has landed. import AppKit @preconcurrency import ApplicationServices @@ -30,32 +32,158 @@ enum MacTextInsertionService { return AXIsProcessTrustedWithOptions(options) } - /// Copy to pasteboard and optionally simulate ⌘V in the front app. + // MARK: - Paste-target tracking + + /// Start observing app activations early (app launch) so a paste target + /// can still be recovered while OSGKeyboard itself is frontmost — e.g. + /// when a recording is started from the menu-bar popover. + @MainActor + static func beginTrackingFrontmostApp() { + _ = FrontmostAppTracker.shared + } + + /// The app a synthesized ⌘V should land in: the current frontmost app, + /// or — when OSGKeyboard is frontmost because the popover has key + /// focus — the app that was active immediately before it. + @MainActor + static func captureTargetApplication() -> NSRunningApplication? { + let selfPid = NSRunningApplication.current.processIdentifier + if let front = NSWorkspace.shared.frontmostApplication, + front.processIdentifier != selfPid { + return front + } + return FrontmostAppTracker.shared.lastExternalApp + } + + // MARK: - Insertion + + /// Copy to pasteboard and optionally simulate ⌘V in the target app. + /// Returns `true` only if the ⌘V event was actually synthesized. After a + /// successful paste the user's original clipboard is put back, so + /// dictation never permanently clobbers it. + @MainActor static func insert( _ text: String, - autoPaste: Bool - ) throws -> Bool { + autoPaste: Bool, + targetApp: NSRunningApplication? = nil + ) async throws -> Bool { guard !text.isEmpty else { return false } let pasteboard = NSPasteboard.general + let snapshot = snapshotItems(of: pasteboard) pasteboard.clearContents() pasteboard.setString(text, forType: .string) guard autoPaste else { return false } guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted } - Thread.sleep(forTimeInterval: 0.08) - postCommandV() + // Make sure ⌘V lands in the app the user was dictating into, not in + // OSGKeyboard's own popover / window. + if let targetApp { await activate(targetApp) } + try? await Task.sleep(nanoseconds: 80_000_000) + guard postCommandV() else { return false } + + // Give the target app time to read the transcript off the + // pasteboard, then restore whatever the user had on it. + try? await Task.sleep(nanoseconds: 300_000_000) + restoreItems(snapshot, to: pasteboard) return true } - private static func postCommandV() { + /// Brings `app` forward and waits (up to ~1 s) until it is frontmost so + /// the synthesized keystroke isn't swallowed mid-switch. + @MainActor + private static func activate(_ app: NSRunningApplication) async { + func isFront() -> Bool { + NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier + } + guard !isFront() else { return } + app.activate() + var attempts = 0 + while !isFront(), attempts < 20 { + try? await Task.sleep(nanoseconds: 50_000_000) + attempts += 1 + } + } + + // MARK: - Pasteboard preservation + + /// Every representation of every pasteboard item, so restore round-trips + /// rich content (images, files, multiple flavours) losslessly. + private static func snapshotItems( + of pasteboard: NSPasteboard + ) -> [[NSPasteboard.PasteboardType: Data]] { + (pasteboard.pasteboardItems ?? []).map { item in + item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { flavours, type in + flavours[type] = item.data(forType: type) + } + } + } + + private static func restoreItems( + _ items: [[NSPasteboard.PasteboardType: Data]], + to pasteboard: NSPasteboard + ) { + guard !items.isEmpty else { return } + pasteboard.clearContents() + pasteboard.writeObjects(items.map { flavours in + let item = NSPasteboardItem() + for (type, data) in flavours { item.setData(data, forType: type) } + return item + }) + } + + /// Returns `false` when the CGEvents could not be created — in that case + /// nothing was pasted and callers must not report success. + private static func postCommandV() -> Bool { let source = CGEventSource(stateID: .combinedSessionState) let keyCode = CGKeyCode(kVK_ANSI_V) - let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true) - let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) - keyDown?.flags = CGEventFlags.maskCommand - keyUp?.flags = CGEventFlags.maskCommand - keyDown?.post(tap: CGEventTapLocation.cghidEventTap) - keyUp?.post(tap: CGEventTapLocation.cghidEventTap) + guard + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) + else { return false } + keyDown.flags = CGEventFlags.maskCommand + keyUp.flags = CGEventFlags.maskCommand + keyDown.post(tap: CGEventTapLocation.cghidEventTap) + keyUp.post(tap: CGEventTapLocation.cghidEventTap) + return true + } +} + +// MARK: - Frontmost-app tracker + +/// Remembers the most recent non-OSGKeyboard frontmost app. Needed because +/// the menu-bar popover activates OSGKeyboard, hiding the real paste target +/// from `NSWorkspace.frontmostApplication`. +@MainActor +private final class FrontmostAppTracker: NSObject { + static let shared = FrontmostAppTracker() + + private(set) var lastExternalApp: NSRunningApplication? + + private override init() { + super.init() + // Seed with whatever is frontmost now (usually not us at launch). + if let front = NSWorkspace.shared.frontmostApplication, + front.processIdentifier != NSRunningApplication.current.processIdentifier { + lastExternalApp = front + } + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(appDidActivate(_:)), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + } + + deinit { + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + @objc private func appDidActivate(_ notification: Notification) { + guard + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.processIdentifier != NSRunningApplication.current.processIdentifier + else { return } + lastExternalApp = app } } diff --git a/OSGKeyboardMac/MacTheme.swift b/OSGKeyboardMac/MacTheme.swift index 8dfba7e..04c101d 100644 --- a/OSGKeyboardMac/MacTheme.swift +++ b/OSGKeyboardMac/MacTheme.swift @@ -6,7 +6,8 @@ // 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. +// grey on macOS); Dark mode uses stepped elevated greys so cards stay +// readable against the page background. import AppKit import SwiftUI @@ -33,8 +34,8 @@ enum MacSystemPalette { surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark), accent: Palette.accent, - accentMuted: Palette.accent.opacity(0.16), - accentGlow: Palette.accent.opacity(0.35), + accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14), + accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32), danger: resolved(.systemRed, dark: dark), success: Palette.accent, @@ -45,8 +46,12 @@ enum MacSystemPalette { textTertiary: resolved(.tertiaryLabelColor, dark: dark), textOnAccent: Color.white, - divider: resolved(.separatorColor, dark: dark), - dividerStrong: resolved(.separatorColor, dark: dark), + divider: dark + ? Color.white.opacity(0.08) + : Color.black.opacity(0.06), + dividerStrong: dark + ? Color.white.opacity(0.12) + : Color.black.opacity(0.10), recordRed: resolved(.systemRed, dark: dark) ) diff --git a/OSGKeyboardMac/OSGKeyboardMacApp.swift b/OSGKeyboardMac/OSGKeyboardMacApp.swift index 5f1af5a..2850e99 100644 --- a/OSGKeyboardMac/OSGKeyboardMacApp.swift +++ b/OSGKeyboardMac/OSGKeyboardMacApp.swift @@ -57,7 +57,9 @@ struct OSGKeyboardMacApp: App { // very top, matching macOS System Settings. .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) - .defaultSize(width: 1_024, height: 720) + // Open at the minimum size — same as `MacMetrics.windowMin*`, so the + // first launch already matches the smallest allowed window. + .defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight) } } @@ -94,8 +96,11 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { MacAppearancePreference.applyToApp(.current) + MacTextInsertionService.beginTrackingFrontmostApp() configurePopover() configureStatusItem() + MacDictationOverlayController.shared.start(observing: MacDictationViewModel.shared) + CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded() // The menu bar always follows the *system* appearance, so the status // item must ignore the app's forced light/dark override. Re-pin the diff --git a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift index 1f81e24..45359c2 100644 --- a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift +++ b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift @@ -13,10 +13,18 @@ import Foundation /// Keep this protocol narrow: only what the shared pipeline needs today. /// Platform-specific settings UI and iCloud sync stay on concrete stores. public protocol ConfigurationStore: Sendable { + /// Polish / LLM provider id. var providerId: String { get } var baseURL: String { get } var apiKey: String { get } var model: String { get } + + /// Cloud ASR provider id — independent from polish when `engineMode == "cloud"`. + var asrProviderId: String { get } + var asrBaseURL: String { get } + var asrApiKey: String { get } + var asrModel: String { get } + var engineMode: String { get } var polishIntensity: PolishIntensity { get } var personalDictionary: PersonalDictionary { get } diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift index dd656a7..5ef1d57 100644 --- a/OSGKeyboardShared/DesignSystem/Theme.swift +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -180,6 +180,8 @@ public enum TypeStyle { public static let title3 = Font.system(size: 20, weight: .semibold) public static let title2 = Font.system(size: 22, weight: .bold) public static let title = Font.system(size: 28, weight: .bold) + /// Home brand line + History / Dictionary / Settings page titles. + public static let pageTitle = Font.system(size: 30, weight: .semibold) public static let largeTitle = Font.system(size: 34, weight: .bold) /// Subtle status line under the brand mark (home header). public static let status = Font.system(size: 13, weight: .regular) diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index cb656f2..126acf4 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -16,6 +16,10 @@ public struct AppGroupConfiguration: Sendable, Equatable { /// Legacy plaintext slot — migrated to Keychain on first read. public static let apiKeyLegacy = "config.apiKey" public static let model = "config.model" + /// Cloud ASR provider — independent from polish `providerId`. + public static let asrProviderId = "config.asrProviderId" + public static let asrBaseURL = "config.asrBaseURL" + public static let asrModel = "config.asrModel" public static let modeId = "config.modeId" public static let localeId = "config.localeId" public static let engineMode = "config.engineMode" @@ -51,6 +55,10 @@ public struct AppGroupConfiguration: Sendable, Equatable { public var providerId: String public var baseURL: String public var model: String + /// Cloud-engine speech-to-text provider (OpenLess-style split from polish). + public var asrProviderId: String + public var asrBaseURL: String + public var asrModel: String public var modeId: String public var localeId: String public var engineMode: String @@ -95,18 +103,32 @@ public struct AppGroupConfiguration: Sendable, Equatable { : .polish } - /// Local engine pins the LLM step to DeepSeek; cloud uses the user's provider. - public var polishProviderIdOverride: String? { - engineMode == "local" ? "deepseek" : nil - } + /// Polish LLM provider. Local engine no longer pins DeepSeek — user picks in Settings. + public var polishProviderIdOverride: String? { nil } - public var isCloudAPIKeyMissingForVoiceInput: Bool { + public var isCloudLLMKeyMissing: Bool { guard engineMode == "cloud" else { return false } return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - /// API key lives in the Keychain (cross-process, encrypted at rest). - /// When settings iCloud sync is on, reads synchronizable Keychain items first. + public var isCloudASRKeyMissing: Bool { + guard engineMode == "cloud" else { return false } + return asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + public var isPolishKeyMissing: Bool { + if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return false + } + return !PreconfiguredKeys.isDeepseekConfigured + } + + public var isCloudAPIKeyMissingForVoiceInput: Bool { + guard engineMode == "cloud" else { return false } + return isCloudASRKeyMissing || isCloudLLMKeyMissing + } + + /// Polish LLM uses `providerId` + Keychain `provider.`. public var apiKey: String { Self.resolveAPIKey( defaults: nil, @@ -115,6 +137,15 @@ public struct AppGroupConfiguration: Sendable, Equatable { ) } + /// Cloud ASR uses `asrProviderId` + Keychain `asr.` (falls back to legacy `provider.`). + public var asrApiKey: String { + Self.resolveASRAPIKey( + defaults: nil, + providerId: asrProviderId, + preferICloudSync: settingsICloudSyncEnabled + ) + } + public func makeClient() -> LLMClient { OpenAICompatibleClient( baseURL: baseURL, @@ -123,6 +154,20 @@ public struct AppGroupConfiguration: Sendable, Equatable { ) } + /// Resolved cloud ASR model — user override or catalog default. + public var resolvedASRModel: String { + let trimmed = asrModel.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + return CloudASRModelCatalog.defaultModel(for: asrProviderId) + } + + /// Resolved cloud ASR base URL for prompt-style providers. + public var resolvedASRBaseURL: String { + let trimmed = asrBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + return LLMProvider.provider(id: asrProviderId).defaultBaseURL + } + // MARK: - Detected app context public func detectedAppContext(from defaults: UserDefaults) -> (context: AppContext, observedAt: Date)? { @@ -152,9 +197,18 @@ public struct AppGroupConfiguration: Sendable, Equatable { providerId: defaults.string(forKey: Keys.providerId) ?? "openai", baseURL: "", model: "", + asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "", + asrBaseURL: "", + asrModel: "", modeId: defaults.string(forKey: Keys.modeId) ?? "polish", localeId: defaults.string(forKey: Keys.localeId) ?? "auto", - engineMode: defaults.string(forKey: Keys.engineMode) ?? "cloud", + // Privacy-critical default: `local` keeps raw audio on-device + // (SpeechAnalyzer). The `cloud` engine uploads recorded audio to + // the user's configured ASR provider and must stay an explicit, + // acknowledged opt-in (see `hasAcknowledgedCloudSharing`) — a + // cloud default would contradict every privacy claim the app + // makes in its docs, App Store listing, and permission prompts. + engineMode: defaults.string(forKey: Keys.engineMode) ?? "local", hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding), onboardingPage: { let saved = defaults.integer(forKey: Keys.onboardingPage) @@ -212,6 +266,19 @@ public struct AppGroupConfiguration: Sendable, Equatable { config.model = defaults.string(forKey: Keys.model) ?? preset.defaultModel } + if config.asrProviderId.isEmpty { + config.asrProviderId = config.providerId + defaults.set(config.asrProviderId, forKey: Keys.asrProviderId) + } + let asrPreset = LLMProvider.provider(id: config.asrProviderId) + if config.asrBaseURL.isEmpty { + config.asrBaseURL = defaults.string(forKey: Keys.asrBaseURL) ?? asrPreset.defaultBaseURL + } + if config.asrModel.isEmpty { + config.asrModel = defaults.string(forKey: Keys.asrModel) + ?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId) + } + // One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain. _ = resolveAPIKey( defaults: defaults, @@ -219,6 +286,26 @@ public struct AppGroupConfiguration: Sendable, Equatable { preferICloudSync: config.settingsICloudSyncEnabled ) + // One-shot default migration for installs that predate an explicit + // stored value. The privacy-safe defaults ("local", 30 min TTL) are + // for NEW installs only — an existing user who ran on the old + // defaults must keep their behavior, both because silently changing + // engines under someone is wrong, and because iCloud settings sync + // would stamp the flip as a fresh "edit" and propagate it to every + // other device, overriding choices made there. Persisting the + // resolved value makes the decision stable and sync-invisible. + let isExistingInstall = defaults.bool(forKey: Keys.hasCompletedOnboarding) + if defaults.string(forKey: Keys.engineMode) == nil { + let resolved = isExistingInstall ? "cloud" : "local" + config.engineMode = resolved + defaults.set(resolved, forKey: Keys.engineMode) + } + if defaults.string(forKey: Keys.flowInactivityDuration) == nil { + let resolved: FlowInactivityDuration = isExistingInstall ? .twelveHours : .default + config.flowInactivityDuration = resolved + defaults.set(resolved.rawValue, forKey: Keys.flowInactivityDuration) + } + // Cloud no longer exposes off/transcribe; migrate legacy values. if config.engineMode == "cloud", config.modeId != "polish" { config.modeId = "polish" @@ -234,6 +321,15 @@ public struct AppGroupConfiguration: Sendable, Equatable { defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL) defaults.set(openAI.defaultModel, forKey: Keys.model) } + if config.engineMode == "cloud", config.asrProviderId == "deepseek" { + let openAI = LLMProvider.provider(id: "openai") + config.asrProviderId = openAI.id + config.asrBaseURL = openAI.defaultBaseURL + config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id) + defaults.set(openAI.id, forKey: Keys.asrProviderId) + defaults.set(openAI.defaultBaseURL, forKey: Keys.asrBaseURL) + defaults.set(openAI.defaultModel, forKey: Keys.asrModel) + } return config } @@ -242,6 +338,9 @@ public struct AppGroupConfiguration: Sendable, Equatable { defaults.set(providerId, forKey: Keys.providerId) defaults.set(baseURL, forKey: Keys.baseURL) defaults.set(model, forKey: Keys.model) + defaults.set(asrProviderId, forKey: Keys.asrProviderId) + defaults.set(asrBaseURL, forKey: Keys.asrBaseURL) + defaults.set(asrModel, forKey: Keys.asrModel) defaults.set(modeId, forKey: Keys.modeId) defaults.set(localeId, forKey: Keys.localeId) defaults.set(engineMode, forKey: Keys.engineMode) @@ -328,4 +427,16 @@ public struct AppGroupConfiguration: Sendable, Equatable { } return "" } + + static func resolveASRAPIKey( + defaults: UserDefaults?, + providerId: String, + preferICloudSync: Bool = false + ) -> String { + if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty { + return stored + } + // Pre-split installs: one shared key under `provider.`. + return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync) + } } diff --git a/OSGKeyboardShared/Models/CloudProviderRole.swift b/OSGKeyboardShared/Models/CloudProviderRole.swift new file mode 100644 index 0000000..cdade49 --- /dev/null +++ b/OSGKeyboardShared/Models/CloudProviderRole.swift @@ -0,0 +1,12 @@ +// CloudProviderRole.swift +// OSGKeyboard · Shared +// +// Distinguishes cloud ASR credentials from polish LLM credentials +// (OpenLess-style split). + +import Foundation + +public enum CloudProviderRole: String, Sendable, Equatable { + case asr + case polish +} diff --git a/OSGKeyboardShared/Models/FlowInactivityDuration.swift b/OSGKeyboardShared/Models/FlowInactivityDuration.swift index 51b6a89..d62269d 100644 --- a/OSGKeyboardShared/Models/FlowInactivityDuration.swift +++ b/OSGKeyboardShared/Models/FlowInactivityDuration.swift @@ -15,7 +15,12 @@ public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable public var id: String { rawValue } - public static let `default`: FlowInactivityDuration = .twelveHours + /// 30 minutes, not hours: competitors cap sessions at 5–60 min for a + /// reason — a very long TTL keeps advertising "session active" long after + /// the host process is likely suspended or dead, amplifying every stale- + /// state bug into hours of confusing UI. Users can still opt into longer + /// windows explicitly. + public static let `default`: FlowInactivityDuration = .thirtyMinutes public var timeInterval: TimeInterval { switch self { diff --git a/OSGKeyboardShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift index d5eed43..a998b14 100644 --- a/OSGKeyboardShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -108,4 +108,11 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { public static var userSelectablePresets: [LLMProvider] { presets.filter(\.isUserSelectable) } + + /// Cloud ASR presets (excludes providers without a cloud transcription API). + public static var asrSelectablePresets: [LLMProvider] { + userSelectablePresets.filter { + CloudASRModelCatalog.strategy(for: $0.id) != .localFallback + } + } } diff --git a/OSGKeyboardShared/Models/LocalASRModelCatalog.swift b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift index beb67d1..614e032 100644 --- a/OSGKeyboardShared/Models/LocalASRModelCatalog.swift +++ b/OSGKeyboardShared/Models/LocalASRModelCatalog.swift @@ -75,6 +75,9 @@ public struct LocalASRModelDefinition: Codable, Sendable, Equatable, Identifiabl public let recommendedLocales: [String] public let supportsHotwords: Bool public let hotwordMode: LocalASRHotwordMode + /// Optional localization key for a short quality/speed badge + /// (e.g. `mac.localASR.badge.fastest`). + public let badgeKey: String? public let installKind: LocalASRInstallKind public let installRelativePath: String? public let archiveBaseName: String? diff --git a/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift b/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift index 14318fb..32398cb 100644 --- a/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift +++ b/OSGKeyboardShared/Models/PersonalDictionary+Merging.swift @@ -9,7 +9,16 @@ import Foundation extension PersonalDictionary { public static let kvsKeyV2 = "personalDictionary.v2" public static let legacyKVSKey = "personalDictionary.v1" - public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60 + /// Tombstones guard against deleted entries "resurrecting" when a + /// long-offline device rejoins and re-merges them. A short wall-clock + /// retention re-opened that window after only 90 days; a year keeps the + /// window closed for any realistically dormant device while staying tiny + /// on the wire (a tombstone is ~60 bytes of JSON), and the count cap + /// bounds the worst case regardless of clock. + public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60 + /// Hard cap independent of wall clock — the oldest tombstones are + /// dropped first once exceeded. + public static let maxTombstones = 500 /// Merges two dictionary snapshots for cross-device sync. /// @@ -100,11 +109,19 @@ extension PersonalDictionary { clearedAt: Date? ) -> [UUID: Date] { let cutoff = Date().addingTimeInterval(-tombstoneRetention) - return tombstones.filter { _, deletedAt in + var kept = tombstones.filter { _, deletedAt in if deletedAt < cutoff { return false } if let clearedAt, deletedAt <= clearedAt { return false } return true } + // Enforce the count cap that makes the 365-day retention safe on the + // KVS byte budget: keep the NEWEST tombstones (dropping an old one + // early only re-opens the resurrection window for that one entry). + if kept.count > maxTombstones { + let newest = kept.sorted { $0.value > $1.value }.prefix(maxTombstones) + kept = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0.value) }) + } + return kept } private static func later(of lhs: Date?, and rhs: Date?) -> Date? { diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 6bf4839..a47dca3 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -53,6 +53,44 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { persistConfiguration() } } + @Published public var asrProviderId: String { + didSet { + guard !isApplyingConfiguration, asrProviderId != configuration.asrProviderId else { return } + configuration.asrProviderId = asrProviderId + isSyncingASRProviderAPIKey = true + asrApiKey = configuration.asrApiKey + isSyncingASRProviderAPIKey = false + persistConfiguration() + } + } + @Published public var asrBaseURL: String { + didSet { + guard !isApplyingConfiguration, asrBaseURL != configuration.asrBaseURL else { return } + configuration.asrBaseURL = asrBaseURL + persistConfiguration() + } + } + @Published public var asrApiKey: String { + didSet { + guard oldValue != asrApiKey, !isSyncingASRProviderAPIKey else { return } + do { + try Keychain.setASRAPIKey( + asrApiKey, + for: asrProviderId, + useICloudSync: configuration.settingsICloudSyncEnabled + ) + } catch { + OSGLog.config.warning("ASR Keychain write failed: \(error.localizedDescription, privacy: .public)") + } + } + } + @Published public var asrModel: String { + didSet { + guard !isApplyingConfiguration, asrModel != configuration.asrModel else { return } + configuration.asrModel = asrModel + persistConfiguration() + } + } @Published public var modeId: String { didSet { guard !isApplyingConfiguration, modeId != configuration.modeId else { return } @@ -67,8 +105,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { persistConfiguration() } } - /// "local" → on-device ASR + built-in DeepSeek polish. - /// "cloud" → provider cloud ASR (with personal dictionary) + user's cloud LLM polish. + /// "local" → on-device ASR + user's LLM polish (or built-in DeepSeek). + /// "cloud" → user's cloud ASR + user's cloud LLM polish (independent picks). @Published public var engineMode: String { didSet { guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return } @@ -213,25 +251,38 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } public var isConfigured: Bool { - // Local engine uses on-device ASR + built-in DeepSeek polish and - // does not need a user API key. Cloud needs base URL, key, and model. - if isLocalEngine { return true } - return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty + if isLocalEngine { + return isPolishConfigured + } + return isASRConfigured && isPolishConfigured + } + + public var isPolishConfigured: Bool { + if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return !baseURL.isEmpty && !model.isEmpty + } + return PreconfiguredKeys.isDeepseekConfigured + } + + public var isASRConfigured: Bool { + guard !isLocalEngine else { return true } + return !asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt) } /// On-device ASR only; no cloud API required. public var isLocalEngine: Bool { configuration.isLocalEngine } - /// Local engine always polishes via the built-in DeepSeek path. - public var shouldPolishLocalTranscript: Bool { isLocalEngine } - - /// Cloud engine uses `providerId`. Local engine pins DeepSeek. - public var localModeProviderId: String { "deepseek" } + /// Built-in DeepSeek path when the user has not supplied their own LLM key. + public var localModeProviderId: String { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "deepseek" : providerId + } private let defaults: UserDefaults private var configuration: AppGroupConfiguration private var isApplyingConfiguration = false private var isSyncingProviderAPIKey = false + private var isSyncingASRProviderAPIKey = false public init(defaults: UserDefaults? = nil) { guard let resolvedDefaults = defaults ?? AppGroup.defaultsIfAvailable else { @@ -270,6 +321,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { baseURL = configuration.baseURL apiKey = configuration.apiKey model = configuration.model + asrProviderId = configuration.asrProviderId + asrBaseURL = configuration.asrBaseURL + asrModel = configuration.asrModel modeId = configuration.modeId localeId = configuration.localeId engineMode = configuration.engineMode @@ -284,6 +338,12 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { flowSkipAppSwitch = configuration.flowSkipAppSwitch flowInactivityDuration = configuration.flowInactivityDuration localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled + isSyncingProviderAPIKey = true + apiKey = configuration.apiKey + isSyncingProviderAPIKey = false + isSyncingASRProviderAPIKey = true + asrApiKey = configuration.asrApiKey + isSyncingASRProviderAPIKey = false isApplyingConfiguration = false } @@ -293,6 +353,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { if engineMode == "cloud", providerId == "deepseek" { apply(preset: LLMProvider.provider(id: "openai")) } + if engineMode == "cloud", asrProviderId == "deepseek" { + applyAsr(preset: LLMProvider.provider(id: "openai")) + } } private func persistConfiguration(postConfigChanged: Bool = false) { @@ -324,6 +387,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { providerId = fresh.providerId baseURL = fresh.baseURL model = fresh.model + asrProviderId = fresh.asrProviderId + asrBaseURL = fresh.asrBaseURL + asrModel = fresh.asrModel modeId = fresh.modeId localeId = fresh.localeId engineMode = fresh.engineMode @@ -341,6 +407,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isSyncingProviderAPIKey = true apiKey = fresh.apiKey isSyncingProviderAPIKey = false + isSyncingASRProviderAPIKey = true + asrApiKey = fresh.asrApiKey + isSyncingASRProviderAPIKey = false isApplyingConfiguration = false } @@ -370,6 +439,23 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { persistConfiguration() } + public func applyAsr(preset: LLMProvider) { + isApplyingConfiguration = true + asrProviderId = preset.id + if !preset.defaultBaseURL.isEmpty { + asrBaseURL = preset.defaultBaseURL + } + asrModel = CloudASRModelCatalog.defaultModel(for: preset.id) + configuration.asrProviderId = asrProviderId + configuration.asrBaseURL = asrBaseURL + configuration.asrModel = asrModel + isSyncingASRProviderAPIKey = true + asrApiKey = configuration.asrApiKey + isSyncingASRProviderAPIKey = false + isApplyingConfiguration = false + persistConfiguration() + } + public func reset() { isApplyingConfiguration = true let preset = LLMProvider.provider(id: "openai") @@ -377,12 +463,19 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { baseURL = preset.defaultBaseURL apiKey = "" model = preset.defaultModel + asrProviderId = preset.id + asrBaseURL = preset.defaultBaseURL + asrModel = CloudASRModelCatalog.defaultModel(for: preset.id) + asrApiKey = "" handednessPreference = .left localASRCustomLanguageModelEnabled = true hasAcknowledgedCloudSharing = false configuration.providerId = preset.id configuration.baseURL = preset.defaultBaseURL configuration.model = preset.defaultModel + configuration.asrProviderId = preset.id + configuration.asrBaseURL = preset.defaultBaseURL + configuration.asrModel = CloudASRModelCatalog.defaultModel(for: preset.id) configuration.handednessPreference = .left configuration.localASRCustomLanguageModelEnabled = true configuration.hasAcknowledgedCloudSharing = false diff --git a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift index 131e6f9..643f12c 100644 --- a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift +++ b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift @@ -14,6 +14,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { public var providerId: SyncedField public var baseURL: SyncedField public var model: SyncedField + /// Cloud ASR provider — independent from polish `providerId`. + public var asrProviderId: SyncedField + public var asrBaseURL: SyncedField + public var asrModel: SyncedField public var modeId: SyncedField public var localeId: SyncedField public var engineMode: SyncedField @@ -31,6 +35,9 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { providerId: SyncedField, baseURL: SyncedField, model: SyncedField, + asrProviderId: SyncedField, + asrBaseURL: SyncedField, + asrModel: SyncedField, modeId: SyncedField, localeId: SyncedField, engineMode: SyncedField, @@ -47,6 +54,9 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { self.providerId = providerId self.baseURL = baseURL self.model = model + self.asrProviderId = asrProviderId + self.asrBaseURL = asrBaseURL + self.asrModel = asrModel self.modeId = modeId self.localeId = localeId self.engineMode = engineMode @@ -60,12 +70,88 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { self.flowInactivityDuration = flowInactivityDuration } + private enum CodingKeys: String, CodingKey { + case schemaVersion + case providerId + case baseURL + case model + case asrProviderId + case asrBaseURL + case asrModel + case modeId + case localeId + case engineMode + case hasAcknowledgedCloudSharing + case uiLanguage + case translationTargetLocaleId + case handednessPreference + case cursorDragNavigationEnabled + case polishIntensity + case flowSkipAppSwitch + case flowInactivityDuration + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + providerId = try container.decode(SyncedField.self, forKey: .providerId) + baseURL = try container.decode(SyncedField.self, forKey: .baseURL) + model = try container.decode(SyncedField.self, forKey: .model) + modeId = try container.decode(SyncedField.self, forKey: .modeId) + localeId = try container.decode(SyncedField.self, forKey: .localeId) + engineMode = try container.decode(SyncedField.self, forKey: .engineMode) + hasAcknowledgedCloudSharing = try container.decode(SyncedField.self, forKey: .hasAcknowledgedCloudSharing) + uiLanguage = try container.decode(SyncedField.self, forKey: .uiLanguage) + translationTargetLocaleId = try container.decode( + SyncedField.self, + forKey: .translationTargetLocaleId + ) + handednessPreference = try container.decode( + SyncedField.self, + forKey: .handednessPreference + ) + cursorDragNavigationEnabled = try container.decode( + SyncedField.self, + forKey: .cursorDragNavigationEnabled + ) + polishIntensity = try container.decode(SyncedField.self, forKey: .polishIntensity) + flowSkipAppSwitch = try container.decode(SyncedField.self, forKey: .flowSkipAppSwitch) + flowInactivityDuration = try container.decode( + SyncedField.self, + forKey: .flowInactivityDuration + ) + + if let asrProvider = try container.decodeIfPresent(SyncedField.self, forKey: .asrProviderId) { + asrProviderId = asrProvider + } else { + asrProviderId = providerId + } + if let asrURL = try container.decodeIfPresent(SyncedField.self, forKey: .asrBaseURL) { + asrBaseURL = asrURL + } else { + asrBaseURL = baseURL + } + if let asrModelField = try container.decodeIfPresent(SyncedField.self, forKey: .asrModel) { + asrModel = asrModelField + } else { + let fallbackModel = CloudASRModelCatalog.defaultModel(for: providerId.value) + asrModel = SyncedField( + value: fallbackModel, + updatedAt: model.updatedAt, + deviceID: model.deviceID + ) + } + } + /// Monotonic stamp used for `settingsCloudUpdatedAt` bookkeeping. public var latestUpdatedAt: Date { [ providerId.updatedAt, baseURL.updatedAt, model.updatedAt, + asrProviderId.updatedAt, + asrBaseURL.updatedAt, + asrModel.updatedAt, modeId.updatedAt, localeId.updatedAt, engineMode.updatedAt, @@ -99,6 +185,9 @@ public extension SyncedAppSettingsV2 { providerId: field(configuration.providerId), baseURL: field(configuration.baseURL), model: field(configuration.model), + asrProviderId: field(configuration.asrProviderId), + asrBaseURL: field(configuration.asrBaseURL), + asrModel: field(configuration.asrModel), modeId: field(configuration.modeId), localeId: field(configuration.localeId), engineMode: field(configuration.engineMode), @@ -119,10 +208,14 @@ public extension SyncedAppSettingsV2 { func field(_ value: T) -> SyncedField { SyncedField(value: value, updatedAt: stamp, deviceID: deviceID) } + let provider = field(legacy.providerId) return SyncedAppSettingsV2( - providerId: field(legacy.providerId), + providerId: provider, baseURL: field(legacy.baseURL), model: field(legacy.model), + asrProviderId: provider, + asrBaseURL: field(legacy.baseURL), + asrModel: field(CloudASRModelCatalog.defaultModel(for: legacy.providerId)), modeId: field(legacy.modeId), localeId: field(legacy.localeId), engineMode: field(legacy.engineMode), @@ -142,6 +235,9 @@ public extension SyncedAppSettingsV2 { providerId: .merge(local: local.providerId, remote: remote.providerId), baseURL: .merge(local: local.baseURL, remote: remote.baseURL), model: .merge(local: local.model, remote: remote.model), + asrProviderId: .merge(local: local.asrProviderId, remote: remote.asrProviderId), + asrBaseURL: .merge(local: local.asrBaseURL, remote: remote.asrBaseURL), + asrModel: .merge(local: local.asrModel, remote: remote.asrModel), modeId: .merge(local: local.modeId, remote: remote.modeId), localeId: .merge(local: local.localeId, remote: remote.localeId), engineMode: .merge(local: local.engineMode, remote: remote.engineMode), @@ -172,6 +268,9 @@ public extension SyncedAppSettingsV2 { configuration.providerId = providerId.value configuration.baseURL = baseURL.value configuration.model = model.value + configuration.asrProviderId = asrProviderId.value + configuration.asrBaseURL = asrBaseURL.value + configuration.asrModel = asrModel.value configuration.modeId = modeId.value configuration.localeId = localeId.value configuration.engineMode = engineMode.value @@ -195,6 +294,9 @@ public extension SyncedAppSettingsV2 { patch(©.providerId, value: configuration.providerId) patch(©.baseURL, value: configuration.baseURL) patch(©.model, value: configuration.model) + patch(©.asrProviderId, value: configuration.asrProviderId) + patch(©.asrBaseURL, value: configuration.asrBaseURL) + patch(©.asrModel, value: configuration.asrModel) patch(©.modeId, value: configuration.modeId) patch(©.localeId, value: configuration.localeId) patch(©.engineMode, value: configuration.engineMode) @@ -221,6 +323,9 @@ public extension SyncedAppSettingsV2 { touch(©.providerId, value: configuration.providerId) touch(©.baseURL, value: configuration.baseURL) touch(©.model, value: configuration.model) + touch(©.asrProviderId, value: configuration.asrProviderId) + touch(©.asrBaseURL, value: configuration.asrBaseURL) + touch(©.asrModel, value: configuration.asrModel) touch(©.modeId, value: configuration.modeId) touch(©.localeId, value: configuration.localeId) touch(©.engineMode, value: configuration.engineMode) diff --git a/OSGKeyboardShared/Models/SyncedField.swift b/OSGKeyboardShared/Models/SyncedField.swift index d422184..3397b09 100644 --- a/OSGKeyboardShared/Models/SyncedField.swift +++ b/OSGKeyboardShared/Models/SyncedField.swift @@ -16,11 +16,36 @@ public struct SyncedField: Codable, Equatable self.deviceID = deviceID } + /// A remote timestamp may be at most this far in OUR future before we + /// stop trusting it. Wall-clock LWW breaks down when one device's clock + /// runs fast: its edits would win every merge forever, silently + /// discarding later edits from correct-clock devices. Anything beyond + /// this skew is a broken clock, not a newer edit. + public static var maxTrustedFutureSkew: TimeInterval { 6 * 60 * 60 } + /// Pick the field with the newer `updatedAt`; ties break lexicographically on `deviceID`. + /// + /// Broken-clock containment: comparing with clamped stamps alone is not + /// enough — a far-future stamp stored in the winner would keep beating + /// every later genuine edit (whose stamps are merely "now") until that + /// wall-clock date actually arrived. So when the winner carries an + /// untrusted future stamp, the stamp itself is REWRITTEN to "now" in the + /// merged result: from then on any real edit, made later, outranks it. public static func merge(local: SyncedField, remote: SyncedField) -> SyncedField { - if remote.updatedAt > local.updatedAt { return remote } - if local.updatedAt > remote.updatedAt { return local } - return remote.deviceID >= local.deviceID ? remote : local + let now = Date() + let horizon = now.addingTimeInterval(maxTrustedFutureSkew) + let remoteAt = remote.updatedAt > horizon ? now : remote.updatedAt + let localAt = local.updatedAt > horizon ? now : local.updatedAt + let winner: SyncedField + if remoteAt > localAt { + winner = remote + } else if localAt > remoteAt { + winner = local + } else { + winner = remote.deviceID >= local.deviceID ? remote : local + } + guard winner.updatedAt > horizon else { return winner } + return SyncedField(value: winner.value, updatedAt: now, deviceID: winner.deviceID) } public static func make(value: T, deviceID: String) -> SyncedField { diff --git a/OSGKeyboardShared/Models/SyncedSpeechHistory.swift b/OSGKeyboardShared/Models/SyncedSpeechHistory.swift index 5cd5a12..59904af 100644 --- a/OSGKeyboardShared/Models/SyncedSpeechHistory.swift +++ b/OSGKeyboardShared/Models/SyncedSpeechHistory.swift @@ -12,7 +12,16 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable { public static let legacyKVSKey = "speechHistory.v1" public static let maxEntries = 300 /// Tombstones older than this window may be pruned during merge. - public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60 + /// Tombstones guard against deleted entries "resurrecting" when a + /// long-offline device rejoins and re-merges them. A short wall-clock + /// retention re-opened that window after only 90 days; a year keeps the + /// window closed for any realistically dormant device while staying tiny + /// on the wire (a tombstone is ~60 bytes of JSON), and the count cap + /// bounds the worst case regardless of clock. + public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60 + /// Hard cap independent of wall clock — the oldest tombstones are + /// dropped first once exceeded. + public static let maxTombstones = 500 public var schemaVersion: Int public var updatedAt: Date @@ -107,15 +116,19 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable { clearedAt: Date? ) -> [UUID: Date] { let cutoff = Date().addingTimeInterval(-tombstoneRetention) - return tombstones.filter { _, deletedAt in - if deletedAt < cutoff { - return false - } - if let clearedAt, deletedAt <= clearedAt { - return false - } + var kept = tombstones.filter { _, deletedAt in + if deletedAt < cutoff { return false } + if let clearedAt, deletedAt <= clearedAt { return false } return true } + // Enforce the count cap that makes the 365-day retention safe on the + // KVS byte budget: keep the NEWEST tombstones (dropping an old one + // early only re-opens the resurrection window for that one entry). + if kept.count > maxTombstones { + let newest = kept.sorted { $0.value > $1.value }.prefix(maxTombstones) + kept = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0.value) }) + } + return kept } private static func later(of lhs: Date?, and rhs: Date?) -> Date? { diff --git a/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json index 424219f..0a92e13 100644 --- a/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json +++ b/OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json @@ -45,6 +45,7 @@ "recommendedLocales": ["zh-CN", "en-US"], "supportsHotwords": true, "hotwordMode": "recognizerScoped", + "badgeKey": "mac.localASR.badge.balanced", "installKind": "archive", "installRelativePath": "models/sherpa-qwen3-0.6b-int8", "archiveBaseName": "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25", @@ -71,6 +72,7 @@ "recommendedLocales": ["zh-CN", "en-US"], "supportsHotwords": true, "hotwordMode": "recognizerScoped", + "badgeKey": "mac.localASR.badge.quality", "installKind": "repository", "installRelativePath": "models/sherpa-qwen3-1.7b-int8", "archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8", @@ -113,30 +115,6 @@ } ] }, - { - "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", @@ -146,6 +124,7 @@ "recommendedLocales": ["zh-CN", "en-US", "ja-JP", "ko-KR"], "supportsHotwords": false, "hotwordMode": "none", + "badgeKey": "mac.localASR.badge.fastest", "installKind": "archive", "installRelativePath": "models/sherpa-sensevoice-small-int8", "archiveBaseName": "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17", diff --git a/OSGKeyboardShared/Services/ASRChunkTranscribing.swift b/OSGKeyboardShared/Services/ASRChunkTranscribing.swift new file mode 100644 index 0000000..5fcb434 --- /dev/null +++ b/OSGKeyboardShared/Services/ASRChunkTranscribing.swift @@ -0,0 +1,25 @@ +// ASRChunkTranscribing.swift +// OSGKeyboard · Shared +// +// Minimal ASR surface for pipelined utterance chunking. Keeps +// `ChunkedUtterancePipeline` independent of iOS-only `SpeechAnalyzer`. + +import Foundation + +public enum ASRChunkResult: Sendable, Equatable { + case success(String) + case failure(String) + case cancelled +} + +/// One-shot chunk transcription used by `ChunkedUtterancePipeline`. +public protocol ASRChunkTranscribing: Sendable { + func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult + func cancel() + func resetForNewUtterance() +} + +extension ASRChunkTranscribing { + public func cancel() {} + public func resetForNewUtterance() {} +} diff --git a/OSGKeyboardShared/Services/ASRService.swift b/OSGKeyboardShared/Services/ASRService.swift index 4fe3015..33f19d6 100644 --- a/OSGKeyboardShared/Services/ASRService.swift +++ b/OSGKeyboardShared/Services/ASRService.swift @@ -30,7 +30,7 @@ extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {} // MARK: - Protocol -public protocol ASRService: Sendable { +public protocol ASRService: ASRChunkTranscribing, Sendable { /// Start a transcription session. The returned stream emits `.partial` /// updates and exactly one `.final` (or `.error`) before finishing. /// `SpeechAnalyzer` is always fully on-device, so there is no @@ -54,12 +54,6 @@ public protocol ASRService: Sendable { func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult } -public enum ASRChunkResult: Sendable, Equatable { - case success(String) - case failure(String) - case cancelled -} - extension ASRService { public func resetForNewUtterance() {} diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index a021bd9..5dd6ddd 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -53,6 +53,10 @@ public struct AppGroupStore: @unchecked Sendable { public var baseURL: String { configuration.baseURL } public var apiKey: String { configuration.apiKey } public var model: String { configuration.model } + public var asrProviderId: String { configuration.asrProviderId } + public var asrBaseURL: String { configuration.resolvedASRBaseURL } + public var asrApiKey: String { configuration.asrApiKey } + public var asrModel: String { configuration.resolvedASRModel } public var modeId: String { configuration.modeId } public var localeId: String { configuration.localeId } public var engineMode: String { configuration.engineMode } @@ -91,6 +95,12 @@ public struct AppGroupStore: @unchecked Sendable { config.baseURL = openAI.defaultBaseURL config.model = openAI.defaultModel } + if mode == "cloud", config.asrProviderId == "deepseek" { + let openAI = LLMProvider.provider(id: "openai") + config.asrProviderId = openAI.id + config.asrBaseURL = openAI.defaultBaseURL + config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id) + } } AppGroupConfigDarwin.postConfigChanged() } diff --git a/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift b/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift index 696f28b..5ea9d39 100644 --- a/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift +++ b/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift @@ -67,13 +67,13 @@ private actor ChunkWorkQueue { } public actor ChunkedUtterancePipeline { - private let asr: ASRService + private let asr: any ASRChunkTranscribing private let locale: Locale private let config: FlowUtteranceChunkConfig private var cancelled = false public init( - asr: ASRService, + asr: any ASRChunkTranscribing, locale: Locale, config: FlowUtteranceChunkConfig = .flowDefault ) { diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift index 36252f9..779eecb 100644 --- a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift +++ b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift @@ -17,31 +17,35 @@ public protocol CloudASRTranscribing: Sendable { public enum CloudASRClientFactory { public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing { - let strategy = CloudASRModelCatalog.strategy(for: store.providerId) + let providerId = store.asrProviderId + let strategy = CloudASRModelCatalog.strategy(for: providerId) + let asrModel = store.asrModel.isEmpty + ? CloudASRModelCatalog.defaultModel(for: providerId) + : store.asrModel switch strategy { case .zhipuHotwords: return ZhipuCloudASRClient( - apiKey: store.apiKey, - model: CloudASRModelCatalog.defaultModel(for: store.providerId), + apiKey: store.asrApiKey, + model: asrModel, session: session ) case .alibabaVocabulary: return AlibabaFunASRClient( - apiKey: store.apiKey, - model: CloudASRModelCatalog.defaultModel(for: store.providerId), + apiKey: store.asrApiKey, + model: asrModel, persistence: store.cloudASRPersistence, session: session ) case .prompt: return PromptCloudASRClient( - providerId: store.providerId, - baseURL: store.baseURL, - apiKey: store.apiKey, - model: CloudASRModelCatalog.defaultModel(for: store.providerId), + providerId: providerId, + baseURL: store.asrBaseURL, + apiKey: store.asrApiKey, + model: asrModel, session: session ) case .localFallback: - return UnsupportedCloudASRClient(providerId: store.providerId) + return UnsupportedCloudASRClient(providerId: providerId) } } } diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift index 59a5171..e8f4d37 100644 --- a/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift +++ b/OSGKeyboardShared/Services/CloudASR/CloudASRService.swift @@ -133,7 +133,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable { } private func bindClientIfNeeded() { - let providerId = store.providerId + let providerId = store.asrProviderId let strategy = CloudASRModelCatalog.strategy(for: providerId) lock.withLock { guard boundProviderId != providerId else { return } diff --git a/OSGKeyboardShared/Services/CustomLanguageModelManager.swift b/OSGKeyboardShared/Services/CustomLanguageModelManager.swift index 26776ea..abb1b47 100644 --- a/OSGKeyboardShared/Services/CustomLanguageModelManager.swift +++ b/OSGKeyboardShared/Services/CustomLanguageModelManager.swift @@ -70,7 +70,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable { /// Fire-and-forget preparation for the host app. Safe to call repeatedly. /// Retries after exponential backoff when a prior attempt failed. public func prepareInBackgroundIfNeeded() { + #if os(iOS) guard AppGroup.isAvailable else { return } + #endif let shouldStart = lock.withLock { () -> Bool in if case .preparing = state { return false } @@ -167,8 +169,8 @@ public final class CustomLanguageModelManager: @unchecked Sendable { throw PrepareError.missingPreparedArtifacts } - AppGroup.defaultsIfAvailable?.set(fingerprint, forKey: Storage.fingerprintKey) - AppGroup.defaultsIfAvailable?.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey) + Self.persistenceDefaults.set(fingerprint, forKey: Storage.fingerprintKey) + Self.persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey) Self.clearRetryState() lock.withLock { @@ -180,8 +182,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable { return configuration } - // MARK: - DictationTranscriber factory + // MARK: - DictationTranscriber factory (iOS host app) + #if os(iOS) public static func makeDictationTranscriber( locale: Locale, lmConfiguration: SFSpeechLanguageModel.Configuration? @@ -202,6 +205,34 @@ public final class CustomLanguageModelManager: @unchecked Sendable { attributeOptions: preset.attributeOptions ) } + #endif + + // MARK: - Legacy Speech request (macOS Apple Speech fallback) + + /// Up to 100 short phrases for `SFSpeechRecognitionRequest.contextualStrings`. + public static func contextualStringsForRecognition( + bias: LocalASRBiasPayload?, + maxCount: Int = 100 + ) -> [String] { + guard let bias, !bias.hardHotwords.isEmpty else { return [] } + return Array(bias.hardHotwords.prefix(max(1, maxCount))) + } + + /// Applies bundled CLM + optional contextual strings to a legacy on-device request. + public static func applyCustomLanguageModel( + to request: SFSpeechURLRecognitionRequest, + locale: Locale, + bias: LocalASRBiasPayload? + ) { + request.requiresOnDeviceRecognition = true + if let configuration = shared.configurationForTranscription(locale: locale) { + request.customizedLanguageModel = configuration + } + let phrases = contextualStringsForRecognition(bias: bias) + if !phrases.isEmpty { + request.contextualStrings = phrases + } + } // MARK: - Bundle / disk helpers @@ -238,14 +269,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable { } static func preparedDirectoryURL() -> URL? { - guard let container = FileManager.default.containerURL( + if let container = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: AppGroup.identifier - ) else { + ) { + let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + #if os(macOS) + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { return nil } - let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true) + let directory = appSupport + .appendingPathComponent("OSGKeyboard", isDirectory: true) + .appendingPathComponent(Storage.subdirectory, isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory + #else + return nil + #endif } static func loadCachedConfigurationFromDisk() -> SFSpeechLanguageModel.Configuration? { @@ -279,7 +324,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable { } private static func storedFingerprint() -> String? { - AppGroup.defaultsIfAvailable?.string(forKey: Storage.fingerprintKey) + persistenceDefaults.string(forKey: Storage.fingerprintKey) } private static func removeItemIfExists(at url: URL) throws { @@ -310,26 +355,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable { // MARK: - Retry / backoff + private static var persistenceDefaults: UserDefaults { + AppGroup.defaultsIfAvailable ?? .standard + } + private static func storedAttemptCount() -> Int { - AppGroup.defaultsIfAvailable?.integer(forKey: Storage.attemptCountKey) ?? 0 + persistenceDefaults.integer(forKey: Storage.attemptCountKey) } private static func storedLastFailureAt() -> TimeInterval? { - let value = AppGroup.defaultsIfAvailable?.double(forKey: Storage.lastFailureAtKey) ?? 0 + let value = persistenceDefaults.double(forKey: Storage.lastFailureAtKey) return value > 0 ? value : nil } private static func recordFailure() { - guard let defaults = AppGroup.defaultsIfAvailable else { return } let nextAttempt = storedAttemptCount() + 1 - defaults.set(nextAttempt, forKey: Storage.attemptCountKey) - defaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey) + persistenceDefaults.set(nextAttempt, forKey: Storage.attemptCountKey) + persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey) } private static func clearRetryState() { - guard let defaults = AppGroup.defaultsIfAvailable else { return } - defaults.removeObject(forKey: Storage.attemptCountKey) - defaults.removeObject(forKey: Storage.lastFailureAtKey) + persistenceDefaults.removeObject(forKey: Storage.attemptCountKey) + persistenceDefaults.removeObject(forKey: Storage.lastFailureAtKey) } /// Returns false when retry budget is exhausted or backoff has not elapsed. diff --git a/OSGKeyboardShared/Services/FlowContinuousCapture.swift b/OSGKeyboardShared/Services/FlowContinuousCapture.swift index 3e835bc..bfe3a64 100644 --- a/OSGKeyboardShared/Services/FlowContinuousCapture.swift +++ b/OSGKeyboardShared/Services/FlowContinuousCapture.swift @@ -159,28 +159,78 @@ private final class FlowAudioProofStore: @unchecked Sendable { /// incoming buffer's format actually changes, so downsampling to the ASR target /// rate is always valid regardless of route churn. private final class AdaptiveDownsampler: @unchecked Sendable { - // `AVAudioConverter` / `AVAudioFormat` are not `Sendable`, so the state and - // the returned converter are guarded manually via the unchecked lock APIs. - private let lock = OSAllocatedUnfairLock<(converter: AVAudioConverter, source: AVAudioFormat)?>(uncheckedState: nil) + // `AVAudioConverter` / `AVAudioFormat` / `AVAudioPCMBuffer` are not + // `Sendable`, so the state is guarded manually via the unchecked lock + // APIs. The scratch output buffer is REUSED across tap callbacks — + // allocating on the realtime audio thread risks priority inversion, and + // taps on one bus are serialized, so a single scratch is safe as long as + // callers copy its contents out before returning (AudioBufferSnapshot + // does exactly that). + private struct State { + var converter: AVAudioConverter + var source: AVAudioFormat + var scratch: AVAudioPCMBuffer + } + + private let lock = OSAllocatedUnfairLock(uncheckedState: nil) let targetFormat: AVAudioFormat + /// Frame headroom for the reusable output buffer. Taps deliver ≤4096 + /// input frames; output frames = input × (16k / hardwareRate), which + /// exceeds input only for sub-16 kHz hardware (rare telephony routes), + /// so 2× the tap size covers every realistic ratio. + private static let scratchCapacity: AVAudioFrameCount = 8_192 + init(targetFormat: AVAudioFormat) { self.targetFormat = targetFormat } - /// Returns a converter valid for `sourceFormat`, rebuilding it lazily when - /// the hardware route (and thus the buffer format) changes. - func converter(for sourceFormat: AVAudioFormat) -> AVAudioConverter? { - lock.withLockUnchecked { state in - if let state, state.source == sourceFormat { - return state.converter + /// Downsamples `buffer` into the reusable scratch buffer and returns it, + /// rebuilding the converter lazily when the hardware route (and thus the + /// source format) changes. The returned buffer is only valid until the + /// next call — copy its samples out synchronously. + func convertReusingScratch(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? { + let sourceFormat = buffer.format + guard sourceFormat.sampleRate > 0 else { return nil } + return lock.withLockUnchecked { state -> AVAudioPCMBuffer? in + if state == nil || state!.source != sourceFormat { + guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat), + let scratch = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: Self.scratchCapacity + ) else { + state = nil + return nil + } + state = State(converter: converter, source: sourceFormat, scratch: scratch) } - guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else { - state = nil - return nil + guard let current = state else { return nil } + + let wanted = AVAudioFrameCount( + Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate + ) + guard wanted > 0, wanted <= current.scratch.frameCapacity else { return nil } + current.scratch.frameLength = 0 + + // ONE-SHOT input: the converter keeps pulling until the output + // buffer's frameCapacity is full, and the scratch is deliberately + // oversized — feeding the same tap buffer on every pull would + // duplicate the audio ~6× (stuttering ASR input). After the + // single feed we report "ran dry", so the expected status is + // `.inputRanDry` (output not full), not `.haveData`. + var provided = false + var error: NSError? + let status = current.converter.convert(to: current.scratch, error: &error) { _, outStatus in + if provided { + outStatus.pointee = .noDataNow + return nil + } + provided = true + outStatus.pointee = .haveData + return buffer } - state = (converter, sourceFormat) - return converter + guard status != .error, error == nil, current.scratch.frameLength > 0 else { return nil } + return current.scratch } } } @@ -240,6 +290,10 @@ public final class FlowContinuousCapture { private var didInstallTap = false private var isRunning = false private var isRebuilding = false + private var interrupted = false + /// When the engine last (re)activated — a freshly started engine has + /// produced no frames yet and must not be misclassified as a zombie. + private var lastActivationAt = Date.distantPast private var routeObserver: NSObjectProtocol? private var interruptionObserver: NSObjectProtocol? @@ -250,6 +304,11 @@ public final class FlowContinuousCapture { public var running: Bool { isRunning } + /// True between interruption `.began` and `.ended` (phone call, Siri). + /// While set, `setActive(true)` is guaranteed to fail — owners should + /// wait for `.ended` (which rebuilds the engine) instead of retrying. + public var isInterrupted: Bool { interrupted } + /// True when the capture session flag, tap, and audio engine are all live. public var engineIsLive: Bool { isRunning && didInstallTap && audioEngine.isRunning @@ -264,9 +323,33 @@ public final class FlowContinuousCapture { /// Called on the main actor when `engineIsLive` may have changed. public var onEngineLiveChanged: ((Bool) -> Void)? + /// Called on the main actor when the system interrupted capture (phone + /// call, Siri). The session owner should fail any mic-open utterance — + /// audio frames stop arriving, so continuing to "record" only captures + /// a silence gap the user cannot see. + public var onInterruptionBegan: (() -> Void)? + /// Configure `.playAndRecord`, install a permanent input tap, start the engine. + /// + /// Idempotent: "already running and healthy" is a warm-start fast path, + /// while "already running but producing no audio" is a zombie state + /// (force-quit relaunch, failed cold start, mediaserverd reset) that is + /// torn down and rebuilt in place. It must never be a silent no-op — + /// a `guard !isRunning` early-return here turned every cold-start retry + /// into a guaranteed audio-proof timeout. public func start() throws { - guard !isRunning else { return } + if isRunning { + let startedMomentsAgo = Date().timeIntervalSince(lastActivationAt) < 2 + if engineIsLive && (engineHasRecentAudio(maxAge: 2) || startedMomentsAgo) { + // Healthy warm engine — or one so fresh it simply hasn't + // produced its first frame yet (interleaved start attempts + // land here; rebuilding a 100 ms-old engine only multiplies + // audio-session churn in the fragile post-relaunch window). + return + } + log.info("start(): zombie engine detected (running but no live audio) — forcing rebuild") + stop() + } audioProofStore.reset() try activateEngine() isRunning = true @@ -353,6 +436,7 @@ public final class FlowContinuousCapture { } catch { throw StartError.engineStartFailed(error.localizedDescription) } + lastActivationAt = Date() } /// Tear down the engine and release the audio session. @@ -371,6 +455,7 @@ public final class FlowContinuousCapture { audioEngine.stop() } isRunning = false + interrupted = false audioProofStore.reset() downsampler = nil targetFormat = nil @@ -384,6 +469,13 @@ public final class FlowContinuousCapture { /// Re-activate capture after returning from background without /// reinstalling the tap (iOS may deactivate the audio session). + /// + /// Doubles as the interruption-recovery probe: `setActive(true)` FAILS + /// while a call/Siri interruption is live and succeeds once it ends, so a + /// successful reassert proves the interruption is over. iOS does not + /// guarantee delivery of `.ended` (commonly dropped when the app was + /// suspended during the call), so this is the only reliable way to clear + /// the `interrupted` latch in that case. @discardableResult public func reassertIfRunning() -> Bool { guard isRunning else { return false } @@ -395,6 +487,7 @@ public final class FlowContinuousCapture { options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers] ) try session.setActive(true, options: .notifyOthersOnDeactivation) + interrupted = false if !audioEngine.isRunning { try audioEngine.start() } @@ -415,7 +508,14 @@ public final class FlowContinuousCapture { if engineHasRecentAudio(maxAge: recentFrameMaxAge) { return true } - try? await Task.sleep(nanoseconds: 50_000_000) + do { + try await Task.sleep(nanoseconds: 50_000_000) + } catch { + // Cancelled — bail out instead of busy-spinning the main + // actor for the rest of the window (a cancelled Task.sleep + // returns immediately, starving concurrent start attempts). + return false + } } return engineHasRecentAudio(maxAge: recentFrameMaxAge) } @@ -497,8 +597,11 @@ public final class FlowContinuousCapture { switch type { case .began: log.info("Audio interruption began") + interrupted = true notifyEngineLiveChanged() + onInterruptionBegan?() case .ended: + interrupted = false guard isRunning else { return } let shouldResume: Bool if let optionsRaw { @@ -627,26 +730,12 @@ public final class FlowContinuousCapture { audioProofStore.markFrameReceived() levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount) - // Derive the converter from the *live* buffer format so a mid-session - // route change (e.g. 48 kHz → 24 kHz) is handled transparently. - let sourceFormat = buffer.format - let targetFormat = downsampler.targetFormat - guard sourceFormat.sampleRate > 0, - let converter = downsampler.converter(for: sourceFormat) else { return } - - let outFrames = AVAudioFrameCount( - Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate - ) - guard outFrames > 0, - let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames) - else { return } - - var error: NSError? - let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in - outStatus.pointee = .haveData - return buffer - } - guard status == .haveData, error == nil, outBuffer.frameLength > 0 else { return } + // The downsampler derives its converter from the *live* buffer + // format (mid-session route changes handled transparently) and + // returns a REUSED scratch buffer — no per-callback allocation + // on the realtime thread. The snapshot below copies the samples + // out before the next tap callback can overwrite the scratch. + guard let outBuffer = downsampler.convertReusingScratch(buffer) else { return } let snapshot = AudioBufferSnapshot(buffer: outBuffer) guard !snapshot.samples.isEmpty else { return } diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift index 353e7e7..be3bf3f 100644 --- a/OSGKeyboardShared/Services/FlowSessionBridge.swift +++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift @@ -130,6 +130,12 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable { public let localeId: String public let busyUtteranceId: UUID? public let sessionExpiresAt: TimeInterval? + /// Host process generation that wrote this snapshot. A snapshot whose + /// generation no longer matches `FlowSessionKeys.hostGeneration` was + /// written by a dead process and is void immediately — no need to wait + /// out the heartbeat-zombie window. Optional for wire compatibility with + /// snapshots written before this field existed. + public let hostGeneration: String? public init( protocolVersion: Int = 1, @@ -142,7 +148,8 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable { engineMode: String, localeId: String, busyUtteranceId: UUID? = nil, - sessionExpiresAt: TimeInterval? = nil + sessionExpiresAt: TimeInterval? = nil, + hostGeneration: String? = nil ) { self.protocolVersion = protocolVersion self.sessionId = sessionId @@ -155,6 +162,7 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable { self.localeId = localeId self.busyUtteranceId = busyUtteranceId self.sessionExpiresAt = sessionExpiresAt + self.hostGeneration = hostGeneration } } @@ -266,13 +274,27 @@ public enum FlowSessionBridge { store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt) } } else { + // Keep the not-ready payload. The keyboard needs `reason` + // (recording / processing / waitingForAudioProof / …) to tell + // "host is busy" apart from "host is still starting". Deleting + // the payload here forced every mid-utterance ready=false into + // a permanent orange `preparingSession` state. clearHostReady(defaults: store, notify: false) - store.removeObject(forKey: FlowSessionKeys.flowReadyPayload) } if let expires = snapshot.sessionExpiresAt { store.set(expires, forKey: FlowSessionKeys.flowSessionExpires) } - store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat) + // Only a genuinely live host — ready, or actively serving an + // utterance — may refresh the heartbeat here. A host stuck in a + // failed cold start would otherwise keep "reviving" itself on every + // engine-state flap, flickering the keyboard between reachable and + // dead and postponing zombie-state cleanup indefinitely. + let provesHostAlive = snapshot.ready + || snapshot.reason == .recording + || snapshot.reason == .processing + if provesHostAlive { + store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat) + } flush(store) FlowSessionDarwin.postHostReadyChanged() } @@ -309,7 +331,8 @@ public enum FlowSessionBridge { heartbeatAt: now, engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode, localeId: AppGroupConfiguration.load(fromAvailable: store).localeId, - sessionExpiresAt: expires + sessionExpiresAt: expires, + hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration) ) if let data = encode(snapshot) { store.set(data, forKey: FlowSessionKeys.flowReadyPayload) @@ -418,6 +441,54 @@ public enum FlowSessionBridge { return staleness <= FlowSessionKeys.heartbeatStaleInterval } + // MARK: - Host process generation + + /// Host app: rotate the per-process generation token. Call exactly once, + /// as early as possible in the host launch path. Returns the previous + /// generation (nil on first-ever launch) so the caller can log it. + /// + /// Rationale: `applicationWillTerminate` is best-effort — it never runs + /// when a *suspended* app is force-quit (the common case after a failed + /// cold start). Instead of anchoring cleanup on a termination callback + /// that may not fire, each launch proves the previous process is dead and + /// voids whatever session state it left behind. + @discardableResult + public static func rotateHostGeneration(defaults: UserDefaults? = nil) -> String? { + let store = resolvedDefaults(defaults) + let previous = store.string(forKey: FlowSessionKeys.hostGeneration) + store.set(UUID().uuidString, forKey: FlowSessionKeys.hostGeneration) + flush(store) + return previous + } + + public static func currentHostGeneration(defaults: UserDefaults? = nil) -> String? { + let store = resolvedDefaults(defaults) + return store.string(forKey: FlowSessionKeys.hostGeneration) + } + + /// Host launch reconciliation: clear every piece of persisted session + /// state a previous (dead) generation left behind. Unlike + /// `clearFlowState()` this keeps `pendingHostBundleId` — on a keyboard + /// `startflow` cold launch the scene delegate stores the host bundle id + /// *before* the SwiftUI hierarchy (and thus the session manager) exists, + /// and wiping it here would break the return-to-host affordance. + public static func clearFlowStateOnHostLaunch(defaults: UserDefaults? = nil) { + let store = resolvedDefaults(defaults) + store.set(false, forKey: FlowSessionKeys.flowSessionActive) + store.removeObject(forKey: FlowSessionKeys.flowSessionExpires) + store.removeObject(forKey: FlowSessionKeys.flowHeartbeat) + store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState) + store.removeObject(forKey: FlowSessionKeys.flowCommandPayload) + store.removeObject(forKey: FlowSessionKeys.flowResultPayload) + store.removeObject(forKey: FlowSessionKeys.flowAckPayload) + store.removeObject(forKey: FlowSessionKeys.flowReadyPayload) + clearTranscription(defaults: store) + store.removeObject(forKey: FlowSessionKeys.audioLevels) + store.removeObject(forKey: FlowSessionKeys.lastActivityAt) + clearHostReady(defaults: store, notify: false) + flush(store) + } + // MARK: - Host ready contract (host app → keyboard) /// Host app: publish whether Flow can accept a new utterance right now. @@ -446,6 +517,13 @@ public enum FlowSessionBridge { let store = resolvedDefaults(defaults) if let snapshot = readySnapshot(defaults: store) { guard snapshot.ready else { return false } + // Snapshot written by a dead host generation → void immediately, + // without waiting out the heartbeat-zombie window. + if let snapshotGeneration = snapshot.hostGeneration, + let currentGeneration = store.string(forKey: FlowSessionKeys.hostGeneration), + snapshotGeneration != currentGeneration { + return false + } guard isHostReachable(defaults: store) else { return false } if let readyAt = snapshot.readyAt { let skew = abs(snapshot.heartbeatAt - readyAt) diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift index f7513a3..379bf64 100644 --- a/OSGKeyboardShared/Services/FlowSessionKeys.swift +++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift @@ -33,6 +33,11 @@ public enum FlowSessionKeys { public static let pendingHostBundleId = "flow.pendingHostBundleId" /// Wall-clock timestamp of the last utterance completion or session start. public static let lastActivityAt = "flow.lastActivityAt" + /// One-shot token rotated by every host-process launch. State written by + /// a previous generation is void by definition — a fresh launch proves the + /// previous process is dead, whether or not its `applicationWillTerminate` + /// cleanup ever ran (it does NOT run when a suspended app is force-quit). + public static let hostGeneration = "flow.hostGeneration.v1" /// Heartbeat older than this → host is not actively reachable for recording. public static let heartbeatStaleInterval: TimeInterval = 3 @@ -59,14 +64,23 @@ public enum FlowSessionKeys { public static let localASRWaitTimeout: TimeInterval = 120 public static let cloudASRWaitTimeout: TimeInterval = 120 - /// Keyboard watchdog after the user stops recording (not utterance max length). - /// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks - /// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap). + /// Hard cap on a single LLM polish request. `PolishingService`'s scaled + /// per-request timeout clamps to this value, so it participates in the + /// keyboard-watchdog budget below. + public static let maxPolishTimeout: TimeInterval = 120 + + /// Extra slack for result serialization, cross-process propagation, and + /// the host's own polling cadence. + public static let resultDeliveryMargin: TimeInterval = 20 + + /// Keyboard watchdog after the user stops recording (not utterance max + /// length). Derived from the host-side budget so it always outlasts the + /// host's worst case (ASR drain wait + polish cap + margin) — hand-tuned + /// constants drifted below the real host maximum, making the keyboard + /// report a timeout for transcriptions that were still going to succeed. public static func keyboardResultTimeout(engineMode: String) -> TimeInterval { - if engineMode == "local" { - return 180 - } - return 240 + let asrWait = engineMode == "local" ? localASRWaitTimeout : cloudASRWaitTimeout + return asrWait + maxPolishTimeout + resultDeliveryMargin } public enum RecordingState: String, Sendable, Equatable { diff --git a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift index a45efd6..f8ef8ad 100644 --- a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift +++ b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift @@ -39,20 +39,48 @@ public final class AppCloudSync { ?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults) } + /// Serializes external-change pulls: KVS posts change notifications in + /// bursts (one per key at times), and overlapping pull-merge-apply runs + /// can interleave their read/write phases. `wantsAnotherPull` coalesces + /// every burst into at most one trailing re-pull. + private var isPulling = false + private var wantsAnotherPull = false + public func startObservingExternalChanges() { guard externalChangeObserver == nil else { return } externalChangeObserver = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: nil, queue: .main - ) { [weak self] _ in + ) { [weak self] note in guard let self else { return } + // Distinguish WHY the store changed. `.accountChange` means the + // user switched iCloud accounts — the incoming values belong to a + // DIFFERENT account and must not be merged into this one's data + // (deleted-entry resurrection, foreign history, wrong settings). + let reason = note.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int + if reason == NSUbiquitousKeyValueStoreAccountChange { + return + } Task { @MainActor in - await self.pullAllIfEnabled() + await self.pullAllCoalesced() } } } + private func pullAllCoalesced() async { + guard !isPulling else { + wantsAnotherPull = true + return + } + isPulling = true + defer { isPulling = false } + repeat { + wantsAnotherPull = false + await pullAllIfEnabled() + } while wantsAnotherPull + } + public func stopObservingExternalChanges() { if let externalChangeObserver { NotificationCenter.default.removeObserver(externalChangeObserver) @@ -79,18 +107,27 @@ public final class AppCloudSync { } /// Low-risk manual sync: pull remote changes, merge, then push local state. + /// Each push runs independently — one payload failing must not abort the + /// others (a too-large history would otherwise also kill the dictionary + /// push). The first error is rethrown after every push has been tried. public func syncNow() async throws { let store = makeStore() await pullAllIfEnabled() + var firstError: Error? + func attempt(_ body: () async throws -> Void) async { + do { try await body() } catch { if firstError == nil { firstError = error } } + } + if store.settingsICloudSyncEnabled { - try await settingsSync.pushLocalIfEnabled() - try await usageStatisticsSync.pushLocalIfEnabled() - try await speechHistorySync.pushLocalIfEnabled() + await attempt { try await settingsSync.pushLocalIfEnabled() } + await attempt { try await usageStatisticsSync.pushLocalIfEnabled() } + await attempt { try await speechHistorySync.pushLocalIfEnabled() } } if store.personalDictionaryICloudSyncEnabled { - try await dictionarySync.pushLocalIfEnabled(store.personalDictionary) + await attempt { try await dictionarySync.pushLocalIfEnabled(store.personalDictionary) } } + if let firstError { throw firstError } } public var settingsSyncService: SettingsCloudSync { settingsSync } diff --git a/OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift index 676146a..f622afa 100644 --- a/OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift +++ b/OSGKeyboardShared/Services/ICloudSync/SpeechHistoryCloudSync.swift @@ -24,8 +24,12 @@ public final class SpeechHistoryCloudSync { public static let kvsKey = SyncedSpeechHistory.kvsKey public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey - /// Stay below the ~1 MB per-key KVS limit. - public static let maxPayloadBytes = 900_000 + /// The 1 MB iCloud KVS quota is for the WHOLE store, not per key. + /// History and the personal dictionary must fit together (plus settings + /// and usage stats) — once the store exceeds 1 MB, KVS rejects writes + /// for ALL keys with `QuotaViolation` and every sync silently stops. + /// Budget: ~400 KB history + ~400 KB dictionary + headroom for the rest. + public static let maxPayloadBytes = 400_000 private let kvs: UbiquitousKeyValueStoreing private let makeStore: () -> AppGroupStore @@ -50,8 +54,16 @@ public final class SpeechHistoryCloudSync { public func pushLocalIfEnabled() async throws { let store = makeStore() guard store.settingsICloudSyncEnabled else { return } - let local = SpeechHistoryStorage.load(from: historyDefaults()) - try push(local) + // Read-merge-write: pushing the local view verbatim would overwrite + // entries another device added since our last pull (KVS is + // last-writer-wins with no server-side merge). + let defaults = historyDefaults() + let local = SpeechHistoryStorage.load(from: defaults) + let merged = loadRemote().map { SyncedSpeechHistory.merge(local: local, remote: $0) } ?? local + if merged != local { + apply(merged, to: defaults, postNotification: true) + } + try push(merged) } /// Called when settings sync is first enabled to union local + remote history. @@ -81,11 +93,34 @@ public final class SpeechHistoryCloudSync { } public func push(_ history: SyncedSpeechHistory) throws { - let data = try encode(history) + let data = try encodeFittingBudget(history) kvs.set(data, forKey: Self.kvsKey) _ = kvs.synchronize() } + /// Encode, dropping the oldest entries until the payload fits the KVS + /// budget. Without this, a history that once fit under the old 900 KB + /// cap (300 long dictations easily exceed 400 KB) would make EVERY push + /// throw forever — automatic pushes are fire-and-forget, so sync would + /// just silently die with no way back short of clearing all history. + /// Only the *uploaded* copy is trimmed; local history keeps its full + /// 300 entries. + func encodeFittingBudget(_ history: SyncedSpeechHistory) throws -> Data { + var payload = history + while true { + do { + return try encode(payload) + } catch SpeechHistoryCloudSyncError.payloadTooLarge { + guard payload.entries.count > 1 else { throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: 0) } + // Drop the oldest ~10% per pass; entries are kept + // newest-first by the store, so trim from the tail. + let sorted = payload.entries.sorted { $0.createdAt > $1.createdAt } + let keep = max(1, sorted.count - max(1, sorted.count / 10)) + payload.entries = Array(sorted.prefix(keep)) + } + } + } + public func loadRemote() -> SyncedSpeechHistory? { if let data = kvs.data(forKey: Self.kvsKey) { return try? decode(data) diff --git a/OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift index b0773a5..0ebb97c 100644 --- a/OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift +++ b/OSGKeyboardShared/Services/ICloudSync/UsageStatisticsCloudSync.swift @@ -45,8 +45,16 @@ public final class UsageStatisticsCloudSync { public func pushLocalIfEnabled() async throws { let store = makeStore() guard store.settingsICloudSyncEnabled else { return } + // Read-merge-write: this fires after every utterance, so pushing the + // local view verbatim would clobber counter slices another device + // advanced since our last pull (KVS is last-writer-wins). The + // G-Counter merge makes the push commutative instead. let local = SyncedUsageStatisticsStorage.load(from: store.defaults) - try push(local) + let merged = loadRemote().map { SyncedUsageStatisticsV2.merge(local: local, remote: $0) } ?? local + if merged != local { + apply(merged, to: store.defaults, postNotification: true) + } + try push(merged) } /// Called when settings sync is first enabled to union local + remote totals. diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 49c13bf..812e520 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -91,8 +91,11 @@ public final class KeyboardState: ObservableObject { @Published public var micDisabled: Bool = false /// One-line helper shown above the mic while `micDisabled == true`. @Published public var micDisabledHint: String = "" - /// "local" → on-device ASR only. "cloud" → ASR + LLM polish. - @Published public var engineMode: String = "cloud" + /// "local" → on-device ASR only. "cloud" → cloud ASR + LLM polish. + /// Boot value must match the privacy-safe app default (`local`) so the + /// keyboard never assumes the audio-uploading engine before the App + /// Group config has been read. + @Published public var engineMode: String = "local" /// v0.2.1 follow-up: derived — translation is on iff a target /// locale has been selected (mirrors `ProviderConfig.translationEnabled` /// so the chip / pipeline read the same source of truth). @@ -148,6 +151,46 @@ public final class KeyboardState: ObservableObject { case openSettings } + // MARK: - Temporary Flow debug (remove after orange-mic investigation) + + /// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel. + @Published public var debugPendingFlowStart: Bool = false + @Published public var debugFlowRecording: Bool = false + @Published public var debugAwaitingFlowResult: Bool = false + @Published public var debugHasFullAccess: Bool = false + + /// Snapshot for the keyboard debug panel. + public func makeFlowDebugRows(hasFullAccess: Bool) -> [FlowDebugRow] { + debugHasFullAccess = hasFullAccess + let micLabel: String = { + switch micVoiceAvailability { + case .ready: return "ready" + case .recording: return "recording" + case .processing: return "processing" + case .unavailable(let reason): + switch reason { + case .hostNotReady: return "unavailable(hostNotReady)" + case .preparingSession: return "unavailable(preparingSession)" + case .noFullAccess: return "unavailable(noFullAccess)" + case .appGroupUnavailable: return "unavailable(appGroupUnavailable)" + case .missingAPIKey: return "unavailable(missingAPIKey)" + } + } + }() + let localRows: [FlowDebugRow] = [ + FlowDebugRow("mic", micLabel), + FlowDebugRow("phase", String(describing: phase)), + FlowDebugRow("pendingStart", debugPendingFlowStart ? "1" : "0"), + FlowDebugRow("kb.recording", debugFlowRecording ? "1" : "0"), + FlowDebugRow("kb.awaiting", debugAwaitingFlowResult ? "1" : "0"), + FlowDebugRow("fullAccess", hasFullAccess ? "1" : "0"), + FlowDebugRow("micDisabled", micDisabled ? "1" : "0"), + FlowDebugRow("flowSessionPub", flowSessionActive ? "1" : "0"), + FlowDebugRow("engine", engineMode) + ] + return localRows + FlowDebugAppGroupSnapshot.rows() + } + // Action hooks — injected by the view controller at install time. public var beginRecording: () -> Void = {} public var endRecording: () -> Void = {} diff --git a/OSGKeyboardShared/Services/Keychain.swift b/OSGKeyboardShared/Services/Keychain.swift index 9d0b7f2..4c50d11 100644 --- a/OSGKeyboardShared/Services/Keychain.swift +++ b/OSGKeyboardShared/Services/Keychain.swift @@ -22,12 +22,29 @@ public enum Keychain: @unchecked Sendable { private static let legacyAccount = "current" private static let defaultProviderId = "openai" - private static func account(for providerId: String) -> String { + private static func normalizedProviderId(_ providerId: String) -> String { let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased() - return "provider.\(normalized)" + return trimmed.isEmpty ? defaultProviderId : trimmed.lowercased() } + /// LLM polish credentials (`provider.`). + private static func account(for providerId: String) -> String { + "provider.\(normalizedProviderId(providerId))" + } + + /// Cloud ASR credentials (`asr.`), independent from polish keys. + private static func asrAccount(for providerId: String) -> String { + "asr.\(normalizedProviderId(providerId))" + } + + // NOTE on kSecAttrAccessGroup: we deliberately rely on the DEFAULT + // access group (the first entry in each target's keychain-access-groups, + // which project.yml pins to `$(AppIdentifierPrefix)com.osgkeyboard.shared` + // for every target). Setting the attribute explicitly would require the + // team-prefixed string at runtime, which is not portably available + // without injecting TeamID through the build system. If a SECOND access + // group is ever added to any target, revisit this — reordered groups + // would silently change which store these queries hit. private static func baseQuery(providerId: String, synchronizable: Bool) -> [String: Any] { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -43,6 +60,130 @@ public enum Keychain: @unchecked Sendable { // MARK: - Read + // MARK: - ASR keys + + public static func asrApiKey(for providerId: String, preferICloudSync: Bool = false) -> String? { + if preferICloudSync, let synced = readASRKey(providerId: providerId, synchronizable: true) { + return synced + } + if let local = readASRKey(providerId: providerId, synchronizable: false) { + return local + } + if preferICloudSync { + return readASRKey(providerId: providerId, synchronizable: true) + } + return nil + } + + public static func asrApiKeyOutcome( + for providerId: String, + preferICloudSync: Bool = false + ) -> ReadOutcome { + let first = readASRKeyOutcome(providerId: providerId, synchronizable: preferICloudSync) + if case .found = first { return first } + let second = readASRKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync) + if case .found = second { return second } + if case .unavailable = first { return first } + if case .unavailable = second { return second } + return .notFound + } + + public static func setASRAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws { + if key.isEmpty { + try deleteASRAPIKey(for: providerId, useICloudSync: useICloudSync) + return + } + if useICloudSync { + try writeASRKey(key, providerId: providerId, synchronizable: true) + try? deleteASRKey(providerId: providerId, synchronizable: false) + } else { + try writeASRKey(key, providerId: providerId, synchronizable: false) + } + } + + public static func deleteASRAPIKey(for providerId: String, useICloudSync: Bool = false) throws { + try deleteASRKey(providerId: providerId, synchronizable: false) + if useICloudSync { + try deleteASRKey(providerId: providerId, synchronizable: true) + } + } + + private static func readASRKey(providerId: String, synchronizable: Bool) -> String? { + if case .found(let value) = readASRKeyOutcome(providerId: providerId, synchronizable: synchronizable) { + return value + } + return nil + } + + private static func readASRKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome { + var query = baseASRQuery(providerId: providerId, synchronizable: synchronizable) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let data = result as? Data, + let str = String(data: data, encoding: .utf8) else { + return .notFound + } + return .found(str) + case errSecItemNotFound: + // Pre-split installs stored one key under `provider.` for both stages. + return readKeyOutcome(providerId: providerId, synchronizable: synchronizable) + default: + #if DEBUG + print("⚠️ [OSGKeyboard] ASR Keychain read returned OSStatus \(status); reporting unavailable.") + #endif + return .unavailable(status) + } + } + + private static func baseASRQuery(providerId: String, synchronizable: Bool) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: asrAccount(for: providerId), + kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!, + ] + #if os(macOS) + query[kSecUseDataProtectionKeychain as String] = true + #endif + return query + } + + private static func writeASRKey(_ key: String, providerId: String, synchronizable: Bool) throws { + let data = Data(key.utf8) + var baseQuery = baseASRQuery(providerId: providerId, synchronizable: synchronizable) + let updateAttrs: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary) + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + baseQuery[kSecValueData as String] = data + baseQuery[kSecAttrAccessible as String] = synchronizable + ? kSecAttrAccessibleAfterFirstUnlock + : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(baseQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + throw KeychainError.unexpectedStatus(addStatus) + } + default: + throw KeychainError.unexpectedStatus(updateStatus) + } + } + + private static func deleteASRKey(providerId: String, synchronizable: Bool) throws { + let query = baseASRQuery(providerId: providerId, synchronizable: synchronizable) + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess, status != errSecItemNotFound { + throw KeychainError.unexpectedStatus(status) + } + } + + // MARK: - LLM keys + public static func apiKey(for providerId: String, preferICloudSync: Bool = false) -> String? { if preferICloudSync, let synced = readKey(providerId: providerId, synchronizable: true) { return synced @@ -60,7 +201,43 @@ public enum Keychain: @unchecked Sendable { apiKey(for: defaultProviderId) } + /// Distinguishes "no key stored" from "keychain temporarily unreadable". + public enum ReadOutcome: Equatable { + case found(String) + case notFound + /// The keychain could not be read (e.g. `errSecInteractionNotAllowed` + /// while the device is locked before first unlock). NOT the same as + /// "no key configured" — telling the user to re-enter their key in + /// this state would be wrong; the read succeeds once unlocked. + case unavailable(OSStatus) + } + + /// Like `apiKey(for:)`, but reports WHY a key was not returned so + /// callers can distinguish a missing key (user action needed) from a + /// transiently locked keychain (retry later). + public static func apiKeyOutcome( + for providerId: String, + preferICloudSync: Bool = false + ) -> ReadOutcome { + let first = readKeyOutcome(providerId: providerId, synchronizable: preferICloudSync) + if case .found = first { return first } + let second = readKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync) + if case .found = second { return second } + // Neither store had it: surface "unavailable" when either read was + // blocked, since the key may well exist behind the lock. + if case .unavailable = first { return first } + if case .unavailable = second { return second } + return .notFound + } + private static func readKey(providerId: String, synchronizable: Bool) -> String? { + if case .found(let value) = readKeyOutcome(providerId: providerId, synchronizable: synchronizable) { + return value + } + return nil + } + + private static func readKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome { var query = baseQuery(providerId: providerId, synchronizable: synchronizable) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -70,16 +247,16 @@ public enum Keychain: @unchecked Sendable { case errSecSuccess: guard let data = result as? Data, let str = String(data: data, encoding: .utf8) else { - return nil + return .notFound } - return str + return .found(str) case errSecItemNotFound: - return nil + return .notFound default: #if DEBUG - print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); treating as no key.") + print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); reporting unavailable.") #endif - return nil + return .unavailable(status) } } @@ -193,6 +370,13 @@ public enum Keychain: @unchecked Sendable { try? writeKey(local, providerId: provider.id, synchronizable: true) try? deleteKey(providerId: provider.id, synchronizable: false) } + for provider in LLMProvider.asrSelectablePresets { + guard let local = readASRKey(providerId: provider.id, synchronizable: false), !local.isEmpty else { + continue + } + try? writeASRKey(local, providerId: provider.id, synchronizable: true) + try? deleteASRKey(providerId: provider.id, synchronizable: false) + } } // MARK: - Onboarding completion (reboot-durable flag) diff --git a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift index bf47a6f..392eb7a 100644 --- a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift +++ b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift @@ -26,8 +26,12 @@ public final class PersonalDictionaryCloudSync { public static let kvsKey = PersonalDictionary.kvsKeyV2 public static let legacyKVSKey = PersonalDictionary.legacyKVSKey - /// Stay below the ~1 MB per-key KVS limit. - public static let maxPayloadBytes = 900_000 + /// The 1 MB iCloud KVS quota covers the WHOLE store, not one key — + /// this payload shares it with speech history, settings, and usage + /// stats. Exceeding the total quota makes KVS reject writes for ALL + /// keys (`QuotaViolation`), silently stopping every sync. + /// Budget: ~400 KB dictionary + ~400 KB history + headroom. + public static let maxPayloadBytes = 400_000 private let kvs: UbiquitousKeyValueStoreing private let makeStore: () -> AppGroupStore @@ -75,7 +79,14 @@ public final class PersonalDictionaryCloudSync { public func pushLocalIfEnabled(_ dictionary: PersonalDictionary) async throws { let store = makeStore() guard store.personalDictionaryICloudSyncEnabled else { return } - try push(dictionary) + // Read-merge-write: KVS is last-writer-wins; uploading the local + // view verbatim would drop entries another device added since our + // last pull. Tombstones in `merge` keep deletions intact. + let merged = loadRemote().map { PersonalDictionary.merge(local: dictionary, remote: $0) } ?? dictionary + if merged != dictionary { + store.setPersonalDictionary(merged) + } + try push(merged) } /// Enable sync: merge local + remote, persist locally, then upload. diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index dd28016..53071fa 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -10,8 +10,8 @@ // English dictation while halving the network round-trip. // // Engine matrix: -// - `engineMode == "cloud"` → provider cloud ASR + user's cloud LLM -// - `engineMode == "local"` → on-device ASR + built-in DeepSeek +// - `engineMode == "cloud"` → user's cloud ASR + user's cloud LLM (independent) +// - `engineMode == "local"` → on-device ASR + user's LLM (or built-in DeepSeek) // - Ultra-short, structure-free utterances skip the LLM entirely // - Cloud without API key → raw + `.missingAPIKey` warning // - Local without build key → raw + `.missingAPIKey` warning @@ -37,6 +37,10 @@ public actor PolishingService { /// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is /// still the repo placeholder, or cloud engine Keychain is empty. case missingAPIKey + /// The keychain was unreadable (device locked before first unlock) + /// — the key likely EXISTS; treat as transient, never as "please + /// re-enter your API key". + case keychainLocked } /// v0.2.1: what the LLM should do with the raw transcript. The @@ -95,8 +99,13 @@ public actor PolishingService { return TranscriptPostProcessor.localClean(trimmed) } - if store.engineMode == "cloud", injectedClient == nil { - guard !store.apiKey.isEmpty else { + if injectedClient == nil { + let providerId = Self.resolvedProviderId(store: store, providerIdOverride: providerIdOverride) + let hasPolishKey = Self.hasPolishAPIKey(store: store, providerId: providerId) + guard hasPolishKey else { + if case .unavailable = Keychain.apiKeyOutcome(for: providerId, preferICloudSync: true) { + throw PolishError.keychainLocked + } throw PolishError.missingAPIKey } } @@ -150,10 +159,14 @@ public actor PolishingService { ) let apiKey: String if effectiveProviderId == "deepseek" { - guard PreconfiguredKeys.isDeepseekConfigured else { + let userKey = store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + if !userKey.isEmpty { + apiKey = userKey + } else if PreconfiguredKeys.isDeepseekConfigured { + apiKey = PreconfiguredKeys.deepseek + } else { throw PolishError.missingAPIKey } - apiKey = PreconfiguredKeys.deepseek } else { apiKey = store.apiKey } @@ -356,7 +369,11 @@ public actor PolishingService { /// (unpolished, unsegmented) ASR text. internal func effectiveTimeout(for text: String) -> TimeInterval { let scaled = timeout + (Double(text.count) / 100.0) * 10.0 - return min(max(scaled, timeout), 120) + // The cap participates in the keyboard-watchdog budget — see + // `FlowSessionKeys.keyboardResultTimeout`. Raising it here without + // going through that constant would silently break the invariant + // "keyboard timeout > host worst case". + return min(max(scaled, timeout), FlowSessionKeys.maxPolishTimeout) } internal static func resolvedProviderId( @@ -366,11 +383,25 @@ public actor PolishingService { if let providerIdOverride { return providerIdOverride } - if store.engineMode == "local" { + let id = store.providerId + // Local installs without a user LLM key keep using the built-in DeepSeek path. + if store.engineMode == "local", + id != "deepseek", + store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + PreconfiguredKeys.isDeepseekConfigured { return "deepseek" } - let id = store.providerId - return id == "deepseek" ? "openai" : id + return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id + } + + internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool { + if !store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + if providerId == "deepseek", PreconfiguredKeys.isDeepseekConfigured { + return true + } + return false } internal static func resolveLLMEndpoint( @@ -396,6 +427,8 @@ extension PolishingService.PolishError: LocalizedError { return "LLM polish timed out." case .missingAPIKey: return "Missing API key (cloud: Settings API key; local: build configuration)." + case .keychainLocked: + return "API key unavailable while the device is locked — will work after unlock." } } } diff --git a/OSGKeyboardShared/Services/SpeechHistoryStore.swift b/OSGKeyboardShared/Services/SpeechHistoryStore.swift index 3bd4742..c661843 100644 --- a/OSGKeyboardShared/Services/SpeechHistoryStore.swift +++ b/OSGKeyboardShared/Services/SpeechHistoryStore.swift @@ -34,6 +34,7 @@ public final class SpeechHistoryStore: ObservableObject { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } + rebaseOnPersistedStateBeforeMutation() let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode) payload.entries.insert(entry, at: 0) payload.trimEntries() @@ -42,6 +43,7 @@ public final class SpeechHistoryStore: ObservableObject { } public func delete(id: UUID) { + rebaseOnPersistedStateBeforeMutation() guard payload.entries.contains(where: { $0.id == id }) else { return } payload.deletedEntryIDs[id] = Date() payload.entries.removeAll { $0.id == id } @@ -51,12 +53,24 @@ public final class SpeechHistoryStore: ObservableObject { } public func clearAll() { + rebaseOnPersistedStateBeforeMutation() payload.recordClearAll() payload.updatedAt = Date() payload.pruneTombstonesIfNeeded() applyPayload(postCloudPush: true) } + /// Cloud pulls write the merged history to disk but only *schedule* the + /// in-memory reload (the notification observer hops through a Task). + /// Mutating a stale snapshot and saving it wholesale would erase whatever + /// that merge just brought in — always rebase on the persisted state + /// before mutating. + private func rebaseOnPersistedStateBeforeMutation() { + let disk = SpeechHistoryStorage.load(from: defaults) + guard disk != payload else { return } + payload = SyncedSpeechHistory.merge(local: payload, remote: disk) + } + public func snapshot() -> SyncedSpeechHistory { payload } diff --git a/OSGKeyboardShared/Services/TranscriptionPolishFallback.swift b/OSGKeyboardShared/Services/TranscriptionPolishFallback.swift new file mode 100644 index 0000000..aae760b --- /dev/null +++ b/OSGKeyboardShared/Services/TranscriptionPolishFallback.swift @@ -0,0 +1,47 @@ +// TranscriptionPolishFallback.swift +// OSGKeyboard · Shared +// +// Shared polish-failure handling: conservative raw ASR cleanup plus +// bilingual user-visible warnings (iOS Flow + macOS dictation). + +import Foundation + +public enum TranscriptionPolishFallback: Sendable { + + public static func makeDelivery( + rawText: String, + error: Error, + engineMode: String, + chunkWarning: String? + ) -> TranscriptionDelivery { + let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText) + let warning = warning(for: error, engineMode: engineMode) + ?? degradedWarning() + ?? chunkWarning + return TranscriptionDelivery(text: fallbackText, polishWarning: warning) + } + + public static func warning(for error: Error, engineMode: String) -> String? { + if let polishError = error as? PolishingService.PolishError { + switch polishError { + case .missingAPIKey: + if engineMode == "local" { + return SharedL10n.string("flow.warning.localPolishUnavailable") + } + return SharedL10n.string("flow.warning.cloudPolishMissingKey") + case .timeout, .keychainLocked: + return degradedWarning() + case .noTranscript: + return nil + } + } + if error is LLMError { + return degradedWarning() + } + return nil + } + + public static func degradedWarning() -> String? { + SharedL10n.string("flow.warning.polishDegraded") + } +} diff --git a/OSGKeyboardShared/Utilities/DictationTextComposer.swift b/OSGKeyboardShared/Utilities/DictationTextComposer.swift index 9fd508a..9ff4af8 100644 --- a/OSGKeyboardShared/Utilities/DictationTextComposer.swift +++ b/OSGKeyboardShared/Utilities/DictationTextComposer.swift @@ -60,6 +60,27 @@ public enum DictationTextComposer { return isCJK(last) && isCJK(first) } + /// Separator to place between existing document text and an inserted + /// transcript. Inserting at a cursor that sits right after "Hello" must + /// produce "Hello world", not "Helloworld" — but CJK, whitespace, and + /// opening-punctuation boundaries take no space. + public static func insertionSeparator(previousContext: String?, insertion: String) -> String { + guard let previousContext, + let last = previousContext.unicodeScalars.last, + let first = insertion.unicodeScalars.first else { + return "" + } + if CharacterSet.whitespacesAndNewlines.contains(last) { return "" } + if isCJK(last) || isCJK(first) { return "" } + // No space after opening brackets/quotes ("(", "[", "「", """…). + if CharacterSet(charactersIn: "([{\u{201C}\u{2018}\u{300C}\u{300E}\u{3010}\u{FF08}").contains(last) { + return "" + } + // No space before closing/clause punctuation (".", ",", ")", "!"…). + if CharacterSet.punctuationCharacters.contains(first) { return "" } + return " " + } + static func normalizeForOverlap(_ text: String) -> String { text.unicodeScalars.filter { !CharacterSet.whitespacesAndNewlines.contains($0) diff --git a/OSGKeyboardShared/Views/FlowDebugPanel.swift b/OSGKeyboardShared/Views/FlowDebugPanel.swift new file mode 100644 index 0000000..bf506ca --- /dev/null +++ b/OSGKeyboardShared/Views/FlowDebugPanel.swift @@ -0,0 +1,174 @@ +// FlowDebugPanel.swift +// OSGKeyboard · Shared +// +// TEMPORARY debug overlay for cross-process Flow state. Remove after the +// orange-mic investigation. Shows the same App Group contract fields on both +// the host app and the keyboard extension so we can see where they diverge. + +import SwiftUI + +/// One labeled row in the temporary Flow debug panel. +public struct FlowDebugRow: Equatable, Sendable { + public let label: String + public let value: String + + public init(_ label: String, _ value: String) { + self.label = label + self.value = value + } +} + +/// Builds the App Group half of the debug snapshot (readable from both processes). +public enum FlowDebugAppGroupSnapshot { + public static func rows(defaults: UserDefaults? = nil) -> [FlowDebugRow] { + FlowSessionBridge.reloadFromDisk(defaults: defaults) + let snapshot = FlowSessionBridge.readySnapshot(defaults: defaults) + let staleness = FlowSessionBridge.heartbeatStaleness(defaults: defaults) + let generation = FlowSessionBridge.currentHostGeneration(defaults: defaults) + let shortGen: String = { + guard let generation, generation.count >= 8 else { return generation ?? "nil" } + return String(generation.prefix(8)) + }() + let snapGen: String = { + guard let g = snapshot?.hostGeneration, g.count >= 8 else { + return snapshot?.hostGeneration ?? "nil" + } + return String(g.prefix(8)) + }() + let expires: String = { + guard let ts = FlowSessionBridge.sessionExpiresAt(defaults: defaults) else { return "nil" } + let remaining = ts - Date().timeIntervalSince1970 + return String(format: "%.0fs", remaining) + }() + + return [ + FlowDebugRow("sessionActive", FlowSessionBridge.isSessionActive(defaults: defaults) ? "1" : "0"), + FlowDebugRow("expiresIn", expires), + FlowDebugRow("hostReachable", FlowSessionBridge.isHostReachable(defaults: defaults) ? "1" : "0"), + FlowDebugRow("hostReady", FlowSessionBridge.isHostReady(defaults: defaults) ? "1" : "0"), + FlowDebugRow("hostStale", FlowSessionBridge.isHostStale(defaults: defaults) ? "1" : "0"), + FlowDebugRow("hbStale", staleness.map { String(format: "%.1fs", $0) } ?? "nil"), + FlowDebugRow("snap.ready", snapshot.map { $0.ready ? "1" : "0" } ?? "nil"), + FlowDebugRow("snap.reason", snapshot?.reason.rawValue ?? "nil"), + FlowDebugRow("snap.session", shortUUID(snapshot?.sessionId)), + FlowDebugRow("gen.now", shortGen), + FlowDebugRow("gen.snap", snapGen), + FlowDebugRow("gen.match", { + guard let a = snapshot?.hostGeneration, + let b = generation else { return "n/a" } + return a == b ? "1" : "0" + }()), + FlowDebugRow("pendingHost", FlowSessionBridge.pendingHostBundleId(defaults: defaults) ?? "nil"), + FlowDebugRow("recState", FlowSessionBridge.recordingState(defaults: defaults).rawValue), + FlowDebugRow("appGroup", AppGroup.isAvailable ? "1" : "0") + ] + } + + private static func shortUUID(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(8)) + } +} + +/// Collapsible monospaced status panel. Temporary — for investigation only. +public struct FlowDebugPanel: View { + public let title: String + public let rows: [FlowDebugRow] + @Binding public var isExpanded: Bool + public var maxContentHeight: CGFloat + + public init( + title: String, + rows: [FlowDebugRow], + isExpanded: Binding, + maxContentHeight: CGFloat = 180 + ) { + self.title = title + self.rows = rows + self._isExpanded = isExpanded + self.maxContentHeight = maxContentHeight + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 6) { + Text(isExpanded ? "▼" : "▶") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + Text(title) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + Spacer(minLength: 0) + Text(summaryChip) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(summaryColor) + } + .foregroundStyle(Color.primary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + HStack(alignment: .top, spacing: 6) { + Text(row.label) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.secondary) + .frame(width: 92, alignment: .leading) + Text(row.value) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(Color.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .frame(maxHeight: maxContentHeight) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.orange.opacity(0.7), lineWidth: 1) + ) + } + + private var summaryChip: String { + let hostReady = rows.first(where: { $0.label == "hostReady" })?.value + ?? rows.first(where: { $0.label == "bridgeReady" })?.value + ?? "?" + let mic = rows.first(where: { $0.label == "mic" })?.value + if let mic { + return "mic=\(shortMic(mic)) hr=\(hostReady)" + } + let active = rows.first(where: { $0.label == "isActive" })?.value ?? "?" + return "active=\(active) hr=\(hostReady)" + } + + private var summaryColor: Color { + let hostReady = rows.first(where: { $0.label == "hostReady" })?.value + ?? rows.first(where: { $0.label == "bridgeReady" })?.value + if hostReady == "1" { return .green } + return .orange + } + + private func shortMic(_ value: String) -> String { + if value.hasPrefix("ready") { return "ready" } + if value.contains("preparing") { return "prep" } + if value.contains("hostNotReady") { return "notReady" } + if value.contains("recording") { return "rec" } + if value.contains("processing") { return "proc" } + if value.contains("noFullAccess") { return "noFA" } + if value.contains("appGroup") { return "noAG" } + if value.contains("missingAPIKey") { return "noKey" } + return String(value.prefix(12)) + } +} diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index b8509ed..8ab9054 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -101,18 +101,25 @@ "keyboard.translation.a11yHint" = "Toggle translation or change the target language."; /* macOS app */ -"mac.section.dashboard" = "Dashboard"; +"mac.section.dashboard" = "Home"; "mac.section.history" = "History"; -"mac.section.dictionary" = "Personal Dictionary"; +"mac.section.dictionary" = "Dictionary"; "mac.section.settings" = "Settings"; "mac.brand.subtitle" = "AI DICTATION"; +"mac.brand.tagline" = "Speak it. It’s typed."; +"mac.brand.tagline.subtitle" = "Local-first · Cross-device · One-tap dictation"; "mac.devices" = "Devices"; -"mac.status.ready" = "Ready to dictate…"; +"mac.page.history.subtitle" = "Every dictation, kept in order."; +"mac.page.dictionary.subtitle" = "Words that teach recognition your voice."; +"mac.page.settings.subtitle" = "Engine, shortcuts, and appearance."; +"mac.status.ready" = "Ready when you are…"; "mac.status.listening" = "Listening…"; "mac.status.transcribing" = "Transcribing…"; +"mac.status.polishing" = "Polishing…"; "mac.status.copied" = "Copied to clipboard"; "mac.status.pasted" = "Inserted into front app"; "mac.status.copiedAndPasted" = "Copied and inserted"; +"mac.status.deliveryWithNote" = "%@ — %@"; "mac.stat.dictationTime" = "Dictation Time"; "mac.stat.words" = "Dictation Chars"; "mac.stat.translation" = "Translation Chars"; @@ -123,6 +130,12 @@ "mac.stat.customTerms" = "Custom terms"; "mac.status.chipReady" = "Ready"; "mac.status.chipProcessing" = "Processing"; +"mac.overlay.listening" = "Listening"; +"mac.overlay.preparing" = "Starting…"; +"mac.overlay.transcribing" = "Transcribing"; +"mac.overlay.polishing" = "Polishing"; +"mac.overlay.live" = "Live"; +"mac.overlay.done" = "Done"; "mac.record.start" = "Record"; "mac.record.stop" = "Stop"; "mac.record.pressStop" = "Press Stop"; @@ -133,8 +146,8 @@ "mac.mode.local" = "Local Mode"; "mac.connected" = "Connected"; "mac.offline" = "Offline"; -"mac.history.recent" = "Recent"; -"mac.history.empty" = "No voice transcripts yet."; +"mac.history.empty" = "No dictations yet"; +"mac.history.emptyBody" = "Hold Option anywhere to speak — transcripts land here."; "mac.history.select" = "Select a dictation"; "mac.history.clearTitle" = "Clear all history?"; "mac.history.clearMessage" = "This cannot be undone."; @@ -151,6 +164,12 @@ "mac.dict.deleteMessage" = "This cannot be undone."; "mac.hint.holdOption" = "Hold Option to dictate"; "mac.settings.cloudProvider" = "CLOUD PROVIDER"; +"mac.settings.polishProvider" = "TEXT POLISH (LLM)"; +"mac.settings.asrProvider" = "SPEECH RECOGNITION (ASR)"; +"mac.settings.asrService" = "ASR service"; +"mac.settings.asrApiKey" = "ASR API key"; +"mac.settings.asrModel" = "ASR model"; +"mac.settings.baseURL" = "Base URL"; "mac.settings.service" = "Service"; "mac.settings.apiKey" = "API Key"; "mac.settings.model" = "Model"; @@ -174,6 +193,15 @@ "mac.settings.autoPasteDesc" = "Simulate ⌘V in the front app after transcription (requires Accessibility)."; "mac.settings.hotkey" = "Global shortcut"; "mac.settings.hotkeyDesc" = "Hold Option (⌥) to dictate from any app."; +"mac.settings.hotkeyTrigger" = "Shortcut key"; +"mac.settings.hotkeyTriggerDesc" = "Which Option (⌥) key starts dictation when held."; +"mac.hotkeyTrigger.rightOption" = "Right Option (⌥)"; +"mac.hotkeyTrigger.leftOption" = "Left Option (⌥)"; +"mac.hotkeyTrigger.eitherOption" = "Either Option key"; +"mac.hint.hold.rightOption" = "Hold right Option (⌥) to dictate"; +"mac.hint.hold.leftOption" = "Hold left Option (⌥) to dictate"; +"mac.hint.hold.eitherOption" = "Hold either Option (⌥) to dictate"; +"mac.hint.holdOption" = "Hold Option to dictate"; "mac.settings.qwen3Model" = "Qwen3 model folder"; "mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt."; "mac.settings.qwen3Browse" = "Choose folder…"; @@ -241,6 +269,9 @@ "mac.localASR.installed" = "Installed"; "mac.localASR.notInstalled" = "Not installed"; "mac.localASR.personalDictionaryTag" = "Personal dictionary"; +"mac.localASR.badge.fastest" = "Fastest"; +"mac.localASR.badge.balanced" = "Most balanced"; +"mac.localASR.badge.quality" = "Best quality"; "mac.localASR.hotwordsYes" = "Hotwords"; "mac.localASR.hotwordsNo" = "No hotwords"; "mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle."; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index 771ca39..9941628 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -101,18 +101,25 @@ "keyboard.translation.a11yHint" = "切换翻译或更改目标语言。"; /* macOS 应用 */ -"mac.section.dashboard" = "仪表盘"; +"mac.section.dashboard" = "首页"; "mac.section.history" = "历史"; -"mac.section.dictionary" = "个性词库"; +"mac.section.dictionary" = "词库"; "mac.section.settings" = "设置"; "mac.brand.subtitle" = "AI 听写"; +"mac.brand.tagline" = "开口即文字。"; +"mac.brand.tagline.subtitle" = "本地优先 · 跨端同步 · 一键听写"; "mac.devices" = "设备"; -"mac.status.ready" = "准备听写…"; -"mac.status.listening" = "录音中…"; +"mac.page.history.subtitle" = "每一次听写,按时间妥善保存。"; +"mac.page.dictionary.subtitle" = "让识别更懂你的用词。"; +"mac.page.settings.subtitle" = "引擎、快捷键与外观。"; +"mac.status.ready" = "准备好了,随时开口…"; +"mac.status.listening" = "正在聆听…"; "mac.status.transcribing" = "识别中…"; +"mac.status.polishing" = "润色中…"; "mac.status.copied" = "已复制到剪贴板"; "mac.status.pasted" = "已插入前台应用"; "mac.status.copiedAndPasted" = "已复制并插入"; +"mac.status.deliveryWithNote" = "%@ — %@"; "mac.stat.dictationTime" = "听写时长"; "mac.stat.words" = "听写字数"; "mac.stat.translation" = "翻译字数"; @@ -123,6 +130,12 @@ "mac.stat.customTerms" = "自定义词条"; "mac.status.chipReady" = "就绪"; "mac.status.chipProcessing" = "处理中"; +"mac.overlay.listening" = "聆听中"; +"mac.overlay.preparing" = "启动中…"; +"mac.overlay.transcribing" = "识别中"; +"mac.overlay.polishing" = "润色中"; +"mac.overlay.live" = "实时"; +"mac.overlay.done" = "已完成"; "mac.record.start" = "开始录音"; "mac.record.stop" = "停止"; "mac.record.pressStop" = "点击停止"; @@ -133,8 +146,8 @@ "mac.mode.local" = "本地模式"; "mac.connected" = "已连接"; "mac.offline" = "离线"; -"mac.history.recent" = "最近"; -"mac.history.empty" = "还没有语音识别记录。"; +"mac.history.empty" = "还没有听写记录"; +"mac.history.emptyBody" = "在任意应用按住 Option 开口说话,记录会出现在这里。"; "mac.history.select" = "选择一条记录"; "mac.history.clearTitle" = "清空全部历史?"; "mac.history.clearMessage" = "此操作无法撤销。"; @@ -151,6 +164,12 @@ "mac.dict.deleteMessage" = "此操作无法撤销。"; "mac.hint.holdOption" = "长按 Option 开始听写"; "mac.settings.cloudProvider" = "云端服务商"; +"mac.settings.polishProvider" = "文本润色(LLM)"; +"mac.settings.asrProvider" = "语音转写(ASR)"; +"mac.settings.asrService" = "转写服务"; +"mac.settings.asrApiKey" = "转写 API 密钥"; +"mac.settings.asrModel" = "转写模型"; +"mac.settings.baseURL" = "接口地址"; "mac.settings.service" = "服务商"; "mac.settings.apiKey" = "API 密钥"; "mac.settings.model" = "模型"; @@ -174,6 +193,15 @@ "mac.settings.autoPasteDesc" = "转写完成后向前台应用模拟 ⌘V(需辅助功能权限)。"; "mac.settings.hotkey" = "全局快捷键"; "mac.settings.hotkeyDesc" = "按住 Option (⌥) 键即可从任意应用开始听写。"; +"mac.settings.hotkeyTrigger" = "快捷键按键"; +"mac.settings.hotkeyTriggerDesc" = "按住哪个 Option (⌥) 键开始听写。"; +"mac.hotkeyTrigger.rightOption" = "右 Option (⌥)"; +"mac.hotkeyTrigger.leftOption" = "左 Option (⌥)"; +"mac.hotkeyTrigger.eitherOption" = "任一 Option 键"; +"mac.hint.hold.rightOption" = "长按右 Option(⌥)开始听写"; +"mac.hint.hold.leftOption" = "长按左 Option(⌥)开始听写"; +"mac.hint.hold.eitherOption" = "长按任一 Option(⌥)开始听写"; +"mac.hint.holdOption" = "长按 Option 开始听写"; "mac.settings.qwen3Model" = "Qwen3 模型目录"; "mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。"; "mac.settings.qwen3Browse" = "选择文件夹…"; @@ -241,6 +269,9 @@ "mac.localASR.installed" = "已安装"; "mac.localASR.notInstalled" = "未安装"; "mac.localASR.personalDictionaryTag" = "个性词库"; +"mac.localASR.badge.fastest" = "速度最快"; +"mac.localASR.badge.balanced" = "最平衡"; +"mac.localASR.badge.quality" = "质量最好"; "mac.localASR.hotwordsYes" = "支持热词"; "mac.localASR.hotwordsNo" = "无热词"; "mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。"; diff --git a/OSGKeyboardTests/AppGroupConfigurationTests.swift b/OSGKeyboardTests/AppGroupConfigurationTests.swift index f34221b..489a9c2 100644 --- a/OSGKeyboardTests/AppGroupConfigurationTests.swift +++ b/OSGKeyboardTests/AppGroupConfigurationTests.swift @@ -20,7 +20,8 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertEqual(config.providerId, "openai") XCTAssertEqual(config.modeId, "polish") XCTAssertEqual(config.localeId, "auto") - XCTAssertEqual(config.engineMode, "cloud") + // Privacy-critical: the default engine must keep audio on-device. + XCTAssertEqual(config.engineMode, "local") XCTAssertFalse(config.hasCompletedOnboarding) XCTAssertEqual(config.onboardingPage, 0) XCTAssertFalse(config.hasAcknowledgedCloudSharing) @@ -31,7 +32,7 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertEqual(config.polishIntensity, .default) XCTAssertTrue(config.personalDictionary.entries.isEmpty) XCTAssertTrue(config.flowSkipAppSwitch) - XCTAssertEqual(config.flowInactivityDuration, .twelveHours) + XCTAssertEqual(config.flowInactivityDuration, .thirtyMinutes) } func testSaveAndLoadRoundTrip() { @@ -40,9 +41,13 @@ final class AppGroupConfigurationTests: XCTestCase { config.providerId = "anthropic" config.baseURL = "https://example.com/v1" config.model = "claude-test" + config.asrProviderId = "zhipu" + config.asrBaseURL = "https://open.bigmodel.cn/api/paas/v4" + config.asrModel = "glm-asr-2512" config.modeId = "polish" config.localeId = "zh-Hans" - config.engineMode = "local" + // Non-default value so the round-trip proves persistence. + config.engineMode = "cloud" config.hasCompletedOnboarding = true config.onboardingPage = 2 config.hasAcknowledgedCloudSharing = true @@ -52,15 +57,19 @@ final class AppGroupConfigurationTests: XCTestCase { config.cursorDragNavigationEnabled = false config.polishIntensity = .light config.flowSkipAppSwitch = false - config.flowInactivityDuration = .thirtyMinutes + // Use a non-default value so the round-trip actually proves persistence. + config.flowInactivityDuration = .threeHours config.save(to: defaults) let loaded = AppGroupConfiguration.load(fromAvailable: defaults) XCTAssertEqual(loaded.providerId, "anthropic") XCTAssertEqual(loaded.baseURL, "https://example.com/v1") XCTAssertEqual(loaded.model, "claude-test") + XCTAssertEqual(loaded.asrProviderId, "zhipu") + XCTAssertEqual(loaded.asrBaseURL, "https://open.bigmodel.cn/api/paas/v4") + XCTAssertEqual(loaded.asrModel, "glm-asr-2512") XCTAssertEqual(loaded.localeId, "zh-Hans") - XCTAssertEqual(loaded.engineMode, "local") + XCTAssertEqual(loaded.engineMode, "cloud") XCTAssertTrue(loaded.hasCompletedOnboarding) XCTAssertEqual(loaded.onboardingPage, 2) XCTAssertTrue(loaded.hasAcknowledgedCloudSharing) @@ -71,7 +80,37 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertFalse(loaded.cursorDragNavigationEnabled) XCTAssertEqual(loaded.polishIntensity, .light) XCTAssertFalse(loaded.flowSkipAppSwitch) - XCTAssertEqual(loaded.flowInactivityDuration, .thirtyMinutes) + XCTAssertEqual(loaded.flowInactivityDuration, .threeHours) + } + + /// Existing installs (onboarding completed, no explicit engineMode key) + /// ran on the old "cloud"/12h defaults — a silent flip to the new + /// privacy defaults would change their engine under them AND propagate + /// through settings sync as a fake fresh edit to their other devices. + func testDefaultMigrationPreservesExistingInstallBehavior() { + let defaults = makeDefaults() + defaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding) + + let config = AppGroupConfiguration.load(fromAvailable: defaults) + + XCTAssertEqual(config.engineMode, "cloud", "pre-picker installs stay on their old default") + XCTAssertEqual(config.flowInactivityDuration, .twelveHours) + // The resolution is persisted so it is stable and sync-invisible. + XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.engineMode), "cloud") + XCTAssertEqual( + defaults.string(forKey: AppGroupConfiguration.Keys.flowInactivityDuration), + FlowInactivityDuration.twelveHours.rawValue + ) + } + + func testDefaultMigrationGivesFreshInstallPrivacyDefaults() { + let defaults = makeDefaults() + + let config = AppGroupConfiguration.load(fromAvailable: defaults) + + XCTAssertEqual(config.engineMode, "local") + XCTAssertEqual(config.flowInactivityDuration, .thirtyMinutes) + XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.engineMode), "local") } func testTranslationEnabledDerivedFromTargetLocale() { diff --git a/OSGKeyboardTests/ConfigurationStoreTests.swift b/OSGKeyboardTests/ConfigurationStoreTests.swift index b66f0ca..fcf0276 100644 --- a/OSGKeyboardTests/ConfigurationStoreTests.swift +++ b/OSGKeyboardTests/ConfigurationStoreTests.swift @@ -41,4 +41,30 @@ final class ConfigurationStoreTests: XCTestCase { let service = ASRServiceFactory.make(store: store as any ConfigurationStore) XCTAssertTrue(service is SpeechAnalyzerASR) } + + func testASRAndPolishProvidersAreIndependent() throws { + try Keychain.setAPIKey("sk-llm", for: "openai", useICloudSync: false) + try Keychain.setASRAPIKey("sk-asr", for: "zhipu", useICloudSync: false) + + var config = AppGroupConfiguration.load(fromAvailable: defaults) + config.engineMode = "cloud" + config.providerId = "openai" + config.asrProviderId = "zhipu" + config.save(to: defaults) + + let loaded = AppGroupStore(defaults: defaults) + XCTAssertEqual(loaded.providerId, "openai") + XCTAssertEqual(loaded.asrProviderId, "zhipu") + XCTAssertEqual(loaded.apiKey, "sk-llm") + XCTAssertEqual(loaded.asrApiKey, "sk-asr") + + let asrClient = CloudASRClientFactory.make(store: loaded) + XCTAssertTrue(asrClient is ZhipuCloudASRClient) + } + + func testLegacyInstallCopiesProviderIdToAsrProviderId() { + defaults.set("qwen", forKey: AppGroupConfiguration.Keys.providerId) + let config = AppGroupConfiguration.load(fromAvailable: defaults) + XCTAssertEqual(config.asrProviderId, "qwen") + } } diff --git a/OSGKeyboardTests/FlowBudgetAndMergeTests.swift b/OSGKeyboardTests/FlowBudgetAndMergeTests.swift new file mode 100644 index 0000000..982c9cf --- /dev/null +++ b/OSGKeyboardTests/FlowBudgetAndMergeTests.swift @@ -0,0 +1,212 @@ +// FlowBudgetAndMergeTests.swift +// OSGKeyboardTests +// +// Guards the cross-cutting invariants introduced by the reliability +// overhaul: timeout budgets derived from a single source, LWW clock +// clamping, and mutation-rebase for the speech history store. + +import XCTest +@testable import OSGKeyboardShared + +final class FlowBudgetAndMergeTests: XCTestCase { + + // MARK: - Timeout budget invariant + + /// The keyboard's post-stop watchdog must outlast the host's worst case + /// (ASR drain wait + LLM polish cap) with real margin — otherwise the + /// keyboard reports a timeout for transcriptions that are still going + /// to succeed, and hand-tuned constants have drifted below the host + /// maximum before. + func testKeyboardResultTimeoutOutlastsHostWorstCase() { + for engineMode in ["local", "cloud"] { + let hostWorstCase = (engineMode == "local" + ? FlowSessionKeys.localASRWaitTimeout + : FlowSessionKeys.cloudASRWaitTimeout) + + FlowSessionKeys.maxPolishTimeout + let keyboardTimeout = FlowSessionKeys.keyboardResultTimeout(engineMode: engineMode) + XCTAssertGreaterThanOrEqual( + keyboardTimeout, + hostWorstCase + 10, + "keyboard watchdog (\(engineMode)) must exceed host worst case with margin" + ) + } + } + + // MARK: - SyncedField future-clock clamping + + func testMergePrefersGenuinelyNewerRemote() { + let older = SyncedField(value: "a", updatedAt: Date(timeIntervalSinceNow: -100), deviceID: "A") + let newer = SyncedField(value: "b", updatedAt: Date(timeIntervalSinceNow: -10), deviceID: "B") + XCTAssertEqual(SyncedField.merge(local: older, remote: newer).value, "b") + XCTAssertEqual(SyncedField.merge(local: newer, remote: older).value, "b") + } + + /// A device with a clock years in the future must not win every merge + /// forever: its timestamp is clamped to "now" for comparison, so an + /// edit carrying a trusted (within-skew) later stamp still beats it — + /// with unclamped LWW the year-ahead stamp would win against everything + /// until that wall-clock date actually arrived. + func testMergeClampsAbsurdFutureRemoteTimestamp() { + let farFuture = Date().addingTimeInterval(365 * 24 * 3600) + let brokenClock = SyncedField(value: "broken", updatedAt: farFuture, deviceID: "B") + // Sane edit one minute ahead of now: inside the trusted skew window, + // so it is NOT clamped — while the broken stamp collapses to ~now. + let local = SyncedField(value: "sane", updatedAt: Date().addingTimeInterval(60), deviceID: "A") + XCTAssertEqual( + SyncedField.merge(local: local, remote: brokenClock).value, + "sane", + "a year-ahead stamp must lose to a trusted, genuinely newer edit" + ) + XCTAssertEqual( + SyncedField.merge(local: brokenClock, remote: local).value, + "sane", + "clamping must be symmetric regardless of which side is remote" + ) + } + + /// The winner's untrusted future stamp must be REWRITTEN to now in the + /// merged result — otherwise the stored far-future stamp keeps beating + /// every later genuine edit until that wall-clock date arrives. + func testMergeFlattensUntrustedWinnerStamp() { + let farFuture = Date().addingTimeInterval(365 * 24 * 3600) + let broken = SyncedField(value: "broken", updatedAt: farFuture, deviceID: "B") + let old = SyncedField(value: "old", updatedAt: Date(timeIntervalSinceNow: -9999), deviceID: "A") + let merged = SyncedField.merge(local: old, remote: broken) + XCTAssertEqual(merged.value, "broken", "newer (clamped) edit still wins this merge") + XCTAssertLessThan( + merged.updatedAt.timeIntervalSinceNow, 60, + "the far-future stamp must be flattened so later real edits can outrank it" + ) + } + + func testMergeTrustsModestFutureSkew() { + // Small forward skew (minutes) is normal clock drift and stays trusted. + let slightlyAhead = SyncedField(value: "ahead", updatedAt: Date().addingTimeInterval(120), deviceID: "A") + let past = SyncedField(value: "past", updatedAt: Date(timeIntervalSinceNow: -3600), deviceID: "B") + XCTAssertEqual(SyncedField.merge(local: past, remote: slightlyAhead).value, "ahead") + } + + // MARK: - History push byte budget + + /// A history that outgrew the KVS byte budget must be trimmed (oldest + /// entries first) for upload, not fail forever — automatic pushes are + /// fire-and-forget, so a throwing encode would silently kill sync with + /// no recovery path short of clearing all history. + @MainActor + func testOversizedHistoryPushTrimsOldestEntriesToFitBudget() throws { + let sync = SpeechHistoryCloudSync( + kvs: FakeUbiquitousKeyValueStore(), + makeStore: { AppGroupStore(defaults: self.makeDefaults()) }, + historyDefaults: { self.makeDefaults() } + ) + + // ~300 entries × ~2.4 KB ≈ 720 KB encoded — over the 400 KB budget. + let filler = String(repeating: "很长的听写内容 long dictation text ", count: 80) + let now = Date() + var history = SyncedSpeechHistory.empty + history.entries = (0..<300).map { index in + SpeechHistoryEntry( + text: "\(filler)#\(index)", + createdAt: now.addingTimeInterval(TimeInterval(-index)), + engineMode: "local" + ) + } + + let data = try sync.encodeFittingBudget(history) + XCTAssertLessThanOrEqual(data.count, SpeechHistoryCloudSync.maxPayloadBytes) + + let decoded = try sync.decode(data) + XCTAssertFalse(decoded.entries.isEmpty) + // Newest entries must survive the trim. + XCTAssertTrue(decoded.entries.contains { $0.text.hasSuffix("#0") }) + XCTAssertFalse(decoded.entries.contains { $0.text.hasSuffix("#299") }) + } + + // MARK: - Insertion word-boundary hygiene + + func testInsertionSeparatorAddsSpaceBetweenLatinWords() { + XCTAssertEqual( + DictationTextComposer.insertionSeparator(previousContext: "Hello", insertion: "world"), + " " + ) + XCTAssertEqual( + DictationTextComposer.insertionSeparator(previousContext: "version 2", insertion: "is out"), + " " + ) + } + + func testInsertionSeparatorSkipsWhitespaceCJKAndPunctuationBoundaries() { + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "Hello ", insertion: "world"), "") + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "line\n", insertion: "next"), "") + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "你好", insertion: "世界"), "") + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "说英文", insertion: "now"), "") + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "see (", insertion: "note"), "") + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "wait", insertion: ", then go"), "") + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: nil, insertion: "fresh"), "") + XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "", insertion: "fresh"), "") + } + + // MARK: - SpeechHistoryStore rebase-before-mutation + + private func makeDefaults() -> UserDefaults { + let suite = "group.com.osgkeyboard.shared.tests.history.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + /// Cloud pulls write merged history to disk and only *schedule* the + /// in-memory reload. A mutation racing that reload must not wipe what + /// the merge brought in. + @MainActor + func testAppendDoesNotEraseEntriesMergedToDiskBehindItsBack() { + let defaults = makeDefaults() + let store = SpeechHistoryStore(defaults: defaults) + + store.append(text: "本地第一条", engineMode: "local") + XCTAssertEqual(store.entries.count, 1) + + // Simulate a cloud merge landing on disk without the store's + // in-memory payload being reloaded yet. + var onDisk = SpeechHistoryStorage.load(from: defaults) + let remoteEntry = SpeechHistoryEntry(text: "远端合并进来的一条", engineMode: "cloud") + onDisk.entries.append(remoteEntry) + onDisk.updatedAt = Date() + SpeechHistoryStorage.save(onDisk, to: defaults) + + // Mutate through the store — pre-fix this overwrote the disk state + // with the stale in-memory payload, deleting the remote entry. + store.append(text: "本地第二条", engineMode: "local") + + let persisted = SpeechHistoryStorage.load(from: defaults) + XCTAssertTrue( + persisted.entries.contains { $0.id == remoteEntry.id }, + "append must rebase on the persisted state instead of clobbering the cloud merge" + ) + XCTAssertTrue(persisted.entries.contains { $0.text == "本地第二条" }) + XCTAssertTrue(persisted.entries.contains { $0.text == "本地第一条" }) + } + + @MainActor + func testDeleteAfterExternalDiskMergeStillTombstones() { + let defaults = makeDefaults() + let store = SpeechHistoryStore(defaults: defaults) + store.append(text: "要删除的一条", engineMode: "local") + guard let target = store.entries.first else { + return XCTFail("expected an entry") + } + + // External merge adds an unrelated entry on disk. + var onDisk = SpeechHistoryStorage.load(from: defaults) + onDisk.entries.append(SpeechHistoryEntry(text: "外部条目", engineMode: "cloud")) + onDisk.updatedAt = Date() + SpeechHistoryStorage.save(onDisk, to: defaults) + + store.delete(id: target.id) + + let persisted = SpeechHistoryStorage.load(from: defaults) + XCTAssertFalse(persisted.entries.contains { $0.id == target.id }) + XCTAssertNotNil(persisted.deletedEntryIDs[target.id], "delete must record a tombstone") + XCTAssertTrue(persisted.entries.contains { $0.text == "外部条目" }, "external entry must survive") + } +} diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift index 286c3f5..5490076 100644 --- a/OSGKeyboardTests/FlowSessionBridgeTests.swift +++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift @@ -238,6 +238,142 @@ final class FlowSessionBridgeTests: XCTestCase { XCTAssertEqual(FlowSessionBridge.latestAck(defaults: defaults), ack) } + func testNotReadySnapshotDoesNotRefreshHeartbeat() { + let defaults = makeDefaults() + FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults) + let zombieHeartbeat = Date().timeIntervalSince1970 - 120 + defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat) + + // A host stuck in a failed cold start writes not-ready snapshots on + // every engine flap; those must NOT revive the heartbeat, or zombie + // detection is postponed forever. + FlowSessionBridge.writeReadySnapshot( + FlowReadySnapshot( + sessionId: UUID(), + ready: false, + reason: .waitingForAudioProof, + engineMode: "local", + localeId: "zh-Hans" + ), + defaults: defaults + ) + + XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults)) + } + + func testBusySnapshotStillRefreshesHeartbeat() { + let defaults = makeDefaults() + FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults) + let staleHeartbeat = Date().timeIntervalSince1970 - 10 + defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat) + + // Recording/processing proves the host is alive even though the + // snapshot is not "ready" — the heartbeat must keep flowing so the + // keyboard does not declare a mid-utterance host dead. + let sessionId = UUID() + FlowSessionBridge.writeReadySnapshot( + FlowReadySnapshot( + sessionId: sessionId, + ready: false, + reason: .recording, + engineMode: "local", + localeId: "zh-Hans", + busyUtteranceId: UUID() + ), + defaults: defaults + ) + + XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults)) + // Not-ready busy snapshots must remain readable so the keyboard can + // distinguish "host is recording" from "host is still starting". + let snap = FlowSessionBridge.readySnapshot(defaults: defaults) + XCTAssertEqual(snap?.reason, .recording) + XCTAssertEqual(snap?.ready, false) + XCTAssertEqual(snap?.sessionId, sessionId) + } + + func testNotReadyStartingSnapshotIsRetainedWithoutRevivingHeartbeat() { + let defaults = makeDefaults() + FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults) + let zombieHeartbeat = Date().timeIntervalSince1970 - 120 + defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat) + + FlowSessionBridge.writeReadySnapshot( + FlowReadySnapshot( + sessionId: UUID(), + ready: false, + reason: .waitingForAudioProof, + engineMode: "local", + localeId: "zh-Hans" + ), + defaults: defaults + ) + + XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults)) + XCTAssertEqual( + FlowSessionBridge.readySnapshot(defaults: defaults)?.reason, + .waitingForAudioProof + ) + } + + func testStaleGenerationSnapshotIsNotReady() { + let defaults = makeDefaults() + let sessionId = UUID() + let now = Date().timeIntervalSince1970 + FlowSessionBridge.rotateHostGeneration(defaults: defaults) + let liveGeneration = FlowSessionBridge.currentHostGeneration(defaults: defaults) + FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults) + FlowSessionBridge.writeReadySnapshot( + FlowReadySnapshot( + sessionId: sessionId, + ready: true, + reason: .ready, + heartbeatAt: now, + readyAt: now, + engineMode: "local", + localeId: "zh-Hans", + hostGeneration: liveGeneration + ), + defaults: defaults + ) + XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults)) + + // Host relaunches (force-quit path) → new generation. The old ready + // snapshot must be void instantly, without waiting out the 60 s + // heartbeat-zombie window. + FlowSessionBridge.rotateHostGeneration(defaults: defaults) + XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults)) + } + + func testClearFlowStateOnHostLaunchPreservesPendingHost() { + let defaults = makeDefaults() + FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults) + FlowSessionBridge.setHostReady(true, defaults: defaults) + FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults) + + FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults) + + XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults)) + XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults)) + // The startflow scene-delegate write happens before the session + // manager exists — launch reconciliation must not eat it. + XCTAssertEqual( + FlowSessionBridge.pendingHostBundleId(defaults: defaults), + "com.example.host" + ) + } + + func testRotateHostGenerationReturnsPreviousToken() { + let defaults = makeDefaults() + XCTAssertNil(FlowSessionBridge.rotateHostGeneration(defaults: defaults)) + let first = FlowSessionBridge.currentHostGeneration(defaults: defaults) + XCTAssertNotNil(first) + + let previous = FlowSessionBridge.rotateHostGeneration(defaults: defaults) + XCTAssertEqual(previous, first) + XCTAssertNotEqual(FlowSessionBridge.currentHostGeneration(defaults: defaults), first) + } + func testReadySnapshotDrivesHostReady() { let defaults = makeDefaults() let sessionId = UUID() diff --git a/OSGKeyboardTests/FlowSessionPolicyTests.swift b/OSGKeyboardTests/FlowSessionPolicyTests.swift index 8c17907..4aca356 100644 --- a/OSGKeyboardTests/FlowSessionPolicyTests.swift +++ b/OSGKeyboardTests/FlowSessionPolicyTests.swift @@ -17,10 +17,10 @@ final class FlowSessionPolicyTests: XCTestCase { XCTAssertTrue(FlowSessionPolicy.skipAppSwitch(defaults: defaults)) } - func testInactivityDurationDefaultsToTwelveHours() { + func testInactivityDurationDefaultsToThirtyMinutes() { let defaults = makeDefaults() - XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .twelveHours) - XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 12 * 60 * 60) + XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .thirtyMinutes) + XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 30 * 60) } func testTouchLastActivityExtendsExpiry() { diff --git a/OSGKeyboardTests/IntelligentPolishTests.swift b/OSGKeyboardTests/IntelligentPolishTests.swift index 9f62a3e..d9162d3 100644 --- a/OSGKeyboardTests/IntelligentPolishTests.swift +++ b/OSGKeyboardTests/IntelligentPolishTests.swift @@ -340,7 +340,7 @@ final class IntelligentPolishTests: XCTestCase { @MainActor func testFlowFallbackDeliveryCleansTextAndCarriesWeakNetworkWarning() { - let delivery = FlowSessionManager.makeFallbackDelivery( + let delivery = TranscriptionPolishFallback.makeDelivery( rawText: " 你 是不是 已经 解决了 这个 问题 ? ", error: LLMError.transport("offline"), engineMode: "cloud", @@ -352,6 +352,20 @@ final class IntelligentPolishTests: XCTestCase { XCTAssertEqual(delivery.polishWarning, SharedL10n.string("flow.warning.polishDegraded")) } + func testTranscriptionPolishFallbackLocalMissingKeyWarning() { + let delivery = TranscriptionPolishFallback.makeDelivery( + rawText: "测试文本", + error: PolishingService.PolishError.missingAPIKey, + engineMode: "local", + chunkWarning: nil + ) + XCTAssertEqual(delivery.text, "测试文本") + XCTAssertEqual( + delivery.polishWarning, + SharedL10n.string("flow.warning.localPolishUnavailable") + ) + } + func testHasStructureSignalDetectsChineseEnumeration() { XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "首先测试其次上线")) XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "第一点修复")) diff --git a/OSGKeyboardTests/LocalASRModelCatalogTests.swift b/OSGKeyboardTests/LocalASRModelCatalogTests.swift index 8a913cc..228c8ea 100644 --- a/OSGKeyboardTests/LocalASRModelCatalogTests.swift +++ b/OSGKeyboardTests/LocalASRModelCatalogTests.swift @@ -13,7 +13,20 @@ final class LocalASRModelCatalogTests: XCTestCase { 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" }) + XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-sensevoice-small-int8" }) + XCTAssertFalse(catalog.models.contains { $0.id == "sherpa-paraformer-zh-int8" }) + XCTAssertEqual( + LocalASRModelCatalog.model("sherpa-sensevoice-small-int8", in: catalog)?.badgeKey, + "mac.localASR.badge.fastest" + ) + XCTAssertEqual( + LocalASRModelCatalog.model("sherpa-qwen3-0.6b-int8", in: catalog)?.badgeKey, + "mac.localASR.badge.balanced" + ) + XCTAssertEqual( + LocalASRModelCatalog.model("sherpa-qwen3-1.7b-int8", in: catalog)?.badgeKey, + "mac.localASR.badge.quality" + ) } func testSherpaQwen317BUsesRepositoryInstall() throws { @@ -32,9 +45,9 @@ final class LocalASRModelCatalogTests: XCTestCase { XCTAssertTrue(model.supportsHotwords) } - func testCapabilitiesForParaformer() throws { + func testCapabilitiesForSenseVoice() throws { let catalog = try LocalASRModelCatalog.loadBundled() - let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-paraformer-zh-int8", in: catalog)) + let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-sensevoice-small-int8", in: catalog)) let caps = LocalASRModelCatalog.capabilities(for: model) XCTAssertEqual(caps.hotwordMode, .none) XCTAssertFalse(model.supportsHotwords) diff --git a/OSGKeyboardTests/SettingsCloudSyncTests.swift b/OSGKeyboardTests/SettingsCloudSyncTests.swift index f67105b..a873890 100644 --- a/OSGKeyboardTests/SettingsCloudSyncTests.swift +++ b/OSGKeyboardTests/SettingsCloudSyncTests.swift @@ -44,6 +44,9 @@ final class SettingsCloudSyncTests: XCTestCase { providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceA), baseURL: SyncedField(value: "https://local.example", updatedAt: stampA, deviceID: deviceA), model: SyncedField(value: "gpt-local", updatedAt: stampA, deviceID: deviceA), + asrProviderId: SyncedField(value: "zhipu", updatedAt: stampA, deviceID: deviceA), + asrBaseURL: SyncedField(value: "https://asr-local.example", updatedAt: stampA, deviceID: deviceA), + asrModel: SyncedField(value: "glm-asr-local", updatedAt: stampA, deviceID: deviceA), modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceA), localeId: SyncedField(value: "auto", updatedAt: stampA, deviceID: deviceA), engineMode: SyncedField(value: "cloud", updatedAt: stampA, deviceID: deviceA), @@ -64,6 +67,9 @@ final class SettingsCloudSyncTests: XCTestCase { providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceB), baseURL: SyncedField(value: "https://remote.example", updatedAt: stampB, deviceID: deviceB), model: SyncedField(value: "gpt-remote", updatedAt: stampB, deviceID: deviceB), + asrProviderId: SyncedField(value: "qwen", updatedAt: stampB, deviceID: deviceB), + asrBaseURL: SyncedField(value: "https://asr-remote.example", updatedAt: stampB, deviceID: deviceB), + asrModel: SyncedField(value: "fun-asr-remote", updatedAt: stampB, deviceID: deviceB), modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceB), localeId: SyncedField(value: "ja", updatedAt: stampB, deviceID: deviceB), engineMode: SyncedField(value: "local", updatedAt: stampB, deviceID: deviceB), @@ -80,6 +86,7 @@ final class SettingsCloudSyncTests: XCTestCase { let merged = SyncedAppSettingsV2.merge(local: local, remote: remote) XCTAssertEqual(merged.baseURL.value, "https://remote.example") + XCTAssertEqual(merged.asrProviderId.value, "qwen") XCTAssertEqual(merged.localeId.value, "ja") XCTAssertEqual(merged.engineMode.value, "local") } diff --git a/README.md b/README.md index 4a4345f..82f1a06 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ OSGKeyboard is a free, source-available alternative to commercial voice-input to 2. Speak naturally (up to 3.5 minutes / 210 seconds per take) 3. Tap again to stop — the AI polishes your words into clean text and inserts at the cursor -Audio is transcribed **on-device** by Apple's `SpeechAnalyzer` + `DictationTranscriber` (iOS 26+). Only the **polished transcript** is sent to your chosen cloud LLM. **No audio ever leaves your phone.** +By default, audio is transcribed **on-device** by Apple's `SpeechAnalyzer` + `DictationTranscriber` (iOS 26+) — **no audio leaves your phone** unless you say so. If polish is enabled, only the transcript text goes to your chosen LLM. Optionally, you can switch to a **cloud ASR engine** (explicit opt-in with a confirmation): in that mode your recordings are uploaded to the ASR provider you configure. Under the hood, OSGKeyboard uses a **Flow session model**: a long-lived audio session runs in the host app, the keyboard extension writes tiny "start / stop" signals to the App Group, and the polished text is delivered back to the keyboard for insertion. You do not need to jump back to the host app between recordings. @@ -34,7 +34,7 @@ Under the hood, OSGKeyboard uses a **Flow session model**: a long-lived audio se - ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists - 🧩 **Local + cloud polish toggle** — local engine is ASR-only by default; opt into a post-ASR cloud polish step (DeepSeek by default) when the iOS speech recognition isn't strong enough for your environment (noisy far-field audio, strong accents, etc.) - 🔌 **Bring-your-own API** — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, your own self-hosted server, …) -- 🔒 **Privacy first** — audio never leaves your device; only the final transcript is sent to the LLM you choose +- 🔒 **Privacy first** — on-device ASR by default, so audio never leaves your device unless you explicitly opt into the cloud engine; polish sends only the transcript to the LLM you choose - 🎨 **Native SwiftUI** — dark theme, frosted glass, pure Swift 6, ~3,600 lines of code - 🪶 **Zero dependencies** — no SwiftPM packages, no CocoaPods, no Carthage - 🔁 **Flow session** — keep recording across multiple takes without bouncing back to the host app @@ -46,7 +46,7 @@ Under the hood, OSGKeyboard uses a **Flow session model**: a long-lived audio se ### Requirements - macOS with **Xcode 26** (matches `project.yml` deployment target iOS 26) -- iPhone running **iOS 26.0+** +- iPhone or iPad running **iOS 26.0+** (iPad fully supported: Split View / Stage Manager, adaptive layout) - [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` - An OpenAI-compatible API key (e.g. from [OpenAI](https://platform.openai.com/api-keys), [DeepSeek](https://platform.deepseek.com/api_keys), or [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey)). Not needed if you stay on the "local ASR only" engine. @@ -65,6 +65,17 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ > `project.yml` by `Scripts/generate-xcodeproj.sh`. Always re-run the script > after `git pull` if `project.yml` has changed. +### macOS distribution (decision) + +The macOS menu-bar app ships via **Developer ID direct distribution** (notarized, +non-sandboxed), NOT the Mac App Store. This is deliberate: its core features — +global hold-to-talk hotkey, Accessibility-based text insertion into other apps, +and synthesized ⌘V — are incompatible with the Mac App Store sandbox, and a +sandboxed Accessibility grant also tends to reset after every app update. +`OSGKeyboardMac.entitlements` therefore keeps `com.apple.security.app-sandbox` +set to `false`; do not flip it back on without redesigning the insertion path. +(The iOS app targets the iOS App Store as usual — see `AUDIT_APPSTORE.md`.) + ### Enable the keyboard in iOS The host app walks you through a **5-step onboarding**: @@ -130,9 +141,9 @@ OSGKeyboard/ **Engine modes:** -- `cloud` (default) — on-device ASR via `SpeechAnalyzer`, transcript is sent to your configured LLM for polish. -- `local` — on-device ASR via `SpeechAnalyzer` only; transcript is inserted as-is. No network round-trip. -- `local` + "Cloud polish after ASR" toggle (Settings → Engine) — same on-device ASR, but the transcript is routed through your configured LLM before insertion. Useful when iOS speech recognition isn't accurate enough in your environment. +- `local` (default) — on-device ASR via `SpeechAnalyzer`; transcript is inserted as-is. No network round-trip. +- `local` + "Cloud polish after ASR" toggle (Settings → Engine) — same on-device ASR, but the transcript (text only) is routed through your configured LLM before insertion. Useful when iOS speech recognition isn't accurate enough in your environment. +- `cloud` (opt-in, requires an explicit confirmation) — **your voice recordings are uploaded** to the ASR provider you configure (e.g. OpenAI `/audio/transcriptions`, DashScope, Zhipu), and the resulting transcript is sent to your LLM for polish. Choose this only when you accept your provider's privacy terms. **Cross-process plumbing (host app ↔ keyboard extension):** diff --git a/README.zh.md b/README.zh.md index a24d80d..7723c37 100644 --- a/README.zh.md +++ b/README.zh.md @@ -20,7 +20,7 @@ OSGKeyboard 是商业语音输入工具的免费、源码可见替代方案。 2. 自由说话(单次上限 3.5 分钟 / 210 秒) 3. 再按一下结束 —— AI 自动整理成干净的文字并插入光标 -**音频始终在设备本地转写**(iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber`),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。** +默认情况下,**音频在设备本地转写**(iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber`)——**除非你主动选择,音频不会离开你的手机**;开启润色时也只有文本会发到你选择的 LLM。你也可以显式切换到**云端识别引擎**(需二次确认的 opt-in):该模式下你的录音会上传到你配置的识别服务商。 项目内部采用 **Flow 会话模型**:主 App 维护一个长生命周期的音频会话,键盘扩展只通过 App Group 写入"开始 / 停止"等轻量信号,润色后的文本再由主 App 回传给键盘插入。**多次录音之间无需反复跳回主 App**。 @@ -33,7 +33,7 @@ OSGKeyboard 是商业语音输入工具的免费、源码可见替代方案。 - ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表 - 🧩 **本地 + 云端润色开关** —— 本地模式默认仅在设备上识别;若 iOS 语音识别效果不理想(远场、噪声、方言),可开启「识别后云端润色」,默认走 DeepSeek - 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / Moonshot / 智谱 / 自建服务器 ……) -- 🔒 **隐私优先** —— 音频不离开设备;只有润色文本会发给你选择的 LLM +- 🔒 **隐私优先** —— 默认端侧识别,音频不离开设备(除非显式开启云端识别引擎);润色只发送文本给你选择的 LLM - 🎨 **原生 SwiftUI** —— 暗色主题、毛玻璃、纯 Swift 6 实现,约 3,600 行代码 - 🪶 **零依赖** —— 无 SwiftPM 包、无 CocoaPods、无 Carthage - 🔁 **Flow 会话** —— 多次录音无需跳回主 App,会话自动维持心跳与续期 @@ -127,9 +127,9 @@ OSGKeyboard/ **引擎模式:** -- `cloud`(默认)—— 端侧 `SpeechAnalyzer` 识别,文本发到云端 LLM 润色。 -- `local` —— 仅端侧 `SpeechAnalyzer` 识别,原始文本直接插入,不联网。 -- `local` + 「识别后云端润色」开关(设置 → 引擎)—— 同样走端侧 ASR,但识别完成后送 LLM 润色再插入。适用于 iOS 识别效果不理想的场景。 +- `local`(默认)—— 仅端侧 `SpeechAnalyzer` 识别,原始文本直接插入,不联网。 +- `local` + 「识别后云端润色」开关(设置 → 引擎)—— 同样走端侧 ASR,但识别完成后送 LLM 润色(仅文本)再插入。适用于 iOS 识别效果不理想的场景。 +- `cloud`(opt-in,需显式确认)—— **你的语音录音会上传**到你配置的识别服务商(如 OpenAI `/audio/transcriptions`、DashScope、智谱),识别文本再送 LLM 润色。请在接受服务商隐私条款的前提下选用。 **跨进程管道(主 App ↔ 键盘扩展):** diff --git a/docs/APPSTORE_METADATA.md b/docs/APPSTORE_METADATA.md index 3805219..acb74b3 100644 --- a/docs/APPSTORE_METADATA.md +++ b/docs/APPSTORE_METADATA.md @@ -50,10 +50,12 @@ OSGKeyboard is a free, open-source custom keyboard for iOS 26 that turns your voice into clean, AI-polished text — in any app. -Hold the mic key, speak naturally, release. The keyboard transcribes -your voice entirely on-device (Apple's iOS 26 SpeechAnalyzer + -DictationTranscriber), and only the final text is sent to the AI you -choose to polish it. Your audio never leaves your iPhone. +Hold the mic key, speak naturally, release. By default the keyboard +transcribes your voice entirely on-device (Apple's iOS 26 +SpeechAnalyzer + DictationTranscriber), and only the final text is +sent to the AI you choose to polish it — your audio never leaves your +device unless you explicitly opt into the cloud ASR engine, which +uploads recordings to the provider you configure. WHY OSGKEYBOARD @@ -62,8 +64,9 @@ WHY OSGKEYBOARD types for you. • Push-to-talk, the way voice should work. No more "Hey Siri" mode that listens to the whole room. -• On-device speech recognition. Powered by Apple's iOS 26 speech - pipeline — no cloud ASR, no audio upload. +• On-device speech recognition by default. Powered by Apple's iOS 26 + speech pipeline — no audio upload unless you explicitly enable the + optional cloud ASR engine (confirmation required). • Bring-your-own AI. Connect any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, your own self-hosted server). Your API key stays in the iOS Keychain. @@ -76,12 +79,13 @@ WHY OSGKEYBOARD • Zero dependencies. No trackers, no analytics, no crash reporters. The whole project is ~8,700 lines of Swift you can audit in an afternoon. -• Privacy first. PrivacyInfo.xcprivacy declares zero collected data; - we don't run a server. +• Privacy first. PrivacyInfo.xcprivacy declares exactly what the app + touches (voice audio + transcripts, on-device by default, never + linked or tracked); we don't run a server. BUILT FOR -• iOS 26 and later, iPhone only. +• iOS 26 and later, iPhone and iPad. • Anyone who types more than 100 words a day on their phone. • Developers, writers, students, and translators who want voice input that respects their privacy. diff --git a/docs/index.html b/docs/index.html index 243340e..896f5d6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ OSGKeyboard — On-device voice-to-text keyboard for iOS - + @@ -19,7 +19,7 @@ - + @@ -459,7 +459,7 @@
On-device recognition Local or cloud AI - Audio never leaves your phone + On-device by default — audio stays on your phone
@@ -498,12 +498,12 @@

On-device recognition

-

iOS 26 SpeechAnalyzer transcribes on your iPhone. Audio never leaves the device.

+

iOS 26 SpeechAnalyzer transcribes on your device by default — audio is uploaded only if you explicitly enable the cloud engine.

AI polish

-

Punctuation, structure, and clarity in three intensity levels. Only text is sent — never audio.

+

Punctuation, structure, and clarity in three intensity levels. Polish sends only text — on the default engine your audio never goes online.

@@ -551,7 +551,7 @@
Privacy by design

Your voice stays yours

-

Speech is processed on-device. We never log ordinary keystrokes. Only transcribed text — never audio — is sent for polish or translation.

+

Speech is processed on-device by default (the optional cloud engine uploads recordings to the provider you configure). We never log ordinary keystrokes; polish and translation receive only transcribed text.

@@ -583,7 +583,7 @@ "hero.cta.secondary": "Privacy", "hero.meta1": "On-device recognition", "hero.meta2": "Local or cloud AI", - "hero.meta3": "Audio never leaves your phone", + "hero.meta3": "On-device by default — audio stays on your phone", "mock.app": "Notes", "mock.line": "Tomorrow at 3 PM, sync with the design team on the new onboarding flow.", "mock.chipEngine": "On-device", @@ -593,9 +593,9 @@ "feat.dictation.t": "Tap-to-talk dictation", "feat.dictation.b": "Tap to start, tap to stop. Up to 3.5 minutes per take, with a live countdown.", "feat.ondevice.t": "On-device recognition", - "feat.ondevice.b": "iOS 26 SpeechAnalyzer transcribes on your iPhone. Audio never leaves the device.", + "feat.ondevice.b": "iOS 26 SpeechAnalyzer transcribes on your device by default — audio is uploaded only if you explicitly enable the cloud engine.", "feat.polish.t": "AI polish", - "feat.polish.b": "Punctuation, structure, and clarity in three intensity levels. Only text is sent — never audio.", + "feat.polish.b": "Punctuation, structure, and clarity in three intensity levels. Polish sends only text — on the default engine your audio never goes online.", "feat.translate.t": "Built-in translation", "feat.translate.b": "Translate after polish into English, 中文, 日本語, 한국어 and more — right from the keyboard.", "feat.dict.t": "Personal dictionary", @@ -612,7 +612,7 @@ "step3.b": "Switch to OSGKeyboard, tap the mic, speak, tap again. Polished text lands at your cursor.", "close.badge": "Privacy by design", "close.title": "Your voice stays yours", - "close.body": "Speech is processed on-device. We never log ordinary keystrokes. Only transcribed text — never audio — is sent for polish or translation.", + "close.body": "Speech is processed on-device by default (the optional cloud engine uploads recordings to the provider you configure). We never log ordinary keystrokes; polish and translation receive only transcribed text.", "close.cta": "Read the privacy policy", "footer.copy": "© OSGKeyboard · v0.3.6 · source available, non-commercial", "footer.source": "Source", @@ -628,7 +628,7 @@ "hero.cta.secondary": "隐私政策", "hero.meta1": "设备端识别", "hero.meta2": "本地或云端 AI", - "hero.meta3": "音频不离开手机", + "hero.meta3": "默认端侧识别,音频留在手机", "mock.app": "备忘录", "mock.line": "明天下午三点,和设计团队同步新引导流程。", "mock.chipEngine": "本地", @@ -638,9 +638,9 @@ "feat.dictation.t": "点按听写", "feat.dictation.b": "点一下开始,再点一下结束。单次最长 3.5 分钟,倒计时实时显示。", "feat.ondevice.t": "设备端识别", - "feat.ondevice.b": "由 iOS 26 SpeechAnalyzer 在本机转写,音频不会离开设备。", + "feat.ondevice.b": "默认由 iOS 26 SpeechAnalyzer 在本机转写——仅当你显式开启云端引擎时,录音才会上传到你配置的服务商。", "feat.polish.t": "AI 润色", - "feat.polish.b": "自动补全标点、结构与表达,分三档强度。仅发送文字,绝不发送音频。", + "feat.polish.b": "自动补全标点、结构与表达,分三档强度。润色仅发送文字;默认引擎下音频不联网。", "feat.translate.t": "内置翻译", "feat.translate.b": "润色后可翻译为 English、中文、日本語、한국어 等——直接在键盘上完成。", "feat.dict.t": "个性词库", @@ -657,7 +657,7 @@ "step3.b": "切换到 OSGKeyboard,点麦克风,说话,再点结束,润色好的文字自动插入光标处。", "close.badge": "隐私优先设计", "close.title": "你的声音,始终属于你", - "close.body": "语音在设备端处理,我们不记录普通击键。只有转写后的文字(绝非音频)会被发送用于润色或翻译。", + "close.body": "默认在设备端处理语音(可选的云端引擎会把录音上传到你配置的服务商)。我们不记录普通击键;润色与翻译只接收转写文字。", "close.cta": "查看隐私政策", "footer.copy": "© OSGKeyboard · v0.3.6 · 源码可见,禁止商用", "footer.source": "源代码", diff --git a/docs/privacy.html b/docs/privacy.html index 412f28a..2354de5 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -16,11 +16,11 @@

中文

OSGKeyboard Privacy Policy

Last updated: July 8, 2026 · v0.5.x

-

OSGKeyboard is a custom iOS keyboard that turns your voice into text. It runs as a Custom Keyboard Extension on iOS 26 and later, and uses Apple's on-device SpeechAnalyzer + DictationTranscriber for transcription. After transcription, text may be polished or translated via a cloud LLM. This policy explains what data the app processes and how it is used.

+

OSGKeyboard is a custom iOS keyboard that turns your voice into text. It runs as a Custom Keyboard Extension on iOS 26 and later, and uses Apple's on-device SpeechAnalyzer + DictationTranscriber for transcription by default. An optional cloud ASR engine (explicit opt-in) uploads recordings to the provider you configure. After transcription, text may be polished or translated via a cloud LLM. This policy explains what data the app processes and how it is used.

What we collect

    -
  • Voice audio — captured only while you actively record. Audio is transcribed on-device with Apple's SpeechAnalyzer + DictationTranscriber; raw audio is not uploaded by OSGKeyboard.
  • +
  • Voice audio — captured only while you actively record. On the default local engine, audio is transcribed on-device with Apple's SpeechAnalyzer + DictationTranscriber and raw audio is not uploaded. If you explicitly enable the cloud engine (a confirmation dialog is shown first), your recordings are uploaded to the ASR provider you configure (e.g. OpenAI, Qwen DashScope, Zhipu) for transcription; that provider's privacy policy applies. OSGKeyboard never stores or proxies your audio on its own servers.
  • Transcribed text — after on-device ASR, the transcript (not audio) is sent for polish. On the local engine, polish uses a built-in DeepSeek endpoint configured at build time. On the cloud engine, polish and optional translation use the OpenAI-compatible API you configure (e.g. OpenAI, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).
  • API credentials — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults. When you enable iCloud settings sync, API keys replicate through Apple's iCloud Keychain to your other signed-in devices — not through iCloud Key-Value Store JSON.
  • App preferences — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group UserDefaults on your device so the main app and keyboard extension stay in sync. When iCloud settings sync is enabled, these preferences (excluding API keys) may also be mirrored in your private iCloud Key-Value Store account.
  • @@ -33,12 +33,12 @@
    • We do not log or upload ordinary keystrokes you type with the keyboard.
    • We do not operate analytics, crash reporting, or advertising SDKs.
    • -
    • We do not upload raw audio to any server, including the LLM provider.
    • +
    • We do not upload raw audio anywhere on the default local engine. The only exception is the optional cloud ASR engine: if you explicitly enable it (a confirmation dialog is shown first), recordings go to the ASR provider you configure — never to servers of ours.
    • We do not sell personal data.

    How the keyboard extension talks to the host app

    -

    OSGKeyboard uses a long-lived "Flow session" hosted in the main app. The keyboard extension writes tiny "start / stop" signals into an App Group, the main app processes the audio on-device, then sends the transcript for polish (and optional translation) before writing the result back. Audio never leaves your device. Only transcribed text — never audio — is sent to the LLM endpoint (built-in DeepSeek on the local engine, or your configured API on the cloud engine).

    +

    OSGKeyboard uses a long-lived "Flow session" hosted in the main app. The keyboard extension writes tiny "start / stop" signals into an App Group, the main app captures the audio, transcribes it (on-device by default; via your configured cloud ASR provider if you opted into the cloud engine), then sends the transcript for polish (and optional translation) before writing the result back. On the default local engine audio never leaves your device; only transcribed text is sent to the LLM endpoint (built-in DeepSeek on the local engine, or your configured API on the cloud engine).

    Permissions

      @@ -68,11 +68,11 @@

      OSGKeyboard 隐私政策

      更新日期:2026 年 7 月 8 日 · v0.5.x

      -

      OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。它以自定义键盘扩展的形式运行,需要 iOS 26 及以上系统,转写全程使用 Apple 端侧的 SpeechAnalyzer + DictationTranscriber。转写完成后,文字可能经云端 LLM 润色或翻译。本政策说明应用处理哪些数据及用途。

      +

      OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。它以自定义键盘扩展的形式运行,需要 iOS 26 及以上系统,默认使用 Apple 端侧的 SpeechAnalyzer + DictationTranscriber 转写;可选的云端识别引擎(需显式二次确认开启)会把录音上传到你配置的服务商。转写完成后,文字可能经云端 LLM 润色或翻译。本政策说明应用处理哪些数据及用途。

      我们处理的数据

        -
      • 语音音频 — 仅在你主动录音时采集。音频在设备端通过 SpeechAnalyzer + DictationTranscriber 转写,OSGKeyboard 不会上传原始录音。
      • +
      • 语音音频 — 仅在你主动录音时采集。默认本地引擎下,音频在设备端通过 SpeechAnalyzer + DictationTranscriber 转写,原始录音不会上传。若你显式开启云端引擎(会先弹出确认对话框),录音会上传到你配置的识别服务商(如 OpenAI、通义 DashScope、智谱)完成转写,适用该服务商的隐私政策。OSGKeyboard 自身绝不存储或中转你的音频。
      • 转写文字 — 端侧 ASR 完成后,转写文字(非音频)会发送润色。本地引擎使用构建时配置的内置 DeepSeek 端点;云端引擎的润色与可选翻译使用你配置的 OpenAI 兼容 API(OpenAI / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。
      • API 凭证 — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,不会写入 UserDefaults。开启iCloud 设置同步后,API 密钥经 Apple iCloud 钥匙串同步到你其他已登录设备,不会写入 iCloud 键值存储 JSON。
      • 应用偏好 — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group UserDefaults,用于主 App 与键盘扩展之间的状态同步。开启 iCloud 设置同步后,这些偏好(不含 API 密钥)也可能镜像到你私有的 iCloud 键值存储账户。
      • @@ -85,12 +85,12 @@
        • 我们不会记录或上传你平时在键盘上的击键内容。
        • 我们不会集成分析、崩溃上报或广告 SDK。
        • -
        • 我们不会将原始录音上传至任何服务器,包括你配置的 LLM 服务商。
        • +
        • 默认本地引擎下,我们不会将原始录音上传至任何服务器。唯一例外是可选的云端识别引擎:你显式开启后(会先弹出确认对话框),录音会发送到配置的识别服务商——绝不会发送到我们的服务器。
        • 我们不会出售个人数据。

        键盘扩展与主 App 的通信方式

        -

        OSGKeyboard 采用主 App 维护的「Flow 会话」机制:键盘扩展在 App Group 中写入轻量的「开始 / 停止」信号,主 App 在设备端处理音频,再将转写文字发送润色(及可选翻译)后回写结果。音频不会离开设备;发送给 LLM 的仅为转写文字(本地引擎走内置 DeepSeek,云端引擎走你配置的 API),不包含录音。

        +

        OSGKeyboard 采用主 App 维护的「Flow 会话」机制:键盘扩展在 App Group 中写入轻量的「开始 / 停止」信号,主 App 采集音频并完成转写(默认在设备端;若你开启云端引擎则经你配置的识别服务商),再将转写文字发送润色(及可选翻译)后回写结果。默认本地引擎下音频不会离开设备;发送给 LLM 的仅为转写文字(本地引擎走内置 DeepSeek,云端引擎走你配置的 API)。

        权限说明

          diff --git a/docs/privacy/index.html b/docs/privacy/index.html index 5710092..2ef0a5d 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -16,12 +16,12 @@

          中文

          OSGKeyboard Privacy Policy

          -

          Last updated: July 5, 2026 · v0.3.6

          -

          OSGKeyboard is a custom iOS keyboard that turns your voice into text. It runs as a Custom Keyboard Extension on iOS 26 and later, and uses Apple's on-device SpeechAnalyzer + DictationTranscriber for transcription. After transcription, text may be polished or translated via a cloud LLM. This policy explains what data the app processes and how it is used.

          +

          Last updated: July 9, 2026 · v0.5.x

          +

          OSGKeyboard is a custom iOS keyboard that turns your voice into text. It runs as a Custom Keyboard Extension on iOS 26 and later, and uses Apple's on-device SpeechAnalyzer + DictationTranscriber for transcription by default. An optional cloud ASR engine (explicit opt-in) uploads recordings to the provider you configure. After transcription, text may be polished or translated via a cloud LLM. This policy explains what data the app processes and how it is used.

          What we collect

            -
          • Voice audio — captured only while you actively record. Audio is transcribed on-device with Apple's SpeechAnalyzer + DictationTranscriber; raw audio is not uploaded by OSGKeyboard.
          • +
          • Voice audio — captured only while you actively record. On the default local engine, audio is transcribed on-device with Apple's SpeechAnalyzer + DictationTranscriber and raw audio is not uploaded. If you explicitly enable the cloud engine (a confirmation dialog is shown first), your recordings are uploaded to the ASR provider you configure (e.g. OpenAI, Qwen DashScope, Zhipu) for transcription; that provider's privacy policy applies. OSGKeyboard never stores or proxies your audio on its own servers.
          • Transcribed text — after on-device ASR, the transcript (not audio) is sent for polish. On the local engine, polish uses a built-in DeepSeek endpoint configured at build time. On the cloud engine, polish and optional translation use the OpenAI-compatible API you configure (e.g. OpenAI, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).
          • API credentials — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults.
          • App preferences — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group UserDefaults on your device so the main app and keyboard extension stay in sync.
          • @@ -34,12 +34,12 @@
            • We do not log or upload ordinary keystrokes you type with the keyboard.
            • We do not operate analytics, crash reporting, or advertising SDKs.
            • -
            • We do not upload raw audio to any server, including the LLM provider.
            • +
            • We do not upload raw audio anywhere on the default local engine. The only exception is the optional cloud ASR engine: if you explicitly enable it (a confirmation dialog is shown first), recordings go to the ASR provider you configure — never to servers of ours.
            • We do not sell personal data.

            How the keyboard extension talks to the host app

            -

            OSGKeyboard uses a long-lived "Flow session" hosted in the main app. The keyboard extension writes tiny "start / stop" signals into an App Group, the main app processes the audio on-device, then sends the transcript for polish (and optional translation) before writing the result back. Audio never leaves your device. Only transcribed text — never audio — is sent to the LLM endpoint (built-in DeepSeek on the local engine, or your configured API on the cloud engine).

            +

            OSGKeyboard uses a long-lived "Flow session" hosted in the main app. The keyboard extension writes tiny "start / stop" signals into an App Group, the main app captures the audio, transcribes it (on-device by default; via your configured cloud ASR provider if you opted into the cloud engine), then sends the transcript for polish (and optional translation) before writing the result back. On the default local engine audio never leaves your device; only transcribed text is sent to the LLM endpoint (built-in DeepSeek on the local engine, or your configured API on the cloud engine).

            Permissions

              @@ -68,17 +68,17 @@

              OSGKeyboard 隐私政策

              -

              更新日期:2026 年 7 月 5 日 · v0.3.6

              -

              OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。它以自定义键盘扩展的形式运行,需要 iOS 26 及以上系统,转写全程使用 Apple 端侧的 SpeechAnalyzer + DictationTranscriber。转写完成后,文字可能经云端 LLM 润色或翻译。本政策说明应用处理哪些数据及用途。

              +

              更新日期:2026 年 7 月 9 日 · v0.5.x

              +

              OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。它以自定义键盘扩展的形式运行,需要 iOS 26 及以上系统,默认使用 Apple 端侧的 SpeechAnalyzer + DictationTranscriber 转写;可选的云端识别引擎(需显式二次确认开启)会把录音上传到你配置的服务商。转写完成后,文字可能经云端 LLM 润色或翻译。本政策说明应用处理哪些数据及用途。

              我们处理的数据

                -
              • 语音音频 — 仅在你主动录音时采集。音频在设备端通过 SpeechAnalyzer + DictationTranscriber 转写,OSGKeyboard 不会上传原始录音。
              • +
              • 语音音频 — 仅在你主动录音时采集。默认本地引擎下,音频在设备端通过 SpeechAnalyzer + DictationTranscriber 转写,原始录音不会上传。若你显式开启云端引擎(会先弹出确认对话框),录音会上传到你配置的识别服务商(如 OpenAI、通义 DashScope、智谱)完成转写,适用该服务商的隐私政策。OSGKeyboard 自身绝不存储或中转你的音频。
              • 转写文字 — 端侧 ASR 完成后,转写文字(非音频)会发送润色。本地引擎使用构建时配置的内置 DeepSeek 端点;云端引擎的润色与可选翻译使用你配置的 OpenAI 兼容 API(OpenAI / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。
              • API 凭证 — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,不会写入 UserDefaults
              • 应用偏好 — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group UserDefaults,仅用于主 App 与键盘扩展之间的状态同步。
              • 个性词库 — 你在「词库」Tab 添加的词条与别名保存在本机。润色时会写入 LLM 提示词以保留你的词汇;词库数据不会上传到独立服务器。
              • -
              • 语音历史 — 主 App 可在「历史」页保留近期成功转写,最多 500 条,仅本机保存,不会上传。
              • +
              • 语音历史 — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。
              • 用量统计 — 首页统计卡片的累计听写时长、听写字数、翻译字数、词库词条数均在本地计算与保存。
              @@ -86,12 +86,12 @@
              • 我们不会记录或上传你平时在键盘上的击键内容。
              • 我们不会集成分析、崩溃上报或广告 SDK。
              • -
              • 我们不会将原始录音上传至任何服务器,包括你配置的 LLM 服务商。
              • +
              • 默认本地引擎下,我们不会将原始录音上传至任何服务器。唯一例外是可选的云端识别引擎:你显式开启后(会先弹出确认对话框),录音会发送到配置的识别服务商——绝不会发送到我们的服务器。
              • 我们不会出售个人数据。

              键盘扩展与主 App 的通信方式

              -

              OSGKeyboard 采用主 App 维护的「Flow 会话」机制:键盘扩展在 App Group 中写入轻量的「开始 / 停止」信号,主 App 在设备端处理音频,再将转写文字发送润色(及可选翻译)后回写结果。音频不会离开设备;发送给 LLM 的仅为转写文字(本地引擎走内置 DeepSeek,云端引擎走你配置的 API),不包含录音。

              +

              OSGKeyboard 采用主 App 维护的「Flow 会话」机制:键盘扩展在 App Group 中写入轻量的「开始 / 停止」信号,主 App 采集音频并完成转写(默认在设备端;若你开启云端引擎则经你配置的识别服务商),再将转写文字发送润色(及可选翻译)后回写结果。默认本地引擎下音频不会离开设备;发送给 LLM 的仅为转写文字(本地引擎走内置 DeepSeek,云端引擎走你配置的 API)。

              权限说明

                diff --git a/project.yml b/project.yml index 30685ed..67edf7a 100644 --- a/project.yml +++ b/project.yml @@ -11,8 +11,8 @@ options: # Minimum OS: iOS 26. We dropped older iOS support so the # legacy speech + AVAudioSession branching could be removed in # favour of iOS 26's `SpeechAnalyzer` (always on-device) and - # `AVAudioApplication.requestRecordPermission` (iOS 17+). iPhone - # only — no Mac Catalyst, no visionOS. + # `AVAudioApplication.requestRecordPermission` (iOS 17+). iPhone + iPad; + # no Mac Catalyst, no visionOS. deploymentTarget: iOS: "26.0" developmentLanguage: en @@ -124,6 +124,11 @@ targets: UIColorName: "BackgroundColor" UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running." @@ -193,7 +198,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios - TARGETED_DEVICE_FAMILY: "1" + TARGETED_DEVICE_FAMILY: "1,2" INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities SUPPORTS_MACCATALYST: NO @@ -266,7 +271,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard - TARGETED_DEVICE_FAMILY: "1" + TARGETED_DEVICE_FAMILY: "1,2" SUPPORTS_MACCATALYST: NO SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO @@ -297,7 +302,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.liveactivity - TARGETED_DEVICE_FAMILY: "1" + TARGETED_DEVICE_FAMILY: "1,2" SUPPORTS_MACCATALYST: NO SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO @@ -333,7 +338,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared - TARGETED_DEVICE_FAMILY: "1" + TARGETED_DEVICE_FAMILY: "1,2" DEFINES_MODULE: YES SKIP_INSTALL: YES BUILD_LIBRARY_FOR_DISTRIBUTION: NO @@ -365,7 +370,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.tests - TARGETED_DEVICE_FAMILY: "1" + TARGETED_DEVICE_FAMILY: "1,2" # ========================================================= # 键盘扩展单元测试 (TEST-4) @@ -382,7 +387,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.ext.tests - TARGETED_DEVICE_FAMILY: "1" + TARGETED_DEVICE_FAMILY: "1,2" # ========================================================= # macOS 菜单栏 App (Phase 1 · 云端 MVP) @@ -419,12 +424,10 @@ targets: - "DesignSystem/RecordButton.swift" - "Services/ASRService.swift" - "Services/CloudASR/CloudASRService.swift" - - "Services/ChunkedUtterancePipeline.swift" - "Services/FlowContinuousCapture.swift" - "Services/LiveDictationController.swift" - "Services/KeyboardState.swift" - "Services/CursorNavigation.swift" - - "Services/CustomLanguageModelManager.swift" - "Models/MicVoiceAvailability+Keyboard.swift" - "Utilities/ProgressiveDictationTranscriptAccumulator.swift" - path: OSGKeyboardShared/en.lproj/Shared.strings @@ -433,6 +436,10 @@ targets: buildPhase: resources - path: OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv buildPhase: resources + - path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin + buildPhase: resources + - path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/compiled-manifest.json + buildPhase: resources - path: OSGKeyboard/Resources/PrivacyPolicy.html buildPhase: resources - path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json From cc8dd1070a33dfafeb2f56912e7e74410ac51c4c Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:10:20 +0800 Subject: [PATCH 2/2] feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3), global Option hotkey, and bottom overlay - Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with provider logos, model listing, and connection checks - Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart) - Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model diagnostic toggle behind DEBUG - Unify iOS onboarding tagline with the macOS brand line ("开口即文字。") - Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages --- .gitignore | 5 + CHANGELOG.md | 5 + .../anthropic.imageset/Contents.json | 16 + .../anthropic.imageset/anthropic.svg | 1 + .../ark.imageset/Contents.json | 16 + .../Assets.xcassets/ark.imageset/ark.svg | 1 + .../codingplanx.imageset/Contents.json | 16 + .../codingplanx.imageset/codingplanx.svg | 1 + .../cometapi.imageset/Contents.json | 16 + .../cometapi.imageset/cometapi.svg | 1 + .../gemini.imageset/Contents.json | 16 + .../gemini.imageset/gemini.svg | 1 + .../groq.imageset/Contents.json | 16 + .../Assets.xcassets/groq.imageset/groq.svg | 1 + .../minimax.imageset/Contents.json | 16 + .../minimax.imageset/minimax.svg | 1 + .../mistral.imageset/Contents.json | 16 + .../mistral.imageset/mistral.svg | 1 + .../openrouter.imageset/Contents.json | 16 + .../openrouter.imageset/openrouter.svg | 1 + .../siliconflow.imageset/Contents.json | 16 + .../siliconflow.imageset/siliconflow.svg | 1 + .../xai.imageset/Contents.json | 16 + .../Assets.xcassets/xai.imageset/xai.svg | 1 + OSGKeyboard/Services/FlowSessionManager.swift | 57 +- .../Services/OpenSourceLicenseCatalog.swift | 51 +- OSGKeyboard/Views/APISettingsCard.swift | 249 ++------ OSGKeyboard/Views/ASRSettingsCard.swift | 272 ++++----- .../Views/Components/HomeStatsCard.swift | 138 ----- .../Components/HomeUsageStatsSection.swift | 61 ++ .../Views/Components/MinimalTabBar.swift | 2 +- .../Components/PrivacyDisclosureViews.swift | 59 -- .../Components/WideLayoutComponents.swift | 161 +---- OSGKeyboard/Views/EnginePickerSection.swift | 29 +- OSGKeyboard/Views/HomeView.swift | 41 +- .../Views/LocalEngineSettingsRows.swift | 16 +- OSGKeyboard/Views/OnboardingView.swift | 101 ++-- .../Views/OpenSourceLicensesView.swift | 3 +- .../Views/PersonalDictionaryEntrySheet.swift | 3 +- .../PersonalDictionaryICloudSyncRow.swift | 99 --- .../Views/PersonalDictionaryView.swift | 3 +- OSGKeyboard/Views/ProviderPickerSection.swift | 77 ++- OSGKeyboard/Views/SettingsCardChrome.swift | 26 + OSGKeyboard/Views/SettingsICloudSyncRow.swift | 64 +- .../Views/SettingsProviderControls.swift | 316 ++++++++++ OSGKeyboard/Views/SettingsView.swift | 74 +-- OSGKeyboard/Views/TranslationPickerRow.swift | 5 +- OSGKeyboard/en.lproj/Localizable.strings | 58 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 60 +- OSGKeyboardExt/KeyboardViewController.swift | 21 + .../Services/KeyboardFlowCoordinator.swift | 183 +++++- OSGKeyboardExt/Views/KeyboardRootView.swift | 23 +- .../Views/ToolbarActionButtons.swift | 17 + OSGKeyboardExt/en.lproj/Keyboard.strings | 13 +- OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 15 +- .../FlowLiveActivityWidget.swift | 2 +- OSGKeyboardMac/DashboardView.swift | 116 ++-- OSGKeyboardMac/MacComponents.swift | 527 +++++++++++++--- OSGKeyboardMac/MacDictationPipeline.swift | 3 +- OSGKeyboardMac/MacDictationViewModel.swift | 25 +- OSGKeyboardMac/MacDictionaryView.swift | 16 +- OSGKeyboardMac/MacHistoryView.swift | 16 +- OSGKeyboardMac/MacICloudSyncRows.swift | 138 ++--- .../MacLocalASRModelSettingsView.swift | 176 +++--- OSGKeyboardMac/MacOnboardingView.swift | 13 +- OSGKeyboardMac/MacQwen3ASREngine.swift | 95 --- OSGKeyboardMac/MacQwen3LocalASR.swift | 58 -- OSGKeyboardMac/MacRootView.swift | 10 +- OSGKeyboardMac/MacSettingsComponents.swift | 543 +++++++++++++++++ OSGKeyboardMac/MacSettingsView.swift | 566 +++++++++--------- OSGKeyboardMac/MacSherpaONNXRunner.swift | 20 + .../Configuration/ConfigurationStore.swift | 1 + .../LiveConfigurationStore.swift | 114 ++++ .../DesignSystem/SevenDayUsageChart.swift | 133 ++++ OSGKeyboardShared/DesignSystem/Theme.swift | 24 +- .../DesignSystem/UsageStatCard.swift | 108 ++++ .../DesignSystem/UsageStatsCluster.swift | 196 ++++++ .../DesignSystem/UsageSurfaceCard.swift | 36 ++ .../Models/AppGroupConfiguration.swift | 51 +- OSGKeyboardShared/Models/CloudASRModels.swift | 75 ++- .../Models/FlowHandoffPolicy.swift | 137 +++++ .../Models/HandednessPreference.swift | 4 +- OSGKeyboardShared/Models/LLMProvider.swift | 145 ++++- OSGKeyboardShared/Models/ProviderConfig.swift | 80 +-- OSGKeyboardShared/Models/ProviderLogo.swift | 16 +- .../Models/SyncedAppSettingsV2.swift | 15 + .../Models/SyncedUsageStatisticsV2.swift | 65 +- .../Models/VolcengineASRFields.swift | 73 +++ .../Services/AnthropicLLMClient.swift | 69 +++ .../Services/AppGroupStore.swift | 22 +- .../CloudASR/BailianRealtimeASRClient.swift | 434 ++++++++++++++ .../Services/CloudASR/CloudASRClients.swift | 167 ++++-- .../CloudASR/CloudASRConnectionCheck.swift | 19 + .../CloudASR/VolcengineCloudASRClient.swift | 400 +++++++++++++ .../Services/ICloudSync/AppCloudSync.swift | 7 +- .../ICloudSync/SettingsCloudSync.swift | 5 +- .../Services/KeyboardState.swift | 17 +- OSGKeyboardShared/Services/LLMClient.swift | 175 +++++- .../Services/PolishingService.swift | 12 +- .../Services/ProviderModelService.swift | 182 ++++++ .../Services/ProviderToolRunnerState.swift | 130 ++++ .../Services/UsageStatisticsStore.swift | 44 +- OSGKeyboardShared/en.lproj/Shared.strings | 69 ++- .../zh-Hans.lproj/Shared.strings | 69 ++- .../AppGroupConfigurationTests.swift | 9 +- OSGKeyboardTests/CloudASRTests.swift | 102 +++- .../ConfigurationStoreTests.swift | 4 +- OSGKeyboardTests/FlowHandoffPolicyTests.swift | 322 ++++++++++ OSGKeyboardTests/LLMClientTests.swift | 77 +++ OSGKeyboardTests/SettingsCloudSyncTests.swift | 3 + README.en.md | 103 ++++ README.md | 235 ++------ README.zh.md | 202 +------ docs/index.html | 290 +++++---- docs/local-asr-architecture.md | 14 +- project.yml | 20 +- 116 files changed, 6659 insertions(+), 2634 deletions(-) create mode 100644 OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg create mode 100644 OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg create mode 100644 OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg create mode 100644 OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg create mode 100644 OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg create mode 100644 OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg create mode 100644 OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg create mode 100644 OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg create mode 100644 OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg create mode 100644 OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg create mode 100644 OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg delete mode 100644 OSGKeyboard/Views/Components/HomeStatsCard.swift create mode 100644 OSGKeyboard/Views/Components/HomeUsageStatsSection.swift delete mode 100644 OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift create mode 100644 OSGKeyboard/Views/SettingsCardChrome.swift create mode 100644 OSGKeyboard/Views/SettingsProviderControls.swift delete mode 100644 OSGKeyboardMac/MacQwen3ASREngine.swift delete mode 100644 OSGKeyboardMac/MacQwen3LocalASR.swift create mode 100644 OSGKeyboardMac/MacSettingsComponents.swift create mode 100644 OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift create mode 100644 OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift create mode 100644 OSGKeyboardShared/DesignSystem/UsageStatCard.swift create mode 100644 OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift create mode 100644 OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift create mode 100644 OSGKeyboardShared/Models/FlowHandoffPolicy.swift create mode 100644 OSGKeyboardShared/Models/VolcengineASRFields.swift create mode 100644 OSGKeyboardShared/Services/AnthropicLLMClient.swift create mode 100644 OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift create mode 100644 OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift create mode 100644 OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift create mode 100644 OSGKeyboardShared/Services/ProviderModelService.swift create mode 100644 OSGKeyboardShared/Services/ProviderToolRunnerState.swift create mode 100644 OSGKeyboardTests/FlowHandoffPolicyTests.swift create mode 100644 README.en.md diff --git a/.gitignore b/.gitignore index e381900..a5f5db1 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,8 @@ __pycache__/ # Standalone experiment: 灵动岛录音机制验证 demo (throwaway) AudioIntentProbe/ + +# Local crash-log pulls and one-off diff/working notes (not for the repo) +.crash-pull/ +all-changes-vs-main.patch +CHANGES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b1fbab6..025397b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.3] - 2026-07-11 + ### Added +- **Shared 7-day usage chart on iOS Home**: iPhone and iPad Home now show the same trailing-7-day dictation chart as macOS, via a shared `UsageStatsCluster`. / **iOS 首页共享近 7 天统计图**:iPhone / iPad 首页与 macOS 共用 `UsageStatsCluster`,展示近 7 天听写字数柱状图。 - **macOS dictation overlay**: a bottom-centered floating pill appears for any recording path (global hotkey, menu bar, or main window) — shows listening / transcribing state, a stronger live waveform, a one-line live transcript preview (partials when chunked ASR runs), front-app name, and a stop control, then briefly confirms success before fading out without stealing focus. / **macOS 听写浮层**:任意录音路径(全局热键、菜单栏或主窗口)都会在屏幕底部居中出现胶囊浮层——显示聆听 / 识别状态、更强的实时波形、单行转写预览(分块识别时显示 partial)、前台应用名与停止按钮,成功后短暂确认再淡出,且不抢前台焦点。 - **macOS Option-key picker**: Settings → Input lets you choose Left / Right / Either Option as the hold-to-talk key, so the shortcut can avoid conflicts with other apps. / **macOS Option 键选择**:设置 → 输入与快捷键可选择左 / 右 / 任一 Option 作为按住听写键,避免与其他应用冲突。 ### Changed +- **Shared home stats UI**: 7-day chart, stat tiles, and surface chrome live in `OSGKeyboardShared` (`UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`); Mac Dashboard and iOS Home (phone stacked / iPad split) both consume them. Localization keys moved from `mac.stat.*` to `stat.*`. / **共享首页统计 UI**:近 7 天图表、统计卡与表面壳迁入 `OSGKeyboardShared`(`UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`);Mac Dashboard 与 iOS 首页(手机上下叠 / iPad 左右分栏)共用。文案 key 由 `mac.stat.*` 改为 `stat.*`。 - **Removed temporary Flow DEBUG panels**: the on-screen App Group / session debug text boxes on Home and the keyboard extension are gone now that the orange-mic investigation is closed. / **移除临时 Flow DEBUG 面板**:橙色麦克风排查结束后,首页与键盘扩展上的 App Group / 会话调试文本框已去掉。 - **macOS local ASR catalog**: removed offline Paraformer; SenseVoice / Qwen3 0.6B / Qwen3 1.7B now show Fastest / Most balanced / Best quality badges (users still on Paraformer migrate to Qwen3 0.6B). / **macOS 本地 ASR 目录**:移除 offline Paraformer;SenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。 - **macOS visual system**: full-app redesign around the brand line “Speak it. It’s typed.” / 「开口即文字。」— grouped sidebar, restrained accent selection, asymmetric Home stats (chars as hero), unified page headers, quieter status footer, and clearer dark-mode card elevation. / **macOS 视觉体系**:围绕品牌句「开口即文字。」/ “Speak it. It’s typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。 @@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **macOS page margins**: Home / History / Dictionary / Settings share `pageHorizontalInset` on titles and scroll *content*; ScrollViews / Forms stay full-bleed so the scrollbar sits on the window edge, while cards stay aligned with the page title. Settings keeps native grouped Form for control layout. / **macOS 页边距**:首页 / 历史 / 词库 / 设置在标题与滚动*内容*上共用 `pageHorizontalInset`;ScrollView / Form 通栏使滚动条贴窗口右缘,卡片仍与页标题对齐。设置保留原生分组 Form 以保证控件排版。 ### Fixed +- **Spurious "Voice is ready" overlay**: a healthy in-app Flow session no longer flashes the cold-start overlay after dictation. Keyboard mic presses wait when the session is still alive (including `preparingSession` / single-frame `hostNotReady` races); only a truly dead host opens `startflow`. The host silences overlay when already ready or busy. Proactive auto-launch is disabled. / **误弹「语音已就绪」**:App 内健康 Flow 会话在听写后不再闪冷启动浮层。键盘在会话仍存活时(含 `preparingSession` / 单帧 `hostNotReady` 竞态)只等待;仅宿主真正死亡才打开 `startflow`。主 App 在已就绪或忙碌时静默忽略浮层。已关闭被动自动拉起。 - **Cold-start overlay recursion crash**: dismissing the ready overlay while an utterance is already recording no longer recurses `refreshHostReady` → `reconcile` → `dismiss` on the main thread until stack overflow (`EXC_BAD_ACCESS`). Handoff flags are cleared before any refresh. / **冷启动浮层递归崩溃**:就绪浮层仍在时若已开始录音,不再在主线程上递归 `refreshHostReady` → `reconcile` → `dismiss` 直至栈溢出(`EXC_BAD_ACCESS`);交接标志会在任何 refresh 之前先清除。 - **macOS Qwen3 “language” garbage transcript**: Sherpa Qwen3 results that still include the model scaffold (`language Chinese…`) are now stripped to the spoken text; incomplete outputs that stop at the bare word `language` are treated as empty instead of being inserted. / **macOS Qwen3「language」乱码转写**:Sherpa Qwen3 结果若仍带模型脚手架(`language Chinese…`)会剥到真实口语文案;不完整输出停在单词 `language` 时按空结果处理,不再插入。 - **macOS local ASR silence garbage output**: dictating with no speech (silence) on a Sherpa-backed local model (SenseVoice/Qwen3/Paraformer) no longer inserts the raw JSON result line (`{"lang": "", "emotion": "", ...}`) as the transcript — it now correctly reports "no speech recognized". / **macOS 本地识别静音乱码**:使用 Sherpa 本地模型(SenseVoice/Qwen3/Paraformer)听写时若未检测到语音,不再把原始 JSON 结果行(`{"lang": "", "emotion": "", ...}`)当作转写文本插入,现在会正确提示「没有识别到语音」。 diff --git a/OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json new file mode 100644 index 0000000..9f10dd1 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "anthropic.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg b/OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg new file mode 100644 index 0000000..5b81844 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json new file mode 100644 index 0000000..6f122ce --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg b/OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg new file mode 100644 index 0000000..28556c3 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg @@ -0,0 +1 @@ +Volcengine \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json new file mode 100644 index 0000000..5b51e01 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "codingplanx.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg new file mode 100644 index 0000000..d80b55c --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg @@ -0,0 +1 @@ + diff --git a/OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json new file mode 100644 index 0000000..39305f7 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cometapi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg b/OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg new file mode 100644 index 0000000..efe9317 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg @@ -0,0 +1 @@ +CometAPI \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json new file mode 100644 index 0000000..ca57c6b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "gemini.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg b/OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg new file mode 100644 index 0000000..87736bb --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json new file mode 100644 index 0000000..3874e19 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "groq.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg b/OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg new file mode 100644 index 0000000..7294646 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg @@ -0,0 +1 @@ +Groq \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json new file mode 100644 index 0000000..ac7e6a2 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "minimax.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg b/OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg new file mode 100644 index 0000000..1d32449 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json new file mode 100644 index 0000000..f28b753 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mistral.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg b/OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg new file mode 100644 index 0000000..32c6cbd --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json new file mode 100644 index 0000000..1ba973b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "openrouter.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg b/OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg new file mode 100644 index 0000000..31fe130 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json new file mode 100644 index 0000000..2386a84 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "siliconflow.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg new file mode 100644 index 0000000..f06093b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json new file mode 100644 index 0000000..a0cd740 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "xai.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg b/OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg new file mode 100644 index 0000000..536e713 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index a19cba6..775321d 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -138,21 +138,43 @@ final class FlowSessionManager: ObservableObject { return } + reconcilePersistedFlowStateBeforeStart() + + // Healthy / busy sessions must not flash the cold-start overlay when a + // spurious `startflow` arrives (e.g. keyboard finalize race). + if coldStart, isActive { + extendSession(duration: duration) + refreshHostReady() + let decision = FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: FlowSessionBridge.isHostReady(), + isUtteranceBusy: isUtteranceRecording || isUtteranceProcessing + ) + switch decision { + case .silence: + traceState( + "startSession.coldStart.silenced", + extra: FlowSessionBridge.isHostReady() ? "reason=alreadyReady" : "reason=busy" + ) + return + case .present: + isColdStartHandoff = true + showColdStartPreparing() + Task { @MainActor [weak self] in + await self?.prepareExistingSessionForColdStartReturn() + } + return + } + } + if coldStart { isColdStartHandoff = true showColdStartPreparing() } - reconcilePersistedFlowStateBeforeStart() - if isActive { extendSession(duration: duration) refreshHostReady() - if coldStart { - Task { @MainActor [weak self] in - await self?.prepareExistingSessionForColdStartReturn() - } - } return } @@ -1354,8 +1376,9 @@ final class FlowSessionManager: ObservableObject { if Task.isCancelled { throw CancellationError() } - let polished = try await polisher.polish( - text, + let polished = try await Self.polishWithHostTimeout( + polisher: polisher, + text: text, mode: polishMode, providerIdOverride: pipelineStore.polishProviderIdOverride ) @@ -1542,6 +1565,22 @@ final class FlowSessionManager: ObservableObject { return FlowSessionKeys.cloudASRWaitTimeout } + /// Host-level polish cap — does not wait for a cancelled LLM task to unwind. + private static func polishWithHostTimeout( + polisher: PolishingService, + text: String, + mode: PolishingService.PolishMode, + providerIdOverride: String? + ) async throws -> String { + try await HardTimeout.run(seconds: FlowSessionKeys.maxPolishTimeout) { + try await polisher.polish( + text, + mode: mode, + providerIdOverride: providerIdOverride + ) + } + } + // MARK: - Level publishing (main thread only) private func startLevelPublishing() { diff --git a/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift b/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift index 5a9773f..a1e9558 100644 --- a/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift +++ b/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift @@ -4,12 +4,10 @@ // Single source of truth for third-party open-source components shipped // with OSGKeyboard. Consumed by Settings → About → Third-Party Licenses. // -// Keep this list aligned with `project.yml` package dependencies. +// Keep this list aligned with bundled resources and runtime downloads. // -// v0.2.0: dropped the `Qwen3Speech` SPM fork (Qwen3 CoreML ASR is gone) -// and the `aufklarer/Qwen3-ASR-CoreML` runtime artefact. The local -// engine now ships with iOS 26 `SpeechAnalyzer` + `DictationTranscriber` -// and has no on-device ML dependencies of our own. +// iOS targets remain zero-SPM. macOS local ASR downloads the sherpa-onnx +// runtime binary on demand; model weights are cached under Application Support. import Foundation @@ -26,19 +24,24 @@ enum OpenSourceLicenseCatalog { let licenseText: String } - /// Bundled libraries referenced by the app. v0.2.0 no longer pulls in - /// `soniqo/speech-swift` (we use iOS 26 `SpeechAnalyzer` instead) and - /// no longer downloads `Qwen3-ASR-CoreML` weights — both entries are - /// intentionally absent. + /// Bundled libraries and runtime components referenced by the app. static let entries: [Entry] = [ .init( id: "material-icons", name: "Google Material Icons", licenseName: "Apache-2.0", - purpose: "MaterialIcons-Regular.ttf bundled for Settings and navigation iconography.", + purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.", url: URL(string: "https://github.com/google/material-design-icons"), licenseText: apache2Text ), + .init( + id: "sherpa-onnx", + name: "sherpa-onnx", + licenseName: "Apache-2.0", + purpose: "macOS local ASR runtime (`sherpa-onnx-offline`) downloaded at install time and cached on device.", + url: URL(string: "https://github.com/k2-fsa/sherpa-onnx"), + licenseText: apache2Text + ), ] // MARK: - License bodies @@ -60,30 +63,4 @@ enum OpenSourceLicenseCatalog { implied. See the License for the specific language governing permissions and limitations under the License. """ - - static let mitText = """ - MIT License - - Copyright (c) 2023 Apple Inc. - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, copy, - modify, merge, publish, distribute, sublicense, and/or sell copies - of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - DEALINGS IN THE SOFTWARE. - """ -} \ No newline at end of file +} diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift index cd00e16..0ad329b 100644 --- a/OSGKeyboard/Views/APISettingsCard.swift +++ b/OSGKeyboard/Views/APISettingsCard.swift @@ -11,215 +11,86 @@ struct APISettingsCard: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig - @State private var showKey: Bool = false - @State private var testStatus: TestStatus = .idle - - private enum TestStatus: Equatable { - case idle - case running - case success - case failure(String) - } + /// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。 + var showsSurface: Bool = true var body: some View { VStack(spacing: 0) { - field( + SettingsCredentialRow( + title: AppL10n.string("api.key"), + placeholder: "sk-…", + text: $config.apiKey, + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsCredentialRow( title: AppL10n.string("api.baseUrl"), - placeholder: "https://api.openai.com/v1", + placeholder: LLMProvider.provider(id: config.providerId).defaultBaseURL, text: $config.baseURL, - autocap: false + isMonospaced: true ) - Divider().background(palette.divider) - keyField - Divider().background(palette.divider) - field( + rowDivider + SettingsModelPickerRow( title: AppL10n.string("api.model"), - placeholder: "gpt-4o-mini", - text: $config.model, - autocap: false + placeholder: LLMProvider.provider(id: config.providerId).defaultModel, + model: $config.model, + fetchModels: fetchModels ) - if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { - Divider().background(palette.divider) - // Use a Button + UIApplication.open instead of SwiftUI - // `Link`. SwiftUI `Link` can have hit-test quirks on some - // makes its tappable area eat gestures from the adjacent - // TextField, which manifests as "typing jumps to a website". - Button { - UIApplication.shared.open(url) - } label: { - HStack { - Text("api.getKey") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Image(systemName: "arrow.up.right.square") - .foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - Divider().background(palette.divider) - testConnectionRow + .id(config.providerId) + rowDivider + thinkingRow + rowDivider + SettingsProviderToolsRow(validate: validateConnection) } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) + .modifier(SettingsSurfaceCardModifier(enabled: showsSurface)) } - // MARK: - Key - - private var keyField: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("api.key") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - Spacer() - Button(action: { showKey.toggle() }) { - Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") - .foregroundStyle(palette.textSecondary) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(showKey - ? AppL10n.string("api.key.hide") - : AppL10n.string("api.key.show"))) - } - Group { - if showKey { - TextField("sk-…", text: $config.apiKey) - } else { - SecureField("sk-…", text: $config.apiKey) - } - } - .keyboardType(.asciiCapable) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + private var rowDivider: some View { + Divider().background(palette.divider) } - // MARK: - Generic field - - @ViewBuilder - private func field( - title: String, - placeholder: String, - text: Binding, - autocap: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - TextField(placeholder, text: text) - .keyboardType(.asciiCapable) - .autocorrectionDisabled(true) - .textInputAutocapitalization(autocap ? .sentences : .never) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - .submitLabel(.done) - .onSubmit { /* no-op: prevent the keyboard from "submitting" - and dismissing the sheet unexpectedly */ } - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) - } - - // MARK: - Test connection - - private var testConnectionRow: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("api.connection") + /// Two-line thinking toggle — opt-in for slower, higher-quality polish. + private var thinkingRow: some View { + HStack(alignment: .center, spacing: Spacing.sm) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(AppL10n.string("settings.provider.thinking")) .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) - Spacer() - Button(action: runTest) { - Group { - if testStatus == .running { - ProgressView().controlSize(.mini) - } else { - Text(testButtonLabel) - .font(TypeStyle.body) - .foregroundStyle(testTint) - } - } - } - .buttonStyle(.plain) - .disabled(testStatus == .running) - } - if let detail = testDetail { - Text(detail) + .fixedSize(horizontal: false, vertical: true) + Text(AppL10n.string("settings.provider.thinkingSubtitle")) .font(TypeStyle.caption2) - .foregroundStyle(testTint) - .lineLimit(3) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } + Spacer(minLength: 0) + Toggle("", isOn: $config.llmThinkingEnabled) + .labelsHidden() + .tint(palette.accent) + .accessibilityLabel(AppL10n.string("settings.provider.thinking")) } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.xs) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center) + .settingsListRow() } - private var testButtonLabel: String { - switch testStatus { - case .idle: return AppL10n.string("api.test.idle") - case .running: return AppL10n.string("api.test.running") - case .success: return AppL10n.string("api.test.success") - case .failure: return AppL10n.string("api.test.failure") - } - } - - private var testTint: Color { - switch testStatus { - case .idle, .running: return palette.accent - case .success: return palette.accent - case .failure: return palette.danger - } - } - - private var testDetail: String? { - switch testStatus { - case .idle, .running, .success: return nil - case .failure(let message): return message - } - } - - private func runTest() { - testStatus = .running - let store = AppGroupStore() - let client = OpenAICompatibleClient( - baseURL: store.baseURL, - apiKey: store.apiKey, - model: store.model + private func validateConnection() async throws { + // Use on-screen config — not a fresh AppGroupStore — so a just-typed + // key is visible even if Keychain write is still settling. + let client = LLMClientFactory.make( + providerId: config.providerId, + baseURL: config.baseURL, + apiKey: config.apiKey, + model: config.model, + thinkingEnabled: config.llmThinkingEnabled + ) + _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") + } + + private func fetchModels() async throws -> [String] { + try await ProviderModelService.listLLMModels( + providerId: config.providerId, + baseURL: config.baseURL, + apiKey: config.apiKey, + currentModel: config.model ) - Task { - do { - _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") - testStatus = .success - } catch LLMError.noAPIKey { - testStatus = .failure(AppL10n.string("api.test.missing")) - } catch let error as LLMError { - switch error { - case .http(let status): - testStatus = .failure(AppL10n.format("api.test.http", status)) - case .rateLimited: - testStatus = .failure(AppL10n.string("api.test.rateLimited")) - case .transport(let msg): - testStatus = .failure(AppL10n.format("api.test.transportWith", msg)) - default: - testStatus = .failure(error.errorDescription ?? "\(error)") - } - } catch { - testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)") - } - } } } diff --git a/OSGKeyboard/Views/ASRSettingsCard.swift b/OSGKeyboard/Views/ASRSettingsCard.swift index 7989c2e..f98cd22 100644 --- a/OSGKeyboard/Views/ASRSettingsCard.swift +++ b/OSGKeyboard/Views/ASRSettingsCard.swift @@ -10,195 +10,131 @@ struct ASRSettingsCard: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig - @State private var showKey: Bool = false - @State private var testStatus: TestStatus = .idle - - private enum TestStatus: Equatable { - case idle - case running - case success - case failure(String) - } + /// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。 + var showsSurface: Bool = true var body: some View { VStack(spacing: 0) { - if CloudASRModelCatalog.strategy(for: config.asrProviderId) == .prompt { - field( - title: AppL10n.string("api.baseUrl"), - placeholder: "https://api.openai.com/v1", - text: $config.asrBaseURL, - autocap: false - ) - Divider().background(palette.divider) + if config.asrProviderId == "volcengine" { + volcengineRows + } else { + genericRows } - keyField - Divider().background(palette.divider) - field( - title: AppL10n.string("settings.asr.model"), - placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId), - text: $config.asrModel, - autocap: false - ) - if let url = LLMProvider.provider(id: config.asrProviderId).apiKeyURL { - Divider().background(palette.divider) - Button { - UIApplication.shared.open(url) - } label: { - HStack { - Text("api.getKey") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Image(systemName: "arrow.up.right.square") - .foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - Divider().background(palette.divider) - testConnectionRow + rowDivider + SettingsProviderToolsRow(validate: validateConnection) } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - - private var keyField: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("api.key") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - Spacer() - Button(action: { showKey.toggle() }) { - Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") - .foregroundStyle(palette.textSecondary) - } - .buttonStyle(.plain) - } - Group { - if showKey { - TextField("sk-…", text: $config.asrApiKey) - } else { - SecureField("sk-…", text: $config.asrApiKey) - } - } - .keyboardType(.asciiCapable) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + .modifier(SettingsSurfaceCardModifier(enabled: showsSurface)) } @ViewBuilder - private func field( - title: String, - placeholder: String, - text: Binding, - autocap: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - TextField(placeholder, text: text) - .keyboardType(.asciiCapable) - .autocorrectionDisabled(true) - .textInputAutocapitalization(autocap ? .sentences : .never) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) + private var genericRows: some View { + if CloudASRModelCatalog.showsASREndpointField(for: config.asrProviderId) { + SettingsCredentialRow( + title: AppL10n.string("api.baseUrl"), + placeholder: LLMProvider.provider(id: config.asrProviderId).defaultBaseURL, + text: $config.asrBaseURL, + isMonospaced: true + ) + rowDivider } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + + SettingsCredentialRow( + title: AppL10n.string("api.key"), + placeholder: "sk-…", + text: $config.asrApiKey, + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsModelPickerRow( + title: AppL10n.string("settings.asr.model"), + placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId), + model: $config.asrModel, + fetchModels: fetchModels + ) + .id(config.asrProviderId) } - private var testConnectionRow: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("settings.asr.testConnection") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Button(action: runTest) { - Group { - if testStatus == .running { - ProgressView().controlSize(.mini) - } else { - Text(testButtonLabel) - .font(TypeStyle.body) - .foregroundStyle(testTint) - } - } - } - .buttonStyle(.plain) - .disabled(testStatus == .running) - } - if let detail = testDetail { - Text(detail) - .font(TypeStyle.caption2) - .foregroundStyle(testTint) - .lineLimit(3) + private var volcengineRows: some View { + Group { + SettingsCredentialRow( + title: AppL10n.string("settings.asr.volcengine.appId"), + placeholder: "APP ID", + text: Binding( + get: { volcengineFields.appID }, + set: { updateVolcengine(appID: $0) } + ), + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsCredentialRow( + title: AppL10n.string("settings.asr.volcengine.accessToken"), + placeholder: "Access Token", + text: Binding( + get: { volcengineFields.accessToken }, + set: { updateVolcengine(accessToken: $0) } + ), + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsCredentialRow( + title: AppL10n.string("settings.asr.volcengine.resourceId"), + placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"), + text: Binding( + get: { volcengineFields.resourceID }, + set: { updateVolcengine(resourceID: $0) } + ), + isMonospaced: true, + defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine") + ) + rowDivider + SettingsProviderRow(title: AppL10n.string("settings.provider.note")) { + Text("settings.asr.volcengine.note") + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.xs) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center) } - private var testButtonLabel: String { - switch testStatus { - case .idle: return AppL10n.string("api.test.idle") - case .running: return AppL10n.string("api.test.running") - case .success: return AppL10n.string("api.test.success") - case .failure: return AppL10n.string("api.test.failure") - } + private var rowDivider: some View { + Divider().background(palette.divider) } - private var testTint: Color { - switch testStatus { - case .idle, .running: return palette.accent - case .success: return palette.accent - case .failure: return palette.danger - } + private var volcengineFields: VolcengineASRFields { + VolcengineASRFields.parse( + apiKey: config.asrApiKey, + resourceFallback: config.asrModel.isEmpty + ? CloudASRModelCatalog.defaultModel(for: "volcengine") + : config.asrModel + ) } - private var testDetail: String? { - switch testStatus { - case .idle, .running, .success: return nil - case .failure(let message): return message + private func updateVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) { + var fields = volcengineFields + if let appID { fields.appID = appID } + if let accessToken { fields.accessToken = accessToken } + if let resourceID { + fields.resourceID = resourceID + config.asrModel = resourceID } + config.asrApiKey = fields.encodedAPIKey } - private func runTest() { - testStatus = .running - let store = AppGroupStore() - let client = CloudASRClientFactory.make(store: store) - Task { - do { - try await client.prepare(dictionary: store.personalDictionary) - let samples = [Float](repeating: 0.01, count: 16_000) - _ = try await client.transcribe( - samples: samples, - sampleRate: 16_000, - locale: Locale(identifier: store.localeId == "auto" ? "zh-CN" : store.localeId), - dictionary: store.personalDictionary - ) - testStatus = .success - } catch CloudASRError.noAPIKey { - testStatus = .failure(AppL10n.string("api.test.missing")) - } catch let error as CloudASRError { - testStatus = .failure(error.localizedDescription ?? "\(error)") - } catch { - testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)") - } - } + private func validateConnection() async throws { + let persisted = AppGroupStore() + let live = LiveConfigurationStore(config: config, fallback: persisted) + try await CloudASRConnectionCheck.validate(store: live) + } + + private func fetchModels() async throws -> [String] { + try await ProviderModelService.listASRModels( + providerId: config.asrProviderId, + baseURL: config.asrBaseURL, + apiKey: config.asrApiKey, + currentModel: config.asrModel + ) } } diff --git a/OSGKeyboard/Views/Components/HomeStatsCard.swift b/OSGKeyboard/Views/Components/HomeStatsCard.swift deleted file mode 100644 index 9b42a7d..0000000 --- a/OSGKeyboard/Views/Components/HomeStatsCard.swift +++ /dev/null @@ -1,138 +0,0 @@ -// HomeStatsCard.swift -// OSGKeyboard · Main App -// -// Home screen summary: dictation time, dictation characters, -// translation characters, and personal-dictionary entry count. - -import SwiftUI -import OSGKeyboardShared - -struct HomeStatsCard: View { - @Environment(\.themePalette) private var palette: ThemePalette - @Environment(\.colorScheme) private var colorScheme - - @ObservedObject private var stats = UsageStatisticsStore.shared - @ObservedObject private var config = ProviderConfig.shared - - @State private var dictionaryCount = 0 - - private enum Layout { - static let fixedHeight: CGFloat = 166 - static let valueFontSize: CGFloat = 24 - static let iconSize: CGFloat = 18 - } - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - statCell( - systemImage: "waveform", - value: UsageStatisticsStore.formatDuration( - stats.dictationDurationSeconds, - language: config.uiLanguage - ), - label: "home.stats.dictationDuration" - ) - divider - statCell( - systemImage: "text.alignleft", - value: UsageStatisticsStore.formatCount( - stats.dictationCharacterCount, - language: config.uiLanguage - ), - label: "home.stats.dictationCharacters" - ) - } - horizontalDivider - HStack(spacing: 0) { - statCell( - systemImage: "character.bubble", - value: UsageStatisticsStore.formatCount( - stats.translationCharacterCount, - language: config.uiLanguage - ), - label: "home.stats.translationCharacters" - ) - divider - statCell( - systemImage: "square.stack.3d.down.right.fill", - value: UsageStatisticsStore.formatCount( - dictionaryCount, - language: config.uiLanguage - ), - label: "home.stats.dictionaryEntries" - ) - } - } - .frame(height: Layout.fixedHeight) - .background(cardBackground) - .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - .onAppear(perform: refreshDictionaryCount) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in - stats.reloadFromDisk() - } - } - - private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View { - HStack(alignment: .top, spacing: Spacing.xs) { - VStack(alignment: .leading, spacing: Spacing.xs) { - Text(value) - .font(.system(size: Layout.valueFontSize, weight: .semibold, design: .rounded)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.75) - Text(label) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.85) - } - Spacer(minLength: Spacing.xs) - Image(systemName: systemImage) - .font(.system(size: Layout.iconSize, weight: .semibold)) - .foregroundStyle(palette.accent) - .padding(.top, 2) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(Spacing.md) - } - - private var divider: some View { - Rectangle() - .fill(palette.divider) - .frame(width: 0.5) - } - - private var horizontalDivider: some View { - Rectangle() - .fill(palette.divider) - .frame(height: 0.5) - } - - private func refreshDictionaryCount() { - dictionaryCount = AppGroupStore().personalDictionary.entries.count - } - - private var cardBackground: Color { - colorScheme == .dark ? palette.surface : .white - } -} - -#if DEBUG -#Preview { - ThemedRoot { - HomeStatsCard() - .padding() - } -} -#endif diff --git a/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift b/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift new file mode 100644 index 0000000..9feeebb --- /dev/null +++ b/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift @@ -0,0 +1,61 @@ +// HomeUsageStatsSection.swift +// OSGKeyboard · Main App +// +// Observes usage + dictionary counts and feeds the shared +// `UsageStatsCluster` (phone stacked / iPad split). + +import SwiftUI +import OSGKeyboardShared + +struct HomeUsageStatsSection: View { + let layout: UsageStatsCluster.Layout + var compact: Bool = false + + @ObservedObject private var stats = UsageStatisticsStore.shared + @ObservedObject private var config = ProviderConfig.shared + + @State private var dictionaryCount = 0 + + var body: some View { + UsageStatsCluster( + layout: layout, + language: config.uiLanguage, + points: stats.last7Days, + dictationCharacterCount: stats.dictationCharacterCount, + dictationDurationSeconds: stats.dictationDurationSeconds, + translationCharacterCount: stats.translationCharacterCount, + dictionaryTermCount: dictionaryCount, + compact: compact + ) + .onAppear(perform: refreshDictionaryCount) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + refreshDictionaryCount() + } + .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in + refreshDictionaryCount() + } + .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in + stats.reloadFromDisk() + } + } + + private func refreshDictionaryCount() { + dictionaryCount = AppGroupStore().personalDictionary.entries.count + } +} + +#if DEBUG +#Preview("Phone stacked") { + ThemedRoot { + HomeUsageStatsSection(layout: .stacked) + .padding() + } +} + +#Preview("Wide split") { + ThemedRoot { + HomeUsageStatsSection(layout: .split) + .padding() + } +} +#endif diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift index f3abcb9..b717ef6 100644 --- a/OSGKeyboard/Views/Components/MinimalTabBar.swift +++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift @@ -23,7 +23,7 @@ enum AppTab: Int, CaseIterable { } } - /// Matches `HomeStatsCard` dictionary stat cell (filled variant). + /// Filled SF Symbol override for the dictionary tab. var sfSymbol: String? { switch self { case .dictionary: return "square.stack.3d.down.right.fill" diff --git a/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift b/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift index eccf0c9..4ebc785 100644 --- a/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift +++ b/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift @@ -32,62 +32,3 @@ struct PrivacyInfoCard: View { ) } } - -/// Footnote when Cloud polish is active — text goes to the user's configured API. -struct CloudPolishDisclosureBanner: View { - @Environment(\.themePalette) private var palette: ThemePalette - - var body: some View { - HStack(alignment: .top, spacing: Spacing.sm) { - Image(systemName: "info.circle") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(palette.accent) - Text("settings.privacy.cloud.body") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.accentMuted.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) - } -} - -/// First-time confirmation before enabling Cloud polish (user-configured third-party API). -struct CloudSharingAcknowledgmentModifier: ViewModifier { - @ObservedObject var config: ProviderConfig - @Binding var isPresented: Bool - let onConfirm: () -> Void - let onCancel: () -> Void - - func body(content: Content) -> some View { - content - .alert("settings.privacy.cloud.alert.title", isPresented: $isPresented) { - Button("common.continue") { - config.hasAcknowledgedCloudSharing = true - onConfirm() - } - Button("common.cancel", role: .cancel) { - onCancel() - } - } message: { - Text("settings.privacy.cloud.alert.message") - } - } -} - -extension View { - func cloudSharingAcknowledgment( - config: ProviderConfig, - isPresented: Binding, - onConfirm: @escaping () -> Void, - onCancel: @escaping () -> Void - ) -> some View { - modifier(CloudSharingAcknowledgmentModifier( - config: config, - isPresented: isPresented, - onConfirm: onConfirm, - onCancel: onCancel - )) - } -} diff --git a/OSGKeyboard/Views/Components/WideLayoutComponents.swift b/OSGKeyboard/Views/Components/WideLayoutComponents.swift index 261ebbd..c9318dd 100644 --- a/OSGKeyboard/Views/Components/WideLayoutComponents.swift +++ b/OSGKeyboard/Views/Components/WideLayoutComponents.swift @@ -4,6 +4,8 @@ // Reusable layout pieces for iPad / regular-width surfaces. Styled with the // shared design tokens so the wide Home dashboard can mirror the macOS shell // without pulling in AppKit-only types from OSGKeyboardMac. +// +// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`. import SwiftUI import OSGKeyboardShared @@ -21,7 +23,7 @@ enum WideLayoutMetrics { // MARK: - Card container -/// Elevated surface used for stat tiles and the dictation canvas. +/// Elevated surface used for the dictation canvas on wide Home. struct WideCard: View { @Environment(\.themePalette) private var palette var padding: CGFloat = Spacing.md @@ -39,160 +41,3 @@ struct WideCard: View { ) } } - -// MARK: - Stat tile - -struct WideStatCard: View { - @Environment(\.themePalette) private var palette - let title: String - let value: String - let caption: String - var systemImage: String? - var accent: Bool = false - /// Hero metric: wide horizontal layout for the primary word count. - var prominent: Bool = false - - var body: some View { - WideCard(padding: Spacing.md) { - if prominent { - prominentBody - } else { - compactBody - } - } - } - - private var compactBody: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - HStack { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Spacer() - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(accent ? palette.accent : palette.textTertiary) - .symbolRenderingMode(.hierarchical) - } - } - Text(value) - .font(TypeStyle.title2) - .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) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var prominentBody: some View { - HStack(spacing: Spacing.md) { - if let systemImage { - ZStack { - Circle() - .fill(palette.accentMuted) - .frame(width: 44, height: 44) - Image(systemName: systemImage) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(palette.accent) - .symbolRenderingMode(.hierarchical) - } - } - VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Text(caption) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - Spacer(minLength: Spacing.md) - Text(value) - .font(.system(size: 34, weight: .bold)) - .foregroundStyle(accent ? palette.accent : palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.6) - .contentTransition(.numericText()) - .animation(Motion.soft, value: value) - } - } -} - -// MARK: - Home stats cluster - -/// Dashboard-style stat cluster for the wide Home layout. -struct WideHomeStatsCluster: View { - @ObservedObject private var stats = UsageStatisticsStore.shared - @ObservedObject private var config = ProviderConfig.shared - - @State private var dictionaryCount = 0 - - private var language: AppUILanguage { config.uiLanguage } - - var body: some View { - VStack(spacing: Spacing.md) { - WideStatCard( - title: AppL10n.string("home.stats.dictationCharacters", language: language), - value: UsageStatisticsStore.formatCount( - stats.dictationCharacterCount, - language: language - ), - caption: AppL10n.string("home.wide.stat.transcribed", language: language), - systemImage: "text.alignleft", - accent: true, - prominent: true - ) - - HStack(spacing: Spacing.md) { - WideStatCard( - title: AppL10n.string("home.stats.dictationDuration", language: language), - value: UsageStatisticsStore.formatDuration( - stats.dictationDurationSeconds, - language: language - ), - caption: AppL10n.string("home.wide.stat.cumulativeDuration", language: language), - systemImage: "waveform" - ) - WideStatCard( - title: AppL10n.string("home.stats.translationCharacters", language: language), - value: UsageStatisticsStore.formatCount( - stats.translationCharacterCount, - language: language - ), - caption: AppL10n.string("home.wide.stat.cumulativeTranslation", language: language), - systemImage: "character.bubble" - ) - WideStatCard( - title: AppL10n.string("home.stats.dictionaryEntries", language: language), - value: UsageStatisticsStore.formatCount( - dictionaryCount, - language: language - ), - caption: AppL10n.string("home.wide.stat.customTerms", language: language), - systemImage: "character.book.closed" - ) - } - } - .onAppear(perform: refreshDictionaryCount) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in - stats.reloadFromDisk() - } - } - - private func refreshDictionaryCount() { - dictionaryCount = AppGroupStore().personalDictionary.entries.count - } -} diff --git a/OSGKeyboard/Views/EnginePickerSection.swift b/OSGKeyboard/Views/EnginePickerSection.swift index 81fbf9d..edc3cee 100644 --- a/OSGKeyboard/Views/EnginePickerSection.swift +++ b/OSGKeyboard/Views/EnginePickerSection.swift @@ -11,8 +11,6 @@ struct EnginePickerSection: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig - @State private var showCloudAcknowledgment = false - @State private var pendingEngineSelection: String? var body: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { @@ -37,17 +35,7 @@ struct EnginePickerSection: View { RoundedRectangle(cornerRadius: Radius.large, style: .continuous) .stroke(palette.divider, lineWidth: 0.5) ) - - if config.engineMode == "cloud" { - CloudPolishDisclosureBanner() - } } - .cloudSharingAcknowledgment( - config: config, - isPresented: $showCloudAcknowledgment, - onConfirm: { applyPendingEngineSelection() }, - onCancel: { pendingEngineSelection = nil } - ) } private var localSubtitle: String { @@ -64,11 +52,6 @@ struct EnginePickerSection: View { let isSelected = config.engineMode == id return Button { guard config.engineMode != id else { return } - if id == "cloud", !config.hasAcknowledgedCloudSharing { - pendingEngineSelection = id - showCloudAcknowledgment = true - return - } selectEngine(id) } label: { HStack(spacing: Spacing.sm) { @@ -92,8 +75,7 @@ struct EnginePickerSection: View { .foregroundStyle(palette.accent) } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -104,19 +86,10 @@ struct EnginePickerSection: View { config.engineMode = id if id == "cloud" { config.modeId = "polish" - if config.providerId == "deepseek" { - config.apply(preset: LLMProvider.provider(id: "openai")) - } } } } - private func applyPendingEngineSelection() { - guard let id = pendingEngineSelection else { return } - pendingEngineSelection = nil - selectEngine(id) - } - @ViewBuilder private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View { ZStack { diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index ff0d257..2f54229 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -85,6 +85,16 @@ struct HomeView: View { private var phoneBody: some View { GeometryReader { geo in let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top + // 小屏(如 iPhone SE)压缩顶部留白,把空间让给自适应的输入框, + // 避免固定块之和超出视口、底部状态行被 tab 栏遮挡。 + let isCompact = geo.size.height < 700 + // logo 上下留白对称,避免视觉上偏下。 + let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl + let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl + let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg + let statusTopPadding = isCompact ? Spacing.sm : Spacing.xl + // 输入框最小高度:小屏可压得更矮,让底部状态行始终留在 tab 栏之上。 + let previewMinHeight: CGFloat = isCompact ? 72 : 160 ZStack(alignment: .top) { sessionHeaderGradient(height: gradientHeight) @@ -92,23 +102,25 @@ struct HomeView: View { .allowsHitTesting(false) VStack(spacing: 0) { - logoHeader - .padding(.top, Spacing.xxxl) - .padding(.bottom, Spacing.xxl) + logoHeader(compact: isCompact) + .padding(.top, logoTopPadding) + .padding(.bottom, logoBottomPadding) if showsFlowSessionExtras { flowSessionExtras .padding(.horizontal, Spacing.lg) - .padding(.bottom, Spacing.lg) + .padding(.bottom, extrasBottomPadding) } - HomeStatsCard() + HomeUsageStatsSection(layout: .stacked, compact: isCompact) .padding(.horizontal, Spacing.lg) .padding(.bottom, Spacing.md) - previewField + // 唯一的弹性区块:吸收全部剩余空间(大屏铺满、小屏优先让位)。 + previewField(minHeight: previewMinHeight) .padding(.horizontal, Spacing.lg) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .layoutPriority(-1) HStack(spacing: Spacing.sm) { engineStatusLine @@ -116,7 +128,7 @@ struct HomeView: View { } .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, Spacing.lg) - .padding(.top, Spacing.xl) + .padding(.top, statusTopPadding) .padding(.bottom, Spacing.sm) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -138,7 +150,7 @@ struct HomeView: View { VStack(alignment: .leading, spacing: Spacing.lg) { wideHeroHeader - WideHomeStatsCluster() + HomeUsageStatsSection(layout: .split) if showsFlowSessionExtras { flowSessionExtras @@ -235,12 +247,15 @@ struct HomeView: View { // MARK: - Header - private var logoHeader: some View { - VStack(spacing: Spacing.xxl) { + // logo 尺寸保持 144:41 比例;小屏进一步缩小,给下方内容让空间。 + private func logoHeader(compact: Bool) -> some View { + let logoWidth: CGFloat = compact ? 104 : 124 + let logoHeight = logoWidth * (41.0 / 144.0) + return VStack(spacing: Spacing.xxl) { Image("osglogo") .resizable() .scaledToFit() - .frame(width: 144, height: 41) + .frame(width: logoWidth, height: logoHeight) .accessibilityHidden(true) } .frame(maxWidth: .infinity) @@ -444,9 +459,9 @@ struct HomeView: View { // MARK: - Preview field - private var previewField: some View { + private func previewField(minHeight: CGFloat) -> some View { previewFieldContent - .frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading) .padding(Spacing.md) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index c76d2ca..6251d08 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -25,8 +25,10 @@ struct LocalModelsGroup: View { speechRow Divider().background(palette.divider) polishRow +#if DEBUG Divider().background(palette.divider) customLanguageModelDiagnosticRow +#endif } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( @@ -45,8 +47,7 @@ struct LocalModelsGroup: View { Spacer(minLength: Spacing.xs) engineBadge("settings.localModels.speechEngine") } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } // MARK: Polish row @@ -61,12 +62,12 @@ struct LocalModelsGroup: View { Spacer(minLength: Spacing.xs) engineBadge("settings.localModels.polishEngine") } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } - // MARK: Custom language model diagnostic row + // MARK: Custom language model diagnostic row (DEBUG builds only) +#if DEBUG private var customLanguageModelDiagnosticRow: some View { Toggle(isOn: $config.localASRCustomLanguageModelEnabled) { VStack(alignment: .leading, spacing: Spacing.xxs) { @@ -79,10 +80,9 @@ struct LocalModelsGroup: View { } } .tint(palette.accent) - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } +#endif // MARK: Helpers diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 985e571..b49cb34 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -1,12 +1,13 @@ // OnboardingView.swift // OSGKeyboard · Main App // -// Five-step onboarding: +// Six-step onboarding: // 1) Welcome // 2) Microphone permission // 3) Speech recognition permission // 4) Enable keyboard + Allow Full Access -// 5) Engine / API setup +// 5) Engine / ASR setup +// 6) Polish LLM setup import SwiftUI import OSGKeyboardShared @@ -18,8 +19,9 @@ private enum OnboardingPage: Int, CaseIterable { case speech case keyboard case api + case polish - static let count = 5 + static let count = 6 } struct OnboardingView: View { @@ -61,6 +63,8 @@ struct OnboardingView: View { EnableKeyboardPage() case .api: APISetupPage(config: config) + case .polish: + PolishSetupPage(config: config) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -234,14 +238,14 @@ struct OnboardingView: View { } } - private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue } + private var isLastPage: Bool { config.onboardingPage == OnboardingPage.polish.rawValue } private var canAdvance: Bool { switch currentPage { - case .welcome, .keyboard: - return !isLastPage || onboardingCompleteReady - case .api: + case .welcome, .keyboard, .api: return !isLastPage || onboardingCompleteReady + case .polish: + return onboardingCompleteReady case .microphone: return micStatus != .undetermined case .speech: @@ -250,9 +254,8 @@ struct OnboardingView: View { } private var onboardingCompleteReady: Bool { - // v0.2.0: the local engine uses iOS `SpeechAnalyzer`, which is - // always available — no model download gate. The cloud engine - // still requires base URL + API key + model. + // Local engine: built-in polish satisfies `isPolishConfigured`. + // Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready. if config.isLocalEngine { return true } return config.isConfigured } @@ -364,7 +367,6 @@ private struct OnboardingTitleBlock: View { // MARK: - Welcome private struct WelcomePage: View { - @Environment(\.themePalette) private var palette: ThemePalette @State private var logoAppeared = false var body: some View { @@ -386,26 +388,13 @@ private struct WelcomePage: View { } OnboardingTitleBlock( - title: "onboarding.welcome.tagline", - subtitle: "onboarding.welcome.subtitle", - secondarySubtitle: "onboarding.welcome.subtitle2" + title: "onboarding.welcome.tagline" ) .opacity(logoAppeared ? 1 : 0) .offset(y: logoAppeared ? 0 : 10) .padding(.top, OnboardingLayoutMetrics.heroTextGap) .animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared) - if let url = LegalLinks.privacyPolicyURL { - Link(destination: url) { - Text("legal.privacyPolicy") - .font(TypeStyle.caption) - .foregroundStyle(palette.accent) - } - .padding(.top, Spacing.xxxl) - .opacity(logoAppeared ? 1 : 0) - .animation(.easeOut(duration: 0.45).delay(0.28), value: logoAppeared) - } - Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -708,7 +697,7 @@ private struct EnableKeyboardPage: View { } } -// MARK: - API setup +// MARK: - Engine / ASR setup private struct APISetupPage: View { @Environment(\.themePalette) private var palette: ThemePalette @@ -728,16 +717,60 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) if config.engineMode == "cloud" { - ProviderPickerSection(config: config) - .padding(.horizontal, Spacing.lg) - APISettingsCard(config: config) - .padding(.horizontal, Spacing.lg) + VStack(spacing: 0) { + ProviderPickerSection(config: config, role: .asr, showsSurface: false) + Divider().background(palette.divider) + ASRSettingsCard(config: config, showsSurface: false) + } + .modifier(SettingsSurfaceCardModifier(enabled: true)) + .padding(.horizontal, Spacing.lg) } else { - // Local engine: built-in ASR + built-in polish (no API card). - LocalModelsGroup(config: config) - .padding(.horizontal, Spacing.lg) + Text("onboarding.api.localModels.hint") + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.xl) } - + } + .padding(.bottom, Spacing.xxxl) + } + } +} + +// MARK: - Polish LLM setup + +private struct PolishSetupPage: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var config: ProviderConfig + + var body: some View { + ScrollView { + VStack(spacing: Spacing.lg) { + OnboardingHeroIcon(systemName: "wand.and.stars", circleSize: 72, iconSize: 30) + .padding(.top, Spacing.xl) + + OnboardingTitleBlock( + title: "onboarding.polish.title", + subtitle: "onboarding.polish.subtitle" + ) + .padding(.horizontal, Spacing.lg) + + if config.isLocalEngine { + Text("onboarding.polish.localHint") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.xl) + } + + VStack(spacing: 0) { + ProviderPickerSection(config: config, role: .polish, showsSurface: false) + Divider().background(palette.divider) + APISettingsCard(config: config, showsSurface: false) + } + .modifier(SettingsSurfaceCardModifier(enabled: true)) + .padding(.horizontal, Spacing.lg) } .padding(.bottom, Spacing.xxxl) } diff --git a/OSGKeyboard/Views/OpenSourceLicensesView.swift b/OSGKeyboard/Views/OpenSourceLicensesView.swift index 21e7f6c..61c99ac 100644 --- a/OSGKeyboard/Views/OpenSourceLicensesView.swift +++ b/OSGKeyboard/Views/OpenSourceLicensesView.swift @@ -64,8 +64,7 @@ struct OpenSourceLicensesView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .padding(.vertical, 10) + .settingsListRow() .contentShape(Rectangle()) } } diff --git a/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift b/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift index 94d0ef9..ec68026 100644 --- a/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift +++ b/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift @@ -30,8 +30,7 @@ struct PersonalDictionaryEntrySheet: View { .focused($termFocused) .textInputAutocapitalization(.never) .autocorrectionDisabled() - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Radius.large, style: .continuous) diff --git a/OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift b/OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift deleted file mode 100644 index f6652a5..0000000 --- a/OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift +++ /dev/null @@ -1,99 +0,0 @@ -// PersonalDictionaryICloudSyncRow.swift -// OSGKeyboard · Main App -// -// Settings-row toggle for mirroring the personal dictionary through -// iCloud Key-Value Store. Lives in Settings → 词库与润色. - -import SwiftUI -import OSGKeyboardShared - -@MainActor -struct PersonalDictionaryICloudSyncRow: View { - @Environment(\.themePalette) private var palette: ThemePalette - - @State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled - @State private var syncErrorMessage: String? - @State private var isApplyingToggle = false - - private let store = AppGroupStore() - - var body: some View { - VStack(alignment: .leading, spacing: Spacing.xxs) { - Toggle(isOn: toggleBinding) { - Text("settings.personalDictionary.iCloudSync.settingsTitle") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .tint(palette.accent) - .disabled(isApplyingToggle) - - if let syncErrorMessage { - Text(syncErrorMessage) - .font(TypeStyle.caption2) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, Spacing.xxs) - } - } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading) - .onAppear { reloadFromStore() } - .onReceive( - NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud) - ) { _ in - reloadFromStore() - } - } - - private var toggleBinding: Binding { - Binding( - get: { isEnabled }, - set: { newValue in - guard newValue != isEnabled else { return } - if newValue { - enableSync() - } else { - disableSync() - } - } - ) - } - - private func reloadFromStore() { - isEnabled = store.personalDictionaryICloudSyncEnabled - } - - private func enableSync() { - isApplyingToggle = true - syncErrorMessage = nil - Task { - do { - try await PersonalDictionaryCloudSync.shared.enableSync() - reloadFromStore() - } catch let error as PersonalDictionaryCloudSyncError { - isEnabled = false - syncErrorMessage = localizedSyncError(error) - } catch { - isEnabled = false - syncErrorMessage = error.localizedDescription - } - isApplyingToggle = false - } - } - - private func disableSync() { - PersonalDictionaryCloudSync.shared.disableSync() - isEnabled = false - syncErrorMessage = nil - } - - private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String { - switch error { - case .payloadTooLarge: - return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge") - case .encodeFailed, .decodeFailed: - return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic") - } - } -} diff --git a/OSGKeyboard/Views/PersonalDictionaryView.swift b/OSGKeyboard/Views/PersonalDictionaryView.swift index 0c4aaf5..8f0ce75 100644 --- a/OSGKeyboard/Views/PersonalDictionaryView.swift +++ b/OSGKeyboard/Views/PersonalDictionaryView.swift @@ -170,8 +170,7 @@ struct PersonalDictionaryView: View { .font(.caption.weight(.semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift index 93c35c2..cd81944 100644 --- a/OSGKeyboard/Views/ProviderPickerSection.swift +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -9,33 +9,47 @@ struct ProviderPickerSection: View { @ObservedObject var config: ProviderConfig var role: CloudProviderRole = .polish + /// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。 + var showsSurface: Bool = true private var selectedProviderId: String { role == .asr ? config.asrProviderId : config.providerId } - var body: some View { - let visiblePresets = role == .asr + private var visiblePresets: [LLMProvider] { + role == .asr ? LLMProvider.asrSelectablePresets : LLMProvider.userSelectablePresets - VStack(spacing: 0) { - ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in - Button { - select(provider) - } label: { - row(provider, selected: provider.id == selectedProviderId) - } - .buttonStyle(.plain) - if index < visiblePresets.count - 1 { - Divider().background(palette.divider) + } + + private var selectedProvider: LLMProvider { + visiblePresets.first(where: { $0.id == selectedProviderId }) + ?? LLMProvider.provider(id: selectedProviderId) + } + + var body: some View { + // 只有右侧芯片是 Menu 的 label;标题留在行外,避免菜单弹出时 + // 把整行 label 一起隐藏,导致左侧「供应商」文字消失。 + SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) { + Menu { + ForEach(visiblePresets, id: \.id) { provider in + Button { + select(provider) + } label: { + let name = ProviderDisplayName.name(for: provider.id) + if provider.id == selectedProviderId { + Label(name, systemImage: "checkmark") + } else { + Text(name) + } + } } + } label: { + providerChip } + .buttonStyle(.plain) } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) + .modifier(SettingsSurfaceCardModifier(enabled: showsSurface)) } private func select(_ provider: LLMProvider) { @@ -49,48 +63,45 @@ struct ProviderPickerSection: View { } } - @ViewBuilder - private func row(_ provider: LLMProvider, selected: Bool) -> some View { + /// 收起状态展示:当前所选供应商 logo + 名称 + 展开箭头。 + private var providerChip: some View { HStack(spacing: Spacing.sm) { - providerMark(provider, selected: selected) + providerMark(selectedProvider) - Text(ProviderDisplayName.name(for: provider.id)) + Text(ProviderDisplayName.name(for: selectedProvider.id)) .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) - if provider.supportsPersonalDictionaryCloudASR { + if selectedProvider.supportsPersonalDictionaryCloudASR { personalDictionaryBadge } Spacer(minLength: Spacing.xs) - if selected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(palette.accent) - } + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @ViewBuilder - private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View { + private func providerMark(_ provider: LLMProvider) -> some View { ZStack { Circle() - .fill(selected ? palette.accentMuted : palette.surfaceElevated) + .fill(palette.accentMuted) .frame(width: 32, height: 32) if let asset = ProviderLogo.assetName(for: provider.id) { Image(asset) .resizable() .scaledToFit() .frame(width: 18, height: 18) - .foregroundStyle(selected ? palette.accent : palette.textPrimary) + .foregroundStyle(palette.accent) } else { Text(String(provider.name.prefix(1))) .font(TypeStyle.caption) - .foregroundStyle(selected ? palette.accent : palette.textSecondary) + .foregroundStyle(palette.accent) } } } diff --git a/OSGKeyboard/Views/SettingsCardChrome.swift b/OSGKeyboard/Views/SettingsCardChrome.swift new file mode 100644 index 0000000..17d4fb2 --- /dev/null +++ b/OSGKeyboard/Views/SettingsCardChrome.swift @@ -0,0 +1,26 @@ +// SettingsCardChrome.swift +// OSGKeyboard · Main App +// +// Shared rounded surface chrome for settings list cards. + +import SwiftUI +import OSGKeyboardShared + +struct SettingsSurfaceCardModifier: ViewModifier { + @Environment(\.themePalette) private var palette: ThemePalette + + let enabled: Bool + + func body(content: Content) -> some View { + if enabled { + content + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } else { + content + } + } +} diff --git a/OSGKeyboard/Views/SettingsICloudSyncRow.swift b/OSGKeyboard/Views/SettingsICloudSyncRow.swift index 7a89baa..0ff408e 100644 --- a/OSGKeyboard/Views/SettingsICloudSyncRow.swift +++ b/OSGKeyboard/Views/SettingsICloudSyncRow.swift @@ -14,6 +14,9 @@ struct SettingsICloudSyncRow: View { @State private var syncErrorMessage: String? @State private var isApplyingToggle = false @State private var isSyncingNow = false + /// Transient success flag: shows a brief "已同步" confirmation so a fast + /// sync gives visible feedback instead of a spinner that flashes once. + @State private var showSyncedConfirmation = false private let store = AppGroupStore() @@ -38,13 +41,21 @@ struct SettingsICloudSyncRow: View { syncNow() } label: { HStack(spacing: Spacing.xs) { + Text(syncButtonTitleKey) + .font(TypeStyle.caption) + Spacer(minLength: 0) if isSyncingNow { ProgressView() - .controlSize(.small) + .controlSize(.mini) + } else if showSyncedConfirmation { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) } - Text("settings.appSettings.iCloudSync.syncNow") - .font(TypeStyle.caption) } + // Fill the row so the whole strip is tappable, not just + // the caption glyphs (previously easy to miss). + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(palette.accent) @@ -60,15 +71,18 @@ struct SettingsICloudSyncRow: View { .padding(.top, Spacing.xxs) } } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading) + .settingsListRow(alignment: .leading) .onAppear { reloadFromStore() } .onReceive( NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud) ) { _ in reloadFromStore() } + .onReceive( + NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud) + ) { _ in + reloadFromStore() + } } private var toggleBinding: Binding { @@ -85,6 +99,16 @@ struct SettingsICloudSyncRow: View { ) } + private var syncButtonTitleKey: LocalizedStringKey { + if isSyncingNow { + return "settings.appSettings.iCloudSync.syncing" + } + if showSyncedConfirmation { + return "settings.appSettings.iCloudSync.synced" + } + return "settings.appSettings.iCloudSync.syncNow" + } + private func reloadFromStore() { isEnabled = store.settingsICloudSyncEnabled } @@ -95,6 +119,15 @@ struct SettingsICloudSyncRow: View { Task { do { try await CloudSyncContext.shared.settingsSyncService.enableSync() + do { + try await CloudSyncContext.shared.dictionarySyncService.enableSync() + } catch let error as PersonalDictionaryCloudSyncError { + CloudSyncContext.shared.settingsSyncService.disableSync() + isEnabled = false + syncErrorMessage = localizedDictionarySyncError(error) + isApplyingToggle = false + return + } reloadFromStore() } catch let error as SettingsCloudSyncError { isEnabled = false @@ -114,15 +147,23 @@ struct SettingsICloudSyncRow: View { } private func syncNow() { + guard !isSyncingNow else { return } isSyncingNow = true + showSyncedConfirmation = false syncErrorMessage = nil Task { do { try await CloudSyncContext.shared.syncNow() + isSyncingNow = false + withAnimation { showSyncedConfirmation = true } + // Auto-dismiss the confirmation so the label returns to + // its default "立即同步" state. + try? await Task.sleep(nanoseconds: 1_800_000_000) + withAnimation { showSyncedConfirmation = false } } catch { + isSyncingNow = false syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic") } - isSyncingNow = false } } @@ -132,4 +173,13 @@ struct SettingsICloudSyncRow: View { return AppL10n.string("settings.appSettings.iCloudSync.error.generic") } } + + private func localizedDictionarySyncError(_ error: PersonalDictionaryCloudSyncError) -> String { + switch error { + case .payloadTooLarge: + return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge") + case .encodeFailed, .decodeFailed: + return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic") + } + } } diff --git a/OSGKeyboard/Views/SettingsProviderControls.swift b/OSGKeyboard/Views/SettingsProviderControls.swift new file mode 100644 index 0000000..d0f7fbc --- /dev/null +++ b/OSGKeyboard/Views/SettingsProviderControls.swift @@ -0,0 +1,316 @@ +// SettingsProviderControls.swift +// OSGKeyboard · Main App +// +// OpenLess-style setting rows for provider credentials and tools. Compact iOS +// stacks label above control; regular-width iPad keeps label/control in one row. + +import SwiftUI +import OSGKeyboardShared + +struct SettingsProviderRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + let title: String + @ViewBuilder var content: () -> Content + + var body: some View { + if horizontalSizeClass == .compact { + VStack(alignment: .leading, spacing: 8) { + label + content() + .frame(maxWidth: .infinity, alignment: .leading) + } + .settingsListRow() + } else { + HStack(alignment: .center, spacing: Spacing.lg) { + label + .frame(width: 150, alignment: .leading) + content() + .frame(maxWidth: .infinity, alignment: .leading) + } + .settingsListRow() + } + } + + private var label: some View { + Text(title) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + } +} + +struct SettingsCredentialRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let title: String + let placeholder: String + @Binding var text: String + var isSecret: Bool = false + var isMonospaced: Bool = false + var defaultValue: String? + var trailing: AnyView? + + @State private var revealed = false + + var body: some View { + SettingsProviderRow(title: title) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: Spacing.xs) { + input + if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + iconButton(systemName: "checkmark", label: "settings.provider.fillDefault") { + text = defaultValue + } + } + if let trailing { + trailing + } + if isSecret { + iconButton(systemName: revealed ? "eye.slash" : "eye", label: revealed ? "api.key.hide" : "api.key.show") { + revealed.toggle() + } + } + } + } + } + } + + @ViewBuilder + private var input: some View { + Group { + if isSecret && !revealed { + SecureField(placeholder, text: $text) + } else { + TextField(placeholder, text: $text) + } + } + .keyboardType(.asciiCapable) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(isMonospaced ? TypeStyle.mono : TypeStyle.caption) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.sm) + .frame(minHeight: 38) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(palette.textSecondary) + .frame(width: 38, height: 38) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } +} + +// MARK: - Editable model field + fetch icon + +/// Model id: type freely, or fetch then pick from the trailing dropdown. +struct SettingsModelPickerRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let title: String + let placeholder: String + @Binding var model: String + let fetchModels: () async throws -> [String] + + @State private var models: [String] = [] + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + + private let controlHeight: CGFloat = 38 + + var body: some View { + SettingsProviderRow(title: title) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: Spacing.xs) { + comboField + refreshButton + } + + if let message { + Text(message) + .font(TypeStyle.caption2) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + } + } + } + } + + /// Editable model id + trailing menu chevron in one well (same chrome as + /// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border. + private var comboField: some View { + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + let chevronWidth: CGFloat = 28 + return ZStack(alignment: .trailing) { + TextField(placeholder, text: $model) + .keyboardType(.asciiCapable) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(TypeStyle.mono) + .foregroundStyle(palette.textPrimary) + .padding(.leading, Spacing.sm) + .padding(.trailing, chevronWidth + Spacing.sm) + .frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading) + .background(palette.surfaceElevated, in: shape) + .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) + + Menu { + if models.isEmpty { + Button(AppL10n.string("settings.provider.modelsEmptyHint")) {} + .disabled(true) + } else { + ForEach(models, id: \.self) { modelId in + Button { + model = modelId + message = AppL10n.format("settings.provider.modelSelected", modelId) + failed = false + } label: { + if modelId == model { + Label(modelId, systemImage: "checkmark") + } else { + Text(modelId) + } + } + } + } + } label: { + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + .frame(width: chevronWidth, height: controlHeight) + .contentShape(Rectangle()) + } + .padding(.trailing, Spacing.sm) + .accessibilityLabel(AppL10n.string("settings.provider.selectModel")) + } + } + + private var refreshButton: some View { + Button { + Task { await runFetchModels() } + } label: { + Group { + if isRunning { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(palette.textSecondary) + } + } + .frame(width: controlHeight, height: controlHeight) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(isRunning) + .accessibilityLabel(AppL10n.string("settings.provider.fetchModels")) + } + + @MainActor + private func runFetchModels() async { + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runFetchModels( + runningMessage: AppL10n.string("settings.provider.loadingModels"), + loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) }, + emptyMessage: SharedL10n.string("providerTools.error.empty"), + currentModel: model, + fetchModels: fetchModels + ) + models = outcome.state.models + message = outcome.state.message + failed = outcome.state.failed + if let selected = outcome.selectedModel { + model = selected + } + } +} + +// MARK: - Connection validate only + +struct SettingsProviderToolsRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let validate: () async throws -> Void + + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + + var body: some View { + HStack(alignment: .center, spacing: Spacing.sm) { + Text(AppL10n.string("settings.provider.tools")) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + + if isRunning { + ProgressView() + .controlSize(.mini) + } else if let message { + Text(message) + .font(TypeStyle.caption2) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + .truncationMode(.tail) + } + + Spacer(minLength: 0) + + Button { + Task { await runValidate() } + } label: { + Text(AppL10n.string("settings.provider.validate")) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.md) + .frame(minHeight: 34) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(isRunning) + } + .settingsListRow() + } + + @MainActor + private func runValidate() async { + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runValidate( + runningMessage: AppL10n.string("api.test.running"), + successMessage: AppL10n.string("api.test.success"), + validate: validate + ) + message = outcome.message + failed = outcome.failed + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 15faaa9..5902b1b 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -43,15 +43,13 @@ struct SettingsView: View { dictionaryAndPolishSection flowSessionSection engineSection - polishProviderSection - polishApiSection if config.engineMode == "cloud" { - asrProviderSection - asrApiSection + asrSettingsSection } if config.engineMode == "local" { localEngineSettingsSection } + polishSettingsSection if presentation == .tab { footerLinks } @@ -115,9 +113,7 @@ struct SettingsView: View { } } .tint(palette.accent) - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() Divider().background(palette.divider) @@ -222,10 +218,6 @@ struct SettingsView: View { Divider().background(palette.divider) TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible) - - Divider().background(palette.divider) - - PersonalDictionaryICloudSyncRow() } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .overlay( @@ -248,40 +240,36 @@ struct SettingsView: View { } } - private var polishProviderSection: some View { + private var polishSettingsSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.polishProvider.title") - Text("settings.polishProvider.subtitle") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) - ProviderPickerSection(config: config, role: .polish) + cloudProviderSettingsCard { + ProviderPickerSection(config: config, role: .polish, showsSurface: false) + Divider().background(palette.divider) + APISettingsCard(config: config, showsSurface: false) + } } } - private var asrProviderSection: some View { + private var asrSettingsSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.asrProvider.title") - Text("settings.asrProvider.subtitle") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) - ProviderPickerSection(config: config, role: .asr) + cloudProviderSettingsCard { + ProviderPickerSection(config: config, role: .asr, showsSurface: false) + Divider().background(palette.divider) + ASRSettingsCard(config: config, showsSurface: false) + } } } - private var polishApiSection: some View { - VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.polishApi.title") - APISettingsCard(config: config) - } - } - - private var asrApiSection: some View { - VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.asrApi.title") - ASRSettingsCard(config: config) + @ViewBuilder + private func cloudProviderSettingsCard( + @ViewBuilder content: () -> Content + ) -> some View { + VStack(spacing: 0) { + content() } + .modifier(SettingsSurfaceCardModifier(enabled: true)) } // MARK: - Language helpers @@ -352,8 +340,7 @@ struct SettingsView: View { .foregroundStyle(palette.textPrimary) } .tint(palette.accent) - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } // MARK: - Footer links (tab settings only) @@ -375,8 +362,7 @@ struct SettingsView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -432,8 +418,7 @@ struct SettingsView: View { MaterialIcon(name: .openInNew, size: 18) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -453,8 +438,7 @@ struct SettingsView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } @@ -596,8 +580,7 @@ private struct PickerRow: View { } } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } private var currentLabel: String { @@ -654,8 +637,7 @@ private struct LocalePickerRow: View { } } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } private func label(for localeId: String) -> String { diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index f37abcc..22bdc35 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -69,13 +69,10 @@ struct TranslationPickerRow: View { } } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } } - // MARK: - Selection plumbing - /// Currently selected id — the picker always reads /// `translationTargetLocaleId` directly (the previous /// `translationEnabled` boolean is now derived from it). diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 70ad5f0..73f3d22 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -4,9 +4,7 @@ /* Onboarding */ "onboarding.progress" = "Step %1$d of %2$d"; -"onboarding.welcome.tagline" = "Speak instead of type"; -"onboarding.welcome.subtitle" = "Open-source voice keyboard"; -"onboarding.welcome.subtitle2" = "We never read or upload anything you type."; +"onboarding.welcome.tagline" = "Speak it. It’s typed."; "onboarding.permission.preface" = "iOS will ask for two permissions — please allow both."; "onboarding.permission.mic.title" = "Microphone"; "onboarding.permission.mic.body" = "Records your voice."; @@ -29,7 +27,10 @@ "onboarding.enable.step3.suffix" = "and select OSGKeyboard"; "onboarding.enable.openSettings" = "Open Settings"; "onboarding.api.title" = "Choose Engine"; -"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition and built-in polish — no API key needed."; +"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition — no API key needed."; +"onboarding.polish.title" = "Text polish (LLM)"; +"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider."; +"onboarding.polish.localHint" = "Built-in polish is included. Add your own API key here to override."; "settings.onboarding.replay" = "Restart permission setup"; /* Common navigation */ @@ -44,6 +45,7 @@ "common.delete" = "Delete"; "common.space" = "Space"; "common.newline" = "Return"; +"common.send" = "Send"; /* Privacy footnote (onboarding engine page) */ "privacy.audio.title" = "On-device transcription"; @@ -92,13 +94,13 @@ "settings.reset.title" = "Reset all settings?"; "settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared."; "settings.reset.confirm" = "Reset all settings"; -"settings.engine.title" = "Recognition method"; -"settings.engine.subtitle" = "Local: on-device ASR only. Cloud: ASR + polish via your API."; -"settings.engine.local.title" = "On-device recognition"; +"settings.engine.title" = "Speech transcription method"; +"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish."; +"settings.engine.local.title" = "On-device transcription"; "settings.engine.local.ios26" = "Always on-device, no network."; -"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; -"settings.engine.cloud.title" = "Cloud recognition & polish"; -"settings.engine.cloud.subtitle" = "Cloud ASR and polish LLM are configured separately. Audio goes to your ASR provider."; +"settings.engine.local.legacy" = "Transcribe with the built-in system speech service"; +"settings.engine.cloud.title" = "Cloud transcription"; +"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish"; "settings.engine.cloud.badge" = "Cloud engine"; "settings.provider.title" = "Provider"; "settings.provider.personalDictionaryBadge" = "Personal dictionary"; @@ -111,6 +113,26 @@ "settings.asrApi.title" = "ASR API"; "settings.asr.model" = "ASR model"; "settings.asr.testConnection" = "Test ASR"; +"settings.provider.supplier" = "Provider"; +"settings.provider.tools" = "Connection check"; +"settings.provider.validate" = "Validate"; +"settings.provider.fetchModels" = "Fetch models"; +"settings.provider.loadingModels" = "Loading models…"; +"settings.provider.modelsLoaded" = "%lld models loaded"; +"settings.provider.selectModel" = "Select model"; +"settings.provider.modelSelected" = "Selected %@"; +"settings.provider.modelsEmptyHint" = "Tap refresh to load models"; +"settings.provider.fillDefault" = "Fill default"; +"settings.provider.thinking" = "Thinking"; +"settings.provider.thinkingSubtitle" = "Slower, higher quality — recommended off"; +"settings.provider.thinkingOn" = "On"; +"settings.provider.thinkingOff" = "Off"; +"settings.provider.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning."; +"settings.provider.note" = "Note"; +"settings.asr.volcengine.appId" = "APP ID"; +"settings.asr.volcengine.accessToken" = "Access Token"; +"settings.asr.volcengine.resourceId" = "Resource ID"; +"settings.asr.volcengine.note" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration."; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "Qwen (DashScope)"; @@ -313,16 +335,8 @@ "home.flow.startShort" = "Start"; "home.preview.label" = "Try typing"; "home.preview.placeholder" = "Tap to type and test…"; -"home.stats.dictationDuration" = "Dictation time"; -"home.stats.dictationCharacters" = "Dictation chars"; -"home.stats.translationCharacters" = "Translation chars"; -"home.stats.dictionaryEntries" = "Dictionary"; "home.wide.tagline" = "Voice dictation, anywhere."; "home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate."; -"home.wide.stat.transcribed" = "Total dictated"; -"home.wide.stat.cumulativeDuration" = "Cumulative duration"; -"home.wide.stat.cumulativeTranslation" = "Cumulative translation"; -"home.wide.stat.customTerms" = "Custom terms"; "home.wide.mode.cloud" = "Cloud"; "home.wide.mode.local" = "On-device"; "home.wide.devices" = "iPhone & iPad"; @@ -387,10 +401,12 @@ "settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again."; "settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later."; -"settings.iCloudSync.title" = "iCloud Sync"; -"settings.appSettings.iCloudSync.title" = "iCloud Sync"; -"settings.appSettings.iCloudSync.subtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud. API keys use your private iCloud Keychain."; +"settings.iCloudSync.title" = "Cross-Device iCloud Sync"; +"settings.appSettings.iCloudSync.title" = "Cross-Device iCloud Sync"; +"settings.appSettings.iCloudSync.subtitle" = "Sync settings, history, and API keys across devices via iCloud."; "settings.appSettings.iCloudSync.syncNow" = "Sync Now"; +"settings.appSettings.iCloudSync.syncing" = "Syncing…"; +"settings.appSettings.iCloudSync.synced" = "Synced"; "settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later."; /* v0.3.0: Polish intensity */ diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 2afcbe0..3fef6c6 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -4,9 +4,7 @@ /* Onboarding */ "onboarding.progress" = "第 %1$d / %2$d 步"; -"onboarding.welcome.tagline" = "能说就不打字"; -"onboarding.welcome.subtitle" = "开源语音输入法"; -"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。"; +"onboarding.welcome.tagline" = "开口即文字。"; "onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。"; "onboarding.permission.mic.title" = "麦克风"; "onboarding.permission.mic.body" = "用于录制语音。"; @@ -29,7 +27,10 @@ "onboarding.enable.step3.suffix" = ",选中 OSGKeyboard"; "onboarding.enable.openSettings" = "去设置"; "onboarding.api.title" = "选择语音转文字 AI 引擎"; -"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别与内置润色,无需填写 API Key。"; +"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别,无需填写 API Key。"; +"onboarding.polish.title" = "文本润色(LLM)"; +"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。"; +"onboarding.polish.localHint" = "已包含内置润色。在此填入 API Key 可替换为自定义模型。"; "settings.onboarding.replay" = "重新开始权限引导"; /* Common navigation */ @@ -43,7 +44,8 @@ "common.clear" = "清空"; "common.delete" = "删除"; "common.space" = "空格"; -"common.newline" = "换行"; +"common.newline" = "回车"; +"common.send" = "发送"; /* Privacy footnote (onboarding engine page) */ "privacy.audio.title" = "支持本地转写"; @@ -92,13 +94,13 @@ "settings.reset.title" = "重置所有设置?"; "settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。"; "settings.reset.confirm" = "重置所有设置"; -"settings.engine.title" = "识别方式"; -"settings.engine.subtitle" = "本地:端侧识别,仅转录;云端:通过 API 润色。"; -"settings.engine.local.title" = "本地识别"; +"settings.engine.title" = "语音转写方式"; +"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。"; +"settings.engine.local.title" = "本地转写"; "settings.engine.local.ios26" = "全程在手机本地,不用联网"; -"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; -"settings.engine.cloud.title" = "云端识别与润色"; -"settings.engine.cloud.subtitle" = "云端 ASR 与润色 LLM 分开配置;音频发送至转写服务商。"; +"settings.engine.local.legacy" = "通过系统内置服务语音转写"; +"settings.engine.cloud.title" = "云端转写"; +"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色"; "settings.engine.cloud.badge" = "云端引擎"; "settings.provider.title" = "云端引擎"; "settings.provider.personalDictionaryBadge" = "个性词库"; @@ -111,6 +113,26 @@ "settings.asrApi.title" = "转写接口"; "settings.asr.model" = "ASR 模型"; "settings.asr.testConnection" = "测试转写"; +"settings.provider.supplier" = "供应商"; +"settings.provider.tools" = "连接检查"; +"settings.provider.validate" = "验证"; +"settings.provider.fetchModels" = "拉取模型"; +"settings.provider.loadingModels" = "正在拉取模型…"; +"settings.provider.modelsLoaded" = "已拉取 %lld 个模型"; +"settings.provider.selectModel" = "选择模型"; +"settings.provider.modelSelected" = "已选择 %@"; +"settings.provider.modelsEmptyHint" = "先点右侧刷新拉取模型"; +"settings.provider.fillDefault" = "填入默认值"; +"settings.provider.thinking" = "思考"; +"settings.provider.thinkingSubtitle" = "速度更慢、质量更高,建议关闭"; +"settings.provider.thinkingOn" = "开启"; +"settings.provider.thinkingOff" = "关闭"; +"settings.provider.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。"; +"settings.provider.note" = "提示"; +"settings.asr.volcengine.appId" = "APP ID"; +"settings.asr.volcengine.accessToken" = "Access Token"; +"settings.asr.volcengine.resourceId" = "Resource ID"; +"settings.asr.volcengine.note" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。"; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "通义千问"; @@ -312,16 +334,8 @@ "home.flow.startShort" = "开启"; "home.preview.label" = "输入测试"; "home.preview.placeholder" = "点这里试试键盘"; -"home.stats.dictationDuration" = "听写时长"; -"home.stats.dictationCharacters" = "听写字数"; -"home.stats.translationCharacters" = "翻译字数"; -"home.stats.dictionaryEntries" = "个性词库"; "home.wide.tagline" = "随处语音听写"; "home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。"; -"home.wide.stat.transcribed" = "累计听写字数"; -"home.wide.stat.cumulativeDuration" = "累计听写时长"; -"home.wide.stat.cumulativeTranslation" = "累计翻译字数"; -"home.wide.stat.customTerms" = "自定义词条"; "home.wide.mode.cloud" = "云端"; "home.wide.mode.local" = "本机"; "home.wide.devices" = "iPhone 与 iPad"; @@ -386,10 +400,12 @@ "settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。"; "settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。"; -"settings.iCloudSync.title" = "iCloud 同步"; -"settings.appSettings.iCloudSync.title" = "iCloud 同步"; -"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。"; +"settings.iCloudSync.title" = "跨设备iCloud 同步"; +"settings.appSettings.iCloudSync.title" = "跨设备iCloud 同步"; +"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 跨设备同步设置、历史记录、API Key"; "settings.appSettings.iCloudSync.syncNow" = "立即同步"; +"settings.appSettings.iCloudSync.syncing" = "同步中…"; +"settings.appSettings.iCloudSync.synced" = "已同步"; "settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。"; /* v0.3.0: 润色强度 */ diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 99847f4..a878341 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -105,6 +105,12 @@ public final class KeyboardViewController: UIInputViewController { super.viewDidAppear(animated) disableSystemGestureDelays() keyboardHeightConstraint?.constant = targetKeyboardHeight + refreshReturnKeyRole() + } + + public override func textDidChange(_ textInput: UITextInput?) { + super.textDidChange(textInput) + refreshReturnKeyRole() } public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { @@ -195,6 +201,21 @@ public final class KeyboardViewController: UIInputViewController { } } + private func refreshReturnKeyRole() { + state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default) + } + + private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole { + switch returnKeyType { + case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall: + return .send + case .default, .next, .done: + return .newline + @unknown default: + return .newline + } + } + // MARK: - System keyboard chrome private func configureDictationBehavior() { diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 0899e3b..2b1bd2d 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -48,8 +48,21 @@ final class KeyboardFlowCoordinator { /// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a /// stale App Group snapshot still says `reason=processing`. private var lastConsumedUtteranceId: UUID? + /// Utterance we just asked the host to stop. Until the host publishes + /// processing/final state, stale App Group snapshots can still say + /// `reason=recording`; do not re-adopt that utterance as locally active. + private var lastStoppedUtteranceId: UUID? private var currentCommandSeq: Int64 = 0 private var lastAvailabilityTraceSignature = "" + /// When true, `completeFlowStartHandoff` starts recording after the host + /// publishes ready — set only for an explicit mic press. + private var recordAfterHandoff = false + /// When true, `startHostReadyWaitIfNeeded` starts recording once ready + /// (mic pressed while session was still warming / mid ready-flap). + private var recordWhenHostReady = false + /// Ignores single-frame "host dead" samples before allowing a cold-start jump + /// from non-press recovery paths. + private var coldStartDebouncer = FlowColdStartDebouncer() init( state: KeyboardState, @@ -120,6 +133,8 @@ final class KeyboardFlowCoordinator { recomputeMicVoiceAvailability() startHostReadyWaitIfNeeded() + // Proactive host auto-launch is disabled (FlowHandoffPolicy): a single + // stale ready snapshot after finalize must never open startflow. // Only surface "session ended" when the session contract *genuinely* // dropped (expired / cleared). A transient host-ready flap — engine @@ -221,6 +236,7 @@ final class KeyboardFlowCoordinator { // forever miss the real delivery and leaves the mic white forever. guard let busyId = snapshot.busyUtteranceId else { return } guard busyId != lastConsumedUtteranceId else { return } + guard busyId != lastStoppedUtteranceId else { return } activeSessionId = sessionId currentUtteranceId = busyId isPendingFlowStart = false @@ -268,6 +284,7 @@ final class KeyboardFlowCoordinator { state.lastTranscript = "" stopFlowWatchdog() currentUtteranceId = nil + lastStoppedUtteranceId = nil traceState( "stickyProcessing.cleared", extra: hostReady ? "hostReady=1" : "hostReady=0" @@ -280,16 +297,24 @@ final class KeyboardFlowCoordinator { guard !isPendingFlowStart else { return } guard FlowSessionBridge.isSessionActive() else { stopHostReadyWait() + if recordWhenHostReady { + // Session gone while waiting — escalate to a real cold start. + let shouldRecord = recordWhenHostReady + recordWhenHostReady = false + beginFlowStart(recordAfterHandoff: shouldRecord) + } return } // Host busy ≠ waiting for ready. Do not spin the ready-wait poll. if let reason = FlowSessionBridge.readySnapshot()?.reason, reason == .recording || reason == .processing { stopHostReadyWait() + recordWhenHostReady = false return } - guard !FlowSessionBridge.isHostReady() else { + if FlowSessionBridge.isHostReady() { stopHostReadyWait() + finishHostReadyWaitIfNeeded() return } @@ -300,16 +325,50 @@ final class KeyboardFlowCoordinator { guard let self, !Task.isCancelled else { return } FlowSessionBridge.reloadFromDisk() self.recomputeMicVoiceAvailability() - if self.state.micVoiceAvailability.isReady - || self.state.micVoiceAvailability == .recording + if self.state.micVoiceAvailability.isReady { + self.finishHostReadyWaitIfNeeded() + return + } + if self.state.micVoiceAvailability == .recording || self.state.micVoiceAvailability == .processing { + self.recordWhenHostReady = false + return + } + // Host died mid-wait — only cold-start after debounced dead samples. + let dead = FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: false + ) + if self.coldStartDebouncer.observe(hostTrulyDead: dead) { + let shouldRecord = self.recordWhenHostReady + self.recordWhenHostReady = false + self.coldStartDebouncer.reset() + self.beginFlowStart(recordAfterHandoff: shouldRecord) return } try? await Task.sleep(nanoseconds: 150_000_000) } + // Timed out still not ready — if the user asked to record, cold-start. + guard let self else { return } + if self.recordWhenHostReady { + let shouldRecord = self.recordWhenHostReady + self.recordWhenHostReady = false + self.beginFlowStart(recordAfterHandoff: shouldRecord) + } } } + private func finishHostReadyWaitIfNeeded() { + coldStartDebouncer.reset() + guard recordWhenHostReady else { return } + recordWhenHostReady = false + guard state.micVoiceAvailability.isReady else { return } + startFlowRecording() + traceState("hostReadyWait.recordStarted") + } + private func stopHostReadyWait() { hostReadyWaitTask?.cancel() hostReadyWaitTask = nil @@ -338,9 +397,6 @@ final class KeyboardFlowCoordinator { recomputeMicVoiceAvailability() switch state.micVoiceAvailability { - case .ready: - detectAndStoreAppContext() - startFlowRecording() case .unavailable(.missingAPIKey): return case .unavailable(.noFullAccess): @@ -348,18 +404,43 @@ final class KeyboardFlowCoordinator { state.phase = .error(.fullAccessRequired, message: msg) scheduleAutoClearError() recomputeMicVoiceAvailability() + return case .unavailable(.appGroupUnavailable): let msg = ExtL10n.string("keyboard.error.appGroupCommunication") state.phase = .error(.appGroupUnavailable, message: msg) scheduleAutoClearError() recomputeMicVoiceAvailability() - case .unavailable(.preparingSession): + return + default: + break + } + + let withinReadyGrace = lastHostReadyAt > 0 + && (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace + let action = FlowHandoffPolicy.micPressAction( + availability: state.micVoiceAvailability, + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: withinReadyGrace + ) + switch action { + case .startRecording: detectAndStoreAppContext() - beginFlowStart() - case .unavailable(.hostNotReady): + startFlowRecording() + case .waitForHostReady(let recordWhenReady): detectAndStoreAppContext() - beginFlowStart() - case .recording, .processing: + recordWhenHostReady = recordWhenReady + coldStartDebouncer.reset() + startHostReadyWaitIfNeeded() + traceState( + "pressBegan.waitForHostReady", + extra: recordWhenReady ? "recordWhenReady=1" : "recordWhenReady=0" + ) + case .openHostColdStart: + detectAndStoreAppContext() + beginFlowStart(recordAfterHandoff: true) + case .ignore: return } } @@ -374,19 +455,23 @@ final class KeyboardFlowCoordinator { isFlowRecording = false stopUtteranceCountdown() ExtensionScreenWakeLock.release() + lastStoppedUtteranceId = currentUtteranceId writeCommand(.stopRecording) debug("pressEnded wrote stop command") state.phase = .processing state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") - recomputeMicVoiceAvailability() startFlowResultWatchdog() + recomputeMicVoiceAvailability() } - func beginFlowStart() { + func beginFlowStart(recordAfterHandoff: Bool = false) { guard !isPendingFlowStart else { traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue") return } + self.recordAfterHandoff = recordAfterHandoff + recordWhenHostReady = false + coldStartDebouncer.reset() isPendingFlowStart = true isFlowRecording = false flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout @@ -394,7 +479,10 @@ final class KeyboardFlowCoordinator { recomputeMicVoiceAvailability() openHostApp("startflow") startFlowStartWatchdog() - traceState("beginFlowStart.started") + traceState( + "beginFlowStart.started", + extra: recordAfterHandoff ? "recordAfterHandoff=1" : "recordAfterHandoff=0" + ) } func handleHostAppOpenResult(path: String, success: Bool) { @@ -406,6 +494,7 @@ final class KeyboardFlowCoordinator { // guide the user to open OSGKeyboard manually. if path == "startflow", isPendingFlowStart { isPendingFlowStart = false + recordAfterHandoff = false flowStartDeadline = 0 stopFlowWatchdog() traceState("openHostApp.failed", extra: "path=startflow cancelPending=1") @@ -425,8 +514,10 @@ final class KeyboardFlowCoordinator { ExtensionScreenWakeLock.release() } currentUtteranceId = nil + lastStoppedUtteranceId = nil isFlowRecording = false isPendingFlowStart = false + recordAfterHandoff = false stopUtteranceCountdown() stopFlowWatchdog() state.level = 0 @@ -472,6 +563,7 @@ final class KeyboardFlowCoordinator { ) FlowSessionBridge.clearResult() lastConsumedUtteranceId = result.utteranceId + lastStoppedUtteranceId = nil currentUtteranceId = nil textInserter.handleFlowTranscript( TranscriptionDelivery(text: text, polishWarning: result.warning) @@ -483,6 +575,7 @@ final class KeyboardFlowCoordinator { stopFlowWatchdog() FlowSessionBridge.clearResult() lastConsumedUtteranceId = result.utteranceId + lastStoppedUtteranceId = nil currentUtteranceId = nil let error = FlowTranscriptionError( message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), @@ -528,6 +621,7 @@ final class KeyboardFlowCoordinator { ExtensionScreenWakeLock.release() writeCommand(.abort) currentUtteranceId = nil + lastStoppedUtteranceId = nil stopFlowWatchdog() state.level = 0 state.phase = .idle @@ -547,10 +641,12 @@ final class KeyboardFlowCoordinator { isAwaitingFlowResult = false isFlowRecording = false isPendingFlowStart = false + recordAfterHandoff = false stopUtteranceCountdown() ExtensionScreenWakeLock.release() writeCommand(.abort) currentUtteranceId = nil + lastStoppedUtteranceId = nil stopFlowWatchdog() state.level = 0 let message = ExtL10n.string("keyboard.flow.hostDisconnected") @@ -585,9 +681,29 @@ final class KeyboardFlowCoordinator { private func startFlowRecording() { recomputeMicVoiceAvailability() - guard state.micVoiceAvailability.isReady else { - traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))") - beginFlowStart() + let withinReadyGrace = lastHostReadyAt > 0 + && (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace + if !state.micVoiceAvailability.isReady { + let action = FlowHandoffPolicy.micPressAction( + availability: state.micVoiceAvailability, + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: withinReadyGrace + ) + traceState( + "startFlowRecording.blocked", + extra: "availability=\(String(describing: state.micVoiceAvailability)) action=\(action)" + ) + switch action { + case .waitForHostReady(let recordWhenReady): + recordWhenHostReady = recordWhenReady + startHostReadyWaitIfNeeded() + case .openHostColdStart: + beginFlowStart(recordAfterHandoff: true) + case .startRecording, .ignore: + break + } return } isPendingFlowStart = false @@ -596,11 +712,23 @@ final class KeyboardFlowCoordinator { guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else { traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot") - beginFlowStart() + // Snapshot lag with a live session → wait; only cold-start if host is dead. + if FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: withinReadyGrace + ) { + beginFlowStart(recordAfterHandoff: true) + } else { + recordWhenHostReady = true + startHostReadyWaitIfNeeded() + } return } activeSessionId = sessionId currentUtteranceId = UUID() + lastStoppedUtteranceId = nil writeCommand(.startRecording) isFlowRecording = true state.lastTranscript = "" @@ -640,8 +768,12 @@ final class KeyboardFlowCoordinator { private func cancelPendingFlowStart() { isPendingFlowStart = false + recordAfterHandoff = false + recordWhenHostReady = false flowStartDeadline = 0 + coldStartDebouncer.reset() stopFlowWatchdog() + stopHostReadyWait() state.phase = .idle state.lastTranscript = "" recomputeMicVoiceAvailability() @@ -660,6 +792,7 @@ final class KeyboardFlowCoordinator { let now = Date().timeIntervalSince1970 if self.flowStartDeadline > 0, now > self.flowStartDeadline { self.isPendingFlowStart = false + self.recordAfterHandoff = false self.flowStartDeadline = 0 self.traceState("startWatchdog.timeout") self.showManualOpenHint(path: "startflow") @@ -671,13 +804,20 @@ final class KeyboardFlowCoordinator { } private func completeFlowStartHandoff() { + let shouldRecord = recordAfterHandoff isPendingFlowStart = false + recordAfterHandoff = false flowStartDeadline = 0 stopFlowWatchdog() state.lastTranscript = "" refreshSessionState() - startFlowRecording() - traceState("completeFlowStartHandoff.done") + if shouldRecord { + startFlowRecording() + traceState("completeFlowStartHandoff.done", extra: "record=1") + } else { + recomputeMicVoiceAvailability() + traceState("completeFlowStartHandoff.done", extra: "record=0 warmOnly") + } } private func startFlowLevelWatchdog() { @@ -735,6 +875,7 @@ final class KeyboardFlowCoordinator { ) FlowSessionBridge.clearResult() self.lastConsumedUtteranceId = result.utteranceId + self.lastStoppedUtteranceId = nil self.currentUtteranceId = nil self.debug("resultWatchdog consumed delivery len=\(text.count)") self.textInserter.handleFlowTranscript( @@ -747,6 +888,7 @@ final class KeyboardFlowCoordinator { self.stopFlowWatchdog() FlowSessionBridge.clearResult() self.lastConsumedUtteranceId = result.utteranceId + self.lastStoppedUtteranceId = nil self.currentUtteranceId = nil let error = FlowTranscriptionError( message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), @@ -784,6 +926,7 @@ final class KeyboardFlowCoordinator { self.isAwaitingFlowResult = false self.stopFlowWatchdog() self.currentUtteranceId = nil + self.lastStoppedUtteranceId = nil self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host") let msg = ExtL10n.string("keyboard.flow.resultTimeout") self.state.phase = .error(.flowResultTimeout, message: msg) diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index f4c0bb5..2bcc2cc 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -12,7 +12,7 @@ // │ (transcript preview) │ // │ ┊ │ // │ ◯ mic (centred) │ ← action cluster: -// │ [delete] [ space ] [return] │ mic + bottom row +// │ [delete] [ return ] [space] │ mic + bottom row // │ ┊ │ // └───────────────────────────────────────────┘ @@ -191,7 +191,7 @@ public struct KeyboardRootView: View { // MARK: - Action cluster - /// Mic centred above a bottom row: delete · space · return (or swapped). + /// Mic centred above a bottom row: delete · smart return · space (or swapped). /// The side cursor-drag pads are SwiftUI layout wrappers around UIKit /// pan recognizers, avoiding SwiftUI gesture delivery issues in /// keyboard extensions. @@ -225,13 +225,13 @@ public struct KeyboardRootView: View { HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) { if swapKeys { - bottomReturnButton(disabled: editingBlocked) bottomSpaceButton(disabled: editingBlocked) + bottomReturnButton(disabled: editingBlocked) bottomDeleteButton(disabled: editingBlocked) } else { bottomDeleteButton(disabled: editingBlocked) - bottomSpaceButton(disabled: editingBlocked) bottomReturnButton(disabled: editingBlocked) + bottomSpaceButton(disabled: editingBlocked) } } .opacity(dragging ? 0 : 1) @@ -265,19 +265,20 @@ public struct KeyboardRootView: View { RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) { state.insertSpace() } - .frame(height: KeyboardLayoutMetrics.bottomActionRowHeight) - } - - private func bottomReturnButton(disabled: Bool) -> some View { - RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) { - state.insertNewline() - } .frame( width: KeyboardLayoutMetrics.bottomActionFixedWidth, height: KeyboardLayoutMetrics.bottomActionRowHeight ) } + private func bottomReturnButton(disabled: Bool) -> some View { + let title = ExtL10n.string(state.returnKeyRole.titleKey) + return RectangularToolbarButton(title: title, label: title, disabled: disabled) { + state.insertNewline() + } + .frame(height: KeyboardLayoutMetrics.bottomActionRowHeight) + } + /// Option C: block typing keys during the full voice-input pipeline. private var voiceInputBlocksEditing: Bool { switch state.phase { diff --git a/OSGKeyboardExt/Views/ToolbarActionButtons.swift b/OSGKeyboardExt/Views/ToolbarActionButtons.swift index 1a69249..e27ef6c 100644 --- a/OSGKeyboardExt/Views/ToolbarActionButtons.swift +++ b/OSGKeyboardExt/Views/ToolbarActionButtons.swift @@ -10,6 +10,7 @@ import OSGKeyboardShared private enum ToolbarButtonMetrics { static let iconSize: CGFloat = 14 + static let titleSize: CGFloat = 16 static let cornerRadius: CGFloat = 12 static let spaceBarCapsuleWidth: CGFloat = 31 static let pressScale: CGFloat = 0.94 @@ -138,6 +139,7 @@ struct RectangularToolbarButton: View { let systemName: String? let spaceStyle: Bool + let title: String? let label: String let disabled: Bool let action: () -> Void @@ -145,14 +147,25 @@ struct RectangularToolbarButton: View { init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { self.systemName = systemName self.spaceStyle = false + self.title = nil self.label = label self.disabled = disabled self.action = action } + init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { + self.systemName = nil + self.spaceStyle = false + self.label = label + self.disabled = disabled + self.action = action + self.title = title + } + init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) { self.systemName = nil self.spaceStyle = spaceStyle + self.title = nil self.label = label self.disabled = disabled self.action = action @@ -170,6 +183,10 @@ struct RectangularToolbarButton: View { Image(systemName: systemName) .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) .foregroundStyle(palette.textPrimary) + } else if let title { + Text(title) + .font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold)) + .foregroundStyle(palette.textPrimary) } } .contentShape(Rectangle()) diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 581eb9d..a1642f4 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -25,6 +25,7 @@ "common.delete" = "Delete"; "common.space" = "Space"; "common.newline" = "Return"; +"common.send" = "Send"; /* Privacy footnote (onboarding welcome page) */ "privacy.audio.title" = "On-device transcription"; @@ -69,13 +70,13 @@ "settings.reset.title" = "Reset all settings?"; "settings.reset.message" = "API key, model, and base URL will be cleared."; "settings.reset.confirm" = "Reset all settings"; -"settings.engine.title" = "Recognition method"; -"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed."; -"settings.engine.local.title" = "On-device recognition"; +"settings.engine.title" = "Speech transcription method"; +"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish."; +"settings.engine.local.title" = "On-device transcription"; "settings.engine.local.ios26" = "Always on-device, no network."; -"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; -"settings.engine.cloud.title" = "Cloud recognition & polish"; -"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required."; +"settings.engine.local.legacy" = "Transcribe with the built-in system speech service"; +"settings.engine.cloud.title" = "Cloud transcription"; +"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish"; "settings.provider.title" = "Provider"; "settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; "settings.api.title" = "API"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index 630ea1d..c5f5134 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -24,7 +24,8 @@ "common.cancel" = "取消"; "common.delete" = "删除"; "common.space" = "空格"; -"common.newline" = "换行"; +"common.newline" = "回车"; +"common.send" = "发送"; /* Privacy footnote (onboarding welcome page) */ "privacy.audio.title" = "支持本地转写"; @@ -69,13 +70,13 @@ "settings.reset.title" = "重置所有设置?"; "settings.reset.message" = "API key、model 和 base URL 都会被清空。"; "settings.reset.confirm" = "重置所有设置"; -"settings.engine.title" = "识别方式"; -"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; -"settings.engine.local.title" = "本地识别"; +"settings.engine.title" = "语音转写方式"; +"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。"; +"settings.engine.local.title" = "本地转写"; "settings.engine.local.ios26" = "始终端侧,无需联网。"; -"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; -"settings.engine.cloud.title" = "云端识别与润色"; -"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; +"settings.engine.local.legacy" = "通过系统内置服务语音转写"; +"settings.engine.cloud.title" = "云端转写"; +"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色"; "settings.provider.title" = "云端引擎"; "settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.api.title" = "接口"; diff --git a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift index f4eb6e6..a91b079 100644 --- a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift +++ b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift @@ -66,7 +66,7 @@ private struct FlowLiveActivityLockScreenView: View { var body: some View { HStack(spacing: 12) { - FlowLiveActivityBrandMark(height: 13) + FlowLiveActivityBrandMark(height: 10.4) VStack(alignment: .leading, spacing: 4) { Text("OSGKeyboard") .font(.headline) diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift index a7ff65e..d83d9ee 100644 --- a/OSGKeyboardMac/DashboardView.swift +++ b/OSGKeyboardMac/DashboardView.swift @@ -22,23 +22,32 @@ struct DashboardView: View { } var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: Spacing.lg) { - heroHeader - statCluster - dictationStage + // Scrollable like the other pages so the shared status footer always + // stays pinned to the window's bottom edge. `minHeight: viewport` keeps + // the balanced Spacer layout when the window is tall (no scrollbar) and + // lets the content scroll only when the window is too short to fit it. + GeometryReader { proxy in + ScrollView { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: Spacing.lg) { + heroHeader + statCluster + dictationStage + } + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.top, Spacing.sm) + + // Leftover window height splits evenly above / below the mic + // bar so spacing stays balanced at any window size. + Spacer(minLength: Spacing.xs) + + BottomDictationBar(viewModel: viewModel) + .padding(.horizontal, MacMetrics.pageHorizontalInset) + + Spacer(minLength: Spacing.xs) + } + .frame(maxWidth: .infinity, minHeight: proxy.size.height) } - .padding(.horizontal, MacMetrics.pageHorizontalInset) - .padding(.top, Spacing.sm) - - // Leftover window height splits evenly above / below the mic bar - // so spacing stays balanced at any window size. - Spacer(minLength: Spacing.xs) - - BottomDictationBar(viewModel: viewModel) - .padding(.horizontal, MacMetrics.pageHorizontalInset) - - Spacer(minLength: Spacing.xs) } .onAppear { stats.reloadFromDisk() } .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in @@ -76,50 +85,18 @@ struct DashboardView: View { .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - Stats (hero word count, full width but content-height — - // never stretched to match a taller sibling and left with dead air) + // MARK: - Stats (7-day chart + cumulative grid — shared cluster) private var statCluster: some View { - VStack(spacing: Spacing.md) { - StatCard( - title: MacL10n.string("mac.stat.words", language: lang), - value: UsageStatisticsStore.formatCount( - stats.dictationCharacterCount, - language: lang - ), - caption: MacL10n.string("mac.stat.transcribed", language: lang), - systemImage: "text.alignleft", - accent: true, - prominent: true - ) - - HStack(spacing: Spacing.md) { - StatCard( - title: MacL10n.string("mac.stat.dictationTime", language: lang), - value: UsageStatisticsStore.formatDuration( - stats.dictationDurationSeconds, - language: lang - ), - caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang), - systemImage: "waveform" - ) - StatCard( - title: MacL10n.string("mac.stat.translation", language: lang), - value: UsageStatisticsStore.formatCount( - stats.translationCharacterCount, - language: lang - ), - caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang), - systemImage: "character.bubble" - ) - StatCard( - title: MacL10n.string("mac.stat.dictionary", language: lang), - value: "\(viewModel.dictionaryTermCount)", - caption: MacL10n.string("mac.stat.customTerms", language: lang), - systemImage: "character.book.closed" - ) - } - } + UsageStatsCluster( + layout: .split, + language: lang, + points: stats.last7Days, + dictationCharacterCount: stats.dictationCharacterCount, + dictationDurationSeconds: stats.dictationDurationSeconds, + translationCharacterCount: stats.translationCharacterCount, + dictionaryTermCount: viewModel.dictionaryTermCount + ) } // MARK: - Dictation stage @@ -220,18 +197,29 @@ struct BottomDictationBar: View { } } } label: { - HStack(spacing: 6) { + HStack(spacing: Spacing.sm) { Image(systemName: "translate") + .foregroundStyle(palette.textSecondary) Text(currentTranslationLabel) + .foregroundStyle(palette.textPrimary) .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: Spacing.xs) Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) } - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) + .font(MacSettingsType.control) .padding(.horizontal, Spacing.sm) - .padding(.vertical, 7) - .background(palette.surfaceElevated, in: Capsule()) + .frame(minHeight: MacMetrics.settingsControlHeight) + .background( + palette.surfaceElevated, + in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) } .menuStyle(.borderlessButton) .fixedSize() diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift index 6a81bff..39fb1fe 100644 --- a/OSGKeyboardMac/MacComponents.swift +++ b/OSGKeyboardMac/MacComponents.swift @@ -14,8 +14,33 @@ import SwiftUI /// Fixed metrics that keep every desktop surface on the same grid. enum MacMetrics { - /// Uniform max width for trailing text controls (API key / model field). - static let controlWidth: CGFloat = 240 + /// Shared height for credential inputs and icon buttons — matches the iOS + /// settings controls (38). + static let settingsControlHeight: CGFloat = 38 + /// Fixed label column for provider rows: wider than iOS (150) so the label + /// text takes a larger share of the row and the trailing control narrows. + static let settingLabelWidth: CGFloat = 200 + /// Uniform minimum height for every settings row, so rows read as an even + /// list regardless of whether they hold a 38pt control or a single-line + /// label. Content is centered within it; taller rows (status text, progress) + /// grow past it. + static let settingsRowMinHeight: CGFloat = 40 + /// The single vertical rhythm for settings cards: the gap *between* rows AND + /// the padding between a card's top/bottom edge and its first/last row are + /// both this value, so the card breathes evenly. Applied as `VStack(spacing:)` + /// between rows and `.padding(.vertical:)` on the card body. + static let settingsRowGap: CGFloat = Spacing.md + /// Provider menu trigger width (legacy). + static let selectWidth: CGFloat = 200 + /// Text-field max width for provider credential rows (narrower than the + /// old 360pt so long keys truncate instead of wrapping when the window shrinks). + static let fieldWidth: CGFloat = 280 + /// Legacy alias used by older call sites; prefer `fieldWidth`. + static let controlWidth: CGFloat = fieldWidth + /// Horizontal inset inside settings cards — matches History / Dictionary rows. + static let settingsCardInset: CGFloat = Spacing.md + /// Below this row width, provider rows stack label above control. + static let settingsCompactBreakpoint: CGFloat = 520 /// Sidebar width and the horizontal inset shared by brand, nav and footer. static let sidebarWidth: CGFloat = 240 /// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo @@ -46,6 +71,55 @@ enum MacMetrics { static let trafficLightInset: CGFloat = 28 } +// MARK: - Settings typography (mirrors the iOS settings rows) + +/// Type scale that maps 1:1 to the iOS settings controls so the desktop and +/// phone read identically: row label / picker value at body (15), hints at +/// caption2 (11), section header at caption2 (uppercased at the call site). +enum MacSettingsType { + static let sectionTitle = TypeStyle.caption2 + static let rowLabel = TypeStyle.body + static let control = TypeStyle.body + static let controlEmph = TypeStyle.bodyEmph + static let hint = TypeStyle.caption2 + static let button = TypeStyle.body +} + +// MARK: - iOS-style toggle + +/// Pill switch mirroring the iOS settings toggle: accent track when on, neutral +/// when off, 16pt white knob, spring slide. Colours resolve through the active +/// OSG palette so it follows the app theme. +struct MacToggleStyle: ToggleStyle { + @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + ZStack(alignment: configuration.isOn ? .trailing : .leading) { + Capsule() + .fill(configuration.isOn ? palette.accent : offTrackColor) + Circle() + .fill(Color.white) + .frame(width: 16, height: 16) + .shadow(color: .black.opacity(0.25), radius: 1, y: 1) + .padding(2) + } + .frame(width: 36, height: 20) + .animation(Motion.quick, value: configuration.isOn) + } + .buttonStyle(.plain) + } + + private var offTrackColor: Color { + colorScheme == .dark + ? Color.white.opacity(0.18) + : Color.black.opacity(0.15) + } +} + // MARK: - Liquid Glass private struct MacGlassSurface: ViewModifier { @@ -75,26 +149,296 @@ extension View { // MARK: - Text field styling -/// Text-field chrome aligned with native macOS Form controls: compact -/// height, text-background fill, hairline border. +/// Theme-aware credential field chrome — comfortable tap target, +/// monospaced-friendly body size, and a fill/border that stays clearly +/// distinct from the section card in both appearances. private struct MacFieldStyleModifier: ViewModifier { @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + var monospaced: Bool = false func body(content: Content) -> some View { - let shape = RoundedRectangle(cornerRadius: 6, style: .continuous) + // Mirrors the iOS credential field: recessed surface-elevated fill, + // medium radius, hairline border, comfortable 38pt tap target. + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) content .textFieldStyle(.plain) - .font(TypeStyle.footnote) - .padding(.horizontal, 8) - .frame(height: 22) - .background(Color(nsColor: .textBackgroundColor), in: shape) + .font(monospaced ? TypeStyle.mono : TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.sm) + .frame(minHeight: MacMetrics.settingsControlHeight) + .background(fieldFill, in: shape) .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) } + + /// Light mode: the section card reads near-white, so a recessed `surfaceElevated` + /// grey barely differs. Use the system text-input background (true white) so the + /// field reads as an editable well. Dark mode keeps the elevated grey, which sits + /// *lighter* than the card and already stands out. + private var fieldFill: Color { + #if os(macOS) + if colorScheme != .dark { + return Color(nsColor: .textBackgroundColor) + } + #endif + return palette.surfaceElevated + } } extension View { /// Theme-aware text-field styling for the settings inputs. - func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) } + func macFieldStyle(monospaced: Bool = false) -> some View { + modifier(MacFieldStyleModifier(monospaced: monospaced)) + } +} + +// MARK: - Provider settings chrome + +/// Section header + `MacCard` shell — same outer alignment as History / +/// Dictionary (title and card share `pageHorizontalInset` from the parent). +struct MacSettingsSection: View { + @Environment(\.themePalette) private var palette + + let title: String + var footer: String? = nil + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + Text(title) + .font(MacSettingsType.sectionTitle) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) + + MacCard(padding: 0) { + VStack(alignment: .leading, spacing: 0) { + content() + } + // Card top/bottom breathe on the same rhythm as the gaps between + // rows (rows own their own inter-row spacing via their container). + .padding(.vertical, MacMetrics.settingsRowGap) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let footer, !footer.isEmpty { + Text(footer) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +/// iOS-style inline row: title (+ optional subtitle) on the left, control +/// right-aligned. Used by simple picker / toggle rows. +struct MacInlineRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var subtitle: String? = nil + @ViewBuilder var control: () -> Control + + var body: some View { + HStack(alignment: .center, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(title) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textPrimary) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: Spacing.sm) + control() + } + .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + } +} + +/// Backwards-compatible alias: title + optional subtitle + trailing control. +struct MacFormSubtitleRow: View { + let title: String + var subtitle: String? = nil + @ViewBuilder var control: () -> Control + + var body: some View { + MacInlineRow(title: title, subtitle: subtitle) { + control() + } + } +} + +/// Tappable navigation / action row inside a settings card. +struct MacFormLinkRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var showsChevron: Bool = true + + var body: some View { + HStack(spacing: Spacing.sm) { + Text(title) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textPrimary) + Spacer(minLength: 0) + if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + } + } + .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .contentShape(Rectangle()) + } +} + +/// Single merged card body for LLM / ASR provider configuration sections. +struct MacSettingsProviderCard: View { + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(spacing: MacMetrics.settingsRowGap) { + content() + } + } +} + +// MARK: - Responsive provider setting row + +/// Label + control row for provider cards. Uses a two-column layout at +/// comfortable widths and stacks vertically when horizontal space is tight. +struct MacProviderSettingRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var subtitle: String? = nil + /// Retained for source compatibility; the control now fills its column. + var controlMaxWidth: CGFloat? = nil + /// Cross-axis alignment between the label column and the control. Provider + /// rows keep `.top` (model row grows a status line below its field); single + /// control rows can pass `.center` to vertically center label and control. + var verticalAlignment: VerticalAlignment = .top + @ViewBuilder var content: () -> Content + + var body: some View { + HStack(alignment: verticalAlignment, spacing: Spacing.md) { + label + .frame(width: MacMetrics.settingLabelWidth, alignment: .leading) + content() + .frame(maxWidth: .infinity, alignment: .leading) + } + // Uniform row height (content centered); inter-row spacing is owned by the + // enclosing card, so the row adds no vertical padding of its own. + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) + } + + private var label: some View { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(title) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + // Align the title with the first 38pt control row, not with any + // status/help text that appears below the control. + .frame(minHeight: MacMetrics.settingsControlHeight, alignment: .leading) + } +} + +/// Square icon button matching credential field height. +struct MacSettingsIconButton: View { + @Environment(\.themePalette) private var palette + + let systemName: String + var help: String? = nil + var disabled: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary) + .frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(disabled) + .help(help ?? "") + } +} + +/// Compact tool button for validate / fetch-models actions. +struct MacSettingsToolButton: View { + @Environment(\.themePalette) private var palette + + let title: String + /// Background fill. Defaults to the neutral elevated surface. + var fill: Color? = nil + /// Text color. Defaults to primary text (tertiary when disabled). + var foreground: Color? = nil + /// Hairline divider border — drawn for the neutral variant, hidden for + /// colored fills (delete / download) so the fill reads as the button. + var showsBorder: Bool = true + var disabled: Bool = false + let action: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + Button(action: action) { + Text(title) + .font(MacSettingsType.button) + .foregroundStyle(resolvedForeground) + .padding(.horizontal, Spacing.md) + .frame(minHeight: 34) + .background(fill ?? palette.surfaceElevated, in: shape) + .overlay { + if showsBorder { + shape.stroke(palette.divider, lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .disabled(disabled) + } + + private var resolvedForeground: Color { + if disabled { return palette.textTertiary } + return foreground ?? palette.textPrimary + } +} + +// MARK: - Legacy setting row + +/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow` +/// for provider configuration cards. +struct MacSettingRow: View { + let title: String + var controlMaxWidth: CGFloat? = nil + @ViewBuilder var content: () -> Content + + var body: some View { + MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) { + content() + } + } } // MARK: - Page header @@ -169,95 +513,6 @@ struct MacCard: View { } } -// MARK: - Stat tile - -struct StatCard: View { - @Environment(\.themePalette) private var palette - let title: String - let value: String - let caption: String - var systemImage: String? - var accent: Bool = false - /// Hero metric: wide horizontal layout that uses full-card width without - /// stretching to fill dead vertical space — used for the primary word count. - var prominent: Bool = false - - var body: some View { - MacCard(padding: prominent ? Spacing.md : Spacing.md) { - if prominent { - prominentBody - } else { - compactBody - } - } - } - - private var compactBody: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - HStack { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Spacer() - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(accent ? palette.accent : palette.textTertiary) - .symbolRenderingMode(.hierarchical) - } - } - Text(value) - .font(TypeStyle.title2) - .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) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - /// Wide "hero bar" layout: icon badge + title/caption on the left, the - /// big number anchored right — fills the full card width edge-to-edge - /// instead of a tall card with empty space below a small number. - private var prominentBody: some View { - HStack(spacing: Spacing.md) { - if let systemImage { - ZStack { - Circle() - .fill(palette.accentMuted) - .frame(width: 44, height: 44) - Image(systemName: systemImage) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(palette.accent) - .symbolRenderingMode(.hierarchical) - } - } - VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Text(caption) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - Spacer(minLength: Spacing.md) - Text(value) - .font(.system(size: 34, weight: .bold)) - .foregroundStyle(accent ? palette.accent : palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.6) - .contentTransition(.numericText()) - .animation(Motion.soft, value: value) - } - } -} - // MARK: - Live waveform /// Compact bar visualiser reacting to the input level while recording. @@ -330,6 +585,30 @@ struct MacStatusFooter: View { var body: some View { HStack(spacing: Spacing.sm) { Spacer() + + // Current models, shown just before the engine mode. Label text is + // the model name only; the full "provider · model" lives in the + // hover tooltip to keep the strip quiet. + Label(asrModel.name, systemImage: "waveform") + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 150, alignment: .trailing) + .help(asrModel.tooltip) + .contentTransition(.opacity) + + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + + Label(llmModel.name, systemImage: "sparkles") + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 150, alignment: .trailing) + .help(llmModel.tooltip) + .contentTransition(.opacity) + + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + Label( viewModel.isCloudMode ? MacL10n.string("mac.mode.cloud", language: lang) @@ -360,6 +639,56 @@ struct MacStatusFooter: View { .padding(.vertical, Spacing.sm) .animation(Motion.quick, value: viewModel.isCloudMode) .animation(Motion.quick, value: viewModel.config.translationTargetLocaleId) + .animation(Motion.quick, value: asrModel.name) + .animation(Motion.quick, value: llmModel.name) + } + + // MARK: - Current model resolution + + /// ASR: cloud shows the configured provider's model (or its default); + /// local shows the selected on-device model's display name. + private var asrModel: (name: String, tooltip: String) { + let config = viewModel.config + if viewModel.isCloudMode { + let providerName = ProviderDisplayName.name(for: config.asrProviderId, language: lang) + let model = config.asrModel.isEmpty + ? CloudASRModelCatalog.defaultModel(for: config.asrProviderId) + : config.asrModel + return (model, "\(providerName) · \(model)") + } + let providerName = MacL10n.string("mac.mode.local", language: lang) + let model = MacLocalASRModelName.displayName(for: MacLocalASRPreferences.selectedModelId, language: lang) + return (model, "\(providerName) · \(model)") + } + + /// LLM: cloud uses the configured provider; local routes through + /// `localModeProviderId` (built-in DeepSeek unless the user supplied a key). + private var llmModel: (name: String, tooltip: String) { + let config = viewModel.config + let providerId = viewModel.isCloudMode ? config.providerId : config.localModeProviderId + let providerName = ProviderDisplayName.name(for: providerId, language: lang) + let model: String + if providerId == config.providerId, !config.model.isEmpty { + model = config.model + } else { + model = LLMProvider.provider(id: providerId).defaultModel + } + return (model, "\(providerName) · \(model)") + } +} + +/// Cached lookup from a local ASR model id to its catalog display name. The +/// bundled catalog is small and immutable, so decoding it once is enough. +enum MacLocalASRModelName { + private static let catalog: LocalASRCatalogDocument? = try? LocalASRModelCatalog.loadBundled() + + static func displayName(for modelId: String, language: AppUILanguage) -> String { + if !modelId.isEmpty, + let catalog, + let model = LocalASRModelCatalog.model(modelId, in: catalog) { + return model.displayName + } + return modelId.isEmpty ? MacL10n.string("mac.mode.local", language: language) : modelId } } diff --git a/OSGKeyboardMac/MacDictationPipeline.swift b/OSGKeyboardMac/MacDictationPipeline.swift index 8cc1671..7f64f04 100644 --- a/OSGKeyboardMac/MacDictationPipeline.swift +++ b/OSGKeyboardMac/MacDictationPipeline.swift @@ -40,7 +40,8 @@ enum MacDictationPipeline { /// Whether the active engine can surface `onPartial` text while recording. static func supportsLivePartials(store: AppGroupStore) -> Bool { if store.engineMode == "local" { return true } - return CloudASRModelCatalog.strategy(for: store.asrProviderId) != .localFallback + let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId) + return strategy != .localFallback } /// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning. diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift index 2429067..e7cf11f 100644 --- a/OSGKeyboardMac/MacDictationViewModel.swift +++ b/OSGKeyboardMac/MacDictationViewModel.swift @@ -92,7 +92,9 @@ final class MacDictationViewModel: ObservableObject { self.config = ProviderConfig(defaults: defaults) self.usageStatistics = UsageStatisticsStore(defaults: defaults) self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true - self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true + // Global hotkey is always on — the settings toggle was removed, so any + // previously-stored "off" value is intentionally ignored. + self.hotkeyEnabled = true self.hotkeyTrigger = MacHotkeyTrigger( rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? "" ) ?? .rightOption @@ -284,7 +286,7 @@ final class MacDictationViewModel: ObservableObject { do { let result: MacDictationResult if let liveTask { - let capture = await liveTask.value + let capture = await Self.awaitLiveCapture(liveTask) let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines) if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty { if self.transcript.isEmpty { @@ -362,6 +364,25 @@ final class MacDictationViewModel: ObservableObject { isStreamingPartial = false } + /// Hard timeout so a hung realtime ASR drain cannot leave `isProcessing` stuck. + private static func awaitLiveCapture( + _ task: Task + ) async -> MacLiveASRCaptureResult { + await HardTimeout.value( + seconds: FlowSessionKeys.cloudASRWaitTimeout, + operation: { await task.value }, + onTimeout: { + task.cancel() + return MacLiveASRCaptureResult( + raw: "", + chunkWarning: nil, + localBias: nil, + shouldFallbackToBatch: true + ) + } + ) + } + /// Stops an in-flight prepare, or finishes an active recording. private func cancelOrFinishRecording() { if isRecording { diff --git a/OSGKeyboardMac/MacDictionaryView.swift b/OSGKeyboardMac/MacDictionaryView.swift index 4656ff4..b95b1ff 100644 --- a/OSGKeyboardMac/MacDictionaryView.swift +++ b/OSGKeyboardMac/MacDictionaryView.swift @@ -98,7 +98,7 @@ struct MacDictionaryView: View { private var list: some View { // Full-bleed ScrollView → scrollbar on the detail pane's right edge. ScrollView { - LazyVStack(alignment: .leading, spacing: Spacing.md) { + LazyVStack(alignment: .leading, spacing: Spacing.xl) { if sections.isEmpty { MacCard { Text(MacL10n.string("mac.dict.noMatch", language: lang)) @@ -120,22 +120,18 @@ struct MacDictionaryView: View { private func categorySection( _ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) ) -> some View { - VStack(alignment: .leading, spacing: Spacing.xs) { + VStack(alignment: .leading, spacing: Spacing.sm) { Text(MacL10n.string(section.category.labelKey, language: lang)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textTertiary) + .font(MacSettingsType.sectionTitle) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) MacCard(padding: 0) { VStack(spacing: 0) { - ForEach(Array(section.items.enumerated()), id: \.element.id) { index, entry in + ForEach(section.items, id: \.id) { entry in row(entry) .padding(.horizontal, Spacing.md) .padding(.vertical, Spacing.sm) - if index < section.items.count - 1 { - // Full-bleed like macOS list rows (not iOS inset separators). - Divider() - .overlay(palette.divider) - } } } } diff --git a/OSGKeyboardMac/MacHistoryView.swift b/OSGKeyboardMac/MacHistoryView.swift index bbb5dbe..b226e2d 100644 --- a/OSGKeyboardMac/MacHistoryView.swift +++ b/OSGKeyboardMac/MacHistoryView.swift @@ -83,7 +83,7 @@ struct MacHistoryView: View { // Full-bleed ScrollView → scrollbar on the detail pane's right edge. // Horizontal inset lives on the content so cards align with the title. ScrollView { - LazyVStack(alignment: .leading, spacing: Spacing.md) { + LazyVStack(alignment: .leading, spacing: Spacing.xl) { ForEach(historyStore.groupedByDay, id: \.day) { group in daySection(group) } @@ -94,22 +94,18 @@ struct MacHistoryView: View { } private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View { - VStack(alignment: .leading, spacing: Spacing.xs) { + VStack(alignment: .leading, spacing: Spacing.sm) { Text(Self.dayFormatter.string(from: group.day)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textTertiary) + .font(MacSettingsType.sectionTitle) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) MacCard(padding: 0) { VStack(spacing: 0) { - ForEach(Array(group.items.enumerated()), id: \.element.id) { index, entry in + ForEach(group.items, id: \.id) { entry in row(entry) .padding(.horizontal, Spacing.md) .padding(.vertical, Spacing.sm) - if index < group.items.count - 1 { - // Full-bleed like macOS list rows (not iOS inset separators). - Divider() - .overlay(palette.divider) - } } } } diff --git a/OSGKeyboardMac/MacICloudSyncRows.swift b/OSGKeyboardMac/MacICloudSyncRows.swift index c6057dd..b5f269d 100644 --- a/OSGKeyboardMac/MacICloudSyncRows.swift +++ b/OSGKeyboardMac/MacICloudSyncRows.swift @@ -1,10 +1,8 @@ // MacICloudSyncRows.swift // OSGKeyboard · Mac // -// iCloud sync toggles for settings and personal dictionary — same KVS -// keys and merge rules as the iOS settings page. Rendered as native Form -// rows (Toggle + optional action / error) so they sit inside a grouped -// `Form` section and match System Settings exactly. +// iCloud sync toggle for settings, history, API keys, and personal +// dictionary — same KVS keys and merge rules as the iOS settings page. import SwiftUI @@ -19,38 +17,51 @@ struct MacSettingsICloudSyncRow: View { @State private var isSyncingNow = false var body: some View { - Toggle(isOn: toggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(MacL10n.string("mac.sync.settingsTitle", language: language)) + MacProviderSettingRow( + title: MacL10n.string("mac.sync.settingsTitle", language: language), + verticalAlignment: .center + ) { + HStack(alignment: .center, spacing: Spacing.sm) { + Toggle("", isOn: toggleBinding) + .labelsHidden() + .toggleStyle(MacToggleStyle()) + .disabled(isApplyingToggle) Text(MacL10n.string("mac.sync.settingsSubtitle", language: language)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) .fixedSize(horizontal: false, vertical: true) } } - .tint(palette.accent) - .disabled(isApplyingToggle) .onAppear { reloadFromStore() } .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in reloadFromStore() } + .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in + reloadFromStore() + } if isEnabled { - Button { - syncNow() - } label: { - HStack(spacing: Spacing.xs) { - if isSyncingNow { ProgressView().controlSize(.small) } - Text(MacL10n.string("mac.sync.syncNow", language: language)) + HStack(spacing: Spacing.xs) { + MacSettingsToolButton( + title: MacL10n.string("mac.sync.syncNow", language: language), + disabled: isSyncingNow || isApplyingToggle + ) { + syncNow() } + if isSyncingNow { ProgressView().controlSize(.small) } + Spacer(minLength: 0) } - .disabled(isSyncingNow || isApplyingToggle) + .padding(.horizontal, MacMetrics.settingsCardInset) + .padding(.bottom, Spacing.sm) } if let syncErrorMessage { Text(syncErrorMessage) - .font(TypeStyle.caption) + .font(MacSettingsType.hint) .foregroundStyle(palette.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) + .padding(.bottom, Spacing.sm) } } @@ -74,6 +85,19 @@ struct MacSettingsICloudSyncRow: View { Task { do { try await MacICloudSyncBootstrap.settingsSync.enableSync() + do { + try await MacICloudSyncBootstrap.dictionarySync.enableSync() + } catch let error as PersonalDictionaryCloudSyncError { + MacICloudSyncBootstrap.settingsSync.disableSync() + isEnabled = false + if case .payloadTooLarge = error { + syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language) + } else { + syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language) + } + isApplyingToggle = false + return + } reloadFromStore() } catch { isEnabled = false @@ -102,79 +126,3 @@ struct MacSettingsICloudSyncRow: View { } } } - -struct MacDictionaryICloudSyncRow: View { - let defaults: UserDefaults - let language: AppUILanguage - - @Environment(\.themePalette) private var palette - @State private var isEnabled = false - @State private var syncErrorMessage: String? - @State private var isApplyingToggle = false - - var body: some View { - Toggle(isOn: toggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(MacL10n.string("mac.sync.dictTitle", language: language)) - Text(MacL10n.string("mac.sync.dictSubtitle", language: language)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .tint(palette.accent) - .disabled(isApplyingToggle) - .onAppear { reloadFromStore() } - .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in - reloadFromStore() - } - - if let syncErrorMessage { - Text(syncErrorMessage) - .font(TypeStyle.caption) - .foregroundStyle(palette.danger) - } - } - - private var toggleBinding: Binding { - Binding( - get: { isEnabled }, - set: { newValue in - guard newValue != isEnabled else { return } - newValue ? enableSync() : disableSync() - } - ) - } - - private func reloadFromStore() { - isEnabled = AppGroupStore(defaults: defaults).personalDictionaryICloudSyncEnabled - } - - private func enableSync() { - isApplyingToggle = true - syncErrorMessage = nil - Task { - do { - try await MacICloudSyncBootstrap.dictionarySync.enableSync() - reloadFromStore() - } catch let error as PersonalDictionaryCloudSyncError { - isEnabled = false - if case .payloadTooLarge = error { - syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language) - } else { - syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language) - } - } catch { - isEnabled = false - syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language) - } - isApplyingToggle = false - } - } - - private func disableSync() { - MacICloudSyncBootstrap.dictionarySync.disableSync() - isEnabled = false - syncErrorMessage = nil - } -} diff --git a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift index c8bf8e4..72f5b17 100644 --- a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift +++ b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift @@ -198,6 +198,9 @@ struct MacLocalASRModelSettingsView: View { @ObservedObject var viewModel: MacDictationViewModel @StateObject private var modelVM = MacLocalASRModelSettingsViewModel() @Environment(\.themePalette) private var palette + /// Only one model installs at a time, so a single hover flag drives the + /// active download ring's pause/resume affordance. + @State private var isHoveringProgress = false private var lang: AppUILanguage { viewModel.config.uiLanguage } @@ -217,42 +220,54 @@ 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) - ) + MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + if let runtime = modelVM.currentRuntime(in: catalog) { + MacFormSubtitleRow(title: runtime.displayName) { + Text( + modelVM.isRuntimeInstalled(runtime) + ? MacL10n.string("mac.localASR.installed", language: lang) + : MacL10n.string("mac.localASR.notInstalled", language: lang) + ) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + } } - } - ForEach(catalog.models) { model in - modelRow(model) - } + ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in + modelRow(model) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) + } - if modelVM.isInstalling, - modelVM.installProgress.phase == .extracting - || modelVM.installProgress.phase == .validating - || modelVM.installProgress.phase == .finalizing { - ProgressView(value: modelVM.installProgress.fraction) { - Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) + if modelVM.isInstalling, + modelVM.installProgress.phase == .extracting + || modelVM.installProgress.phase == .validating + || modelVM.installProgress.phase == .finalizing { + ProgressView(value: modelVM.installProgress.fraction) { + Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) + .font(TypeStyle.caption) + } + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) + } + + if !modelVM.statusMessage.isEmpty { + Text(modelVM.statusMessage) .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) } - } - if !modelVM.statusMessage.isEmpty { - Text(modelVM.statusMessage) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) + HStack(spacing: 0) { + MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) { + modelVM.revealStorageRoot() + } + Spacer(minLength: 0) + } + .padding(.horizontal, MacMetrics.settingsCardInset) } - - Button(MacL10n.string("mac.localASR.openStorage", language: lang)) { - modelVM.revealStorageRoot() - } - } header: { - Text(MacL10n.string("mac.localASR.models", language: lang)) } } @@ -305,7 +320,7 @@ struct MacLocalASRModelSettingsView: View { .animation(Motion.soft, value: installed) } } - .padding(.vertical, 2) + .padding(.vertical, 0) } private func modelBadge(_ title: String, emphasized: Bool) -> some View { @@ -329,49 +344,39 @@ struct MacLocalASRModelSettingsView: View { installing: Bool ) -> some View { if installing { - HStack(spacing: Spacing.sm) { - circularInstallProgress(for: model) - if modelVM.installProgress.phase == .downloading - || modelVM.installProgress.phase == .paused { - Button { - if modelVM.isDownloadPaused { - modelVM.resumeDownload() - } else { - modelVM.pauseDownload() - } - } label: { - Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill") - .font(.system(size: 12, weight: .semibold)) - .frame(width: 28, height: 28) - } - .buttonStyle(.bordered) - .controlSize(.small) - .help( - modelVM.isDownloadPaused - ? MacL10n.string("mac.localASR.resume", language: lang) - : MacL10n.string("mac.localASR.pause", language: lang) - ) - } - } + installProgressControl(for: model) + .animation(Motion.quick, value: isHoveringProgress) + .animation(Motion.quick, value: modelVM.isDownloadPaused) } else if installed { - Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) { + MacSettingsToolButton( + title: MacL10n.string("mac.localASR.delete", language: lang), + fill: palette.danger.opacity(0.15), + foreground: palette.danger, + showsBorder: false + ) { modelVM.deleteModel(model) } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(palette.danger) } else { - Button(MacL10n.string("mac.localASR.download", language: lang)) { + MacSettingsToolButton( + title: MacL10n.string("mac.localASR.download", language: lang), + fill: palette.accent, + foreground: .white, + showsBorder: false + ) { modelVM.installModel(model) } - .buttonStyle(.borderedProminent) - .controlSize(.small) } } - private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View { + /// Single download ring. Shows the percentage while downloading; on hover it + /// reveals a clickable pause icon, and once paused it keeps a resume (play) + /// icon so the state stays clear without a second button. + private func installProgressControl(for model: LocalASRModelDefinition) -> some View { + let phase = modelVM.installProgress.phase + let pausable = phase == .downloading || phase == .paused + let paused = modelVM.isDownloadPaused let fraction: Double = { - if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused, + if pausable, let received = modelVM.installProgress.bytesReceived, let total = modelVM.installProgress.bytesTotal, total > 0 { @@ -379,6 +384,7 @@ struct MacLocalASRModelSettingsView: View { } return modelVM.installProgress.fraction }() + return ZStack { Circle() .stroke(palette.textTertiary.opacity(0.25), lineWidth: 3) @@ -387,20 +393,46 @@ struct MacLocalASRModelSettingsView: View { .stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(Motion.instant, value: fraction) - if modelVM.installProgress.phase == .paused { - Image(systemName: "pause.fill") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(palette.textSecondary) - } else { - Text("\(Int(fraction * 100))%") - .font(.system(size: 9, weight: .medium, design: .rounded)) - .foregroundStyle(palette.textSecondary) - } + + progressCenter(pausable: pausable, paused: paused, fraction: fraction) } .frame(width: 36, height: 36) + .contentShape(Circle()) + .onHover { hovering in + guard pausable else { return } + isHoveringProgress = hovering + } + .onTapGesture { + guard pausable else { return } + if paused { modelVM.resumeDownload() } else { modelVM.pauseDownload() } + } + .help( + pausable + ? (paused + ? MacL10n.string("mac.localASR.resume", language: lang) + : MacL10n.string("mac.localASR.pause", language: lang)) + : modelVM.progressLabel(for: modelVM.installProgress, language: lang) + ) .accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) } + @ViewBuilder + private func progressCenter(pausable: Bool, paused: Bool, fraction: Double) -> some View { + if pausable, paused { + Image(systemName: "play.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(palette.accent) + } else if pausable, isHoveringProgress { + Image(systemName: "pause.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(palette.textSecondary) + } else { + Text("\(Int(fraction * 100))%") + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(palette.textSecondary) + } + } + private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String { let size = modelVM.formattedSize(model.sizeBytes) if let usage = modelVM.installedDiskUsage(model) { diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift index 32c3362..354f1d0 100644 --- a/OSGKeyboardMac/MacOnboardingView.swift +++ b/OSGKeyboardMac/MacOnboardingView.swift @@ -422,12 +422,15 @@ struct MacOnboardingView: View { 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) + MacInlinePicker( + selection: providerBinding, + options: viewModel.selectableProviders.map { + MacInlinePickerOption( + value: $0.id, + label: ProviderDisplayName.name(for: $0.id, language: lang) + ) } - } - .labelsHidden() + ) .frame(maxWidth: .infinity, alignment: .leading) SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) { diff --git a/OSGKeyboardMac/MacQwen3ASREngine.swift b/OSGKeyboardMac/MacQwen3ASREngine.swift deleted file mode 100644 index e06fc3b..0000000 --- a/OSGKeyboardMac/MacQwen3ASREngine.swift +++ /dev/null @@ -1,95 +0,0 @@ -// MacQwen3ASREngine.swift -// OSGKeyboard · Mac -// -// Singleton actor that loads, warms up, and runs Qwen3-ASR via mlx-swift-asr. -// Model load + Metal JIT warmup take several seconds — call `prepareIfNeeded` -// at launch so the first dictation is fast. - -import Foundation -import MLXASR - -/// Lifecycle of the on-disk MLX model inside the app process. -enum MacQwen3EnginePhase: Sendable, Equatable { - case idle - case loading - case ready - case failed(String) -} - -actor MacQwen3ASREngine { - static let shared = MacQwen3ASREngine() - - private var stt: Qwen3ASRSTT? - private var loadedModelPath: String? - private(set) var phase: MacQwen3EnginePhase = .idle - - private init() {} - - /// Load and warm up the model when the path changes or nothing is loaded yet. - func prepareIfNeeded(modelPath: String) async throws { - if loadedModelPath == modelPath, stt != nil, phase == .ready { return } - - phase = .loading - stt = nil - loadedModelPath = nil - - let directory = URL(fileURLWithPath: modelPath, isDirectory: true) - do { - let instance = try await Qwen3ASRSTT.loadWithWarmup(from: directory) - stt = instance - loadedModelPath = modelPath - phase = .ready - } catch { - let detail = error.localizedDescription - phase = .failed(detail) - throw MacLocalASRError.qwen3LoadFailed(detail) - } - } - - /// Transcribe mono 16 kHz float PCM. Ensures the model is loaded first. - func transcribe( - samples: [Float], - language: String?, - modelPath: String, - context: String? = nil - ) async throws -> String { - try await prepareIfNeeded(modelPath: modelPath) - guard let stt else { - throw MacLocalASRError.qwen3LoadFailed("Engine not initialized") - } - - let result = try await stt.transcribe(audio: samples, language: language, context: context) - let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - throw MacLocalASRError.emptyTranscript - } - return text - } - - /// Drop cached weights (e.g. after the user changes the model folder). - func unload() { - stt = nil - loadedModelPath = nil - phase = .idle - } -} - -enum MacQwen3LanguageHint { - /// Map persisted BCP-47 locale ids to Qwen3 prompt language names. - /// Returns `nil` for auto-detect. - static func from(locale: Locale) -> String? { - let raw = locale.identifier.lowercased() - if raw.isEmpty || raw == "auto" { return nil } - if raw.hasPrefix("zh") { return "Chinese" } - if raw.hasPrefix("en") { return "English" } - if raw.hasPrefix("ja") { return "Japanese" } - if raw.hasPrefix("ko") { return "Korean" } - if raw.hasPrefix("fr") { return "French" } - if raw.hasPrefix("de") { return "German" } - if raw.hasPrefix("es") { return "Spanish" } - if raw.hasPrefix("pt") { return "Portuguese" } - if raw.hasPrefix("ru") { return "Russian" } - if raw.hasPrefix("ar") { return "Arabic" } - return nil - } -} diff --git a/OSGKeyboardMac/MacQwen3LocalASR.swift b/OSGKeyboardMac/MacQwen3LocalASR.swift deleted file mode 100644 index 4088df3..0000000 --- a/OSGKeyboardMac/MacQwen3LocalASR.swift +++ /dev/null @@ -1,58 +0,0 @@ -// MacQwen3LocalASR.swift -// OSGKeyboard · Mac -// -// Qwen3-ASR-1.7B (MLX) via mlx-swift-asr. Expects a converted model directory -// containing config.json, model.safetensors, and tokenizer files. - -import Foundation - -enum MacQwen3LocalASR { - /// Transcribe with Qwen3-ASR MLX weights at `modelPath`. - static func transcribe( - samples: [Float], - sampleRate: Int, - locale: Locale, - modelPath: String, - bias: LocalASRBiasPayload? = nil - ) async throws -> String { - guard modelDirectoryIsInstalled(at: modelPath) else { - throw MacLocalASRError.qwen3ModelMissing - } - guard sampleRate == 16_000 else { - throw MacLocalASRError.qwen3InferenceFailed( - "Qwen3-ASR expects 16 kHz audio (got \(sampleRate) Hz)" - ) - } - - let language = MacQwen3LanguageHint.from(locale: locale) - let context = bias?.promptBias?.trimmingCharacters(in: .whitespacesAndNewlines) - let promptContext = (context?.isEmpty == false) ? context : nil - do { - return try await MacQwen3ASREngine.shared.transcribe( - samples: samples, - language: language, - modelPath: modelPath, - context: promptContext - ) - } catch let error as MacLocalASRError { - throw error - } catch { - 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 fdef0aa..382242d 100644 --- a/OSGKeyboardMac/MacRootView.swift +++ b/OSGKeyboardMac/MacRootView.swift @@ -38,7 +38,7 @@ struct MacRootView: View { private var sidebar: some View { VStack(spacing: 0) { brandHeader - VStack(spacing: 4) { + VStack(spacing: 6) { ForEach(MacSection.allCases) { section in MacSidebarRow( section: section, @@ -70,8 +70,8 @@ struct MacRootView: View { } .padding(.leading, MacMetrics.sidebarContentInset) .padding(.trailing, MacMetrics.sidebarInset) - .padding(.top, Spacing.lg) - .padding(.bottom, Spacing.md) + .padding(.top, 30) + .padding(.bottom, 30) } private var devicesFooter: some View { @@ -127,10 +127,10 @@ private struct MacSidebarRow: View { .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, Spacing.sm) - .padding(.vertical, 7) + .padding(.vertical, 9) .background( rowBackground, - in: RoundedRectangle(cornerRadius: 7, style: .continuous) + in: RoundedRectangle(cornerRadius: 8, style: .continuous) ) .contentShape(Rectangle()) } diff --git a/OSGKeyboardMac/MacSettingsComponents.swift b/OSGKeyboardMac/MacSettingsComponents.swift new file mode 100644 index 0000000..740a7bc --- /dev/null +++ b/OSGKeyboardMac/MacSettingsComponents.swift @@ -0,0 +1,543 @@ +// MacSettingsComponents.swift +// OSGKeyboard · Mac +// +// Provider configuration rows for the Settings Form — merged card layout, +// responsive label/control rows, and theme-aware credential chrome. + +import SwiftUI +#if os(macOS) +import AppKit +#endif + +// MARK: - Width-locked inline picker + +/// Option for `MacInlinePicker`: a hashable value plus its display label. +struct MacInlinePickerOption: Identifiable { + let value: Value + let label: String + var id: Value { value } +} + +/// Boxed dropdown trigger that mirrors `.macFieldStyle()` chrome so every picker +/// reads as an editable field: the selected value on the left, a trailing +/// up/down chevron inside the *same* well. Holds a `minWidth` field footprint and +/// expands up to its column when the caller offers width (provider rows). +private struct MacPickerFieldBox: View { + @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + + let text: String + + var body: some View { + // Same shape/fill/border rules as `MacFieldStyleModifier` so the dropdown + // sits flush with the credential text fields around it. + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + HStack(spacing: Spacing.sm) { + Text(text) + .font(MacSettingsType.control) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: Spacing.xs) + + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + } + .padding(.horizontal, Spacing.sm) + // `minWidth` gives short values (e.g. "深色") a proper field footprint; + // `maxWidth: .infinity` lets fixed-label rows (provider picker) fill their + // column, while `.fixedSize()` callers (inline rows) hug to the minimum. + .frame(minWidth: 132, maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading) + .background(fieldFill, in: shape) + .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) + .contentShape(shape) + } + + /// Light mode uses the true text-input white so the box reads as an editable + /// well; dark mode keeps the elevated grey (mirrors `MacFieldStyleModifier`). + private var fieldFill: Color { + #if os(macOS) + if colorScheme != .dark { + return Color(nsColor: .textBackgroundColor) + } + #endif + return palette.surfaceElevated + } +} + +/// Dropdown that renders as an editable-looking field (see `MacPickerFieldBox`). +/// A `.menu` `Picker` renders as a native pop-up that ignores `.frame` and drops +/// custom labels; `Menu` instead renders its `label:` as the real control. Kept at +/// `.fixedSize()` so it hugs its content and is right-aligned by the enclosing +/// row's `Spacer`, matching the toggles/status text that share `MacInlineRow`. +struct MacInlinePicker: View { + @Binding var selection: Value + let options: [MacInlinePickerOption] + /// When true the boxed field fills its column and is left-aligned — matches + /// the two-column provider rows (see `MacProviderPickerRow`). When false it + /// hugs its content so the enclosing row's `Spacer` can right-align it. + var fillsWidth: Bool = false + + var body: some View { + if fillsWidth { + menuButton + .frame(maxWidth: .infinity, alignment: .leading) + } else { + menuButton + .fixedSize() + } + } + + private var menuButton: some View { + Menu { + ForEach(options) { option in + Button { + selection = option.value + } label: { + if option.value == selection { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) + } + } + } + } label: { + MacPickerFieldBox(text: selectedLabel) + } + // `.button` menu style + `.plain` button style renders the custom + // `MacPickerFieldBox` label verbatim; `.borderlessButton` would instead + // draw macOS's own borderless pop-up chrome (accent chevrons, no box). + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + } + + private var selectedLabel: String { + options.first { $0.value == selection }?.label ?? "—" + } +} + +// MARK: - Provider picker + +struct MacProviderPickerRow: View { + let title: String + let providers: [LLMProvider] + @Binding var selection: String + + var body: some View { + MacProviderSettingRow(title: title) { + Menu { + ForEach(providers) { provider in + Button { + selection = provider.id + } label: { + let name = ProviderDisplayName.name(for: provider.id) + if provider.id == selection { + Label(name, systemImage: "checkmark") + } else { + Text(name) + } + } + } + } label: { + MacPickerFieldBox(text: selectedName) + } + // See `MacInlinePicker`: `.button` + `.plain` keeps our boxed label + // instead of macOS's borderless pop-up chrome. + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var selectedName: String { + let id = providers.first { $0.id == selection }?.id ?? selection + return ProviderDisplayName.name(for: id) + } +} + +// MARK: - Credential field + +struct MacCredentialField: View { + let title: String + let placeholder: String + @Binding var text: String + var isSecret: Bool = false + var isMonospaced: Bool = true + var defaultValue: String? + var trailing: AnyView? + + @State private var revealed = false + + var body: some View { + MacProviderSettingRow(title: title) { + HStack(spacing: Spacing.xs) { + input + .frame(maxWidth: .infinity, alignment: .leading) + + if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + MacSettingsIconButton(systemName: "checkmark", help: "Fill default") { + text = defaultValue + } + } + + if let trailing { + trailing + } + + if isSecret { + MacSettingsIconButton( + systemName: revealed ? "eye.slash" : "eye", + help: revealed ? "Hide" : "Show" + ) { + revealed.toggle() + } + } + + MacSettingsIconButton(systemName: "doc.on.doc", help: "Copy", disabled: text.isEmpty) { + #if os(macOS) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + #endif + } + } + } + } + + @ViewBuilder + private var input: some View { + Group { + if isSecret && !revealed { + SecureField(text: $text, prompt: Text(verbatim: placeholder)) { + Text(title) + } + } else { + TextField(text: $text, prompt: Text(verbatim: placeholder)) { + Text(title) + } + } + } + .labelsHidden() + .autocorrectionDisabled(true) + .lineLimit(1) + .truncationMode(.middle) + .macFieldStyle(monospaced: isMonospaced) + } +} + +// MARK: - Thinking toggle + +struct MacProviderThinkingRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var subtitle: String? = nil + @Binding var isOn: Bool + + var body: some View { + MacProviderSettingRow(title: title, verticalAlignment: .center) { + HStack(alignment: .center, spacing: Spacing.sm) { + Toggle("", isOn: $isOn) + .labelsHidden() + .toggleStyle(MacToggleStyle()) + .accessibilityLabel(title) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} + +// MARK: - Model picker (editable field + fetch icon) + +private struct MacModelComboFieldWidthKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +struct MacProviderModelRow: View { + @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + + let title: String + let placeholder: String + @Binding var model: String + let apiKey: String + let fetchModels: () async throws -> [String] + let language: AppUILanguage + + @State private var models: [String] = [] + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + @State private var isDropdownOpen = false + @State private var fieldWidth: CGFloat = 0 + + private let chevronWidth: CGFloat = 28 + private let dropdownMaxHeight: CGFloat = 240 + + private var trimmedAPIKey: String { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Match `MacFieldStyleModifier` fill so the combo well sits flush with + /// neighboring credential fields. + private var fieldFill: Color { + #if os(macOS) + if colorScheme != .dark { + return Color(nsColor: .textBackgroundColor) + } + #endif + return palette.surfaceElevated + } + + var body: some View { + MacProviderSettingRow(title: title) { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack(spacing: Spacing.xs) { + comboField + + MacSettingsIconButton( + systemName: "arrow.triangle.2.circlepath", + help: MacL10n.string("mac.settings.fetchModels", language: language), + disabled: isRunning || trimmedAPIKey.isEmpty + ) { + isDropdownOpen = false + Task { await runFetchModels() } + } + + if isRunning { + ProgressView() + .controlSize(.small) + } + } + + statusMessage + } + } + } + + /// Editable model id + trailing chevron. Model list is a true popover so the + /// settings row height stays fixed (not an in-flow panel that grows the card). + private var comboField: some View { + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + return ZStack(alignment: .trailing) { + TextField(text: $model, prompt: Text(verbatim: placeholder)) { + Text(title) + } + .labelsHidden() + .textFieldStyle(.plain) + .autocorrectionDisabled(true) + .font(TypeStyle.mono) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .padding(.leading, Spacing.sm) + .padding(.trailing, chevronWidth + Spacing.sm) + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading) + .background(fieldFill, in: shape) + .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) + + Button { + isDropdownOpen.toggle() + } label: { + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + .frame(width: chevronWidth, height: MacMetrics.settingsControlHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.trailing, Spacing.sm) + .help(MacL10n.string("mac.settings.selectModel", language: language)) + .accessibilityLabel(MacL10n.string("mac.settings.selectModel", language: language)) + } + .background( + GeometryReader { geo in + Color.clear.preference(key: MacModelComboFieldWidthKey.self, value: geo.size.width) + } + ) + .onPreferenceChange(MacModelComboFieldWidthKey.self) { fieldWidth = $0 } + .popover(isPresented: $isDropdownOpen, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + dropdownPanel + .frame(width: max(fieldWidth, 180)) + .padding(Spacing.xs) + } + } + + private var dropdownPanel: some View { + Group { + if models.isEmpty { + Text(MacL10n.string("mac.settings.modelsEmptyHint", language: language)) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .padding(.horizontal, Spacing.sm) + .padding(.vertical, Spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ScrollView { + VStack(alignment: .leading, spacing: Spacing.xxs) { + ForEach(models, id: \.self) { modelId in + dropdownRow(modelId) + } + } + } + .frame(maxHeight: dropdownMaxHeight) + } + } + } + + private func dropdownRow(_ modelId: String) -> some View { + let isSelected = modelId == model + return Button { + model = modelId + message = MacL10n.format( + "mac.settings.modelSelected", + language: language, + modelId + ) + failed = false + isDropdownOpen = false + } label: { + HStack(spacing: Spacing.sm) { + Text(modelId) + .font(TypeStyle.mono) + .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.accent) + } + } + .padding(.horizontal, Spacing.sm) + .padding(.vertical, Spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Radius.small, style: .continuous) + .fill(isSelected ? palette.accentMuted : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var statusMessage: some View { + if let message { + Text(message) + .font(MacSettingsType.hint) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + + @MainActor + private func runFetchModels() async { + guard !trimmedAPIKey.isEmpty else { + failed = true + message = SharedL10n.string("providerTools.error.missingAPIKey", language: language) + return + } + + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runFetchModels( + runningMessage: MacL10n.string("mac.settings.loadingModels", language: language), + loadedMessage: { MacL10n.format("mac.settings.modelsLoaded", language: language, $0) }, + emptyMessage: SharedL10n.string("providerTools.error.empty", language: language), + currentModel: model, + fetchModels: fetchModels + ) + models = outcome.state.models + message = outcome.state.message + failed = outcome.state.failed + if let selected = outcome.selectedModel { + model = selected + } + } +} + +// MARK: - Connection validate + +struct MacProviderToolsRow: View { + @Environment(\.themePalette) private var palette + + let title: String + let validate: () async throws -> Void + let language: AppUILanguage + + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + + var body: some View { + MacProviderSettingRow(title: title) { + HStack(alignment: .center, spacing: Spacing.sm) { + MacSettingsToolButton( + title: MacL10n.string("mac.settings.validate", language: language), + disabled: isRunning + ) { + Task { await runValidate() } + } + + if isRunning { + ProgressView() + .controlSize(.small) + } else if let message { + Text(message) + .font(MacSettingsType.hint) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + .truncationMode(.tail) + } + } + } + } + + @MainActor + private func runValidate() async { + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runValidate( + runningMessage: MacL10n.string("mac.settings.validating", language: language), + successMessage: MacL10n.string("mac.settings.validateSuccess", language: language), + validate: validate + ) + message = outcome.message + failed = outcome.failed + } +} + +// MARK: - Caption note row + +struct MacProviderNoteRow: View { + @Environment(\.themePalette) private var palette + + let text: String + + var body: some View { + Text(text) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) + } +} diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift index 808c66d..5c0609a 100644 --- a/OSGKeyboardMac/MacSettingsView.swift +++ b/OSGKeyboardMac/MacSettingsView.swift @@ -1,10 +1,8 @@ // MacSettingsView.swift // OSGKeyboard · Mac // -// Settings uses native grouped `Form` for correct control layout (Picker / -// Toggle / LabeledContent). Title and Form share the same plain -// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so -// card chrome lines up with the page title. +// Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged +// `MacSettingsProviderCard` with responsive rows and theme-aware controls. import SwiftUI #if os(macOS) @@ -20,8 +18,6 @@ struct MacSettingsView: View { @AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = true @State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted - @State private var showProviderPicker = false - @State private var showAsrProviderPicker = false private var lang: AppUILanguage { viewModel.config.uiLanguage } private let recognitionLocales: [(id: String, key: String, fallback: String)] = [ @@ -41,33 +37,25 @@ struct MacSettingsView: View { subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang) ) - Form { - generalSection - recognitionSection - polishProviderSection - if viewModel.config.engineMode == "cloud" { - asrProviderSection - .transition(.opacity) + ScrollView { + LazyVStack(alignment: .leading, spacing: Spacing.xl) { + generalSection + recognitionSection + if viewModel.config.engineMode == "cloud" { + asrProviderSection + .transition(.opacity) + } + if viewModel.config.engineMode == "local" { + MacLocalASRModelSettingsView(viewModel: viewModel) + .transition(.opacity) + } + polishProviderSection + inputSection + legalSection } - if viewModel.config.engineMode == "local" { - MacLocalASRModelSettingsView(viewModel: viewModel) - .transition(.opacity) - } - inputSection - legalSection + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.bottom, Spacing.md) } - .formStyle(.grouped) - // Zero Form's own scroll margins, then inset via padding so the - // section cards line up with MacPageHeader (contentMargins alone - // does not match plain padding on macOS). - // - // grouped Form adds its own built-in section inset on top of our - // padding, so cards sat ~`groupedFormSectionInset` wider than the - // History page. Subtract that inset here so the card OUTER edge - // lands on `pageHorizontalInset` (40pt), matching History and the - // page title's left edge. - .contentMargins(.horizontal, 0, for: .scrollContent) - .padding(.horizontal, MacMetrics.pageHorizontalInset - MacMetrics.groupedFormSectionInset) .tint(palette.accent) .scrollContentBackground(.hidden) .background(palette.background) @@ -80,79 +68,83 @@ struct MacSettingsView: View { // MARK: - General private var generalSection: some View { - Section(MacL10n.string("mac.settings.general", language: lang)) { - Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) { - ForEach(MacAppearancePreference.allCases) { pref in - Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue) + MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) { + MacInlinePicker( + selection: $appearanceRaw, + options: MacAppearancePreference.allCases.map { + MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang)) + }, + fillsWidth: true + ) } - } - Picker(MacL10n.string("mac.settings.interfaceLanguage", language: lang), selection: interfaceLanguageBinding) { - ForEach(AppUILanguage.allCases, id: \.self) { language in - Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue) + MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) { + MacInlinePicker( + selection: interfaceLanguageBinding, + options: AppUILanguage.allCases.map { + MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang)) + }, + fillsWidth: true + ) } - } - Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) { - ForEach(recognitionLocales, id: \.id) { locale in - Text(localeLabel(locale)).tag(locale.id) + MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) { + MacInlinePicker( + selection: recognitionLanguageBinding, + options: recognitionLocales.map { + MacInlinePickerOption(value: $0.id, label: localeLabel($0)) + }, + fillsWidth: true + ) } - } - MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang) - MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang) + MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang) + } } } // MARK: - Polish LLM private var polishProviderSection: some View { - Section(MacL10n.string("mac.settings.polishProvider", language: lang)) { - providerPickerRow( - title: MacL10n.string("mac.settings.service", language: lang), - provider: currentPolishProvider, - isPresented: $showProviderPicker - ) { - providerPickerList( + MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) { + MacSettingsProviderCard { + MacProviderPickerRow( + title: MacL10n.string("mac.settings.service", language: lang), providers: viewModel.polishSelectableProviders, - selectedId: viewModel.config.providerId - ) { provider in - viewModel.selectProvider(provider) - showProviderPicker = false - } - } - - LabeledContent { - SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) { - Text(MacL10n.string("mac.settings.apiKey", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.apiKey", language: lang)) - } - - LabeledContent { - TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - - LabeledContent { - TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.model", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.model", language: lang)) + selection: polishProviderBinding + ) + MacCredentialField( + title: MacL10n.string("mac.settings.apiKey", language: lang), + placeholder: "sk-…", + text: $viewModel.config.apiKey, + isSecret: true + ) + MacCredentialField( + title: MacL10n.string("mac.settings.baseURL", language: lang), + placeholder: currentPolishProvider.defaultBaseURL, + text: $viewModel.config.baseURL + ) + MacProviderModelRow( + title: MacL10n.string("mac.settings.model", language: lang), + placeholder: currentPolishProvider.defaultModel, + model: $viewModel.config.model, + apiKey: viewModel.config.apiKey, + fetchModels: fetchMacLLMModels, + language: lang + ) + .id(viewModel.config.providerId) + MacProviderThinkingRow( + title: MacL10n.string("mac.settings.thinking", language: lang), + subtitle: MacL10n.string("mac.settings.thinkingSubtitle", language: lang), + isOn: $viewModel.config.llmThinkingEnabled + ) + MacProviderToolsRow( + title: MacL10n.string("mac.settings.connectionCheck", language: lang), + validate: validateMacLLM, + language: lang + ) } } } @@ -160,154 +152,148 @@ struct MacSettingsView: View { // MARK: - Cloud ASR private var asrProviderSection: some View { - Section(MacL10n.string("mac.settings.asrProvider", language: lang)) { - providerPickerRow( - title: MacL10n.string("mac.settings.asrService", language: lang), - provider: currentAsrProvider, - isPresented: $showAsrProviderPicker - ) { - providerPickerList( + MacSettingsSection(title: MacL10n.string("mac.settings.asrProvider", language: lang)) { + MacSettingsProviderCard { + MacProviderPickerRow( + title: MacL10n.string("mac.settings.asrService", language: lang), providers: viewModel.asrSelectableProviders, - selectedId: viewModel.config.asrProviderId - ) { provider in - viewModel.selectAsrProvider(provider) - showAsrProviderPicker = false + selection: asrProviderBinding + ) + if viewModel.config.asrProviderId == "volcengine" { + volcengineAsrRows + } else { + genericAsrRows } - } - - LabeledContent { - SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) { - Text(MacL10n.string("mac.settings.apiKey", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.asrApiKey", language: lang)) - } - - if CloudASRModelCatalog.strategy(for: viewModel.config.asrProviderId) == .prompt { - LabeledContent { - TextField(text: $viewModel.config.asrBaseURL, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - } - - LabeledContent { - TextField(text: $viewModel.config.asrModel, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.asrModel", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.asrModel", language: lang)) + MacProviderToolsRow( + title: MacL10n.string("mac.settings.connectionCheck", language: lang), + validate: validateMacASR, + language: lang + ) } } } - private func providerPickerRow( - title: String, - provider: LLMProvider, - isPresented: Binding, - @ViewBuilder picker: @escaping () -> Content - ) -> some View { - LabeledContent(title) { - Button { - isPresented.wrappedValue = true - } label: { - HStack(spacing: 6) { - providerLogo(provider.id) - Text(provider.name) - .foregroundStyle(palette.textPrimary) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(palette.textTertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .popover(isPresented: isPresented, arrowEdge: .bottom, content: picker) + @ViewBuilder + private var volcengineAsrRows: some View { + MacCredentialField( + title: MacL10n.string("mac.settings.volcengineAppId", language: lang), + placeholder: "APP ID", + text: Binding( + get: { macVolcengineFields.appID }, + set: { updateMacVolcengine(appID: $0) } + ), + isSecret: true + ) + MacCredentialField( + title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang), + placeholder: "Access Token", + text: Binding( + get: { macVolcengineFields.accessToken }, + set: { updateMacVolcengine(accessToken: $0) } + ), + isSecret: true + ) + MacCredentialField( + title: MacL10n.string("mac.settings.volcengineResourceId", language: lang), + placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"), + text: Binding( + get: { macVolcengineFields.resourceID }, + set: { updateMacVolcengine(resourceID: $0) } + ), + defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine") + ) + MacProviderNoteRow(text: MacL10n.string("mac.settings.volcengineNote", language: lang)) + } + + @ViewBuilder + private var genericAsrRows: some View { + if CloudASRModelCatalog.showsASREndpointField(for: viewModel.config.asrProviderId) { + MacCredentialField( + title: MacL10n.string("mac.settings.baseURL", language: lang), + placeholder: currentAsrProvider.defaultBaseURL, + text: $viewModel.config.asrBaseURL + ) } + + MacCredentialField( + title: MacL10n.string("mac.settings.asrApiKey", language: lang), + placeholder: "sk-…", + text: $viewModel.config.asrApiKey, + isSecret: true + ) + MacProviderModelRow( + title: MacL10n.string("mac.settings.asrModel", language: lang), + placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId), + model: $viewModel.config.asrModel, + apiKey: viewModel.config.asrApiKey, + fetchModels: fetchMacASRModels, + language: lang + ) + .id(viewModel.config.asrProviderId) } // MARK: - Recognition method private var recognitionSection: some View { - Section(MacL10n.string("mac.settings.recognition", language: lang)) { - methodRow( + MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + methodRow( title: MacL10n.string("mac.settings.cloudEngine", language: lang), subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang), systemImage: "cloud", selected: viewModel.config.engineMode == "cloud" ) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } } - - methodRow( + methodRow( title: MacL10n.string("mac.settings.localEngine", language: lang), subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang), systemImage: "cpu", selected: viewModel.config.engineMode == "local" ) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } } + } } } // MARK: - Hotkey / paste private var inputSection: some View { - Section(MacL10n.string("mac.settings.input", language: lang)) { - Toggle(isOn: hotkeyBinding) { - rowLabel( - MacL10n.string("mac.settings.hotkey", language: lang), - subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang) - ) - } - - Picker(selection: hotkeyTriggerBinding) { - ForEach(MacHotkeyTrigger.allCases) { trigger in - Text(MacL10n.string(trigger.labelKey, language: lang)) - .tag(trigger.rawValue) - } - } label: { - rowLabel( - MacL10n.string("mac.settings.hotkeyTrigger", language: lang), - subtitle: MacL10n.string("mac.settings.hotkeyTriggerDesc", language: lang) - ) - } - .disabled(!viewModel.hotkeyEnabled) - - Toggle(isOn: autoPasteBinding) { - rowLabel( - MacL10n.string("mac.settings.autoPaste", language: lang), - subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang) - ) - } - - LabeledContent { - HStack(spacing: Spacing.sm) { - Label( - accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded, - systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle" + MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) { + MacInlinePicker( + selection: hotkeyTriggerBinding, + options: MacHotkeyTrigger.allCases.map { + MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang)) + }, + fillsWidth: true ) - .font(TypeStyle.caption) - .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) - .contentTransition(.opacity) - .animation(Motion.quick, value: accessibilityTrusted) + } + MacProviderSettingRow( + title: MacL10n.string("mac.settings.autoPaste", language: lang), + verticalAlignment: .center + ) { + Toggle("", isOn: autoPasteBinding) + .labelsHidden() + .toggleStyle(MacToggleStyle()) + } + MacProviderSettingRow( + title: MacL10n.string("mac.settings.accessibility", language: lang), + verticalAlignment: .center + ) { + HStack(spacing: Spacing.sm) { + MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) { + openAccessibilitySettings() + } - Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) { - openAccessibilitySettings() + Label( + accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded, + systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle" + ) + .font(TypeStyle.caption) + .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) + .contentTransition(.opacity) + .animation(Motion.quick, value: accessibilityTrusted) } } - } label: { - rowLabel( - MacL10n.string("mac.settings.accessibility", language: lang), - subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang) - ) } } } @@ -315,37 +301,32 @@ struct MacSettingsView: View { // 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 + MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + NavigationLink { + MacPrivacyPolicyView(uiLanguage: lang) + } label: { + MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang)) + } + .buttonStyle(.plain) + NavigationLink { + MacOpenSourceLicensesView(uiLanguage: lang) + } label: { + MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang)) + } + .buttonStyle(.plain) + Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) { + hasCompletedMacOnboarding = false + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) } } } // MARK: - Row helpers - private func rowLabel(_ title: String, subtitle: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(title) - Text(subtitle) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - private func methodRow( title: String, subtitle: String, @@ -372,6 +353,8 @@ struct MacSettingsView: View { .foregroundStyle(selected ? palette.accent : palette.textTertiary) .contentTransition(.symbolEffect(.replace)) } + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) .animation(Motion.quick, value: selected) .contentShape(Rectangle()) } @@ -390,60 +373,59 @@ struct MacSettingsView: View { ?? LLMProvider.presets[0] } - private func providerPickerList( - providers: [LLMProvider], - selectedId: String, - onSelect: @escaping (LLMProvider) -> Void - ) -> some View { - VStack(spacing: 0) { - ForEach(providers) { provider in - Button { - onSelect(provider) - } label: { - HStack(spacing: Spacing.sm) { - providerLogo(provider.id) - Text(provider.name) - .foregroundStyle(palette.textPrimary) - Spacer(minLength: Spacing.md) - if provider.id == selectedId { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(palette.accent) - } - } - .padding(.horizontal, Spacing.md) - .frame(height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.vertical, Spacing.xs) - .frame(width: 260) + private var macVolcengineFields: VolcengineASRFields { + VolcengineASRFields.parse( + apiKey: viewModel.config.asrApiKey, + resourceFallback: viewModel.config.asrModel.isEmpty + ? CloudASRModelCatalog.defaultModel(for: "volcengine") + : viewModel.config.asrModel + ) } - /// Brand mark tinted to the current label colour (black on light / white - /// on dark). Template rendering + an explicit frame make the vector assets - /// resolve at a text-matched size inside the pop-up menu — without a size - /// hint they collapse to zero and disappear. - @ViewBuilder - private func providerLogo(_ providerId: String) -> some View { - if let asset = ProviderLogo.assetName(for: providerId) { - Image(asset) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundStyle(palette.textPrimary) + private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) { + var fields = macVolcengineFields + if let appID { fields.appID = appID } + if let accessToken { fields.accessToken = accessToken } + if let resourceID { + fields.resourceID = resourceID + viewModel.config.asrModel = resourceID } + viewModel.config.asrApiKey = fields.encodedAPIKey } - @ViewBuilder - private func providerLabel(_ provider: LLMProvider) -> some View { - Label { - Text(provider.name) - } icon: { - providerLogo(provider.id) - } + private func validateMacLLM() async throws { + let client = LLMClientFactory.make( + providerId: viewModel.config.providerId, + baseURL: viewModel.config.baseURL, + apiKey: viewModel.config.apiKey, + model: viewModel.config.model, + thinkingEnabled: viewModel.config.llmThinkingEnabled + ) + _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") + } + + private func fetchMacLLMModels() async throws -> [String] { + try await ProviderModelService.listLLMModels( + providerId: viewModel.config.providerId, + baseURL: viewModel.config.baseURL, + apiKey: viewModel.config.apiKey, + currentModel: viewModel.config.model + ) + } + + private func validateMacASR() async throws { + let persisted = AppGroupStore(defaults: viewModel.defaults) + let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted) + try await CloudASRConnectionCheck.validate(store: store) + } + + private func fetchMacASRModels() async throws -> [String] { + try await ProviderModelService.listASRModels( + providerId: viewModel.config.asrProviderId, + baseURL: viewModel.config.asrBaseURL, + apiKey: viewModel.config.asrApiKey, + currentModel: viewModel.config.asrModel + ) } // MARK: - Bindings @@ -455,17 +437,28 @@ struct MacSettingsView: View { ) } - private var providerBinding: Binding { + private var polishProviderBinding: Binding { Binding( get: { viewModel.config.providerId }, set: { newId in - if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) { + if let provider = viewModel.polishSelectableProviders.first(where: { $0.id == newId }) { viewModel.selectProvider(provider) } } ) } + private var asrProviderBinding: Binding { + Binding( + get: { viewModel.config.asrProviderId }, + set: { newId in + if let provider = viewModel.asrSelectableProviders.first(where: { $0.id == newId }) { + viewModel.selectAsrProvider(provider) + } + } + ) + } + private var recognitionLanguageBinding: Binding { Binding( get: { @@ -478,13 +471,6 @@ struct MacSettingsView: View { ) } - private var hotkeyBinding: Binding { - Binding( - get: { viewModel.hotkeyEnabled }, - set: { viewModel.setHotkeyEnabled($0) } - ) - } - private var hotkeyTriggerBinding: Binding { Binding( get: { viewModel.hotkeyTrigger.rawValue }, diff --git a/OSGKeyboardMac/MacSherpaONNXRunner.swift b/OSGKeyboardMac/MacSherpaONNXRunner.swift index c49744c..5602691 100644 --- a/OSGKeyboardMac/MacSherpaONNXRunner.swift +++ b/OSGKeyboardMac/MacSherpaONNXRunner.swift @@ -231,3 +231,23 @@ enum MacSherpaONNXRunner { } } } + +enum MacQwen3LanguageHint { + /// Map persisted BCP-47 locale ids to Qwen3 prompt language names. + /// Returns `nil` for auto-detect. + static func from(locale: Locale) -> String? { + let raw = locale.identifier.lowercased() + if raw.isEmpty || raw == "auto" { return nil } + if raw.hasPrefix("zh") { return "Chinese" } + if raw.hasPrefix("en") { return "English" } + if raw.hasPrefix("ja") { return "Japanese" } + if raw.hasPrefix("ko") { return "Korean" } + if raw.hasPrefix("fr") { return "French" } + if raw.hasPrefix("de") { return "German" } + if raw.hasPrefix("es") { return "Spanish" } + if raw.hasPrefix("pt") { return "Portuguese" } + if raw.hasPrefix("ru") { return "Russian" } + if raw.hasPrefix("ar") { return "Arabic" } + return nil + } +} diff --git a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift index 45359c2..1992b35 100644 --- a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift +++ b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift @@ -27,6 +27,7 @@ public protocol ConfigurationStore: Sendable { var engineMode: String { get } var polishIntensity: PolishIntensity { get } + var llmThinkingEnabled: Bool { get } var personalDictionary: PersonalDictionary { get } /// Foreground-app context for polish prompts (keyboard extension publishes this). diff --git a/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift new file mode 100644 index 0000000..864a8cf --- /dev/null +++ b/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift @@ -0,0 +1,114 @@ +// LiveConfigurationStore.swift +// OSGKeyboard · Shared +// +// In-memory settings snapshot for connection checks and model probes. +// Reads the values the user is editing — not a delayed Keychain re-fetch. + +import Foundation + +public struct LiveConfigurationSnapshot { + public let providerId: String + public let baseURL: String + public let apiKey: String + public let model: String + public let asrProviderId: String + public let asrBaseURL: String + public let asrApiKey: String + public let asrModel: String + public let engineMode: String + public let polishIntensity: PolishIntensity + public let llmThinkingEnabled: Bool + public let personalDictionary: PersonalDictionary + public let detectedAppContext: (context: AppContext, observedAt: Date)? + public let cloudASRPersistence: UserDefaults + + public init( + providerId: String, + baseURL: String, + apiKey: String, + model: String, + asrProviderId: String, + asrBaseURL: String, + asrApiKey: String, + asrModel: String, + engineMode: String, + polishIntensity: PolishIntensity, + llmThinkingEnabled: Bool, + personalDictionary: PersonalDictionary, + detectedAppContext: (context: AppContext, observedAt: Date)?, + cloudASRPersistence: UserDefaults + ) { + self.providerId = providerId + self.baseURL = baseURL + self.apiKey = apiKey + self.model = model + self.asrProviderId = asrProviderId + self.asrBaseURL = asrBaseURL + self.asrApiKey = asrApiKey + self.asrModel = asrModel + self.engineMode = engineMode + self.polishIntensity = polishIntensity + self.llmThinkingEnabled = llmThinkingEnabled + self.personalDictionary = personalDictionary + self.detectedAppContext = detectedAppContext + self.cloudASRPersistence = cloudASRPersistence + } + + /// Build from live `ProviderConfig` plus persisted App Group extras. + public init(config: ProviderConfig, fallback: AppGroupStore) { + self.init( + providerId: config.providerId, + baseURL: config.baseURL, + apiKey: config.apiKey, + model: config.model, + asrProviderId: config.asrProviderId, + asrBaseURL: config.asrBaseURL, + asrApiKey: config.asrApiKey, + asrModel: config.asrModel, + engineMode: config.engineMode, + polishIntensity: config.polishIntensity, + llmThinkingEnabled: config.llmThinkingEnabled, + personalDictionary: fallback.personalDictionary, + detectedAppContext: fallback.detectedAppContext, + cloudASRPersistence: fallback.defaults + ) + } +} + +/// Ephemeral `ConfigurationStore` backed by a user-edited snapshot. +public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable { + private let snapshot: LiveConfigurationSnapshot + + public init(snapshot: LiveConfigurationSnapshot) { + self.snapshot = snapshot + } + + public init(config: ProviderConfig, fallback: AppGroupStore) { + self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback)) + } + + public var providerId: String { snapshot.providerId } + public var baseURL: String { snapshot.baseURL } + public var apiKey: String { snapshot.apiKey } + public var model: String { snapshot.model } + public var asrProviderId: String { snapshot.asrProviderId } + public var asrBaseURL: String { snapshot.asrBaseURL } + public var asrApiKey: String { snapshot.asrApiKey } + public var asrModel: String { snapshot.asrModel } + public var engineMode: String { snapshot.engineMode } + public var polishIntensity: PolishIntensity { snapshot.polishIntensity } + public var llmThinkingEnabled: Bool { snapshot.llmThinkingEnabled } + public var personalDictionary: PersonalDictionary { snapshot.personalDictionary } + public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext } + public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence } + + public func makeClient() -> LLMClient { + LLMClientFactory.make( + providerId: providerId, + baseURL: baseURL, + apiKey: apiKey, + model: model, + thinkingEnabled: llmThinkingEnabled + ) + } +} diff --git a/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift b/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift new file mode 100644 index 0000000..6b48845 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift @@ -0,0 +1,133 @@ +// SevenDayUsageChart.swift +// OSGKeyboard · Shared +// +// 7-day dictation bar chart. Platform shells wrap this in their own page +// layout; the chart itself only needs points + UI language. + +import Charts +import SwiftUI + +public struct SevenDayUsageChart: View { + @Environment(\.themePalette) private var palette + + public let points: [UsageStatisticsStore.DailyUsagePoint] + public let language: AppUILanguage + /// Bar area height. Phone stacked layout uses a shorter value. + public var chartMinHeight: CGFloat + /// When true, wrap content in `UsageSurfaceCard` (default). Pass false if + /// the caller already provides a surface. + public var embedsInCard: Bool + /// When true (iPad split), the chart fills all available height. When false + /// (phone stacked home), the bar area is a fixed `chartMinHeight` so the card + /// stays compact and does not steal space from surrounding content. + public var expands: Bool + + public init( + points: [UsageStatisticsStore.DailyUsagePoint], + language: AppUILanguage, + chartMinHeight: CGFloat = 96, + embedsInCard: Bool = true, + expands: Bool = true + ) { + self.points = points + self.language = language + self.chartMinHeight = chartMinHeight + self.embedsInCard = embedsInCard + self.expands = expands + } + + private var total: Int { + points.reduce(0) { $0 + $1.value } + } + + private var maxValue: Int { + max(points.map(\.value).max() ?? 0, 1) + } + + private var chartLocale: Locale { + Locale(identifier: language.resolvedLanguageCode()) + } + + public var body: some View { + Group { + if embedsInCard { + UsageSurfaceCard(padding: Spacing.md) { + chartContent + } + } else { + chartContent + } + } + } + + private var chartContent: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + header + chart + .frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil) + } + .frame( + maxWidth: .infinity, + maxHeight: expands ? .infinity : nil, + alignment: .topLeading + ) + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(SharedL10n.string("stat.weekChart.title", language: language).uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Text(SharedL10n.string("stat.weekChart.caption", language: language)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + Spacer(minLength: Spacing.sm) + Text(UsageStatisticsStore.formatCount(total, language: language)) + .font(TypeStyle.title2) + .foregroundStyle(palette.accent) + .lineLimit(1) + .minimumScaleFactor(0.7) + .contentTransition(.numericText()) + .animation(Motion.soft, value: total) + } + } + + // MARK: - Bars + + @ViewBuilder + private var chart: some View { + if total == 0 { + Text(SharedL10n.string("stat.weekChart.empty", language: language)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil, alignment: .center) + .multilineTextAlignment(.center) + .frame(height: expands ? nil : chartMinHeight) + .frame(minHeight: expands ? chartMinHeight : nil) + } else { + Chart(points) { point in + BarMark( + x: .value("day", point.date, unit: .day), + y: .value("chars", point.value) + ) + .cornerRadius(4) + .foregroundStyle(palette.accent.gradient) + } + .chartYScale(domain: 0...max(1, Int(ceil(Double(maxValue) * 1.15)))) + .chartYAxis(.hidden) + .chartXAxis { + AxisMarks(values: points.map(\.date)) { _ in + AxisValueLabel(format: .dateTime.weekday(.narrow)) + } + } + .environment(\.locale, chartLocale) + .frame(height: expands ? nil : chartMinHeight) + .frame(minHeight: expands ? chartMinHeight : nil) + } + } +} diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift index 5ef1d57..3dcd704 100644 --- a/OSGKeyboardShared/DesignSystem/Theme.swift +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -162,10 +162,13 @@ public enum Radius { // MARK: - Typography public enum SettingsListMetrics { - /// Single-line list rows (provider, footer link, picker). - public static let singleLineMinHeight: CGFloat = 52 - /// Two-line rows (engine option, labeled API field). - public static let doubleLineMinHeight: CGFloat = 72 + /// Floor for settings list rows (slightly above HIG 44pt). + /// Row height grows with content; this only enforces a touch-target minimum. + public static let singleLineMinHeight: CGFloat = 48 + /// Horizontal inset inside a settings list row. + public static let rowHorizontalPadding: CGFloat = Spacing.md + /// Vertical inset inside a settings list row (`Spacing.sm`). + public static let rowVerticalPadding: CGFloat = 12 /// Space between a section label and its card. public static let sectionLabelSpacing: CGFloat = Spacing.sm } @@ -263,6 +266,19 @@ private struct SecondaryButtonModifier: ViewModifier { } public extension View { + /// Standard settings list row insets: horizontal + vertical padding and a + /// touch-target floor. Height grows with content — do not hand-roll + /// per-section padding/`minHeight` for ordinary settings rows. + func settingsListRow( + minHeight: CGFloat = SettingsListMetrics.singleLineMinHeight, + alignment: Alignment = .center + ) -> some View { + self + .padding(.horizontal, SettingsListMetrics.rowHorizontalPadding) + .padding(.vertical, SettingsListMetrics.rowVerticalPadding) + .frame(minHeight: minHeight, alignment: alignment) + } + /// Standard card surface used in the main app. func cardSurface(padding: CGFloat = Spacing.md) -> some View { modifier(CardSurfaceModifier(padding: padding)) diff --git a/OSGKeyboardShared/DesignSystem/UsageStatCard.swift b/OSGKeyboardShared/DesignSystem/UsageStatCard.swift new file mode 100644 index 0000000..3b8ae74 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/UsageStatCard.swift @@ -0,0 +1,108 @@ +// UsageStatCard.swift +// OSGKeyboard · Shared +// +// Single cumulative metric tile. Compact = title / value / caption stack; +// prominent = horizontal hero bar for the primary word-count metric. + +import SwiftUI + +public struct UsageStatCard: View { + @Environment(\.themePalette) private var palette + + public let title: String + public let value: String + public let caption: String + public var systemImage: String? + public var accent: Bool + /// Hero metric: wide horizontal layout for the primary word count. + public var prominent: Bool + + public init( + title: String, + value: String, + caption: String, + systemImage: String? = nil, + accent: Bool = false, + prominent: Bool = false + ) { + self.title = title + self.value = value + self.caption = caption + self.systemImage = systemImage + self.accent = accent + self.prominent = prominent + } + + public var body: some View { + UsageSurfaceCard(padding: Spacing.md) { + if prominent { + prominentBody + } else { + compactBody + } + } + } + + private var compactBody: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Spacer() + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(accent ? palette.accent : palette.textTertiary) + .symbolRenderingMode(.hierarchical) + } + } + Text(value) + .font(TypeStyle.title2) + .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) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Wide "hero bar": icon badge + title/caption left, big number right. + private var prominentBody: some View { + HStack(spacing: Spacing.md) { + if let systemImage { + ZStack { + Circle() + .fill(palette.accentMuted) + .frame(width: 44, height: 44) + Image(systemName: systemImage) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(palette.accent) + .symbolRenderingMode(.hierarchical) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Text(caption) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + Spacer(minLength: Spacing.md) + Text(value) + .font(.system(size: 34, weight: .bold)) + .foregroundStyle(accent ? palette.accent : palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) + } + } +} diff --git a/OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift b/OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift new file mode 100644 index 0000000..e327202 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift @@ -0,0 +1,196 @@ +// UsageStatsCluster.swift +// OSGKeyboard · Shared +// +// Cross-platform home / dashboard stats: 7-day chart + cumulative metrics. +// Callers observe their store and pass plain values — Shared stays unbound +// from platform singletons. + +import SwiftUI + +public struct UsageStatsCluster: View { + @Environment(\.themePalette) private var palette + + public enum Layout: Sendable, Equatable { + /// Chart left, 2×2 `UsageStatCard` grid right (Mac / iPad). + case split + /// Chart above a compact single-card 2×2 grid (iPhone). + case stacked + } + + /// 手机端 2×2 统计网格的紧凑固定高度(沿用旧版 HomeStatsCard 数值)。 + static let compactGridHeight: CGFloat = 166 + + public let layout: Layout + public let language: AppUILanguage + public let points: [UsageStatisticsStore.DailyUsagePoint] + public let dictationCharacterCount: Int + public let dictationDurationSeconds: TimeInterval + public let translationCharacterCount: Int + public let dictionaryTermCount: Int + /// 小屏(如 iPhone SE)收紧 stacked 图表高度,把空间让给下方的输入框。 + public let compact: Bool + + public init( + layout: Layout, + language: AppUILanguage, + points: [UsageStatisticsStore.DailyUsagePoint], + dictationCharacterCount: Int, + dictationDurationSeconds: TimeInterval, + translationCharacterCount: Int, + dictionaryTermCount: Int, + compact: Bool = false + ) { + self.layout = layout + self.language = language + self.points = points + self.dictationCharacterCount = dictationCharacterCount + self.dictationDurationSeconds = dictationDurationSeconds + self.translationCharacterCount = translationCharacterCount + self.dictionaryTermCount = dictionaryTermCount + self.compact = compact + } + + public var body: some View { + switch layout { + case .split: + splitBody + case .stacked: + stackedBody + } + } + + // MARK: - Split (Mac / iPad) + + private var splitBody: some View { + HStack(alignment: .top, spacing: Spacing.md) { + SevenDayUsageChart(points: points, language: language) + .frame(maxWidth: .infinity, maxHeight: .infinity) + splitStatGrid + .frame(maxWidth: .infinity) + } + .fixedSize(horizontal: false, vertical: true) + } + + private var splitStatGrid: some View { + VStack(spacing: Spacing.md) { + HStack(spacing: Spacing.md) { + UsageStatCard( + title: SharedL10n.string("stat.words", language: language), + value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), + caption: SharedL10n.string("stat.transcribed", language: language), + systemImage: "text.alignleft", + accent: true + ) + UsageStatCard( + title: SharedL10n.string("stat.dictationTime", language: language), + value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), + caption: SharedL10n.string("stat.cumulativeDuration", language: language), + systemImage: "waveform" + ) + } + HStack(spacing: Spacing.md) { + UsageStatCard( + title: SharedL10n.string("stat.translation", language: language), + value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), + caption: SharedL10n.string("stat.cumulativeTranslation", language: language), + systemImage: "character.bubble" + ) + UsageStatCard( + title: SharedL10n.string("stat.dictionary", language: language), + value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), + caption: SharedL10n.string("stat.customTerms", language: language), + systemImage: "character.book.closed" + ) + } + } + } + + // MARK: - Stacked (iPhone) + + private var stackedBody: some View { + VStack(spacing: compact ? Spacing.sm : Spacing.md) { + SevenDayUsageChart( + points: points, + language: language, + chartMinHeight: compact ? 72 : 96, + expands: false + ) + compactStatGrid + } + } + + /// Phone-friendly 2×2: value + label only, single card with hairline dividers. + private var compactStatGrid: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + compactCell( + systemImage: "waveform", + value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), + label: SharedL10n.string("stat.dictationTime", language: language) + ) + compactDivider + compactCell( + systemImage: "text.alignleft", + value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), + label: SharedL10n.string("stat.words", language: language) + ) + } + Rectangle() + .fill(palette.divider) + .frame(height: 0.5) + HStack(spacing: 0) { + compactCell( + systemImage: "character.bubble", + value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), + label: SharedL10n.string("stat.translation", language: language) + ) + compactDivider + compactCell( + systemImage: "character.book.closed", + value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), + label: SharedL10n.string("stat.dictionary", language: language) + ) + } + } + // 锁定紧凑固定高度(对齐旧版 HomeStatsCard 的 166pt),避免格子按内容撑高。 + .frame(height: UsageStatsCluster.compactGridHeight) + .background(palette.surface) + .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + private var compactDivider: some View { + Rectangle() + .fill(palette.divider) + .frame(width: 0.5) + } + + private func compactCell(systemImage: String, value: String, label: String) -> some View { + HStack(alignment: .top, spacing: Spacing.xs) { + VStack(alignment: .leading, spacing: Spacing.xs) { + Text(value) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.75) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) + Text(label) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + Spacer(minLength: Spacing.xs) + Image(systemName: systemImage) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(palette.accent) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Spacing.md) + } +} diff --git a/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift b/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift new file mode 100644 index 0000000..04fc087 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift @@ -0,0 +1,36 @@ +// UsageSurfaceCard.swift +// OSGKeyboard · Shared +// +// Flat semantic surface used by home / dashboard stats on every platform. +// Deliberately shadowless — hierarchy comes from fill + hairline border. + +import SwiftUI + +public struct UsageSurfaceCard: View { + @Environment(\.themePalette) private var palette + + public var padding: CGFloat + public var cornerRadius: CGFloat + @ViewBuilder public var content: () -> Content + + public init( + padding: CGFloat = Spacing.md, + cornerRadius: CGFloat = Radius.medium, + @ViewBuilder content: @escaping () -> Content + ) { + self.padding = padding + self.cornerRadius = cornerRadius + self.content = content + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + content() + .padding(padding) + .background(palette.surface, in: shape) + .overlay( + shape.stroke(palette.divider, lineWidth: 0.5) + ) + } +} diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index 126acf4..9887f74 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -8,6 +8,11 @@ import Foundation public struct AppGroupConfiguration: Sendable, Equatable { + /// Default polish LLM for fresh installs (local + cloud pickers). + public static let defaultPolishProviderId = "deepseek" + /// Default cloud ASR provider for fresh installs (independent from polish). + public static let defaultCloudASRProviderId = "volcengine" + // MARK: - Keys public enum Keys { @@ -31,6 +36,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { public static let handednessPreference = "config.handednessPreference" public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled" public static let polishIntensity = "config.polishIntensity" + public static let llmThinkingEnabled = "config.llmThinkingEnabled" public static let detectedAppContext = "config.detectedAppContext" public static let detectedAppContextAt = "config.detectedAppContextAt" public static let personalDictionary = "config.personalDictionary.v1" @@ -70,6 +76,8 @@ public struct AppGroupConfiguration: Sendable, Equatable { public var handednessPreference: HandednessPreference public var cursorDragNavigationEnabled: Bool public var polishIntensity: PolishIntensity + /// Enables provider-specific reasoning / thinking controls for polish LLM requests. + public var llmThinkingEnabled: Bool public var personalDictionary: PersonalDictionary /// Opt-in iCloud KVS sync for the personal dictionary (main app only). public var personalDictionaryICloudSyncEnabled: Bool @@ -150,7 +158,9 @@ public struct AppGroupConfiguration: Sendable, Equatable { OpenAICompatibleClient( baseURL: baseURL, apiKey: apiKey, - model: model + model: model, + providerId: providerId, + thinkingEnabled: llmThinkingEnabled ) } @@ -193,8 +203,9 @@ public struct AppGroupConfiguration: Sendable, Equatable { /// Loads configuration from a known-available UserDefaults suite. public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration { + let storedProviderId = defaults.string(forKey: Keys.providerId) var config = AppGroupConfiguration( - providerId: defaults.string(forKey: Keys.providerId) ?? "openai", + providerId: storedProviderId ?? defaultPolishProviderId, baseURL: "", model: "", asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "", @@ -228,6 +239,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { return defaults.bool(forKey: Keys.cursorDragNavigationEnabled) }(), polishIntensity: resolvePolishIntensity(from: defaults), + llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled), personalDictionary: decodePersonalDictionary(from: defaults), personalDictionaryICloudSyncEnabled: { if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil { @@ -267,7 +279,8 @@ public struct AppGroupConfiguration: Sendable, Equatable { } if config.asrProviderId.isEmpty { - config.asrProviderId = config.providerId + // Pre-split installs only stored `providerId`; copy it so ASR keeps working. + config.asrProviderId = storedProviderId ?? defaultCloudASRProviderId defaults.set(config.asrProviderId, forKey: Keys.asrProviderId) } let asrPreset = LLMProvider.provider(id: config.asrProviderId) @@ -279,6 +292,17 @@ public struct AppGroupConfiguration: Sendable, Equatable { ?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId) } + // Legacy qwen cloud ASR → bailian realtime (HTTP Flash path removed). + if config.asrProviderId == "qwen" { + let bailian = LLMProvider.provider(id: "bailian") + config.asrProviderId = "bailian" + config.asrBaseURL = bailian.defaultBaseURL + config.asrModel = CloudASRModelCatalog.alibabaFunASRRealtime + defaults.set(config.asrProviderId, forKey: Keys.asrProviderId) + defaults.set(config.asrBaseURL, forKey: Keys.asrBaseURL) + defaults.set(config.asrModel, forKey: Keys.asrModel) + } + // One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain. _ = resolveAPIKey( defaults: defaults, @@ -311,26 +335,6 @@ public struct AppGroupConfiguration: Sendable, Equatable { config.modeId = "polish" defaults.set("polish", forKey: Keys.modeId) } - // DeepSeek is local-engine only — never a cloud picker choice. - if config.engineMode == "cloud", config.providerId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.providerId = openAI.id - config.baseURL = openAI.defaultBaseURL - config.model = openAI.defaultModel - defaults.set(openAI.id, forKey: Keys.providerId) - defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL) - defaults.set(openAI.defaultModel, forKey: Keys.model) - } - if config.engineMode == "cloud", config.asrProviderId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.asrProviderId = openAI.id - config.asrBaseURL = openAI.defaultBaseURL - config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id) - defaults.set(openAI.id, forKey: Keys.asrProviderId) - defaults.set(openAI.defaultBaseURL, forKey: Keys.asrBaseURL) - defaults.set(openAI.defaultModel, forKey: Keys.asrModel) - } - return config } @@ -352,6 +356,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference) defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled) defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity) + defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled) defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch) defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration) defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled) diff --git a/OSGKeyboardShared/Models/CloudASRModels.swift b/OSGKeyboardShared/Models/CloudASRModels.swift index 1f1ba6b..751e3b3 100644 --- a/OSGKeyboardShared/Models/CloudASRModels.swift +++ b/OSGKeyboardShared/Models/CloudASRModels.swift @@ -10,10 +10,14 @@ import Foundation public enum CloudASRStrategy: String, Sendable, Equatable { /// 智谱 GLM-ASR — `hotwords` + optional `prompt`. case zhipuHotwords - /// 阿里百炼 Fun-ASR — managed `vocabulary_id` + context text. - case alibabaVocabulary - /// OpenAI / 小米 MiMo / 自定义端点 — `prompt` on transcription APIs. + /// 百炼 Fun-ASR Realtime — DashScope 经典 inference WebSocket 流式。 + case bailianStreaming + /// OpenAI / Groq / 硅基流动 / Whisper 等 — `prompt` on transcription APIs. case prompt + /// OpenRouter `/audio/transcriptions` — JSON body + base64 WAV (not multipart). + case openRouterJson + /// 火山引擎 SAUC 大模型流式 ASR(WebSocket + binary frame)。 + case volcengineStreaming /// Moonshot 托管 API 暂无音频转写;云端引擎回退端侧 ASR。 case localFallback } @@ -54,8 +58,23 @@ public enum CloudASRError: Error, LocalizedError, Sendable, Equatable { } public enum CloudASRModelCatalog { + /// Provider ids shown in the cloud ASR picker (explicit allowlist). + public static let selectableProviderIds: Set = [ + "openai", + "whisper", + "bailian", + "zhipu", + "groq", + "siliconflow", + "openrouter", + "mimo", + "volcengine", + "custom", + ] + /// Sync Fun-ASR Flash — base64 upload, ≤ 5 min, supports context + vocabulary. public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15" + public static let alibabaFunASRRealtime = "fun-asr-realtime" /// Must match the ASR model used at recognition time. public static let alibabaVocabularyTargetModel = alibabaFunASRFlash @@ -63,24 +82,38 @@ public enum CloudASRModelCatalog { public static let openAITranscribe = "gpt-4o-mini-transcribe" public static let openAIWhisper = "whisper-1" public static let mimoASR = "mimo-v2.5-asr" + public static let groqWhisper = "whisper-large-v3-turbo" + public static let siliconflowASR = "FunAudioLLM/SenseVoiceSmall" + public static let openrouterWhisper = "openai/whisper-large-v3-turbo" + public static let volcengineDefaultResourceID = "volc.seedasr.sauc.duration" + public static let volcengineEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async" + public static let bailianDefaultEndpoint = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/" public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1" public static let alibabaCustomizationPath = "/services/audio/asr/customization" public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation" public static let zhipuTranscriptionPath = "/audio/transcriptions" + public static func supportsCloudASRSelection(providerId: String) -> Bool { + selectableProviderIds.contains(providerId) + } + public static func strategy(for providerId: String) -> CloudASRStrategy { switch providerId { case "zhipu": return .zhipuHotwords - case "qwen": - return .alibabaVocabulary + case "bailian": + return .bailianStreaming case "moonshot": return .localFallback - case "openai", "mimo", "custom": + case "volcengine": + return .volcengineStreaming + case "openrouter": + return .openRouterJson + case "openai", "whisper", "mimo", "groq", "siliconflow", "custom": return .prompt default: - return .prompt + return .localFallback } } @@ -88,16 +121,36 @@ public enum CloudASRModelCatalog { switch providerId { case "zhipu": return zhipuGLMASR - case "qwen": - return alibabaFunASRFlash + case "bailian": + return alibabaFunASRRealtime + case "whisper": + return openAIWhisper case "mimo": return mimoASR + case "groq": + return groqWhisper + case "siliconflow": + return siliconflowASR + case "openrouter": + return openrouterWhisper + case "volcengine": + return volcengineDefaultResourceID case "openai", "custom": return openAITranscribe default: return openAITranscribe } } + + /// Whether the ASR settings card should expose a custom endpoint field. + public static func showsASREndpointField(for providerId: String) -> Bool { + switch strategy(for: providerId) { + case .prompt, .openRouterJson, .bailianStreaming: + return true + case .zhipuHotwords, .volcengineStreaming, .localFallback: + return false + } + } } extension LLMProvider { @@ -112,9 +165,9 @@ extension LLMProvider { /// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias). public var supportsPersonalDictionaryCloudASR: Bool { switch cloudASRStrategy { - case .zhipuHotwords, .alibabaVocabulary: + case .zhipuHotwords: return true - case .prompt, .localFallback: + case .bailianStreaming, .prompt, .openRouterJson, .volcengineStreaming, .localFallback: return false } } diff --git a/OSGKeyboardShared/Models/FlowHandoffPolicy.swift b/OSGKeyboardShared/Models/FlowHandoffPolicy.swift new file mode 100644 index 0000000..2cc1a84 --- /dev/null +++ b/OSGKeyboardShared/Models/FlowHandoffPolicy.swift @@ -0,0 +1,137 @@ +// FlowHandoffPolicy.swift +// OSGKeyboard · Shared +// +// Pure decision helpers for keyboard → host handoff. Keeps "session still +// alive, ready contract briefly missing" from being treated as a cold start. + +import Foundation + +/// Action the keyboard should take when the user presses the mic. +public enum FlowMicPressAction: Equatable, Sendable { + case startRecording + /// Session is alive (or was very recently); poll for ready, then optionally record. + case waitForHostReady(recordWhenReady: Bool) + /// Host process is gone / no session — open `osgkeyboard://startflow`. + case openHostColdStart + case ignore +} + +/// Whether the host app should show the cold-start overlay for a `startflow`. +public enum FlowColdStartOverlayDecision: Equatable, Sendable { + /// Do not set handoff flags or show preparing/ready UI. + case silence + /// Show preparing and run the cold-start / recovery path. + case present +} + +public enum FlowHandoffPolicy { + /// Proactive keyboard auto-launch of the host is intentionally disabled. + /// Opening the host must be driven by an explicit mic press (or Live Activity). + public static let allowsProactiveHostAutoLaunch = false + + /// Samples of "host truly dead" required before a cold-start jump is allowed + /// from a non-press path. Mic press uses `shouldOpenHostColdStart` directly. + public static let coldStartDeadSampleThreshold = 2 + + /// True when the session contract still implies a living (or recoverable) + /// host — so a transient `ready=false` must wait, not jump. + public static func shouldTreatHostAsAlive( + sessionActive: Bool, + hostReachable: Bool, + hostStale: Bool, + withinReadyGrace: Bool + ) -> Bool { + if hostStale { return false } + guard sessionActive else { return false } + // Reachable heartbeat, or a recent ready sample, means the process is + // still ours — finalize races often look like hostNotReady for one frame. + if hostReachable || withinReadyGrace { return true } + // Session flag still valid and not past the zombie window: prefer wait. + return true + } + + /// Whether `osgkeyboard://startflow` is justified for the current host state. + public static func shouldOpenHostColdStart( + sessionActive: Bool, + hostReachable: Bool, + hostStale: Bool, + withinReadyGrace: Bool + ) -> Bool { + !shouldTreatHostAsAlive( + sessionActive: sessionActive, + hostReachable: hostReachable, + hostStale: hostStale, + withinReadyGrace: withinReadyGrace + ) + } + + /// Mic-press routing shared by the keyboard coordinator and unit tests. + public static func micPressAction( + availability: MicVoiceAvailability, + sessionActive: Bool, + hostReachable: Bool, + hostStale: Bool, + withinReadyGrace: Bool + ) -> FlowMicPressAction { + switch availability { + case .ready: + return .startRecording + case .recording, .processing: + return .ignore + case .unavailable(.missingAPIKey), + .unavailable(.noFullAccess), + .unavailable(.appGroupUnavailable): + // Caller surfaces the specific error UI. + return .ignore + case .unavailable(.preparingSession): + // Session is warming — never cold-start; wait then record. + return .waitForHostReady(recordWhenReady: true) + case .unavailable(.hostNotReady): + if shouldTreatHostAsAlive( + sessionActive: sessionActive, + hostReachable: hostReachable, + hostStale: hostStale, + withinReadyGrace: withinReadyGrace + ) { + return .waitForHostReady(recordWhenReady: true) + } + return .openHostColdStart + } + } + + /// Host-app gate: a `startflow` against an already-healthy (or busy) session + /// must not flash "Voice is ready". + public static func coldStartOverlayDecision( + sessionIsActive: Bool, + hostIsReady: Bool, + isUtteranceBusy: Bool + ) -> FlowColdStartOverlayDecision { + guard sessionIsActive else { return .present } + if hostIsReady || isUtteranceBusy { return .silence } + // Active but not ready and not busy — engine recovery may need UI. + return .present + } +} + +/// Counts consecutive "host truly dead" observations to ignore single-frame races. +public struct FlowColdStartDebouncer: Equatable, Sendable { + public private(set) var consecutiveDeadSamples: Int = 0 + + public init(consecutiveDeadSamples: Int = 0) { + self.consecutiveDeadSamples = consecutiveDeadSamples + } + + /// Returns true once enough consecutive dead samples have been seen. + public mutating func observe(hostTrulyDead: Bool) -> Bool { + if hostTrulyDead { + consecutiveDeadSamples += 1 + } else { + consecutiveDeadSamples = 0 + } + return consecutiveDeadSamples >= FlowHandoffPolicy.coldStartDeadSampleThreshold + } + + public mutating func reset() { + consecutiveDeadSamples = 0 + } +} diff --git a/OSGKeyboardShared/Models/HandednessPreference.swift b/OSGKeyboardShared/Models/HandednessPreference.swift index 1e6535c..8c5b273 100644 --- a/OSGKeyboardShared/Models/HandednessPreference.swift +++ b/OSGKeyboardShared/Models/HandednessPreference.swift @@ -2,7 +2,7 @@ // OSGKeyboard · Shared // // Which hand the user holds the phone with — controls bottom-row key order -// on the keyboard (delete ↔ return swap for right-handed use). +// on the keyboard (delete ↔ space swap for right-handed use). import Foundation @@ -19,7 +19,7 @@ public enum HandednessPreference: String, CaseIterable, Identifiable, Sendable, } } - /// Right-handed preference places return on the left and delete on the right. + /// Right-handed preference places space on the left and delete on the right. public var swapsActionKeys: Bool { self == .right } public static func fromStored(_ raw: String?) -> HandednessPreference { diff --git a/OSGKeyboardShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift index a998b14..c015850 100644 --- a/OSGKeyboardShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -48,15 +48,21 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { apiKeyURL: URL(string: "https://platform.openai.com/api-keys"), blurb: "GPT-4o mini · 多语言 · Multilingual" ), + .init( + id: "ark", + name: "火山方舟 Ark", + defaultBaseURL: "https://ark.cn-beijing.volces.com/api/v3", + defaultModel: "deepseek-v3-2-251201", + apiKeyURL: URL(string: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey"), + blurb: "豆包 / DeepSeek · OpenAI 兼容 · OpenAI-compatible" + ), .init( id: "deepseek", name: "DeepSeek", defaultBaseURL: "https://api.deepseek.com/v1", defaultModel: "deepseek-v4-flash", apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"), - blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in", - // Local engine only — never shown in cloud-engine pickers. - isUserSelectable: false + blurb: "deepseek-v4-flash · 本地引擎可内置 · Local engine optional built-in" ), .init( id: "qwen", @@ -82,6 +88,30 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"), blurb: "Kimi · 长上下文 · Long context" ), + .init( + id: "siliconflow", + name: "硅基流动 SiliconFlow", + defaultBaseURL: "https://api.siliconflow.cn/v1", + defaultModel: "Qwen/Qwen2.5-7B-Instruct", + apiKeyURL: URL(string: "https://cloud.siliconflow.cn/account/ak"), + blurb: "多模型聚合 · OpenAI 兼容 · OpenAI-compatible" + ), + .init( + id: "groq", + name: "Groq", + defaultBaseURL: "https://api.groq.com/openai/v1", + defaultModel: "llama-3.3-70b-versatile", + apiKeyURL: URL(string: "https://console.groq.com/keys"), + blurb: "超低延迟 LPU · Ultra-low latency" + ), + .init( + id: "minimax", + name: "MiniMax", + defaultBaseURL: "https://api.minimaxi.com/v1", + defaultModel: "MiniMax-M2.5", + apiKeyURL: URL(string: "https://platform.minimaxi.com/user-center/basic-information"), + blurb: "MiniMax-M2.5 · 中文优化 · Chinese-optimized" + ), .init( id: "mimo", name: "小米 MiMo", @@ -90,6 +120,106 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { apiKeyURL: URL(string: "https://platform.xiaomimimo.com"), blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized" ), + .init( + id: "openrouter", + name: "OpenRouter", + defaultBaseURL: "https://openrouter.ai/api/v1", + defaultModel: "qwen/qwen3-coder:free", + apiKeyURL: URL(string: "https://openrouter.ai/keys"), + blurb: "多模型路由 · Model routing · OpenAI-compatible" + ), + .init( + id: "gemini", + name: "Google Gemini", + defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + defaultModel: "gemini-2.5-flash", + apiKeyURL: URL(string: "https://aistudio.google.com/apikey"), + blurb: "Gemini 2.5 Flash · OpenAI 兼容端点" + ), + .init( + id: "anthropic", + name: "Anthropic Claude", + defaultBaseURL: "https://api.anthropic.com/v1", + defaultModel: "claude-sonnet-4-6", + apiKeyURL: URL(string: "https://console.anthropic.com/settings/keys"), + blurb: "Claude Sonnet · Messages API" + ), + .init( + id: "xai", + name: "xAI Grok", + defaultBaseURL: "https://api.x.ai/v1", + defaultModel: "grok-3-mini", + apiKeyURL: URL(string: "https://console.x.ai"), + blurb: "Grok · OpenAI 兼容 · OpenAI-compatible" + ), + .init( + id: "mistral", + name: "Mistral AI", + defaultBaseURL: "https://api.mistral.ai/v1", + defaultModel: "mistral-small-latest", + apiKeyURL: URL(string: "https://console.mistral.ai/api-keys"), + blurb: "Mistral Small · 欧洲托管 · EU-hosted" + ), + .init( + id: "cometapi", + name: "CometAPI", + defaultBaseURL: "https://api.cometapi.com/v1", + defaultModel: "gpt-4o", + apiKeyURL: URL(string: "https://api.cometapi.com"), + blurb: "多模型聚合 · OpenAI 兼容" + ), + .init( + id: "alibabaCoding", + name: "阿里 Coding Plan", + defaultBaseURL: "https://coding-intl.dashscope.aliyuncs.com/v1", + defaultModel: "qwen3-coder-plus", + apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"), + blurb: "通义 Coder · 代码润色 · Coding polish" + ), + .init( + id: "codingPlanX", + name: "CodingPlanX", + defaultBaseURL: "https://api.codingplanx.ai/v1", + defaultModel: "gpt-5-mini", + apiKeyURL: URL(string: "https://codingplanx.ai"), + blurb: "CodingPlanX · OpenAI 兼容" + ), + // MARK: - ASR-only presets (hidden from polish picker) + .init( + id: "volcengine", + name: "火山引擎 Volcengine", + defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async", + defaultModel: "volc.seedasr.sauc.duration", + apiKeyURL: URL(string: "https://console.volcengine.com/speech"), + blurb: "流式大模型 ASR · API Key 填 appId:accessToken[:resourceId]", + isUserSelectable: false + ), + .init( + id: "bailian", + name: "百炼实时 ASR", + defaultBaseURL: "wss://dashscope.aliyuncs.com/api-ws/v1/inference/", + defaultModel: "fun-asr-realtime", + apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"), + blurb: "Fun-ASR Realtime · 百炼词表", + isUserSelectable: false + ), + .init( + id: "whisper", + name: "Whisper (OpenAI)", + defaultBaseURL: "https://api.openai.com/v1", + defaultModel: "whisper-1", + apiKeyURL: URL(string: "https://platform.openai.com/api-keys"), + blurb: "whisper-1 · 经典 Whisper 端点", + isUserSelectable: false + ), + .init( + id: "codex_oauth", + name: "Codex OAuth", + defaultBaseURL: "", + defaultModel: "gpt-5.3-codex-spark", + blurb: "ChatGPT Codex OAuth · 暂不支持", + isUserSelectable: false + ), .init( id: "custom", name: "Custom · 自定义", @@ -103,16 +233,13 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { presets.first(where: { $0.id == id }) ?? .presets[0] } - /// Presets the user may pick in Settings / onboarding. DeepSeek is - /// excluded — it is wired exclusively to the local engine. + /// Presets the user may pick in Settings / onboarding. public static var userSelectablePresets: [LLMProvider] { presets.filter(\.isUserSelectable) } - /// Cloud ASR presets (excludes providers without a cloud transcription API). + /// Cloud ASR presets (explicit allowlist — polish-only providers excluded). public static var asrSelectablePresets: [LLMProvider] { - userSelectablePresets.filter { - CloudASRModelCatalog.strategy(for: $0.id) != .localFallback - } + presets.filter { CloudASRModelCatalog.supportsCloudASRSelection(providerId: $0.id) } } } diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index a47dca3..cb2ef26 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -111,7 +111,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { didSet { guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return } configuration.engineMode = engineMode - applyEngineModeSideEffects() persistConfiguration(postConfigChanged: true) } } @@ -179,7 +178,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } } /// Which hand the user holds the phone with — mirrors to the keyboard - /// extension so delete / return can swap on the bottom row. + /// extension so delete / space can swap on the bottom row. @Published public var handednessPreference: HandednessPreference { didSet { guard !isApplyingConfiguration, @@ -218,6 +217,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } } + /// Enables provider-specific reasoning / thinking controls when the + /// selected polish LLM supports them. + @Published public var llmThinkingEnabled: Bool { + didSet { + guard !isApplyingConfiguration, + llmThinkingEnabled != configuration.llmThinkingEnabled else { return } + configuration.llmThinkingEnabled = llmThinkingEnabled + persistConfiguration(postConfigChanged: true) + } + } + /// When enabled, the host app tries to return to the source app after a cold-start handoff. @Published public var flowSkipAppSwitch: Bool { didSet { @@ -335,6 +345,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { handednessPreference = configuration.handednessPreference cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled polishIntensity = configuration.polishIntensity + llmThinkingEnabled = configuration.llmThinkingEnabled flowSkipAppSwitch = configuration.flowSkipAppSwitch flowInactivityDuration = configuration.flowInactivityDuration localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled @@ -347,15 +358,34 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isApplyingConfiguration = false } - /// Keep cloud vs local provider choices isolated when the user - /// switches engines in Settings / onboarding. - private func applyEngineModeSideEffects() { - if engineMode == "cloud", providerId == "deepseek" { - apply(preset: LLMProvider.provider(id: "openai")) - } - if engineMode == "cloud", asrProviderId == "deepseek" { - applyAsr(preset: LLMProvider.provider(id: "openai")) - } + public func reset() { + isApplyingConfiguration = true + let polishPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultPolishProviderId) + let asrPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultCloudASRProviderId) + providerId = polishPreset.id + baseURL = polishPreset.defaultBaseURL + apiKey = "" + model = polishPreset.defaultModel + asrProviderId = asrPreset.id + asrBaseURL = asrPreset.defaultBaseURL + asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id) + asrApiKey = "" + handednessPreference = .left + localASRCustomLanguageModelEnabled = true + llmThinkingEnabled = false + hasAcknowledgedCloudSharing = false + configuration.providerId = polishPreset.id + configuration.baseURL = polishPreset.defaultBaseURL + configuration.model = polishPreset.defaultModel + configuration.asrProviderId = asrPreset.id + configuration.asrBaseURL = asrPreset.defaultBaseURL + configuration.asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id) + configuration.handednessPreference = .left + configuration.localASRCustomLanguageModelEnabled = true + configuration.llmThinkingEnabled = false + configuration.hasAcknowledgedCloudSharing = false + isApplyingConfiguration = false + persistConfiguration() } private func persistConfiguration(postConfigChanged: Bool = false) { @@ -401,6 +431,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { handednessPreference = fresh.handednessPreference cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled polishIntensity = fresh.polishIntensity + llmThinkingEnabled = fresh.llmThinkingEnabled flowSkipAppSwitch = fresh.flowSkipAppSwitch flowInactivityDuration = fresh.flowInactivityDuration localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled @@ -455,31 +486,4 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isApplyingConfiguration = false persistConfiguration() } - - public func reset() { - isApplyingConfiguration = true - let preset = LLMProvider.provider(id: "openai") - providerId = preset.id - baseURL = preset.defaultBaseURL - apiKey = "" - model = preset.defaultModel - asrProviderId = preset.id - asrBaseURL = preset.defaultBaseURL - asrModel = CloudASRModelCatalog.defaultModel(for: preset.id) - asrApiKey = "" - handednessPreference = .left - localASRCustomLanguageModelEnabled = true - hasAcknowledgedCloudSharing = false - configuration.providerId = preset.id - configuration.baseURL = preset.defaultBaseURL - configuration.model = preset.defaultModel - configuration.asrProviderId = preset.id - configuration.asrBaseURL = preset.defaultBaseURL - configuration.asrModel = CloudASRModelCatalog.defaultModel(for: preset.id) - configuration.handednessPreference = .left - configuration.localASRCustomLanguageModelEnabled = true - configuration.hasAcknowledgedCloudSharing = false - isApplyingConfiguration = false - persistConfiguration() - } } diff --git a/OSGKeyboardShared/Models/ProviderLogo.swift b/OSGKeyboardShared/Models/ProviderLogo.swift index 1723e58..b763c09 100644 --- a/OSGKeyboardShared/Models/ProviderLogo.swift +++ b/OSGKeyboardShared/Models/ProviderLogo.swift @@ -10,12 +10,24 @@ public enum ProviderLogo { /// Asset name for the provider's logo, or `nil` when there is no bundled logo. public static func assetName(for providerId: String) -> String? { switch providerId { - case "openai": return "openai" + case "openai", "whisper": return "openai" case "deepseek": return "deepseek" - case "qwen": return "qwen" + case "qwen", "bailian", "alibabaCoding": return "qwen" case "moonshot": return "moonshot" case "zhipu": return "zhipu" case "mimo": return "mimo" + case "ark", "volcengine": return "ark" + case "siliconflow": return "siliconflow" + case "groq": return "groq" + case "minimax": return "minimax" + case "openrouter": return "openrouter" + case "gemini": return "gemini" + case "anthropic": return "anthropic" + case "xai": return "xai" + case "mistral": return "mistral" + case "cometapi": return "cometapi" + case "codingPlanX": return "codingplanx" + case "codex_oauth": return "openai" case "apple": return "apple" case "custom": return "custom" default: return nil diff --git a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift index 643f12c..70b928f 100644 --- a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift +++ b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift @@ -27,6 +27,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { public var handednessPreference: SyncedField public var cursorDragNavigationEnabled: SyncedField public var polishIntensity: SyncedField + public var llmThinkingEnabled: SyncedField public var flowSkipAppSwitch: SyncedField public var flowInactivityDuration: SyncedField @@ -47,6 +48,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { handednessPreference: SyncedField, cursorDragNavigationEnabled: SyncedField, polishIntensity: SyncedField, + llmThinkingEnabled: SyncedField, flowSkipAppSwitch: SyncedField, flowInactivityDuration: SyncedField ) { @@ -66,6 +68,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { self.handednessPreference = handednessPreference self.cursorDragNavigationEnabled = cursorDragNavigationEnabled self.polishIntensity = polishIntensity + self.llmThinkingEnabled = llmThinkingEnabled self.flowSkipAppSwitch = flowSkipAppSwitch self.flowInactivityDuration = flowInactivityDuration } @@ -87,6 +90,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { case handednessPreference case cursorDragNavigationEnabled case polishIntensity + case llmThinkingEnabled case flowSkipAppSwitch case flowInactivityDuration } @@ -115,6 +119,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { forKey: .cursorDragNavigationEnabled ) polishIntensity = try container.decode(SyncedField.self, forKey: .polishIntensity) + llmThinkingEnabled = try container.decodeIfPresent( + SyncedField.self, + forKey: .llmThinkingEnabled + ) ?? SyncedField(value: false, updatedAt: polishIntensity.updatedAt, deviceID: polishIntensity.deviceID) flowSkipAppSwitch = try container.decode(SyncedField.self, forKey: .flowSkipAppSwitch) flowInactivityDuration = try container.decode( SyncedField.self, @@ -161,6 +169,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { handednessPreference.updatedAt, cursorDragNavigationEnabled.updatedAt, polishIntensity.updatedAt, + llmThinkingEnabled.updatedAt, flowSkipAppSwitch.updatedAt, flowInactivityDuration.updatedAt, ].max() ?? .distantPast @@ -197,6 +206,7 @@ public extension SyncedAppSettingsV2 { handednessPreference: field(configuration.handednessPreference), cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled), polishIntensity: field(configuration.polishIntensity), + llmThinkingEnabled: field(configuration.llmThinkingEnabled), flowSkipAppSwitch: field(configuration.flowSkipAppSwitch), flowInactivityDuration: field(configuration.flowInactivityDuration) ) @@ -225,6 +235,7 @@ public extension SyncedAppSettingsV2 { handednessPreference: field(legacy.handednessPreference), cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled), polishIntensity: field(legacy.polishIntensity), + llmThinkingEnabled: field(false), flowSkipAppSwitch: field(legacy.flowSkipAppSwitch), flowInactivityDuration: field(legacy.flowInactivityDuration) ) @@ -256,6 +267,7 @@ public extension SyncedAppSettingsV2 { remote: remote.cursorDragNavigationEnabled ), polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity), + llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled), flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch), flowInactivityDuration: .merge( local: local.flowInactivityDuration, @@ -280,6 +292,7 @@ public extension SyncedAppSettingsV2 { configuration.handednessPreference = handednessPreference.value configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value configuration.polishIntensity = polishIntensity.value + configuration.llmThinkingEnabled = llmThinkingEnabled.value configuration.flowSkipAppSwitch = flowSkipAppSwitch.value configuration.flowInactivityDuration = flowInactivityDuration.value } @@ -306,6 +319,7 @@ public extension SyncedAppSettingsV2 { patch(©.handednessPreference, value: configuration.handednessPreference) patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled) patch(©.polishIntensity, value: configuration.polishIntensity) + patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled) patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch) patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration) return copy @@ -335,6 +349,7 @@ public extension SyncedAppSettingsV2 { touch(©.handednessPreference, value: configuration.handednessPreference) touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled) touch(©.polishIntensity, value: configuration.polishIntensity) + touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled) touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch) touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration) return copy diff --git a/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift b/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift index a10937b..c2e949a 100644 --- a/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift +++ b/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift @@ -5,30 +5,78 @@ import Foundation +/// Local-calendar day key (`yyyy-MM-dd`) for daily usage buckets. String keys +/// sort lexicographically in chronological order, which keeps pruning and +/// range queries index-free. +public enum UsageStatisticsDayKey { + public static func key(for date: Date, calendar: Calendar = .current) -> String { + let c = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0) + } + + /// Drops buckets older than `days` so the synced blob stays small even + /// after months of use (the chart only ever needs the last 7 days). + public static func prune(_ daily: inout [String: Int], keepingDays days: Int, now: Date = Date(), calendar: Calendar = .current) { + guard let cutoff = calendar.date(byAdding: .day, value: -days, to: now) else { return } + let cutoffKey = key(for: cutoff, calendar: calendar) + daily = daily.filter { $0.key >= cutoffKey } + } +} + public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable { public var updatedAt: Date public var dictationDurationSeconds: TimeInterval public var dictationCharacterCount: Int public var translationCharacterCount: Int + /// Grow-only per-day dictation character counts, keyed by local `yyyy-MM-dd`. + /// Powers the home page's 7-day chart; merged per-key with `max` (each device + /// only ever grows its own days) and summed across devices when aggregated. + public var dailyDictationCharacters: [String: Int] public init( updatedAt: Date = Date(), dictationDurationSeconds: TimeInterval = 0, dictationCharacterCount: Int = 0, - translationCharacterCount: Int = 0 + translationCharacterCount: Int = 0, + dailyDictationCharacters: [String: Int] = [:] ) { self.updatedAt = updatedAt self.dictationDurationSeconds = dictationDurationSeconds self.dictationCharacterCount = dictationCharacterCount self.translationCharacterCount = translationCharacterCount + self.dailyDictationCharacters = dailyDictationCharacters + } + + private enum CodingKeys: String, CodingKey { + case updatedAt + case dictationDurationSeconds + case dictationCharacterCount + case translationCharacterCount + case dailyDictationCharacters + } + + // Custom decode so slices written before the daily-buckets field still load + // (the missing key defaults to an empty map rather than failing the decode). + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + dictationDurationSeconds = try container.decode(TimeInterval.self, forKey: .dictationDurationSeconds) + dictationCharacterCount = try container.decode(Int.self, forKey: .dictationCharacterCount) + translationCharacterCount = try container.decode(Int.self, forKey: .translationCharacterCount) + dailyDictationCharacters = try container.decodeIfPresent([String: Int].self, forKey: .dailyDictationCharacters) ?? [:] } public static func merge(local: UsageStatisticsDeviceSlice, remote: UsageStatisticsDeviceSlice) -> UsageStatisticsDeviceSlice { - UsageStatisticsDeviceSlice( + var mergedDaily = local.dailyDictationCharacters + for (day, value) in remote.dailyDictationCharacters { + mergedDaily[day] = max(mergedDaily[day] ?? 0, value) + } + return UsageStatisticsDeviceSlice( updatedAt: max(local.updatedAt, remote.updatedAt), dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds), dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount), - translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount) + translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount), + dailyDictationCharacters: mergedDaily ) } @@ -75,6 +123,17 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable { ) } + /// Cross-device daily dictation characters (summed per `yyyy-MM-dd`). + public var aggregatedDailyDictationCharacters: [String: Int] { + var result: [String: Int] = [:] + for slice in devices.values { + for (day, value) in slice.dailyDictationCharacters { + result[day, default: 0] += value + } + } + return result + } + public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 { var mergedDevices = local.devices for (deviceID, remoteSlice) in remote.devices { diff --git a/OSGKeyboardShared/Models/VolcengineASRFields.swift b/OSGKeyboardShared/Models/VolcengineASRFields.swift new file mode 100644 index 0000000..97b786a --- /dev/null +++ b/OSGKeyboardShared/Models/VolcengineASRFields.swift @@ -0,0 +1,73 @@ +// VolcengineASRFields.swift +// OSGKeyboard · Shared +// +// Parse / encode Volcengine SAUC credentials stored in the ASR API key field. + +import Foundation + +public struct VolcengineASRFields: Sendable, Equatable { + public var appID: String + public var accessToken: String + public var resourceID: String + + public init( + appID: String = "", + accessToken: String = "", + resourceID: String = CloudASRModelCatalog.defaultModel(for: "volcengine") + ) { + self.appID = appID + self.accessToken = accessToken + self.resourceID = resourceID + } + + public var encodedAPIKey: String { + let object = [ + "app_id": appID, + "access_token": accessToken, + "resource_id": resourceID, + ] + guard let data = try? JSONSerialization.data(withJSONObject: object), + let string = String(data: data, encoding: .utf8) else { + return [appID, accessToken, resourceID].joined(separator: ":") + } + return string + } + + public static func parse(apiKey: String, resourceFallback: String) -> VolcengineASRFields { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + var fields = VolcengineASRFields( + appID: "", + accessToken: "", + resourceID: resourceFallback.isEmpty + ? CloudASRModelCatalog.defaultModel(for: "volcengine") + : resourceFallback + ) + + if let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? "" + fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? "" + fields.resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) ?? fields.resourceID + return fields + } + + let parts = trimmed + .components(separatedBy: CharacterSet(charactersIn: ":\n,")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if parts.indices.contains(0) { fields.appID = parts[0] } + if parts.indices.contains(1) { fields.accessToken = parts[1] } + if parts.indices.contains(2) { fields.resourceID = parts[2] } + return fields + } + + private static func string(_ json: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = json[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return nil + } +} diff --git a/OSGKeyboardShared/Services/AnthropicLLMClient.swift b/OSGKeyboardShared/Services/AnthropicLLMClient.swift new file mode 100644 index 0000000..5355a1d --- /dev/null +++ b/OSGKeyboardShared/Services/AnthropicLLMClient.swift @@ -0,0 +1,69 @@ +// AnthropicLLMClient.swift +// OSGKeyboard · Shared +// +// Anthropic Messages API client for polish / translation prompts. + +import Foundation + +public struct AnthropicMessagesClient: LLMClient { + public let apiKey: String + public let model: String + public let session: URLSession + public let requestTimeout: TimeInterval = 15 + + public init( + apiKey: String, + model: String, + session: URLSession = .shared + ) { + self.apiKey = apiKey + self.model = model + self.session = session + } + + public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String { + guard !apiKey.isEmpty else { throw LLMError.noAPIKey } + + let url = URL(string: "https://api.anthropic.com/v1/messages")! + let body: [String: Any] = [ + "model": model, + "max_tokens": 4_096, + "system": systemPrompt, + "messages": [ + ["role": "user", "content": text], + ], + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.timeoutInterval = timeout ?? requestTimeout + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw LLMError.transport("non-HTTP response") + } + if !(200..<300).contains(http.statusCode) { + if http.statusCode == 429 { throw LLMError.rateLimited } + throw LLMError.http(status: http.statusCode) + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let content = json["content"] as? [[String: Any]], + let first = content.first, + let textBlock = first["text"] as? String else { + throw LLMError.decoding("anthropic content") + } + return textBlock.trimmingCharacters(in: .whitespacesAndNewlines) + } catch let err as LLMError { + throw err + } catch is CancellationError { + throw LLMError.cancelled + } catch { + throw LLMError.transport(String(describing: error)) + } + } +} diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 5dd6ddd..8d6c021 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -66,6 +66,7 @@ public struct AppGroupStore: @unchecked Sendable { public var handednessPreference: HandednessPreference { configuration.handednessPreference } public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled } public var polishIntensity: PolishIntensity { configuration.polishIntensity } + public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled } public var isTranslationEffective: Bool { configuration.isTranslationEffective } public var isLocalEngine: Bool { configuration.isLocalEngine } public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline } @@ -87,21 +88,7 @@ public struct AppGroupStore: @unchecked Sendable { } public func setEngineMode(_ mode: String) { - mutateConfiguration { config in - config.engineMode = mode - if mode == "cloud", config.providerId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.providerId = openAI.id - config.baseURL = openAI.defaultBaseURL - config.model = openAI.defaultModel - } - if mode == "cloud", config.asrProviderId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.asrProviderId = openAI.id - config.asrBaseURL = openAI.defaultBaseURL - config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id) - } - } + mutateConfiguration { $0.engineMode = mode } AppGroupConfigDarwin.postConfigChanged() } @@ -134,6 +121,11 @@ public struct AppGroupStore: @unchecked Sendable { mutateConfiguration { $0.polishIntensity = intensity } } + public func setLLMThinkingEnabled(_ enabled: Bool) { + mutateConfiguration { $0.llmThinkingEnabled = enabled } + AppGroupConfigDarwin.postConfigChanged() + } + public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) { mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled } } diff --git a/OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift b/OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift new file mode 100644 index 0000000..dc54258 --- /dev/null +++ b/OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift @@ -0,0 +1,434 @@ +// BailianRealtimeASRClient.swift +// OSGKeyboard · Shared +// +// Alibaba Cloud Bailian / DashScope realtime ASR over the classic inference +// WebSocket (`/api-ws/v1/inference`). Matches OpenLess' `bailian.rs` wire +// protocol: run-task → PCM binary frames → finish-task → result events. + +import Foundation + +struct BailianRealtimeASRClient: CloudASRTranscribing { + let apiKey: String + let endpoint: String + let model: String + let vocabularyID: String? + let session: URLSession + + /// 100 ms of 16 kHz / 16-bit / mono PCM. + private static let targetChunkBytes = 3_200 + private static let startTimeout: TimeInterval = 8 + private static let finalTimeout: TimeInterval = 12 + private static let sessionTimeout: TimeInterval = startTimeout + finalTimeout + 4 + + func prepare(dictionary: PersonalDictionary) async throws {} + + func transcribe( + samples: [Float], + sampleRate: Int, + locale: Locale, + dictionary: PersonalDictionary + ) async throws -> String { + guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey } + guard sampleRate == 16_000 else { + throw CloudASRError.transport("Bailian realtime expects 16 kHz audio") + } + guard !samples.isEmpty else { throw CloudASRError.emptyTranscript } + + let url = try resolvedEndpointURL() + let pcm = Self.pcm16Data(samples: samples) + let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.alibabaFunASRRealtime + : model.trimmingCharacters(in: .whitespacesAndNewlines) + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue( + "bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))", + forHTTPHeaderField: "Authorization" + ) + + let wsTask = session.webSocketTask(with: request) + wsTask.resume() + + return try await withThrowingTaskGroup(of: String.self) { group in + let events = BailianEventStream(task: wsTask) + + group.addTask { + defer { events.cancel() } + return try await Self.runSession( + taskID: taskID, + model: resolvedModel, + pcm: pcm, + wsTask: wsTask, + events: events + ) + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Self.sessionTimeout * 1_000_000_000)) + events.cancel() + wsTask.cancel(with: .goingAway, reason: nil) + throw CloudASRError.transport("session timed out") + } + + guard let result = try await group.next() else { + throw CloudASRError.emptyTranscript + } + group.cancelAll() + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + /// Settings connection probe: handshake to `task-started` only. + /// + /// Reaching `task-started` proves endpoint + `Authorization` + model are + /// all valid — which is exactly what "validate connection" must check. + /// It deliberately sends NO audio: DashScope realtime rejects a short + /// silent probe with a `task-failed: emptyAudio`, which is a false + /// negative for a connectivity test. A real auth/quota/model failure + /// still arrives as `task-failed` before `task-started` and surfaces. + func probeConnection() async throws { + guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey } + + let url = try resolvedEndpointURL() + let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.alibabaFunASRRealtime + : model.trimmingCharacters(in: .whitespacesAndNewlines) + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue( + "bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))", + forHTTPHeaderField: "Authorization" + ) + + let wsTask = session.webSocketTask(with: request) + wsTask.resume() + + try await withThrowingTaskGroup(of: Void.self) { group in + let events = BailianEventStream(task: wsTask) + + group.addTask { + defer { events.cancel() } + try await Self.sendText( + Self.runTaskMessage(taskID: taskID, model: resolvedModel, vocabularyID: nil), + task: wsTask + ) + try await events.waitForStarted(timeout: Self.startTimeout) + // Politely end the task; the connection is already proven. + try? await Self.sendText(Self.finishTaskMessage(taskID: taskID), task: wsTask) + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Self.startTimeout * 1_000_000_000)) + events.cancel() + wsTask.cancel(with: .goingAway, reason: nil) + throw CloudASRError.transport("connection probe timed out") + } + + _ = try await group.next() + group.cancelAll() + } + } + + private static func runSession( + taskID: String, + model: String, + pcm: Data, + wsTask: URLSessionWebSocketTask, + events: BailianEventStream + ) async throws -> String { + try await sendText( + runTaskMessage(taskID: taskID, model: model, vocabularyID: nil), + task: wsTask + ) + + try await events.waitForStarted(timeout: startTimeout) + + var offset = 0 + while offset < pcm.count { + let end = min(offset + targetChunkBytes, pcm.count) + try await sendBinary(pcm.subdata(in: offset.. URL { + let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.bailianDefaultEndpoint + : endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw) else { throw CloudASRError.invalidURL } + return url + } + + private static func sendText(_ text: String, task: URLSessionWebSocketTask) async throws { + do { + try await task.send(.string(text)) + } catch { + throw CloudASRError.transport(error.localizedDescription) + } + } + + private static func sendBinary(_ data: Data, task: URLSessionWebSocketTask) async throws { + do { + try await task.send(.data(data)) + } catch { + throw CloudASRError.transport(error.localizedDescription) + } + } + + private static func pcm16Data(samples: [Float]) -> Data { + var data = Data() + data.reserveCapacity(samples.count * 2) + for sample in samples { + let scaled = sample * 32_767.0 + let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled)) + var littleEndian = Int16(clipped.rounded()).littleEndian + withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) } + } + return data + } + + /// Overlap-aware join to avoid cumulative duplicate text from interim replays. + static func mergeSegments(_ segments: [String]) -> String { + var result = "" + for segment in segments { + if result.isEmpty { + result = segment + continue + } + let resultChars = Array(result) + let segmentChars = Array(segment) + let maxOverlap = min(resultChars.count, segmentChars.count) + var overlap = 0 + if maxOverlap >= 2 { + for length in stride(from: maxOverlap, through: 2, by: -1) { + let tail = resultChars.suffix(length) + let head = segmentChars.prefix(length) + if tail.elementsEqual(head) { + overlap = length + break + } + } + } + result.append(contentsOf: segmentChars.dropFirst(overlap)) + } + return result + } + + static func runTaskMessage(taskID: String, model: String, vocabularyID: String?) -> String { + var parameters: [String: Any] = [ + "sample_rate": 16_000, + "format": "pcm", + ] + if let vocabularyID = vocabularyID?.trimmingCharacters(in: .whitespacesAndNewlines), + !vocabularyID.isEmpty { + parameters["vocabulary_id"] = vocabularyID + } + let body: [String: Any] = [ + "header": [ + "action": "run-task", + "task_id": taskID, + "streaming": "duplex", + ], + "payload": [ + "task_group": "audio", + "task": "asr", + "function": "recognition", + "model": model, + "parameters": parameters, + "input": [:] as [String: Any], + ], + ] + guard let data = try? JSONSerialization.data(withJSONObject: body), + let json = String(data: data, encoding: .utf8) else { + return "{}" + } + return json + } + + static func finishTaskMessage(taskID: String) -> String { + let body: [String: Any] = [ + "header": [ + "action": "finish-task", + "task_id": taskID, + "streaming": "duplex", + ], + "payload": ["input": [:] as [String: Any]], + ] + guard let data = try? JSONSerialization.data(withJSONObject: body), + let json = String(data: data, encoding: .utf8) else { + return "{}" + } + return json + } +} + +// MARK: - Concurrent read loop + +private final class BailianEventStream: @unchecked Sendable { + private let task: URLSessionWebSocketTask + private let lock = NSLock() + private var started = false + private var finalText: String? + private var failure: Error? + private var readTask: Task? + + init(task: URLSessionWebSocketTask) { + self.task = task + readTask = Task { [weak self] in + await self?.readLoop() + } + } + + func cancel() { + readTask?.cancel() + task.cancel(with: .goingAway, reason: nil) + } + + func waitForStarted(timeout: TimeInterval) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let failure = snapshotFailure() { throw failure } + if snapshotStarted() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + cancel() + throw CloudASRError.transport("task-started timed out") + } + + func waitForFinalText(timeout: TimeInterval) async throws -> String { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let failure = snapshotFailure() { throw failure } + if let text = snapshotFinalText() { return text } + try await Task.sleep(nanoseconds: 20_000_000) + } + cancel() + throw CloudASRError.transport("final result timed out") + } + + private func snapshotStarted() -> Bool { + lock.lock() + defer { lock.unlock() } + return started + } + + private func snapshotFinalText() -> String? { + lock.lock() + defer { lock.unlock() } + return finalText + } + + private func snapshotFailure() -> Error? { + lock.lock() + defer { lock.unlock() } + return failure + } + + private func readLoop() async { + var finalSegments: [Int64: String] = [:] + var partialSegments: [Int64: String] = [:] + var lastResultText = "" + + while !Task.isCancelled { + let message: URLSessionWebSocketTask.Message + do { + message = try await task.receive() + } catch { + publishFailure(CloudASRError.transport(error.localizedDescription)) + return + } + + let text: String + switch message { + case .string(let value): + text = value + case .data(let data): + text = String(data: data, encoding: .utf8) ?? "" + @unknown default: + continue + } + guard !text.isEmpty else { continue } + + guard let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any], + let header = json["header"] as? [String: Any] else { + continue + } + let event = header["event"] as? String ?? "" + + switch event { + case "task-started": + publishStarted() + case "result-generated": + guard let payload = json["payload"] as? [String: Any], + let output = payload["output"] as? [String: Any], + let sentenceObj = output["sentence"] as? [String: Any] else { + continue + } + if sentenceObj["heartbeat"] as? Bool == true { continue } + guard let rawText = sentenceObj["text"] as? String else { continue } + let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + lastResultText = trimmed + let sentenceID = sentenceObj["sentence_id"] as? Int64 ?? 0 + let sentenceEndValue = sentenceObj["sentence_end"] + let sentenceEnd = sentenceEndValue as? Bool ?? false + let endTime = sentenceObj["end_time"] as? Int64 ?? 0 + let isFinal = sentenceEndValue != nil ? sentenceEnd : endTime > 0 + + if isFinal { + finalSegments[sentenceID] = trimmed + partialSegments.removeValue(forKey: sentenceID) + } else { + partialSegments[sentenceID] = trimmed + } + case "task-finished": + if finalSegments.isEmpty { + publishFinal(lastResultText) + } else { + let ordered = finalSegments.keys.sorted().compactMap { finalSegments[$0] } + publishFinal(BailianRealtimeASRClient.mergeSegments(ordered)) + } + return + case "task-failed": + let message = header["error_message"] as? String ?? "task failed" + publishFailure(CloudASRError.transport(message)) + return + default: + break + } + } + } + + private func publishStarted() { + lock.lock() + started = true + lock.unlock() + } + + private func publishFinal(_ text: String) { + lock.lock() + finalText = text + lock.unlock() + } + + private func publishFailure(_ error: Error) { + lock.lock() + failure = error + lock.unlock() + cancel() + } +} diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift index 779eecb..81e55af 100644 --- a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift +++ b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift @@ -13,6 +13,28 @@ public protocol CloudASRTranscribing: Sendable { locale: Locale, dictionary: PersonalDictionary ) async throws -> String + + /// Settings "validate connection" probe. Verifies transport + auth only. + func probeConnection() async throws +} + +extension CloudASRTranscribing { + /// Default probe: transcribe ~1 s of near-silence. An empty transcript + /// counts as success — HTTP/streaming providers only need to prove that + /// transport + auth work. Providers whose service rejects silent/short + /// audio (e.g. DashScope realtime returns `emptyAudio`) override this. + public func probeConnection() async throws { + do { + _ = try await transcribe( + samples: [Float](repeating: 0.01, count: 16_000), + sampleRate: 16_000, + locale: Locale(identifier: "zh-CN"), + dictionary: .empty + ) + } catch CloudASRError.emptyTranscript { + return + } + } } public enum CloudASRClientFactory { @@ -29,11 +51,12 @@ public enum CloudASRClientFactory { model: asrModel, session: session ) - case .alibabaVocabulary: - return AlibabaFunASRClient( + case .bailianStreaming: + return BailianRealtimeASRClient( apiKey: store.asrApiKey, + endpoint: store.asrBaseURL, model: asrModel, - persistence: store.cloudASRPersistence, + vocabularyID: nil, session: session ) case .prompt: @@ -44,6 +67,22 @@ public enum CloudASRClientFactory { model: asrModel, session: session ) + case .openRouterJson: + return PromptCloudASRClient( + providerId: providerId, + baseURL: store.asrBaseURL, + apiKey: store.asrApiKey, + model: asrModel, + session: session, + requestFormat: .openRouterJson + ) + case .volcengineStreaming: + return VolcengineCloudASRClient( + apiKey: store.asrApiKey, + endpoint: store.asrBaseURL, + resourceID: asrModel, + session: session + ) case .localFallback: return UnsupportedCloudASRClient(providerId: providerId) } @@ -151,25 +190,14 @@ struct ZhipuCloudASRClient: CloudASRTranscribing { } } -// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context) +// MARK: - Alibaba Fun-ASR Flash (HTTP sync, context text bias) -/// `UserDefaults` is not `Sendable`; we only touch `persistence` on the -/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder. -struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { +struct AlibabaFunASRClient: CloudASRTranscribing { let apiKey: String let model: String - let persistence: UserDefaults let session: URLSession - func prepare(dictionary: PersonalDictionary) async throws { - _ = try await AlibabaVocabularySync.ensureVocabularyID( - dictionary: dictionary, - apiKey: apiKey, - targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel, - defaults: persistence, - session: session - ) - } + func prepare(dictionary: PersonalDictionary) async throws {} func transcribe( samples: [Float], @@ -179,14 +207,6 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { ) async throws -> String { guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey } - let vocabularyID = try await AlibabaVocabularySync.ensureVocabularyID( - dictionary: dictionary, - apiKey: apiKey, - targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel, - defaults: persistence, - session: session - ) - let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate) let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL } @@ -211,13 +231,10 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { ], ]) - var parameters: [String: Any] = [ + let parameters: [String: Any] = [ "format": "wav", "sample_rate": "\(sampleRate)", ] - if let vocabularyID, !vocabularyID.isEmpty { - parameters["vocabulary_id"] = vocabularyID - } let body: [String: Any] = [ "model": model, @@ -235,11 +252,11 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { let (data, response) = try await session.data(for: request) try ZhipuCloudASRClient.validateHTTP(response: response, data: data) - guard let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), - !text.isEmpty else { - throw CloudASRError.emptyTranscript + if let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return text } - return text + return "" } private static func parseText(from data: Data) -> String? { @@ -256,7 +273,13 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { } } -// MARK: - Prompt-biased transcription (OpenAI / MiMo / custom) +// MARK: - Prompt-biased transcription (OpenAI / MiMo / Groq / custom) + +enum PromptCloudASRRequestFormat: Sendable { + case multipart + /// OpenRouter expects JSON `{ model, input_audio: { data, format } }`. + case openRouterJson +} struct PromptCloudASRClient: CloudASRTranscribing { let providerId: String @@ -264,6 +287,26 @@ struct PromptCloudASRClient: CloudASRTranscribing { let apiKey: String let model: String let session: URLSession + var requestFormat: PromptCloudASRRequestFormat = .multipart + + /// Groq / OpenRouter batch uploads cap around 30 s per request. + private static let whisperCompatibleMaxDurationSeconds: TimeInterval = 30 + + init( + providerId: String, + baseURL: String, + apiKey: String, + model: String, + session: URLSession, + requestFormat: PromptCloudASRRequestFormat = .multipart + ) { + self.providerId = providerId + self.baseURL = baseURL + self.apiKey = apiKey + self.model = model + self.session = session + self.requestFormat = requestFormat + } func prepare(dictionary: PersonalDictionary) async throws {} @@ -281,6 +324,13 @@ struct PromptCloudASRClient: CloudASRTranscribing { dictionary: dictionary ) } + if requestFormat == .openRouterJson { + return try await transcribeOpenRouterJSON( + samples: samples, + sampleRate: sampleRate, + dictionary: dictionary + ) + } return try await transcribeOpenAIStyle( samples: samples, sampleRate: sampleRate, @@ -288,11 +338,21 @@ struct PromptCloudASRClient: CloudASRTranscribing { ) } + private func enforceWhisperDuration(samples: [Float], sampleRate: Int) throws { + let duration = Double(samples.count) / Double(sampleRate) + guard duration <= Self.whisperCompatibleMaxDurationSeconds else { + throw CloudASRError.audioTooLong + } + } + private func transcribeOpenAIStyle( samples: [Float], sampleRate: Int, dictionary: PersonalDictionary ) async throws -> String { + if providerId == "groq" || providerId == "openai" || providerId == "custom" { + try enforceWhisperDuration(samples: samples, sampleRate: sampleRate) + } let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate) let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL let urlString = "\(trimmedBase)/audio/transcriptions" @@ -335,6 +395,45 @@ struct PromptCloudASRClient: CloudASRTranscribing { return text } + private func transcribeOpenRouterJSON( + samples: [Float], + sampleRate: Int, + dictionary: PersonalDictionary + ) async throws -> String { + try enforceWhisperDuration(samples: samples, sampleRate: sampleRate) + let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate) + let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL + let urlString = "\(trimmedBase)/audio/transcriptions" + guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL } + + var body: [String: Any] = [ + "model": model, + "input_audio": [ + "data": wav.base64EncodedString(), + "format": "wav", + ], + ] + let prompt = dictionary.asrPromptBias(maxCharacters: 600) + if !prompt.isEmpty { + body["prompt"] = prompt + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.timeoutInterval = 90 + + let (data, response) = try await session.data(for: request) + try ZhipuCloudASRClient.validateHTTP(response: response, data: data) + guard let text = Self.parseOpenAIText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty else { + throw CloudASRError.emptyTranscript + } + return text + } + private func transcribeMiMo( samples: [Float], sampleRate: Int, diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift new file mode 100644 index 0000000..9879f0a --- /dev/null +++ b/OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift @@ -0,0 +1,19 @@ +// CloudASRConnectionCheck.swift +// OSGKeyboard · Shared +// +// Settings "validate connection" probe shared by iOS and macOS. + +import Foundation + +public enum CloudASRConnectionCheck { + /// Verifies the active cloud ASR client can connect + authenticate. + /// + /// Each backend decides how to probe (see `CloudASRTranscribing`): + /// HTTP/batch providers transcribe a short silence clip and treat an + /// empty transcript as success; DashScope realtime only handshakes to + /// `task-started` (pushing fake audio makes it fail with `emptyAudio`). + public static func validate(store: any ConfigurationStore) async throws { + let client = CloudASRClientFactory.make(store: store) + try await client.probeConnection() + } +} diff --git a/OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift b/OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift new file mode 100644 index 0000000..e10b2fd --- /dev/null +++ b/OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift @@ -0,0 +1,400 @@ +// VolcengineCloudASRClient.swift +// OSGKeyboard · Shared +// +// Volcengine SAUC bigmodel ASR client. The service uses a WebSocket with a +// small custom binary frame wrapper; this file keeps that protocol isolated +// from the HTTP-style cloud ASR clients. + +import Foundation + +struct VolcengineCloudASRClient: CloudASRTranscribing { + let apiKey: String + let endpoint: String + let resourceID: String + let session: URLSession + + private static let targetChunkBytes = 6_400 // 200 ms @ 16 kHz, 16-bit, mono. + private static let finalTimeout: TimeInterval = 12 + private static let hotwordCap = 80 + + func prepare(dictionary: PersonalDictionary) async throws {} + + func transcribe( + samples: [Float], + sampleRate: Int, + locale: Locale, + dictionary: PersonalDictionary + ) async throws -> String { + guard !samples.isEmpty else { throw CloudASRError.emptyTranscript } + let credentials = try VolcengineCredentials.parse( + apiKey: apiKey, + fallbackResourceID: resolvedResourceID + ) + let url = try resolvedEndpointURL() + let pcm = Self.pcm16Data(samples: samples) + let connectID = UUID().uuidString + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue(credentials.appID, forHTTPHeaderField: "X-Api-App-Key") + request.setValue(credentials.accessToken, forHTTPHeaderField: "X-Api-Access-Key") + request.setValue(credentials.resourceID, forHTTPHeaderField: "X-Api-Resource-Id") + request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id") + + let task = session.webSocketTask(with: request) + task.resume() + defer { + task.cancel(with: .normalClosure, reason: nil) + } + + let firstPayload = try Self.firstFramePayload(connectID: connectID, dictionary: dictionary) + try await send( + VolcengineFrame.build( + messageType: .fullClientRequest, + flags: .positiveSequence, + serialization: .json, + payload: firstPayload, + sequence: 1 + ), + task: task + ) + + var sequence = 2 + var offset = 0 + while offset < pcm.count { + let end = min(offset + Self.targetChunkBytes, pcm.count) + try await send( + VolcengineFrame.build( + messageType: .audioOnlyRequest, + flags: .positiveSequence, + serialization: .none, + payload: pcm.subdata(in: offset.. URL { + let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.volcengineEndpoint + : endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw) else { throw CloudASRError.invalidURL } + return url + } + + private func send(_ data: Data, task: URLSessionWebSocketTask) async throws { + do { + try await task.send(.data(data)) + } catch { + throw CloudASRError.transport(error.localizedDescription) + } + } + + private func receiveFinalText(task: URLSessionWebSocketTask) async throws -> String { + try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + var lastPartial = "" + while true { + let message = try await task.receive() + let data: Data + switch message { + case .data(let payload): + data = payload + case .string(let string): + data = Data(string.utf8) + @unknown default: + continue + } + + guard let frame = VolcengineFrame.parse(data) else { continue } + if frame.messageType == .errorMessage { + let body = String(data: frame.payload, encoding: .utf8) ?? "" + let code = frame.errorCode ?? 0 + throw CloudASRError.transport("ASR error \(code): \(body)") + } + guard frame.messageType == .fullServerResponse else { continue } + let parsedText = Self.text(from: frame.payload) + if !parsedText.isEmpty { + lastPartial = parsedText + } + if frame.isFinal { + return parsedText.isEmpty ? lastPartial : parsedText + } + } + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Self.finalTimeout * 1_000_000_000)) + throw CloudASRError.transport("Volcengine final result timed out") + } + let result = try await group.next()! + group.cancelAll() + return result + } + } + + private static func firstFramePayload( + connectID: String, + dictionary: PersonalDictionary + ) throws -> Data { + var request: [String: Any] = [ + "model_name": "bigmodel", + "enable_itn": true, + "enable_punc": true, + "show_utterances": true, + "enable_speaker_info": true, + ] + if let context = hotwordContext(dictionary: dictionary) { + request["context"] = context + } + + let payload: [String: Any] = [ + "user": ["uid": connectID], + "audio": [ + "format": "pcm", + "rate": 16_000, + "bits": 16, + "channel": 1, + "codec": "raw", + ], + "request": request, + ] + return try JSONSerialization.data(withJSONObject: payload) + } + + private static func hotwordContext(dictionary: PersonalDictionary) -> String? { + var seen: [String] = [] + for word in dictionary.asrHotwords() { + let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard !seen.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) else { + continue + } + seen.append(trimmed) + if seen.count >= hotwordCap { break } + } + guard !seen.isEmpty else { return nil } + let words = seen.map { ["word": $0] } + guard let data = try? JSONSerialization.data(withJSONObject: ["hotwords": words]) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + private static func pcm16Data(samples: [Float]) -> Data { + var data = Data() + data.reserveCapacity(samples.count * 2) + for sample in samples { + let scaled = sample * 32_767.0 + let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled)) + var littleEndian = Int16(clipped.rounded()).littleEndian + withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) } + } + return data + } + + private static func text(from payload: Data) -> String { + guard let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let result = normalizedResult(from: json) else { + return "" + } + + if let utterances = result["utterances"] as? [[String: Any]], !utterances.isEmpty { + let pieces = utterances.compactMap { $0["text"] as? String } + let joined = pieces.joined() + if !joined.isEmpty { return joined } + } + return result["text"] as? String ?? "" + } + + private static func normalizedResult(from json: [String: Any]) -> [String: Any]? { + if let result = json["result"] as? [String: Any] { + return result + } + if let results = json["result"] as? [[String: Any]] { + return results.first + } + if json["text"] as? String != nil { + return json + } + return nil + } +} + +private struct VolcengineCredentials { + let appID: String + let accessToken: String + let resourceID: String + + static func parse(apiKey: String, fallbackResourceID: String) throws -> VolcengineCredentials { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw CloudASRError.noAPIKey } + + if let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let appID = string(json, keys: ["app_id", "appId", "appid"]) + let token = string(json, keys: ["access_token", "accessToken", "token"]) + let resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) + ?? fallbackResourceID + guard let appID, let token, !resourceID.isEmpty else { throw CloudASRError.noAPIKey } + return VolcengineCredentials(appID: appID, accessToken: token, resourceID: resourceID) + } + + let separators = CharacterSet(charactersIn: ":\n,") + let parts = trimmed + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard parts.count >= 2 else { throw CloudASRError.noAPIKey } + let resourceID = parts.count >= 3 ? parts[2] : fallbackResourceID + return VolcengineCredentials(appID: parts[0], accessToken: parts[1], resourceID: resourceID) + } + + private static func string(_ json: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = json[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return nil + } +} + +private enum VolcengineMessageType: UInt8 { + case fullClientRequest = 0b0001 + case audioOnlyRequest = 0b0010 + case fullServerResponse = 0b1001 + case errorMessage = 0b1111 +} + +private enum VolcengineFlags: UInt8 { + case none = 0b0000 + case positiveSequence = 0b0001 + case lastPacket = 0b0010 + case negativeSequence = 0b0011 +} + +private enum VolcengineSerialization: UInt8 { + case none = 0b0000 + case json = 0b0001 +} + +private struct VolcengineFrame { + let messageType: VolcengineMessageType? + let flags: UInt8 + let sequence: Int32? + let errorCode: UInt32? + let payload: Data + + var isFinal: Bool { + flags == VolcengineFlags.lastPacket.rawValue + || flags == VolcengineFlags.negativeSequence.rawValue + || (sequence ?? 0) < 0 + } + + static func build( + messageType: VolcengineMessageType, + flags: VolcengineFlags, + serialization: VolcengineSerialization, + payload: Data, + sequence: Int32? + ) -> Data { + var data = Data() + data.append(0x11) + data.append((messageType.rawValue << 4) | flags.rawValue) + data.append(serialization.rawValue << 4) + data.append(0x00) + + if flags == .positiveSequence || flags == .negativeSequence, let sequence { + data.appendBE32(UInt32(bitPattern: sequence)) + } + data.appendBE32(UInt32(payload.count)) + data.append(payload) + return data + } + + static func parse(_ data: Data) -> VolcengineFrame? { + guard data.count >= 8 else { return nil } + let bytes = [UInt8](data) + let headerSize = Int(bytes[0] & 0x0F) * 4 + guard headerSize >= 4, data.count >= headerSize + 4 else { return nil } + + let typeRaw = (bytes[1] >> 4) & 0x0F + let messageType = VolcengineMessageType(rawValue: typeRaw) + let flags = bytes[1] & 0x0F + let compression = bytes[2] & 0x0F + guard compression == 0 else { return nil } + + var offset = headerSize + var sequence: Int32? + if flags == VolcengineFlags.positiveSequence.rawValue + || flags == VolcengineFlags.negativeSequence.rawValue { + guard let value = data.readBE32(at: offset) else { return nil } + sequence = Int32(bitPattern: value) + offset += 4 + } + + if messageType == .errorMessage { + guard let code = data.readBE32(at: offset), + let size = data.readBE32(at: offset + 4) else { return nil } + offset += 8 + guard data.count >= offset + Int(size) else { return nil } + return VolcengineFrame( + messageType: messageType, + flags: flags, + sequence: sequence, + errorCode: code, + payload: data.subdata(in: offset..<(offset + Int(size))) + ) + } + + guard let size = data.readBE32(at: offset) else { return nil } + offset += 4 + guard data.count >= offset + Int(size) else { return nil } + return VolcengineFrame( + messageType: messageType, + flags: flags, + sequence: sequence, + errorCode: nil, + payload: data.subdata(in: offset..<(offset + Int(size))) + ) + } +} + +private extension Data { + mutating func appendBE32(_ value: UInt32) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) } + } + + func readBE32(at offset: Int) -> UInt32? { + guard count >= offset + 4 else { return nil } + return self[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } +} diff --git a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift index f8ef8ad..250110e 100644 --- a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift +++ b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift @@ -93,7 +93,12 @@ public final class AppCloudSync { let store = makeStore() ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store) - let toggles = ICloudSyncPreferences.load(from: kvs, store: store) + var toggles = ICloudSyncPreferences.load(from: kvs, store: store) + // Merged UI toggle: settings sync implies dictionary sync. + if toggles.settings, !toggles.dictionary { + ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs) + toggles.dictionary = true + } ICloudSyncPreferences.cacheToAppGroup( settingsEnabled: toggles.settings, dictionaryEnabled: toggles.dictionary, diff --git a/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift index 5a5883c..9573bc4 100644 --- a/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift +++ b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift @@ -62,9 +62,10 @@ public final class SettingsCloudSync { public func enableSync() async throws { let store = makeStore() ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs) + ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs) ICloudSyncPreferences.cacheToAppGroup( settingsEnabled: true, - dictionaryEnabled: store.personalDictionaryICloudSyncEnabled, + dictionaryEnabled: true, store: store ) @@ -93,7 +94,9 @@ public final class SettingsCloudSync { public func disableSync() { let store = makeStore() ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs) + ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs) store.setSettingsICloudSyncEnabled(false) + store.setPersonalDictionaryICloudSyncEnabled(false) } public func pullAndMerge(store: AppGroupStore) async { diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 812e520..8d4b3b4 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -107,8 +107,11 @@ public final class KeyboardState: ObservableObject { /// Defaults to `offLocaleId` so the keyboard boots in the "off" /// state on first install. @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId - /// Mirrored from App Group — swaps delete / return on the bottom row. + /// Mirrored from App Group — swaps delete / space on the bottom row. @Published public var handednessPreference: HandednessPreference = .left + /// Mirrors the host field's return-key intent. The action stays a newline + /// insert; host apps decide whether that submits or creates a line break. + @Published public var returnKeyRole: ReturnKeyRole = .newline /// Press-and-drag pads beside the mic for four-way caret movement. @Published public var cursorDragNavigationEnabled: Bool = true /// `true` while a cursor-drag pad is being pressed — drives the hint @@ -151,6 +154,18 @@ public final class KeyboardState: ObservableObject { case openSettings } + public enum ReturnKeyRole: Equatable { + case newline + case send + + public var titleKey: String { + switch self { + case .newline: return "common.newline" + case .send: return "common.send" + } + } + } + // MARK: - Temporary Flow debug (remove after orange-mic investigation) /// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel. diff --git a/OSGKeyboardShared/Services/LLMClient.swift b/OSGKeyboardShared/Services/LLMClient.swift index fc78309..59e980b 100644 --- a/OSGKeyboardShared/Services/LLMClient.swift +++ b/OSGKeyboardShared/Services/LLMClient.swift @@ -61,6 +61,8 @@ public struct OpenAICompatibleClient: LLMClient { public let baseURL: String public let apiKey: String public let model: String + public let providerId: String + public let thinkingEnabled: Bool public let session: URLSession /// Canonical request timeout for a single LLM HTTP round-trip. Both @@ -73,11 +75,15 @@ public struct OpenAICompatibleClient: LLMClient { baseURL: String, apiKey: String, model: String, + providerId: String = "", + thinkingEnabled: Bool = false, session: URLSession = .shared ) { self.baseURL = baseURL self.apiKey = apiKey self.model = model + self.providerId = providerId + self.thinkingEnabled = thinkingEnabled self.session = session } @@ -107,8 +113,13 @@ public struct OpenAICompatibleClient: LLMClient { // the baseline when the caller does not supply one. req.timeoutInterval = timeout ?? requestTimeout - let encoder = JSONEncoder() - req.httpBody = try encoder.encode(request) + req.httpBody = try Self.encodedBody( + request, + providerId: providerId, + baseURL: baseURL, + model: model, + thinkingEnabled: thinkingEnabled + ) do { let (data, response) = try await session.data(for: req) @@ -140,6 +151,27 @@ public struct OpenAICompatibleClient: LLMClient { throw LLMError.transport(String(describing: error)) } } + + private static func encodedBody( + _ request: LLMRequest, + providerId: String, + baseURL: String, + model: String, + thinkingEnabled: Bool + ) throws -> Data { + let encoded = try JSONEncoder().encode(request) + guard var body = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { + return encoded + } + LLMThinkingControl.apply( + to: &body, + providerId: providerId, + baseURL: baseURL, + model: model, + enabled: thinkingEnabled + ) + return try JSONSerialization.data(withJSONObject: body) + } } // MARK: - Factory @@ -147,13 +179,51 @@ public struct OpenAICompatibleClient: LLMClient { public enum LLMClientFactory { /// Build a client from the current `ProviderConfig`. public static func make(from config: ProviderConfig) -> LLMClient { - OpenAICompatibleClient( + make( + providerId: config.providerId, baseURL: config.baseURL, apiKey: config.apiKey, - model: config.model + model: config.model, + thinkingEnabled: config.llmThinkingEnabled ) } + /// Provider-aware factory used by `PolishingService`. + public static func make( + providerId: String, + baseURL: String, + apiKey: String, + model: String, + thinkingEnabled: Bool = false, + session: URLSession = .shared + ) -> LLMClient { + switch providerId { + case "anthropic": + return AnthropicMessagesClient(apiKey: apiKey, model: model, session: session) + default: + let resolvedBase = resolvedOpenAICompatibleBaseURL(providerId: providerId, baseURL: baseURL) + return OpenAICompatibleClient( + baseURL: resolvedBase, + apiKey: apiKey, + model: model, + providerId: providerId, + thinkingEnabled: thinkingEnabled, + session: session + ) + } + } + + /// Gemini exposes an OpenAI-compatible shim under `/v1beta/openai`. + private static func resolvedOpenAICompatibleBaseURL(providerId: String, baseURL: String) -> String { + if !baseURL.isEmpty { return baseURL } + switch providerId { + case "gemini": + return "https://generativelanguage.googleapis.com/v1beta/openai" + default: + return baseURL + } + } + /// Single source of truth for the LLM request timeout, shared by /// `LLMClient.requestTimeout` implementations and any caller that /// wants to bound total time spent waiting on the LLM (e.g. @@ -163,3 +233,100 @@ public enum LLMClientFactory { OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout } } + +// MARK: - Provider-specific thinking controls +// +// Cloud polish defaults to thinking OFF (`llmThinkingEnabled == false`). +// DeepSeek V4 thinking defaults to *enabled* server-side, so we must send an +// explicit `thinking: { type: "disabled" }` — merely omitting the field (or +// sending `reasoning_effort: "low"`, which DeepSeek maps to `high`) leaves +// CoT on and makes polish appear stuck. + +enum LLMThinkingControl { + static func apply( + to body: inout [String: Any], + providerId: String, + baseURL: String, + model: String, + enabled: Bool + ) { + switch control(providerId: providerId, baseURL: baseURL, model: model) { + case .deepSeek: + // Official toggle; do not send reasoning_effort when disabled — + // DeepSeek maps low/medium → high while thinking stays on. + body["thinking"] = ["type": enabled ? "enabled" : "disabled"] + if enabled { + body["reasoning_effort"] = "high" + } else { + body.removeValue(forKey: "reasoning_effort") + } + case .miniMax: + body["thinking"] = ["type": enabled ? "adaptive" : "disabled"] + case .gemini: + body["thinking_config"] = [ + "thinking_budget": enabled ? -1 : 0 + ] + case .openAIReasoning: + // o-series / gpt-5: only touch the field when the user opts in, + // or when disabling an always-on reasoner with the lowest effort. + if enabled { + body["reasoning_effort"] = "medium" + } else { + body["reasoning_effort"] = "low" + } + case .none: + return + } + } + + private enum Control { + /// DeepSeek / Ark: explicit thinking type toggle. + case deepSeek + case miniMax + case gemini + case openAIReasoning + } + + private static func control( + providerId: String, + baseURL: String, + model: String + ) -> Control? { + switch providerId { + case "deepseek", "ark": + return .deepSeek + case "minimax": + return .miniMax + case "gemini": + return .gemini + case "openai": + return isOpenAIReasoningModel(model) ? .openAIReasoning : nil + default: + return control(baseURL: baseURL, model: model) + } + } + + private static func control(baseURL: String, model: String) -> Control? { + let lower = baseURL.lowercased() + if lower.contains("minimax") || lower.contains("minimaxi") { + return .miniMax + } + if lower.contains("generativelanguage.googleapis.com") { + return .gemini + } + // Hosted DeepSeek (SiliconFlow / OpenRouter / custom proxies). + if lower.contains("deepseek") || model.lowercased().contains("deepseek") { + return .deepSeek + } + return nil + } + + private static func isOpenAIReasoningModel(_ model: String) -> Bool { + let lower = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lower.hasPrefix("o1") + || lower.hasPrefix("o3") + || lower.hasPrefix("o4") + || lower.hasPrefix("gpt-5") + || lower.contains("reasoning") + } +} diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 53071fa..29f9cbc 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -170,7 +170,13 @@ public actor PolishingService { } else { apiKey = store.apiKey } - client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model) + client = LLMClientFactory.make( + providerId: effectiveProviderId, + baseURL: baseURL, + apiKey: apiKey, + model: model, + thinkingEnabled: store.llmThinkingEnabled + ) } let prompt: String @@ -350,7 +356,7 @@ public actor PolishingService { private func shouldUseChineseGuidance(providerId: String) -> Bool { switch providerId { - case "zhipu", "moonshot", "qwen", "deepseek": + case "zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo": return true default: return false @@ -391,7 +397,7 @@ public actor PolishingService { PreconfiguredKeys.isDeepseekConfigured { return "deepseek" } - return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id + return id } internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool { diff --git a/OSGKeyboardShared/Services/ProviderModelService.swift b/OSGKeyboardShared/Services/ProviderModelService.swift new file mode 100644 index 0000000..3b1808f --- /dev/null +++ b/OSGKeyboardShared/Services/ProviderModelService.swift @@ -0,0 +1,182 @@ +// ProviderModelService.swift +// OSGKeyboard · Shared +// +// Lightweight provider tools used by Settings to validate endpoints and fetch +// model ids without coupling the UI to each vendor's response shape. + +import Foundation + +public enum ProviderModelServiceError: Error, LocalizedError, Sendable { + case invalidURL + case missingAPIKey + case http(Int) + case empty + case decoding + case transport(String) + + public var errorDescription: String? { + switch self { + case .invalidURL: + return SharedL10n.string("providerTools.error.invalidURL") + case .missingAPIKey: + return SharedL10n.string("providerTools.error.missingAPIKey") + case .http(let status): + return SharedL10n.format("providerTools.error.http", status) + case .empty: + return SharedL10n.string("providerTools.error.empty") + case .decoding: + return SharedL10n.string("providerTools.error.decoding") + case .transport: + return SharedL10n.string("providerTools.error.transport") + } + } +} + +public enum ProviderModelService { + public static func listLLMModels( + providerId: String, + baseURL: String, + apiKey: String, + currentModel: String, + session: URLSession = .shared + ) async throws -> [String] { + if providerId == "anthropic" { + return try await fetchModels( + baseURL: "https://api.anthropic.com/v1", + apiKey: apiKey, + authorization: .anthropic, + session: session + ) + } + return try await fetchModels( + baseURL: resolvedLLMBaseURL(providerId: providerId, baseURL: baseURL), + apiKey: apiKey, + authorization: .bearer, + session: session, + fallback: currentModel + ) + } + + public static func listASRModels( + providerId: String, + baseURL: String, + apiKey: String, + currentModel: String, + session: URLSession = .shared + ) async throws -> [String] { + switch CloudASRModelCatalog.strategy(for: providerId) { + case .volcengineStreaming, .bailianStreaming: + return singleModel(currentModel, fallback: CloudASRModelCatalog.defaultModel(for: providerId)) + case .localFallback: + return [] + case .prompt, .openRouterJson, .zhipuHotwords: + return try await fetchModels( + baseURL: baseURL.isEmpty ? LLMProvider.provider(id: providerId).defaultBaseURL : baseURL, + apiKey: apiKey, + authorization: .bearer, + session: session, + fallback: currentModel + ) + } + } + + private enum Authorization { + case bearer + case anthropic + } + + private static func fetchModels( + baseURL: String, + apiKey: String, + authorization: Authorization, + session: URLSession, + fallback: String = "" + ) async throws -> [String] { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ProviderModelServiceError.missingAPIKey + } + guard let url = URL(string: modelsEndpoint(baseURL: baseURL)) else { + throw ProviderModelServiceError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 12 + request.setValue("application/json", forHTTPHeaderField: "Accept") + switch authorization { + case .bearer: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .anthropic: + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ProviderModelServiceError.transport("non-HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw ProviderModelServiceError.http(http.statusCode) + } + let models = try parseModels(from: data) + let resolved = models.isEmpty ? singleModel(fallback, fallback: "") : models + guard !resolved.isEmpty else { throw ProviderModelServiceError.empty } + return resolved + } catch let error as ProviderModelServiceError { + throw error + } catch { + throw ProviderModelServiceError.transport(String(describing: error)) + } + } + + private static func parseModels(from data: Data) throws -> [String] { + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProviderModelServiceError.decoding + } + if let data = root["data"] as? [[String: Any]] { + return normalize(data.compactMap { $0["id"] as? String ?? $0["name"] as? String }) + } + if let models = root["models"] as? [[String: Any]] { + return normalize(models.compactMap { $0["id"] as? String ?? $0["name"] as? String }) + } + if let models = root["models"] as? [String] { + return normalize(models) + } + return [] + } + + private static func normalize(_ models: [String]) -> [String] { + var seen = Set() + return models + .map { model in + model + .replacingOccurrences(of: "models/", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + .filter { !$0.isEmpty } + .filter { seen.insert($0).inserted } + .sorted() + } + + private static func modelsEndpoint(baseURL: String) -> String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix("/models") { return trimmed } + return trimmed.hasSuffix("/") ? "\(trimmed)models" : "\(trimmed)/models" + } + + private static func resolvedLLMBaseURL(providerId: String, baseURL: String) -> String { + if !baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return baseURL } + if providerId == "gemini" { + return "https://generativelanguage.googleapis.com/v1beta/openai" + } + return LLMProvider.provider(id: providerId).defaultBaseURL + } + + private static func singleModel(_ model: String, fallback: String) -> [String] { + let resolved = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? fallback + : model + return resolved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [resolved] + } +} diff --git a/OSGKeyboardShared/Services/ProviderToolRunnerState.swift b/OSGKeyboardShared/Services/ProviderToolRunnerState.swift new file mode 100644 index 0000000..c696c49 --- /dev/null +++ b/OSGKeyboardShared/Services/ProviderToolRunnerState.swift @@ -0,0 +1,130 @@ +// ProviderToolRunnerState.swift +// OSGKeyboard · Shared +// +// Pure state machine for Settings provider tool rows (validate / fetch models). + +import Foundation + +public struct ProviderToolRunnerState: Equatable, Sendable { + public var isRunning: Bool + public var message: String? + public var failed: Bool + public var models: [String] + + public init( + isRunning: Bool = false, + message: String? = nil, + failed: Bool = false, + models: [String] = [] + ) { + self.isRunning = isRunning + self.message = message + self.failed = failed + self.models = models + } +} + +@MainActor +public enum ProviderToolRunner { + public static func runValidate( + runningMessage: String, + successMessage: String, + validate: () async throws -> Void + ) async -> ProviderToolRunnerState { + var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false) + do { + try await validate() + state.isRunning = false + state.message = successMessage + state.failed = false + } catch { + state.isRunning = false + state.failed = true + state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } + return state + } + + public static func runFetchModels( + runningMessage: String, + loadedMessage: (Int) -> String, + emptyMessage: String, + currentModel: String, + fetchModels: () async throws -> [String] + ) async -> (state: ProviderToolRunnerState, selectedModel: String?) { + var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false) + do { + let fetched = try await fetchModels() + guard !fetched.isEmpty else { + state.isRunning = false + state.failed = true + state.message = emptyMessage + state.models = [] + return (state, nil) + } + + var resolved = fetched + let trimmed = currentModel.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, !resolved.contains(trimmed) { + resolved.insert(trimmed, at: 0) + } + state.models = resolved + state.isRunning = false + state.failed = false + state.message = loadedMessage(resolved.count) + + let selected: String? + if trimmed.isEmpty, let first = resolved.first { + selected = first + } else { + selected = nil + } + return (state, selected) + } catch { + state.isRunning = false + state.failed = true + state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)" + state.models = [] + return (state, nil) + } + } +} + +public enum HardTimeout { + /// Returns the first completed result; the losing task is cancelled. + public static func run( + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw CancellationError() + } + guard let result = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + return result + } + } + + /// Non-throwing variant for tasks that should fall back when time elapses. + public static func value( + seconds: TimeInterval, + operation: @escaping @Sendable () async -> T, + onTimeout: @escaping @Sendable () -> T + ) async -> T { + await withTaskGroup(of: T.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return onTimeout() + } + let result = await group.next() ?? onTimeout() + group.cancelAll() + return result + } + } +} diff --git a/OSGKeyboardShared/Services/UsageStatisticsStore.swift b/OSGKeyboardShared/Services/UsageStatisticsStore.swift index 75ed0af..e178446 100644 --- a/OSGKeyboardShared/Services/UsageStatisticsStore.swift +++ b/OSGKeyboardShared/Services/UsageStatisticsStore.swift @@ -14,6 +14,13 @@ public final class UsageStatisticsStore: ObservableObject { @Published public private(set) var dictationDurationSeconds: TimeInterval = 0 @Published public private(set) var dictationCharacterCount: Int = 0 @Published public private(set) var translationCharacterCount: Int = 0 + /// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by + /// the home page's 7-day chart. + @Published public private(set) var dailyDictationCharacters: [String: Int] = [:] + + /// How many days of daily buckets to retain on disk. Well beyond the 7-day + /// chart window so a device that syncs in late still contributes recent days. + private static let dailyRetentionDays = 90 public let defaults: UserDefaults @@ -53,6 +60,9 @@ public final class UsageStatisticsStore: ObservableObject { slice.translationCharacterCount += count } else { slice.dictationCharacterCount += count + let dayKey = UsageStatisticsDayKey.key(for: Date()) + slice.dailyDictationCharacters[dayKey, default: 0] += count + UsageStatisticsDayKey.prune(&slice.dailyDictationCharacters, keepingDays: Self.dailyRetentionDays) } slice.dictationDurationSeconds += max(0, duration) slice.updatedAt = Date() @@ -69,10 +79,42 @@ public final class UsageStatisticsStore: ObservableObject { /// aggregated cross-device sum and NEVER writes it back (writing would /// corrupt the per-device slices — see `recordUtterance`). public func reloadFromDisk() { - let aggregated = SyncedUsageStatisticsStorage.load(from: defaults).aggregated + let payload = SyncedUsageStatisticsStorage.load(from: defaults) + let aggregated = payload.aggregated dictationDurationSeconds = aggregated.dictationDurationSeconds dictationCharacterCount = aggregated.dictationCharacterCount translationCharacterCount = aggregated.translationCharacterCount + dailyDictationCharacters = payload.aggregatedDailyDictationCharacters + } + + // MARK: - 7-day chart data + + /// One day's dictation total for the home page chart. + public struct DailyUsagePoint: Identifiable, Equatable, Sendable { + public let date: Date + public let value: Int + public var id: Date { date } + } + + /// The trailing 7 local days (oldest → newest), zero-filled for days with no + /// dictation, so the chart always renders a full week. + public var last7Days: [DailyUsagePoint] { + Self.last7Days(from: dailyDictationCharacters) + } + + public static func last7Days( + from daily: [String: Int], + now: Date = Date(), + calendar: Calendar = .current + ) -> [DailyUsagePoint] { + let startOfToday = calendar.startOfDay(for: now) + var points: [DailyUsagePoint] = [] + for offset in stride(from: 6, through: 0, by: -1) { + guard let day = calendar.date(byAdding: .day, value: -offset, to: startOfToday) else { continue } + let key = UsageStatisticsDayKey.key(for: day, calendar: calendar) + points.append(DailyUsagePoint(date: day, value: daily[key] ?? 0)) + } + return points } /// One-time cleanup: the pre-fix code overwrote a device slice with the diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index 8ab9054..ed6a91d 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -11,11 +11,27 @@ /* LLM providers */ "provider.openai" = "OpenAI"; +"provider.ark" = "Volcengine Ark"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "Qwen (DashScope)"; "provider.zhipu" = "Zhipu GLM"; "provider.moonshot" = "Moonshot"; +"provider.siliconflow" = "SiliconFlow"; +"provider.groq" = "Groq"; +"provider.minimax" = "MiniMax"; "provider.mimo" = "Xiaomi MiMo"; +"provider.openrouter" = "OpenRouter"; +"provider.gemini" = "Google Gemini"; +"provider.anthropic" = "Anthropic Claude"; +"provider.xai" = "xAI Grok"; +"provider.mistral" = "Mistral AI"; +"provider.cometapi" = "CometAPI"; +"provider.alibabaCoding" = "Alibaba Coding"; +"provider.codingPlanX" = "CodingPlanX"; +"provider.volcengine" = "Volcengine ASR"; +"provider.bailian" = "Bailian Realtime ASR"; +"provider.whisper" = "Whisper (OpenAI)"; +"provider.codex_oauth" = "Codex OAuth"; "provider.custom" = "Custom"; /* LLM errors */ @@ -44,6 +60,15 @@ "error.cloudASR.emptyTranscript" = "Cloud ASR returned an empty transcript."; "error.cloudASR.audioTooLong" = "Audio segment is too long for this cloud ASR provider."; "error.cloudASR.providerUnsupported" = "This provider does not support cloud speech recognition yet."; +"error.cloudASR.streamingNotImplemented" = "This provider requires streaming ASR (WebSocket), which is not available in this build yet. Try Qwen, Zhipu, Groq, or OpenAI."; + +/* Provider tools */ +"providerTools.error.invalidURL" = "Invalid model endpoint."; +"providerTools.error.missingAPIKey" = "API Key is missing."; +"providerTools.error.http" = "Model endpoint returned HTTP %lld."; +"providerTools.error.empty" = "No models returned."; +"providerTools.error.decoding" = "Failed to parse model list."; +"providerTools.error.transport" = "Network error while loading models."; /* Polish scenarios */ "polishScenario.daily_chat" = "Daily Chat"; @@ -120,14 +145,17 @@ "mac.status.pasted" = "Inserted into front app"; "mac.status.copiedAndPasted" = "Copied and inserted"; "mac.status.deliveryWithNote" = "%@ — %@"; -"mac.stat.dictationTime" = "Dictation Time"; -"mac.stat.words" = "Dictation Chars"; -"mac.stat.translation" = "Translation Chars"; -"mac.stat.dictionary" = "Dictionary"; -"mac.stat.cumulativeDuration" = "Total time"; -"mac.stat.transcribed" = "Transcribed"; -"mac.stat.cumulativeTranslation" = "Translated"; -"mac.stat.customTerms" = "Custom terms"; +"stat.dictationTime" = "Dictation Time"; +"stat.words" = "Dictation Chars"; +"stat.translation" = "Translation Chars"; +"stat.dictionary" = "Dictionary"; +"stat.cumulativeDuration" = "Total time"; +"stat.transcribed" = "Transcribed"; +"stat.cumulativeTranslation" = "Translated"; +"stat.customTerms" = "Custom terms"; +"stat.weekChart.title" = "Last 7 days"; +"stat.weekChart.caption" = "Dictation chars"; +"stat.weekChart.empty" = "No dictation yet this week"; "mac.status.chipReady" = "Ready"; "mac.status.chipProcessing" = "Processing"; "mac.overlay.listening" = "Listening"; @@ -173,6 +201,25 @@ "mac.settings.service" = "Service"; "mac.settings.apiKey" = "API Key"; "mac.settings.model" = "Model"; +"mac.settings.modelFetchHint" = "Enter an API Key, then fetch models."; +"mac.settings.connectionCheck" = "Connection check"; +"mac.settings.validate" = "Validate"; +"mac.settings.fetchModels" = "Fetch models"; +"mac.settings.validating" = "Validating…"; +"mac.settings.validateSuccess" = "Success · Retry"; +"mac.settings.validateFailure" = "Failed · Retry"; +"mac.settings.loadingModels" = "Loading models…"; +"mac.settings.modelsLoaded" = "%lld models loaded."; +"mac.settings.selectModel" = "Select model"; +"mac.settings.modelSelected" = "Selected %@"; +"mac.settings.modelsEmptyHint" = "Tap refresh to load models"; +"mac.settings.thinking" = "Thinking"; +"mac.settings.thinkingSubtitle" = "Slower, higher quality — recommended off"; +"mac.settings.thinkingHint" = "Off by default. Enable only for slower, deeper reasoning."; +"mac.settings.volcengineAppId" = "APP ID"; +"mac.settings.volcengineAccessToken" = "Access Token"; +"mac.settings.volcengineResourceId" = "Resource ID"; +"mac.settings.volcengineNote" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration."; "mac.settings.recognition" = "RECOGNITION METHOD"; "mac.settings.cloudEngine" = "Cloud Engine & AI Refinement"; "mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing."; @@ -209,7 +256,7 @@ "mac.settings.mlxModelMissing" = "Select a Qwen3 MLX model folder below, or choose another installed model."; "mac.settings.selectedModelMissing" = "%@ is not installed — using Apple Speech for now."; "mac.settings.localModelFallbackApple" = "No local model is ready — using Apple Speech for now."; -"mac.settings.accessibility" = "Accessibility"; +"mac.settings.accessibility" = "Accessibility Permission"; "mac.settings.accessibilityDesc" = "Required for global shortcut and auto-paste."; "mac.settings.openAccessibility" = "Open System Settings"; "mac.settings.appearance" = "Appearance"; @@ -300,8 +347,8 @@ "mac.localASR.phase.completed" = "Completed"; "mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings"; "mac.foregroundApp" = "Front app: %@"; -"mac.sync.settingsTitle" = "iCloud Sync"; -"mac.sync.settingsSubtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud."; +"mac.sync.settingsTitle" = "Cross-Device iCloud Sync"; +"mac.sync.settingsSubtitle" = "Sync settings, history, and API keys across devices via iCloud."; "mac.sync.syncNow" = "Sync Now"; "mac.sync.dictTitle" = "Personal dictionary iCloud sync"; "mac.sync.dictSubtitle" = "Keep your dictionary in sync across all devices."; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index 9941628..1f7731b 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -11,11 +11,27 @@ /* LLM providers */ "provider.openai" = "OpenAI"; +"provider.ark" = "火山方舟 Ark"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "通义千问"; "provider.zhipu" = "智谱 GLM"; "provider.moonshot" = "月之暗面"; +"provider.siliconflow" = "硅基流动"; +"provider.groq" = "Groq"; +"provider.minimax" = "MiniMax"; "provider.mimo" = "小米 MiMo"; +"provider.openrouter" = "OpenRouter"; +"provider.gemini" = "Google Gemini"; +"provider.anthropic" = "Anthropic Claude"; +"provider.xai" = "xAI Grok"; +"provider.mistral" = "Mistral AI"; +"provider.cometapi" = "CometAPI"; +"provider.alibabaCoding" = "阿里 Coding"; +"provider.codingPlanX" = "CodingPlanX"; +"provider.volcengine" = "火山引擎 ASR"; +"provider.bailian" = "百炼实时 ASR"; +"provider.whisper" = "Whisper (OpenAI)"; +"provider.codex_oauth" = "Codex OAuth"; "provider.custom" = "自定义"; /* LLM errors */ @@ -44,6 +60,15 @@ "error.cloudASR.emptyTranscript" = "云端识别返回了空文本。"; "error.cloudASR.audioTooLong" = "音频片段超过该云端识别服务的时长限制。"; "error.cloudASR.providerUnsupported" = "该服务商暂不支持云端语音识别。"; +"error.cloudASR.streamingNotImplemented" = "该服务商需要流式 ASR(WebSocket),当前版本尚未接入。可改用通义、智谱、Groq 或 OpenAI。"; + +/* 服务商工具 */ +"providerTools.error.invalidURL" = "模型接口地址无效。"; +"providerTools.error.missingAPIKey" = "未填写 API Key。"; +"providerTools.error.http" = "模型接口返回 HTTP %lld。"; +"providerTools.error.empty" = "未返回可用模型。"; +"providerTools.error.decoding" = "解析模型列表失败。"; +"providerTools.error.transport" = "拉取模型时发生网络错误。"; /* 润色场景 */ "polishScenario.daily_chat" = "日常聊天"; @@ -120,14 +145,17 @@ "mac.status.pasted" = "已插入前台应用"; "mac.status.copiedAndPasted" = "已复制并插入"; "mac.status.deliveryWithNote" = "%@ — %@"; -"mac.stat.dictationTime" = "听写时长"; -"mac.stat.words" = "听写字数"; -"mac.stat.translation" = "翻译字数"; -"mac.stat.dictionary" = "词库"; -"mac.stat.cumulativeDuration" = "累计时长"; -"mac.stat.transcribed" = "累计转写"; -"mac.stat.cumulativeTranslation" = "累计翻译"; -"mac.stat.customTerms" = "自定义词条"; +"stat.dictationTime" = "听写时长"; +"stat.words" = "听写字数"; +"stat.translation" = "翻译字数"; +"stat.dictionary" = "词库"; +"stat.cumulativeDuration" = "累计时长"; +"stat.transcribed" = "累计转写"; +"stat.cumulativeTranslation" = "累计翻译"; +"stat.customTerms" = "自定义词条"; +"stat.weekChart.title" = "近 7 天"; +"stat.weekChart.caption" = "听写字数"; +"stat.weekChart.empty" = "本周还没有听写记录"; "mac.status.chipReady" = "就绪"; "mac.status.chipProcessing" = "处理中"; "mac.overlay.listening" = "聆听中"; @@ -173,6 +201,25 @@ "mac.settings.service" = "服务商"; "mac.settings.apiKey" = "API 密钥"; "mac.settings.model" = "模型"; +"mac.settings.modelFetchHint" = "填写 API Key 后拉取模型。"; +"mac.settings.connectionCheck" = "连接检查"; +"mac.settings.validate" = "验证"; +"mac.settings.fetchModels" = "拉取模型"; +"mac.settings.validating" = "正在验证…"; +"mac.settings.validateSuccess" = "成功 · 重试"; +"mac.settings.validateFailure" = "失败 · 重试"; +"mac.settings.loadingModels" = "正在拉取模型…"; +"mac.settings.modelsLoaded" = "已拉取 %lld 个模型。"; +"mac.settings.selectModel" = "选择模型"; +"mac.settings.modelSelected" = "已选择 %@"; +"mac.settings.modelsEmptyHint" = "先点右侧刷新拉取模型"; +"mac.settings.thinking" = "思考"; +"mac.settings.thinkingSubtitle" = "速度更慢、质量更高,建议关闭"; +"mac.settings.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。"; +"mac.settings.volcengineAppId" = "APP ID"; +"mac.settings.volcengineAccessToken" = "Access Token"; +"mac.settings.volcengineResourceId" = "Resource ID"; +"mac.settings.volcengineNote" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。"; "mac.settings.recognition" = "识别方式"; "mac.settings.cloudEngine" = "云端引擎与 AI 润色"; "mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。"; @@ -209,7 +256,7 @@ "mac.settings.mlxModelMissing" = "请在下方选择 Qwen3 MLX 模型目录,或改用其他已安装的模型。"; "mac.settings.selectedModelMissing" = "「%@」尚未安装,暂时使用 Apple Speech。"; "mac.settings.localModelFallbackApple" = "没有可用的本地模型,暂时使用 Apple Speech。"; -"mac.settings.accessibility" = "辅助功能"; +"mac.settings.accessibility" = "辅助功能权限"; "mac.settings.accessibilityDesc" = "全局快捷键与自动粘贴需要此权限。"; "mac.settings.openAccessibility" = "打开系统设置"; "mac.settings.appearance" = "外观"; @@ -300,8 +347,8 @@ "mac.localASR.phase.completed" = "已完成"; "mac.error.accessibilityRequired" = "请在系统设置中为 OSGKeyboard 启用辅助功能"; "mac.foregroundApp" = "前台应用:%@"; -"mac.sync.settingsTitle" = "iCloud 同步"; -"mac.sync.settingsSubtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。"; +"mac.sync.settingsTitle" = "跨设备iCloud 同步"; +"mac.sync.settingsSubtitle" = "通过 iCloud 跨设备同步设置、历史记录、API Key"; "mac.sync.syncNow" = "立即同步"; "mac.sync.dictTitle" = "个人词库 iCloud 同步"; "mac.sync.dictSubtitle" = "在所有设备间同步个人词库。"; diff --git a/OSGKeyboardTests/AppGroupConfigurationTests.swift b/OSGKeyboardTests/AppGroupConfigurationTests.swift index 489a9c2..aa75a85 100644 --- a/OSGKeyboardTests/AppGroupConfigurationTests.swift +++ b/OSGKeyboardTests/AppGroupConfigurationTests.swift @@ -17,7 +17,8 @@ final class AppGroupConfigurationTests: XCTestCase { let defaults = makeDefaults() let config = AppGroupConfiguration.load(fromAvailable: defaults) - XCTAssertEqual(config.providerId, "openai") + XCTAssertEqual(config.providerId, "deepseek") + XCTAssertEqual(config.asrProviderId, "volcengine") XCTAssertEqual(config.modeId, "polish") XCTAssertEqual(config.localeId, "auto") // Privacy-critical: the default engine must keep audio on-device. @@ -125,14 +126,14 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertFalse(config.translationEnabled) } - func testCloudDeepSeekProviderMigratesToOpenAI() { + func testCloudDeepSeekProviderIsPreserved() { let defaults = makeDefaults() defaults.set("deepseek", forKey: AppGroupConfiguration.Keys.providerId) defaults.set("cloud", forKey: AppGroupConfiguration.Keys.engineMode) let config = AppGroupConfiguration.load(fromAvailable: defaults) - XCTAssertEqual(config.providerId, "openai") - XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.providerId), "openai") + XCTAssertEqual(config.providerId, "deepseek") + XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.providerId), "deepseek") } func testPolishIntensityLegacyOffMigratesToMedium() { diff --git a/OSGKeyboardTests/CloudASRTests.swift b/OSGKeyboardTests/CloudASRTests.swift index 22caed7..3e5f1a2 100644 --- a/OSGKeyboardTests/CloudASRTests.swift +++ b/OSGKeyboardTests/CloudASRTests.swift @@ -8,26 +8,122 @@ final class CloudASRTests: XCTestCase { func testCloudASRStrategyRouting() { XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords) - XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .alibabaVocabulary) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .localFallback) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "bailian"), .bailianStreaming) XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "whisper"), .prompt) XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "groq"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "siliconflow"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openrouter"), .openRouterJson) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "volcengine"), .volcengineStreaming) XCTAssertEqual(CloudASRModelCatalog.strategy(for: "moonshot"), .localFallback) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "ark"), .localFallback) } func testCloudASRModelDefaults() { - XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "qwen"), "fun-asr-flash-2026-06-15") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "bailian"), "fun-asr-realtime") XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "zhipu"), "glm-asr-2512") XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "mimo"), "mimo-v2.5-asr") XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-4o-mini-transcribe") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "whisper"), "whisper-1") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "groq"), "whisper-large-v3-turbo") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "siliconflow"), "FunAudioLLM/SenseVoiceSmall") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openrouter"), "openai/whisper-large-v3-turbo") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "volcengine"), "volc.seedasr.sauc.duration") + } + + func testAsrSelectablePresetsAllowlist() { + let ids = Set(LLMProvider.asrSelectablePresets.map(\.id)) + XCTAssertTrue(ids.contains("groq")) + XCTAssertTrue(ids.contains("siliconflow")) + XCTAssertTrue(ids.contains("openrouter")) + XCTAssertTrue(ids.contains("bailian")) + XCTAssertTrue(ids.contains("whisper")) + XCTAssertTrue(ids.contains("volcengine")) + XCTAssertFalse(ids.contains("qwen")) + XCTAssertFalse(ids.contains("moonshot")) + XCTAssertFalse(ids.contains("ark")) + XCTAssertFalse(ids.contains("anthropic")) + XCTAssertFalse(ids.contains("gemini")) + } + + func testPolishOnlyProvidersExcludedFromASRPicker() { + let polishIds = Set(LLMProvider.userSelectablePresets.map(\.id)) + let asrIds = Set(LLMProvider.asrSelectablePresets.map(\.id)) + XCTAssertTrue(polishIds.contains("ark")) + XCTAssertFalse(asrIds.contains("ark")) + XCTAssertTrue(polishIds.contains("gemini")) + XCTAssertFalse(asrIds.contains("gemini")) } func testPersonalDictionaryCloudASRBadgeProviders() { XCTAssertTrue(LLMProvider.provider(id: "zhipu").supportsPersonalDictionaryCloudASR) - XCTAssertTrue(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR) + XCTAssertFalse(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR) + XCTAssertFalse(LLMProvider.provider(id: "bailian").supportsPersonalDictionaryCloudASR) XCTAssertFalse(LLMProvider.provider(id: "openai").supportsPersonalDictionaryCloudASR) XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR) } + func testShowsASREndpointField() { + XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "bailian")) + XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "openai")) + XCTAssertFalse(CloudASRModelCatalog.showsASREndpointField(for: "qwen")) + XCTAssertFalse(CloudASRModelCatalog.showsASREndpointField(for: "volcengine")) + } + + func testBailianMergeSegmentsDedupesOverlap() { + let merged = BailianRealtimeASRClient.mergeSegments(["你好吗", "好吗我们"]) + XCTAssertEqual(merged, "你好吗我们") + } + + func testBailianRunTaskMessageIncludesModel() { + let json = BailianRealtimeASRClient.runTaskMessage( + taskID: "task-1", + model: "fun-asr-realtime", + vocabularyID: nil + ) + XCTAssertTrue(json.contains("fun-asr-realtime")) + XCTAssertTrue(json.contains("run-task")) + XCTAssertTrue(json.contains("\"format\":\"pcm\"") || json.contains("\"format\": \"pcm\"")) + XCTAssertTrue(json.contains("16000") || json.contains("16_000")) + } + + func testLegacyQwenASRConfigMigratesToBailian() { + let suite = "group.com.osgkeyboard.tests.qwen-asr.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set("qwen", forKey: AppGroupConfiguration.Keys.asrProviderId) + defaults.set("https://dashscope.aliyuncs.com/compatible-mode/v1", forKey: AppGroupConfiguration.Keys.asrBaseURL) + defaults.set("fun-asr-flash-2026-06-15", forKey: AppGroupConfiguration.Keys.asrModel) + + let config = AppGroupConfiguration.load(fromAvailable: defaults) + XCTAssertEqual(config.asrProviderId, "bailian") + XCTAssertEqual(config.asrBaseURL, CloudASRModelCatalog.bailianDefaultEndpoint) + XCTAssertEqual(config.asrModel, CloudASRModelCatalog.alibabaFunASRRealtime) + } + + func testVolcengineASRFieldsJSONParsing() { + let json = #"{"app_id":"app-1","access_token":"tok-2","resource_id":"res-3"}"# + let fields = VolcengineASRFields.parse(apiKey: json, resourceFallback: "") + XCTAssertEqual(fields.appID, "app-1") + XCTAssertEqual(fields.accessToken, "tok-2") + XCTAssertEqual(fields.resourceID, "res-3") + } + + func testVolcengineASRFieldsColonParsing() { + let fields = VolcengineASRFields.parse( + apiKey: "app-1:tok-2:res-3", + resourceFallback: CloudASRModelCatalog.defaultModel(for: "volcengine") + ) + XCTAssertEqual(fields.appID, "app-1") + XCTAssertEqual(fields.accessToken, "tok-2") + XCTAssertEqual(fields.resourceID, "res-3") + XCTAssertTrue(fields.encodedAPIKey.contains("app-1")) + } + func testPersonalDictionaryASRHotwordsDedupesTerms() { let dict = PersonalDictionary(entries: [ PersonalDictionary.Entry(term: "Kubernetes", category: .technical, source: .manual), diff --git a/OSGKeyboardTests/ConfigurationStoreTests.swift b/OSGKeyboardTests/ConfigurationStoreTests.swift index fcf0276..500f6a1 100644 --- a/OSGKeyboardTests/ConfigurationStoreTests.swift +++ b/OSGKeyboardTests/ConfigurationStoreTests.swift @@ -62,9 +62,9 @@ final class ConfigurationStoreTests: XCTestCase { XCTAssertTrue(asrClient is ZhipuCloudASRClient) } - func testLegacyInstallCopiesProviderIdToAsrProviderId() { + func testLegacyInstallCopiesProviderIdToAsrProviderIdThenMigratesQwen() { defaults.set("qwen", forKey: AppGroupConfiguration.Keys.providerId) let config = AppGroupConfiguration.load(fromAvailable: defaults) - XCTAssertEqual(config.asrProviderId, "qwen") + XCTAssertEqual(config.asrProviderId, "bailian") } } diff --git a/OSGKeyboardTests/FlowHandoffPolicyTests.swift b/OSGKeyboardTests/FlowHandoffPolicyTests.swift new file mode 100644 index 0000000..8f46371 --- /dev/null +++ b/OSGKeyboardTests/FlowHandoffPolicyTests.swift @@ -0,0 +1,322 @@ +// FlowHandoffPolicyTests.swift +// OSGKeyboardTests + +import XCTest +@testable import OSGKeyboardShared + +final class FlowHandoffPolicyTests: XCTestCase { + + // MARK: - shouldTreatHostAsAlive / shouldOpenHostColdStart + + func testAliveWhenSessionActiveAndReachable() { + XCTAssertTrue( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + ) + XCTAssertFalse( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + ) + } + + func testAliveWhenSessionActiveWithinReadyGrace() { + // Finalize race: ready flap but we were ready moments ago. + XCTAssertTrue( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: true + ) + ) + XCTAssertFalse( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: true + ) + ) + } + + func testAliveWhenSessionActiveEvenIfHeartbeatBrieflyStale() { + // Session flag still valid and not zombie → wait, do not jump. + XCTAssertTrue( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + XCTAssertFalse( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + } + + func testDeadWhenHostStale() { + XCTAssertFalse( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: false, + hostStale: true, + withinReadyGrace: true + ) + ) + XCTAssertTrue( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: false, + hostStale: true, + withinReadyGrace: true + ) + ) + } + + func testDeadWhenNoSession() { + XCTAssertFalse( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: false, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + XCTAssertTrue( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: false, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + } + + // MARK: - micPressAction + + func testMicPressReadyStartsRecording() { + let action = FlowHandoffPolicy.micPressAction( + availability: .ready, + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .startRecording) + } + + func testMicPressPreparingSessionWaitsAndRecords() { + // 0.5.2 regression: preparingSession must NOT open cold start. + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.preparingSession), + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + } + + func testMicPressHostNotReadyWithLiveSessionWaits() { + // User log scenario: finalize just completed, stale ready=false frame. + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: true + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + } + + func testMicPressHostNotReadyWithActiveSessionNoGraceStillWaits() { + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + } + + func testMicPressHostNotReadyWhenDeadOpensColdStart() { + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: false, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .openHostColdStart) + } + + func testMicPressHostNotReadyWhenStaleOpensColdStart() { + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: true, + hostReachable: false, + hostStale: true, + withinReadyGrace: false + ) + XCTAssertEqual(action, .openHostColdStart) + } + + func testMicPressBusyPhasesIgnored() { + XCTAssertEqual( + FlowHandoffPolicy.micPressAction( + availability: .recording, + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ), + .ignore + ) + XCTAssertEqual( + FlowHandoffPolicy.micPressAction( + availability: .processing, + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ), + .ignore + ) + } + + // MARK: - coldStartOverlayDecision + + func testOverlaySilencedWhenAlreadyReady() { + // User log: active=true hostReady=true coldStart=true → must silence. + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: true, + isUtteranceBusy: false + ), + .silence + ) + } + + func testOverlaySilencedWhenUtteranceBusy() { + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: false, + isUtteranceBusy: true + ), + .silence + ) + } + + func testOverlayPresentedForTrueColdStart() { + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: false, + hostIsReady: false, + isUtteranceBusy: false + ), + .present + ) + } + + func testOverlayPresentedWhenActiveButNeedsRecovery() { + // Engine hiccup: session up, not ready, not busy → preparing UI OK. + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: false, + isUtteranceBusy: false + ), + .present + ) + } + + // MARK: - debouncer + proactive launch flag + + func testProactiveAutoLaunchDisabled() { + XCTAssertFalse(FlowHandoffPolicy.allowsProactiveHostAutoLaunch) + } + + func testDebouncerIgnoresSingleDeadSample() { + var debouncer = FlowColdStartDebouncer() + XCTAssertFalse(debouncer.observe(hostTrulyDead: true)) + XCTAssertEqual(debouncer.consecutiveDeadSamples, 1) + XCTAssertTrue(debouncer.observe(hostTrulyDead: true)) + XCTAssertEqual(debouncer.consecutiveDeadSamples, 2) + } + + func testDebouncerResetsOnAliveSample() { + var debouncer = FlowColdStartDebouncer() + XCTAssertFalse(debouncer.observe(hostTrulyDead: true)) + XCTAssertFalse(debouncer.observe(hostTrulyDead: false)) + XCTAssertEqual(debouncer.consecutiveDeadSamples, 0) + XCTAssertFalse(debouncer.observe(hostTrulyDead: true)) + } + + func testDebouncerReset() { + var debouncer = FlowColdStartDebouncer() + _ = debouncer.observe(hostTrulyDead: true) + debouncer.reset() + XCTAssertEqual(debouncer.consecutiveDeadSamples, 0) + } + + /// Mirrors the user log: session still active after finalize, ready flap + /// must not justify startflow even when availability reads hostNotReady. + func testFinalizeRaceDoesNotOpenColdStart() { + let suite = "group.com.osgkeyboard.shared.tests.handoff.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + let sessionId = UUID() + FlowSessionBridge.markSessionActive(duration: 1_800, sessionId: sessionId, defaults: defaults) + FlowSessionBridge.writeReadySnapshot( + FlowReadySnapshot( + sessionId: sessionId, + ready: false, + reason: .processing, + engineMode: "local", + localeId: "zh-Hans", + busyUtteranceId: UUID(), + sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(defaults: defaults), + hostGeneration: FlowSessionBridge.currentHostGeneration(defaults: defaults) + ), + defaults: defaults + ) + + XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults)) + XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults)) + + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: FlowSessionBridge.isSessionActive(defaults: defaults), + hostReachable: FlowSessionBridge.isHostReachable(defaults: defaults), + hostStale: FlowSessionBridge.isHostStale(defaults: defaults), + withinReadyGrace: true + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: true, + isUtteranceBusy: false + ), + .silence + ) + } +} diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index a1be712..f476da6 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -347,6 +347,83 @@ final class LLMClientTests: XCTestCase { /// Local engine pins DeepSeek — cloud-provider URL/model in App Group /// must not leak into the LLM request (regression: Qwen URL + DeepSeek key → 401). + func testLLMClientFactoryRoutesAnthropic() { + let client = LLMClientFactory.make( + providerId: "anthropic", + baseURL: "", + apiKey: "sk-test", + model: "claude-sonnet-4-6" + ) + XCTAssertTrue(client is AnthropicMessagesClient) + } + + func testLLMClientFactoryResolvesGeminiOpenAICompatBaseURL() { + let client = LLMClientFactory.make( + providerId: "gemini", + baseURL: "", + apiKey: "key", + model: "gemini-2.5-flash" + ) as! OpenAICompatibleClient + XCTAssertEqual( + client.baseURL, + "https://generativelanguage.googleapis.com/v1beta/openai" + ) + } + + /// DeepSeek V4 thinking defaults ON server-side; polish must explicitly disable it. + func testDeepSeekPolishDisablesThinkingByDefault() { + var body: [String: Any] = ["model": "deepseek-v4-flash"] + LLMThinkingControl.apply( + to: &body, + providerId: "deepseek", + baseURL: "https://api.deepseek.com/v1", + model: "deepseek-v4-flash", + enabled: false + ) + let thinking = body["thinking"] as? [String: Any] + XCTAssertEqual(thinking?["type"] as? String, "disabled") + XCTAssertNil(body["reasoning_effort"], "disabled path must not send reasoning_effort (low→high on DeepSeek)") + } + + func testDeepSeekPolishEnablesThinkingWhenToggledOn() { + var body: [String: Any] = ["model": "deepseek-v4-flash"] + LLMThinkingControl.apply( + to: &body, + providerId: "deepseek", + baseURL: "https://api.deepseek.com/v1", + model: "deepseek-v4-flash", + enabled: true + ) + let thinking = body["thinking"] as? [String: Any] + XCTAssertEqual(thinking?["type"] as? String, "enabled") + XCTAssertEqual(body["reasoning_effort"] as? String, "high") + } + + /// Ordinary OpenAI chat models must not get thinking fields when the toggle is off. + func testOpenAIChatDoesNotInjectThinkingWhenDisabled() { + var body: [String: Any] = ["model": "gpt-4o-mini"] + LLMThinkingControl.apply( + to: &body, + providerId: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-4o-mini", + enabled: false + ) + XCTAssertNil(body["thinking"]) + XCTAssertNil(body["reasoning_effort"]) + XCTAssertNil(body["thinking_config"]) + } + + func testLLMThinkingDefaultIsOffInFreshConfiguration() { + let suiteName = "group.com.osgkeyboard.shared.tests.thinking.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let config = ProviderConfig(defaults: defaults) + XCTAssertFalse(config.llmThinkingEnabled, "cloud polish thinking must default off") + } + func testResolveLLMEndpointUsesPresetWhenProviderPinned() { let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! diff --git a/OSGKeyboardTests/SettingsCloudSyncTests.swift b/OSGKeyboardTests/SettingsCloudSyncTests.swift index a873890..50f12ef 100644 --- a/OSGKeyboardTests/SettingsCloudSyncTests.swift +++ b/OSGKeyboardTests/SettingsCloudSyncTests.swift @@ -60,6 +60,7 @@ final class SettingsCloudSyncTests: XCTestCase { handednessPreference: SyncedField(value: .left, updatedAt: stampA, deviceID: deviceA), cursorDragNavigationEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA), polishIntensity: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA), + llmThinkingEnabled: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA), flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA), flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA) ) @@ -79,6 +80,7 @@ final class SettingsCloudSyncTests: XCTestCase { handednessPreference: SyncedField(value: .right, updatedAt: stampB, deviceID: deviceB), cursorDragNavigationEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB), polishIntensity: SyncedField(value: .light, updatedAt: stampB, deviceID: deviceB), + llmThinkingEnabled: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB), flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB), flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB) ) @@ -129,6 +131,7 @@ final class SettingsCloudSyncTests: XCTestCase { try await settingsSync.enableSync() XCTAssertTrue(store.settingsICloudSyncEnabled) + XCTAssertTrue(store.personalDictionaryICloudSyncEnabled) XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: true), "sk-local-openai") } diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..1908ddc --- /dev/null +++ b/README.en.md @@ -0,0 +1,103 @@ +# OSGKeyboard + +**Speak it. It's typed.** + +Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands at your cursor. + +![Platform](https://img.shields.io/badge/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple) +![Platform](https://img.shields.io/badge/macOS-14%2B-555?logo=apple) +![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) +![Version](https://img.shields.io/badge/version-0.5.3-3aa05a) +![License](https://img.shields.io/badge/license-Source%20Available-blue) + +[Website](https://hkgood.github.io/OSGKeyboard/) · [中文 README](./README.md) · [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) + +--- + +## Why OSGKeyboard + +- **Works everywhere** — Messages, Notes, Notion, Cursor, Mail, WeChat — wherever you type +- **Speak, don't edit** — tap (iOS) or hold Option (Mac); AI adds punctuation and structure for you +- **On-device by default** — local recognition on iOS; optional local models on Mac. Cloud upload only when you opt in +- **Bring your own LLM** — built-in polish out of the box, or plug in DeepSeek, OpenAI, Anthropic, OpenRouter, and more +- **Mac global dictation** — menu-bar app, bottom overlay with live feedback, inserts into the frontmost app + +--- + +## Three steps + +1. **Install & authorize** — add the iOS keyboard with Full Access; grant mic + Accessibility on Mac +2. **Pick an engine** — local ASR + built-in polish (zero config), or your own API keys +3. **Start talking** — switch to OSGKeyboard, or hold Option on Mac + +--- + +## Platforms + +| | iOS / iPadOS | macOS | +|---|:---:|:---:| +| Keyboard / global hotkey | ✅ | ✅ hold Option | +| Local speech recognition | ✅ SpeechAnalyzer | ✅ SenseVoice / Qwen3 | +| AI polish | ✅ | ✅ | +| Post-polish translation | ✅ | ✅ | +| Personal dictionary | ✅ iCloud sync | ✅ | +| Dictation history | ✅ | ✅ | +| Live UI | ✅ Dynamic Island | ✅ floating pill | + +--- + +## Privacy + +Speech is transcribed on-device by default. Polish sends **text only** — not raw audio. We never log ordinary keystrokes. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/). + +--- + +## Build from source + +Requires macOS with **Xcode 26** and [XcodeGen](https://github.com/yonaskolb/XcodeGen). + +```bash +git clone https://github.com/hkgood/OSGKeyboard.git +cd OSGKeyboard +./Scripts/generate-xcodeproj.sh +open OSGKeyboard.xcodeproj +``` + +Run tests: + +```bash +xcodebuild test -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ + -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +--- + +## Architecture (brief) + +``` +OSGKeyboard/ Main iOS app (Flow session host) +OSGKeyboardExt/ Custom keyboard extension +OSGKeyboardMac/ macOS menu-bar app +OSGKeyboardShared/ Shared framework (ASR, LLM, sync, design system) +``` + +**Flow session model (iOS):** the host app keeps a long-lived audio session; the keyboard sends start/stop signals via App Group; polished text is delivered back for insertion. + +**Engine modes:** + +- `local` — on-device ASR; built-in polish (or your own LLM key) +- `cloud` — uploads audio to your configured ASR provider, then polishes via LLM + +See [CHANGELOG.md](./CHANGELOG.md) for release history and [CONTRIBUTING.md](./CONTRIBUTING.md) for PR guidelines. + +--- + +## Adding an LLM provider + +Append a preset in `OSGKeyboardShared/Models/LLMProvider.swift` — any OpenAI-compatible `/chat/completions` endpoint works out of the box. + +--- + +## License + +[Source Available License](./LICENSE) — personal, non-commercial use only. Commercial licensing: [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com). diff --git a/README.md b/README.md index 82f1a06..898f33d 100644 --- a/README.md +++ b/README.md @@ -1,217 +1,86 @@ # OSGKeyboard -> Tap to talk, tap to stop — AI-polished text appears at your cursor in any app. -> A source-available, custom-keyboard-based voice input tool for iOS 26+, inspired by [Typeless](https://typeless.com) and [OpenLess](https://github.com/Open-Less/openless). +**开口即文字。** -![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple) +在 iPhone、iPad 和 Mac 上,用说的代替打字。任意 App 里开口,润色好的文字直接落到光标处。 + +![Platform](https://img.shields.io/badge/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple) +![Platform](https://img.shields.io/badge/macOS-14%2B-555?logo=apple) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) +![Version](https://img.shields.io/badge/version-0.5.3-3aa05a) ![License](https://img.shields.io/badge/license-Source%20Available-blue) -![CI](https://github.com/hkgood/OSGKeyboard/actions/workflows/ci.yml/badge.svg) -![Version](https://img.shields.io/badge/version-0.2.1-3aa05a) -[中文 README](./README.zh.md) · [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) +[官网](https://hkgood.github.io/OSGKeyboard/) · [English](./README.en.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) --- -## What is it? +## 为什么用它 -OSGKeyboard is a free, source-available alternative to commercial voice-input tools. It runs as a **Custom Keyboard Extension** on iOS, so you can use it in **any app** — Messages, Notes, Mail, WeChat, ChatGPT, Claude, Cursor, you name it. - -1. Tap the mic to start recording -2. Speak naturally (up to 3.5 minutes / 210 seconds per take) -3. Tap again to stop — the AI polishes your words into clean text and inserts at the cursor - -By default, audio is transcribed **on-device** by Apple's `SpeechAnalyzer` + `DictationTranscriber` (iOS 26+) — **no audio leaves your phone** unless you say so. If polish is enabled, only the transcript text goes to your chosen LLM. Optionally, you can switch to a **cloud ASR engine** (explicit opt-in with a confirmation): in that mode your recordings are uploaded to the ASR provider you configure. - -Under the hood, OSGKeyboard uses a **Flow session model**: a long-lived audio session runs in the host app, the keyboard extension writes tiny "start / stop" signals to the App Group, and the polished text is delivered back to the keyboard for insertion. You do not need to jump back to the host app between recordings. +- **真的随处可用** — 微信、备忘录、Notion、Cursor、邮件……光标在哪,文字就落在哪 +- **说完就能用** — 点按(iOS)或按住 Option(Mac)开口,AI 自动补标点、整理结构,不用自己改稿 +- **默认不上传录音** — iOS 本地识别、Mac 可选本地模型;只有你主动开启云端引擎时,音频才会离开设备 +- **模型随你选** — 内置润色开箱即用;也可接入 DeepSeek、OpenAI、Anthropic、OpenRouter 等任意兼容 API +- **Mac 也能全局听写** — 菜单栏常驻,屏幕底部浮层实时反馈,说完自动插入当前 App --- -## Features +## 三步开始 -- 🎙 **Tap-to-toggle recording** with a Typeless-style circular mic button, 3.5-minute (210s) per-take cap with live countdown -- 🧠 **On-device ASR** (`SpeechAnalyzer` + `DictationTranscriber`, iOS 26+) -- ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists -- 🧩 **Local + cloud polish toggle** — local engine is ASR-only by default; opt into a post-ASR cloud polish step (DeepSeek by default) when the iOS speech recognition isn't strong enough for your environment (noisy far-field audio, strong accents, etc.) -- 🔌 **Bring-your-own API** — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, your own self-hosted server, …) -- 🔒 **Privacy first** — on-device ASR by default, so audio never leaves your device unless you explicitly opt into the cloud engine; polish sends only the transcript to the LLM you choose -- 🎨 **Native SwiftUI** — dark theme, frosted glass, pure Swift 6, ~3,600 lines of code -- 🪶 **Zero dependencies** — no SwiftPM packages, no CocoaPods, no Carthage -- 🔁 **Flow session** — keep recording across multiple takes without bouncing back to the host app +1. **安装并授权** — iOS 添加键盘并开启「完全访问」;Mac 授予麦克风与辅助功能 +2. **选引擎** — 本地识别 + 内置润色(零配置),或填入自己的 API Key +3. **开口说话** — 切换到 OSGKeyboard 键盘,或按住 Option 键,文字即出现 + +> iOS 首次打开会走 6 步引导:权限 → 键盘 → 识别引擎 → 润色模型,约 2 分钟完成。 --- -## Quick start +## 核心能力 -### Requirements +| | iOS / iPadOS | macOS | +|---|:---:|:---:| +| 自定义键盘 / 全局热键 | ✅ | ✅ Option 按住说话 | +| 本地语音识别 | ✅ Apple SpeechAnalyzer | ✅ SenseVoice / Qwen3 | +| AI 文本润色 | ✅ | ✅ | +| 润色后翻译 | ✅ | ✅ | +| 个性词库 | ✅ iCloud 同步 | ✅ | +| 听写历史 | ✅ | ✅ | +| 灵动岛 / 听写浮层 | ✅ Live Activity | ✅ 底部胶囊浮层 | -- macOS with **Xcode 26** (matches `project.yml` deployment target iOS 26) -- iPhone or iPad running **iOS 26.0+** (iPad fully supported: Split View / Stage Manager, adaptive layout) -- [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` -- An OpenAI-compatible API key (e.g. from [OpenAI](https://platform.openai.com/api-keys), [DeepSeek](https://platform.deepseek.com/api_keys), or [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey)). Not needed if you stay on the "local ASR only" engine. +--- -### Build & run +## 隐私 + +- **默认本地识别** — 录音在设备上转写,不经过我们的服务器 +- **润色只发文字** — 发给 LLM 的是转写文本,不是原始音频 +- **不记录击键** — 键盘扩展不采集、不上传你的日常输入内容 +- 详见 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) + +--- + +## 获取 + +**从源码构建**(需 macOS + Xcode 26): ```bash git clone https://github.com/hkgood/OSGKeyboard.git cd OSGKeyboard -./Scripts/generate-xcodeproj.sh # generates OSGKeyboard.xcodeproj via XcodeGen -open OSGKeyboard.xcodeproj # or build via CLI: -xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ - -destination 'generic/platform=iOS Simulator' build +./Scripts/generate-xcodeproj.sh +open OSGKeyboard.xcodeproj ``` -> The `OSGKeyboard.xcodeproj` is **not** committed — it is regenerated from -> `project.yml` by `Scripts/generate-xcodeproj.sh`. Always re-run the script -> after `git pull` if `project.yml` has changed. +- iOS:选择 `OSGKeyboard` scheme,跑在 iPhone / iPad 模拟器或真机 +- macOS:选择 `OSGKeyboardMac` scheme,编译产物为 `OSGKeyboard.app` -### macOS distribution (decision) - -The macOS menu-bar app ships via **Developer ID direct distribution** (notarized, -non-sandboxed), NOT the Mac App Store. This is deliberate: its core features — -global hold-to-talk hotkey, Accessibility-based text insertion into other apps, -and synthesized ⌘V — are incompatible with the Mac App Store sandbox, and a -sandboxed Accessibility grant also tends to reset after every app update. -`OSGKeyboardMac.entitlements` therefore keeps `com.apple.security.app-sandbox` -set to `false`; do not flip it back on without redesigning the insertion path. -(The iOS app targets the iOS App Store as usual — see `AUDIT_APPSTORE.md`.) - -### Enable the keyboard in iOS - -The host app walks you through a **5-step onboarding**: - -1. **Welcome** — intro to OSGKeyboard -2. **Microphone** — request mic access -3. **Speech recognition** — request on-device speech recognition access -4. **Enable keyboard + Full Access** — open iOS Settings to add OSGKeyboard and allow Full Access -5. **Engine + API** — pick the local or cloud engine, then paste your API key (cloud / cloud-polish only) - -After onboarding, in any text field, tap 🌐 to switch to **OSGKeyboard**, then tap the circular mic to start, speak, and tap again to stop. - -> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy) and our [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/). +开发细节、架构说明与贡献指南见 [README.en.md](./README.en.md) 与 [CONTRIBUTING.md](./CONTRIBUTING.md)。 --- -## Architecture +## 许可 -``` -OSGKeyboard/ -├── OSGKeyboard/ # Main iOS app (host of the Flow session) -│ ├── Services/ # FlowSessionManager, AppPermissions, SpeechHistoryStore, … -│ ├── Views/ # SwiftUI: OnboardingView, HomeView, SettingsView, HistoryView, … -│ ├── OSGKeyboardApp.swift # @main entry, owns the FlowSessionManager -│ ├── PrivacyInfo.xcprivacy # Required privacy manifest -│ └── OSGKeyboard.entitlements # App Group + Keychain Group -├── OSGKeyboardExt/ # Custom Keyboard Extension -│ ├── KeyboardViewController.swift # Principal class (drives SwiftUI) -│ ├── Services/ # AppGroupPersistor, HostAppLauncher, AudioCaptureService (legacy, unused) -│ ├── Views/ # KeyboardRootView, RecordButton, WaveformView -│ └── PrivacyInfo.xcprivacy -├── OSGKeyboardShared/ # Framework shared by app + extension (APPLICATION_EXTENSION_API_ONLY=YES) -│ ├── Services/ # FlowSessionBridge, FlowSessionDarwin, LLMClient, PolishingService, ASRService, Keychain, AppGroupStore, … -│ ├── Models/ # LLMProvider, ProviderConfig, TranscriptionDelivery, AudioBufferSnapshot, … -│ ├── DesignSystem/ # Theme, ThemedRoot -│ └── Constants/ # AppGroup identifier -├── OSGKeyboardTests/ # XCTest unit tests (LLM, Keychain, ASR, Flow bridge, …) -├── OSGKeyboardExtTests/ # Keyboard-extension-side unit tests -├── Scripts/ # generate-xcodeproj.sh, patch-icon-composer.sh -├── docs/ # GitHub Pages site (privacy policy + landing) -├── project.yml # XcodeGen project definition (source of truth) -└── .github/workflows/ci.yml # Lint + build CI -``` - -### Data flow — Flow session model - -``` -[Tap mic in keyboard] - └─► KeyboardViewController.pressBegan - └─► FlowSessionBridge.setRecordingState(.recording) [App Group UserDefaults] - └─► Darwin notification: "recordingState changed" - └─► FlowSessionManager (host app) sees the signal - └─► FlowContinuousCapture feeds 16 kHz PCM into ChunkedUtterancePipeline - └─► ASRService.transcribe (iOS 26 SpeechAnalyzer) - └─► ASREvent.partial / .final - └─► UtteranceTranscriptStitcher stitches the chunks - └─► PolishingService (LLMClient) [optional, configurable] - └─► FlowSessionBridge.storeTranscriptionResult -[Keyboard polls + Darwin notif] - └─► KeyboardViewController sees the result - └─► textDocumentProxy.insertText(polished) -``` - -**Engine modes:** - -- `local` (default) — on-device ASR via `SpeechAnalyzer`; transcript is inserted as-is. No network round-trip. -- `local` + "Cloud polish after ASR" toggle (Settings → Engine) — same on-device ASR, but the transcript (text only) is routed through your configured LLM before insertion. Useful when iOS speech recognition isn't accurate enough in your environment. -- `cloud` (opt-in, requires an explicit confirmation) — **your voice recordings are uploaded** to the ASR provider you configure (e.g. OpenAI `/audio/transcriptions`, DashScope, Zhipu), and the resulting transcript is sent to your LLM for polish. Choose this only when you accept your provider's privacy terms. - -**Cross-process plumbing (host app ↔ keyboard extension):** - -- **App Group `group.com.osgkeyboard.shared`** — `UserDefaults` for the live Flow session state, recording state, audio levels, transcription delivery, and most preferences. -- **Shared Keychain group `com.osgkeyboard.shared`** — the LLM API key is written by the host app's Settings, read by both processes before every LLM call. -- **Darwin notifications (`CFNotificationCenter`)** — light-weight "something changed" pings; payloads still travel through the App Group. +[源码可见许可](./LICENSE) — 个人学习与非商用本地使用;商用请联系 [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com)。 --- -## Adding a new LLM provider - -Open `OSGKeyboardShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol. - -```swift -LLMProvider( - id: "groq", - name: "Groq", - defaultBaseURL: "https://api.groq.com/openai/v1", - defaultModel: "llama-3.1-70b-versatile", - apiKeyURL: URL(string: "https://console.groq.com/keys") -) -``` - -That's it — no other code changes required. - -To set it as the new default for first-time users, also bump the `defaultProviderId` constant used by `ProviderConfig`. - ---- - -## Known limitations - -- **iOS 26+ only.** Earlier iOS versions are not supported. We dropped the pre-26 SFSpeechRecognizer / AVAudioSession branching so the entire ASR path can use the iOS 26 `SpeechAnalyzer` API exclusively. -- **~60 MB memory cap** for the keyboard extension (iOS sandbox). The Flow session is hosted in the main app, so audio buffers and ASR models live there, not in the extension. -- **"Allow Full Access" required.** Without it, the keyboard can't reach the microphone or make network requests for cloud polish. -- **Password fields and some `WKWebView` textareas** are blocked by iOS itself — not something we can work around. -- **3.5-minute (210s) per-take cap.** A long take is automatically stopped and dispatched for transcription; a new take can be started immediately. -- **Force-quitting the host app does not resurrect the old session.** The Live Activity is cleared immediately; the next time you open the app (with permissions granted) a fresh voice session starts automatically. -- **3-minute per-utterance ASR cap.** If you exceed it, the pipeline gracefully splits into multiple stitched chunks. -- **No on-device LLM polish.** The local engine is ASR-only; "AI polish" is always cloud-based and configurable. On-device model support was explored in v0.2.0 and rolled back in v0.2.1 to keep the dependency surface at zero SPM packages. -- **URL scheme `osgkeyboard://`** can be opened by any app on the device. We don't trust it for anything beyond "wake the host app and (re)start the Flow session"; it never carries your API key or other secrets. - ---- - -## Development - -- **Build setup** — see the [Build Setup](#build-setup) section at the top of this file. Run `./Scripts/generate-xcodeproj.sh` after any `project.yml` change. -- **Tests** — `xcodebuild test -project OSGKeyboard.xcodeproj -scheme OSGKeyboard -destination 'platform=iOS Simulator,name=iPhone 17'` runs both `OSGKeyboardTests` and `OSGKeyboardExtTests` targets. -- **CI** — `.github/workflows/ci.yml` runs SwiftLint, a clean Debug build, and the test suite on every push to `0.1` / `0.2` and PRs. -- **Logging** — `print` is debug-only; release builds use `NSLog` for the few cross-process status messages. - ---- - -## Project status - -- **Current release: v0.2.1** (2026-06-24) -- **Default branch: `0.2`** (renamed from `main` on 2026-06-24; the previous `main` is preserved as `0.1`). -- See [`CHANGELOG.md`](./CHANGELOG.md) for the full release history and [`TYPEWHISPER_FLOW_MIGRATION_TRACKER.md`](./TYPEWHISPER_FLOW_MIGRATION_TRACKER.md) for the architecture-decision log behind the Flow session model. - ---- - -## License - -[OSGKeyboard Source Available License](./LICENSE) — personal learning and non-commercial local use only. No commercial use, redistribution, or public forks without permission. Commercial licensing: [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com). - ---- - -## Acknowledgements - -- Inspired by [Typeless](https://typeless.com) and the desktop open-source [OpenLess](https://github.com/Open-Less/openless) -- Built with [XcodeGen](https://github.com/yonaskolb/XcodeGen) -- Powered by Apple's [SpeechAnalyzer](https://developer.apple.com/documentation/speech/speechanalyzer) and [SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer) +

                + 灵感来自 Typeless · 端侧识别基于 Apple SpeechAnalyzer · Mac 本地模型基于 Sherpa-ONNX +

                diff --git a/README.zh.md b/README.zh.md index 7723c37..1984f63 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,203 +1,3 @@ # OSGKeyboard -> 按一下开始,再按一下结束 —— AI 润色文字直接出现在任意 App 的光标处。 -> 一款源码可见的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。 - -![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple) -![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) -![License](https://img.shields.io/badge/license-Source%20Available-blue) -![Version](https://img.shields.io/badge/version-0.2.1-3aa05a) - -[English README](./README.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) - ---- - -## 这是什么? - -OSGKeyboard 是商业语音输入工具的免费、源码可见替代方案。它以 **iOS 自定义键盘扩展** 的形式运行,所以你可以在 **任何 App** 里使用 —— 微信、备忘录、邮件、ChatGPT、Claude、Cursor,无所不能。 - -1. 按下麦克风键开始录音 -2. 自由说话(单次上限 3.5 分钟 / 210 秒) -3. 再按一下结束 —— AI 自动整理成干净的文字并插入光标 - -默认情况下,**音频在设备本地转写**(iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber`)——**除非你主动选择,音频不会离开你的手机**;开启润色时也只有文本会发到你选择的 LLM。你也可以显式切换到**云端识别引擎**(需二次确认的 opt-in):该模式下你的录音会上传到你配置的识别服务商。 - -项目内部采用 **Flow 会话模型**:主 App 维护一个长生命周期的音频会话,键盘扩展只通过 App Group 写入"开始 / 停止"等轻量信号,润色后的文本再由主 App 回传给键盘插入。**多次录音之间无需反复跳回主 App**。 - ---- - -## 特性 - -- 🎙 **点按录音** —— Typeless 风格的圆形麦克风按钮,单次上限 3.5 分钟(210 秒)并实时倒计时 -- 🧠 **端侧 ASR**(iOS 26+ `SpeechAnalyzer` + `DictationTranscriber`) -- ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表 -- 🧩 **本地 + 云端润色开关** —— 本地模式默认仅在设备上识别;若 iOS 语音识别效果不理想(远场、噪声、方言),可开启「识别后云端润色」,默认走 DeepSeek -- 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / Moonshot / 智谱 / 自建服务器 ……) -- 🔒 **隐私优先** —— 默认端侧识别,音频不离开设备(除非显式开启云端识别引擎);润色只发送文本给你选择的 LLM -- 🎨 **原生 SwiftUI** —— 暗色主题、毛玻璃、纯 Swift 6 实现,约 3,600 行代码 -- 🪶 **零依赖** —— 无 SwiftPM 包、无 CocoaPods、无 Carthage -- 🔁 **Flow 会话** —— 多次录音无需跳回主 App,会话自动维持心跳与续期 - ---- - -## 快速开始 - -### 环境要求 - -- macOS + **Xcode 26**(与 `project.yml` 中 iOS 26 部署目标对齐) -- iPhone 运行 **iOS 26.0+** -- [XcodeGen](https://github.com/yonaskolb/XcodeGen):`brew install xcodegen` -- 一个 OpenAI 兼容 API Key([OpenAI](https://platform.openai.com/api-keys) / [DeepSeek](https://platform.deepseek.com/api_keys) / [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey) 任一)。如果一直使用「纯本地 ASR」引擎则无需 Key。 - -### 编译与运行 - -```bash -git clone https://github.com/hkgood/OSGKeyboard.git -cd OSGKeyboard -./Scripts/generate-xcodeproj.sh # 通过 XcodeGen 生成 OSGKeyboard.xcodeproj -open OSGKeyboard.xcodeproj # 或命令行编译: -xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ - -destination 'generic/platform=iOS Simulator' build -``` - -> `OSGKeyboard.xcodeproj` **不进入版本库**,由 `Scripts/generate-xcodeproj.sh` 从 `project.yml` 生成。每次 `git pull` 后若 `project.yml` 有变更,请重新执行该脚本。 - -### 在 iOS 中启用键盘 - -主 App 会引导你走完 **5 步**: - -1. **欢迎** —— 介绍 OSGKeyboard -2. **麦克风** —— 申请麦克风权限 -3. **语音识别** —— 申请端侧语音识别权限 -4. **启用键盘 + 完全访问** —— 跳转 iOS 设置添加 OSGKeyboard 并允许完全访问 -5. **引擎 + API** —— 选择本地或云端引擎,粘贴 API Key(仅云端 / 识别后云端润色需要) - -完成后,在任意输入框点 🌐 切换到 **OSGKeyboard**,再点圆形麦克风键开始说话,再次点击结束。 - -> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy) 与 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)。 - ---- - -## 架构 - -``` -OSGKeyboard/ -├── OSGKeyboard/ # 主 iOS App(Flow 会话宿主) -│ ├── Services/ # FlowSessionManager、AppPermissions、SpeechHistoryStore、… -│ ├── Views/ # SwiftUI:OnboardingView、HomeView、SettingsView、HistoryView、… -│ ├── OSGKeyboardApp.swift # @main 入口,持有 FlowSessionManager -│ ├── PrivacyInfo.xcprivacy # 隐私清单 -│ └── OSGKeyboard.entitlements # App Group + Keychain Group -├── OSGKeyboardExt/ # 自定义键盘扩展 -│ ├── KeyboardViewController.swift # 主体类(驱动 SwiftUI) -│ ├── Services/ # AppGroupPersistor、HostAppLauncher、AudioCaptureService(旧版,未使用) -│ ├── Views/ # KeyboardRootView、RecordButton、WaveformView -│ └── PrivacyInfo.xcprivacy -├── OSGKeyboardShared/ # 主 App + 键盘共享 framework(APPLICATION_EXTENSION_API_ONLY=YES) -│ ├── Services/ # FlowSessionBridge、FlowSessionDarwin、LLMClient、PolishingService、ASRService、Keychain、AppGroupStore、… -│ ├── Models/ # LLMProvider、ProviderConfig、TranscriptionDelivery、AudioBufferSnapshot、… -│ ├── DesignSystem/ # Theme、ThemedRoot -│ └── Constants/ # AppGroup ID -├── OSGKeyboardTests/ # XCTest 单元测试(LLM、Keychain、ASR、Flow bridge、…) -├── OSGKeyboardExtTests/ # 键盘扩展侧单元测试 -├── Scripts/ # generate-xcodeproj.sh、patch-icon-composer.sh -├── docs/ # GitHub Pages(隐私政策 + 落地页) -├── project.yml # XcodeGen 工程定义(唯一源) -└── .github/workflows/ci.yml # Lint + 编译 CI -``` - -### 数据流 —— Flow 会话模型 - -``` -[键盘点按麦克风] - └─► KeyboardViewController.pressBegan - └─► FlowSessionBridge.setRecordingState(.recording) [App Group UserDefaults] - └─► Darwin 通知:"recordingState changed" - └─► 主 App 的 FlowSessionManager 收到信号 - └─► FlowContinuousCapture 持续把 16kHz PCM 喂给 ChunkedUtterancePipeline - └─► ASRService.transcribe(iOS 26 SpeechAnalyzer) - └─► ASREvent.partial / .final - └─► UtteranceTranscriptStitcher 拼接 - └─► PolishingService(LLMClient) [可选,由引擎模式决定] - └─► FlowSessionBridge.storeTranscriptionResult -[键盘轮询 + Darwin 通知] - └─► KeyboardViewController 拿到结果 - └─► textDocumentProxy.insertText(润色后文本) -``` - -**引擎模式:** - -- `local`(默认)—— 仅端侧 `SpeechAnalyzer` 识别,原始文本直接插入,不联网。 -- `local` + 「识别后云端润色」开关(设置 → 引擎)—— 同样走端侧 ASR,但识别完成后送 LLM 润色(仅文本)再插入。适用于 iOS 识别效果不理想的场景。 -- `cloud`(opt-in,需显式确认)—— **你的语音录音会上传**到你配置的识别服务商(如 OpenAI `/audio/transcriptions`、DashScope、智谱),识别文本再送 LLM 润色。请在接受服务商隐私条款的前提下选用。 - -**跨进程管道(主 App ↔ 键盘扩展):** - -- **App Group `group.com.osgkeyboard.shared`** —— `UserDefaults` 存放 Flow 会话状态、录音状态、音量、转写投递、绝大部分偏好。 -- **共享 Keychain 组 `com.osgkeyboard.shared`** —— LLM API Key 由主 App「设置」写入,键盘扩展在每次 LLM 调用前读取。 -- **Darwin 通知(`CFNotificationCenter`)** —— 轻量级"有变化"信号;具体负载仍走 App Group。 - ---- - -## 新增 LLM 提供商 - -打开 `OSGKeyboardShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。 - -```swift -LLMProvider( - id: "groq", - name: "Groq", - defaultBaseURL: "https://api.groq.com/openai/v1", - defaultModel: "llama-3.1-70b-versatile", - apiKeyURL: URL(string: "https://console.groq.com/keys") -) -``` - -仅此而已,**无需改动其他代码**。 - -如需设为新用户的默认值,还需同步调整 `ProviderConfig` 中的 `defaultProviderId` 常量。 - ---- - -## 已知限制 - -- **仅支持 iOS 26+。** 我们已移除 26 以下 `SFSpeechRecognizer` / `AVAudioSession` 的兼容分支,让 ASR 路径全部走 iOS 26 `SpeechAnalyzer`。 -- **键盘扩展约 60 MB 内存上限**(iOS 沙盒)。Flow 会话由主 App 承载,音频缓冲与 ASR 模型都在主 App 侧,不占用扩展内存。 -- **必须「允许完全访问」**。否则键盘无法使用麦克风,也无法发起云端润色请求。 -- **密码框与部分 `WKWebView` 输入框不可用**(iOS 系统限制,无法绕过)。 -- **单次录音上限 3.5 分钟(210 秒)**。到点自动停止并提交识别,下次可立即开始新的录音。 -- **杀掉主 App 后不复活旧会话**。灵动岛会立即清理;下次回到主 App 时(权限齐全)自动开启新的语音会话。 -- **单次 utterance ASR 上限 3 分钟**。超出后会拆成多个 chunk 拼接识别。 -- **不做端侧 LLM 润色**。本地引擎仅做 ASR;"AI 润色"始终走云端、可配置。v0.2.0 曾尝试引入端侧模型,v0.2.1 回滚以保持零 SPM 依赖。 -- **URL Scheme `osgkeyboard://`** 任何 App 都可调用。OSGKeyboard 只把它用于"唤醒主 App / 续期 Flow 会话",**不** 传递 API Key 等敏感信息。 - ---- - -## 开发指南 - -- **构建** —— 见本文开头的 [编译与运行](#编译与运行) 节。`project.yml` 变更后请重新执行 `./Scripts/generate-xcodeproj.sh`。 -- **测试** —— `xcodebuild test -project OSGKeyboard.xcodeproj -scheme OSGKeyboard -destination 'platform=iOS Simulator,name=iPhone 17'` 会同时跑 `OSGKeyboardTests` 与 `OSGKeyboardExtTests` 两个 target。 -- **CI** —— `.github/workflows/ci.yml` 在每次 push 到 `0.1` / `0.2` 分支及 PR 时跑 SwiftLint、Debug 干净构建和测试套件。 -- **日志** —— `print` 仅在 Debug 启用;Release 仅保留少量跨进程状态相关的 `NSLog`。 - ---- - -## 项目状态 - -- **当前版本:v0.2.1**(2026-06-24) -- **默认分支:`0.2`**(2026-06-24 从 `main` 改名;旧 `main` 保留为 `0.1`)。 -- 完整发布记录见 [`CHANGELOG.md`](./CHANGELOG.md);Flow 会话模型的架构决策日志见 [`TYPEWHISPER_FLOW_MIGRATION_TRACKER.md`](./TYPEWHISPER_FLOW_MIGRATION_TRACKER.md)。 - ---- - -## 许可 - -[OSGKeyboard 源码可见许可协议](./LICENSE) —— 仅限个人学习与非商用本地使用;禁止商用、再分发及公开 fork。商业授权请联系 [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com)。 - ---- - -## 致谢 - -- 灵感来源:[Typeless](https://typeless.com) 与桌面端开源版 [OpenLess](https://github.com/Open-Less/openless) -- 工程脚手架:[XcodeGen](https://github.com/yonaskolb/XcodeGen) -- 端侧 ASR:Apple [SpeechAnalyzer](https://developer.apple.com/documentation/speech/speechanalyzer) / [SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer) +中文说明已合并至主文档:[README.md](./README.md) diff --git a/docs/index.html b/docs/index.html index 896f5d6..543e4e9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,13 +1,13 @@ - + - OSGKeyboard — On-device voice-to-text keyboard for iOS - - + OSGKeyboard — 开口即文字 · iOS & Mac 语音输入 + + @@ -18,8 +18,8 @@ - - + + @@ -29,8 +29,8 @@ - - + + @@ -39,22 +39,22 @@ "@context": "https://schema.org", "@type": "SoftwareApplication", "name": "OSGKeyboard", - "operatingSystem": "iOS 26.0 or later", + "operatingSystem": "iOS 26.0 or later, macOS 14.0 or later", "applicationCategory": "UtilitiesApplication", "url": "https://hkgood.github.io/OSGKeyboard/", "image": "https://hkgood.github.io/OSGKeyboard/assets/app-icon.png", - "description": "A source-available iOS keyboard that turns speech into polished text in any app. On-device recognition (iOS 26+), AI polish, translation, and a personal dictionary. Local or cloud engine.", - "softwareVersion": "0.3.6", + "description": "Voice input for iPhone, iPad, and Mac. On-device recognition, AI polish, personal dictionary, and iCloud sync. Speak in any app — polished text lands at your cursor.", + "softwareVersion": "0.5.3", "isAccessibleForFree": true, "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, "author": { "@type": "Organization", "name": "OSGKeyboard" }, "featureList": [ - "On-device speech recognition (SpeechAnalyzer, iOS 26+)", - "AI polish with three intensity levels", - "Post-polish translation", - "Personal dictionary", - "Long-lived Flow session", - "Local or cloud engine" + "Custom keyboard for iOS and iPadOS", + "macOS global dictation with Option hotkey", + "On-device speech recognition", + "AI text polish with multiple LLM providers", + "Personal dictionary with iCloud sync", + "Post-polish translation" ] } @@ -389,6 +389,30 @@ .step h3 { margin: 0 0 0.35rem; font-size: 1.05rem; font-weight: 650; } .step p { margin: 0; color: var(--text-secondary); font-size: 0.95rem; } + /* Platforms */ + .platforms { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.25rem; + } + @media (max-width: 640px) { .platforms { grid-template-columns: 1fr; } } + + .platform { + padding: 1.5rem; + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--bg-soft); + } + .platform-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.75rem; + } + .platform-head .material-symbols-outlined { font-size: 22px; color: var(--accent); } + .platform h3 { margin: 0; font-size: 1.05rem; font-weight: 650; } + .platform p { margin: 0; font-size: 0.93rem; color: var(--text-secondary); line-height: 1.55; } + /* Closing */ .closing { border-top: 1px solid var(--line); @@ -424,8 +448,8 @@ + +
                +
                +

                两个平台,同一种体验

                +

                手机上用键盘,Mac 上按住 Option — 说完即落字。

                +
                +
                +
                +
                + +

                iOS / iPadOS

                +
                +

                自定义键盘 + Flow 会话。点按麦克风说话,连续听写不用跳 App。灵动岛显示录音状态。

                +
                +
                +
                + +

                macOS

                +
                +

                菜单栏常驻,按住 Option 全局听写。底部浮层实时反馈,支持 SenseVoice / Qwen3 本地模型。

                +
                +
                +
                +
                -

                Everything you need to type by voice

                -

                On-device by default, refined by AI when you want it — in a single, native keyboard.

                +

                开口即写,所需尽在此

                +

                本地优先,AI 加持 — 从识别到润色,一条链路搞定。

                -

                Tap-to-talk dictation

                -

                Tap to start, tap to stop. Up to 3.5 minutes per take, with a live countdown.

                +

                点按听写

                +

                点一下开始,再点一下结束。单次最长 3.5 分钟,倒计时实时显示。

                -

                On-device recognition

                -

                iOS 26 SpeechAnalyzer transcribes on your device by default — audio is uploaded only if you explicitly enable the cloud engine.

                +

                本地识别

                +

                iOS 端侧 SpeechAnalyzer;Mac 可选 SenseVoice / Qwen3。默认不上传录音。

                -

                AI polish

                -

                Punctuation, structure, and clarity in three intensity levels. Polish sends only text — on the default engine your audio never goes online.

                +

                AI 润色

                +

                自动补标点、整理结构。内置润色开箱即用,也可接入 DeepSeek、OpenAI 等 API。

                -

                Built-in translation

                -

                Translate after polish into English, 中文, 日本語, 한국어 and more — right from the keyboard.

                +

                润色后翻译

                +

                润色完成后一键翻译为 English、中文、日本語、한국어 等。

                -

                Personal dictionary

                -

                Keep names, products, and jargon accurate. The AI preserves your terms every time.

                +

                个性词库

                +

                人名、产品名、专业术语 — AI 润色时原样保留,iCloud 多设备同步。

                -
                -

                Flow session

                -

                A long-lived session keeps the recorder warm, so you can dictate take after take.

                +
                +

                iCloud 同步

                +

                设置、词库、听写历史在多设备间自动同步,换机无缝衔接。

                @@ -526,21 +574,21 @@
                -

                Three steps to your first message

                -

                Set up once, then dictate anywhere you type.

                +

                三步,开始第一句话

                +

                设置一次,之后在任意输入处开口即写。

                -

                Enable the keyboard

                -

                Add OSGKeyboard in iOS Settings and allow Full Access — needed for the mic and network.

                +

                安装并授权

                +

                iOS 添加键盘并开启完全访问;Mac 授予麦克风与辅助功能权限。

                -

                Choose your engine

                -

                Local: on-device ASR with built-in polish, no API key. Cloud: use your own OpenAI-compatible key.

                +

                选引擎与润色

                +

                本地识别 + 内置润色零配置可用;也可填入自己的 API Key 接入任意兼容模型。

                -

                Tap to talk

                -

                Switch to OSGKeyboard, tap the mic, speak, tap again. Polished text lands at your cursor.

                +

                开口说话

                +

                切换到 OSGKeyboard 键盘,或按住 Option 键。润色好的文字自动插入光标处。

                @@ -549,13 +597,13 @@
                - Privacy by design -

                Your voice stays yours

                -

                Speech is processed on-device by default (the optional cloud engine uploads recordings to the provider you configure). We never log ordinary keystrokes; polish and translation receive only transcribed text.

                + 隐私优先 +

                你的声音,始终属于你

                +

                默认在设备端识别,录音不上传。润色只发送转写文字,不记录日常击键。云端引擎需你主动开启,并适用所配置服务商的隐私条款。

                - Read the privacy policy + 查看隐私政策
                @@ -563,7 +611,7 @@