diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e7406..99652b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-07-07 + +### Added +- **iCloud settings sync**: engine, language, polish, and Flow preferences now stay in sync across your devices via iCloud (key-value store); a new "Sync settings via iCloud" toggle in Settings controls it. API keys stay on each device and are never uploaded. / **iCloud 设置同步**:引擎、语言、润色与 Flow 偏好现可通过 iCloud(键值存储)在多设备间自动同步;设置中新增「通过 iCloud 同步设置」开关控制此功能。API 密钥仅保留在各自设备本地,绝不上传。 + +### Changed +- **Cold-start return guidance**: the handoff screen now points to the bottom Home indicator with a left-to-right swipe animation (instead of the misleading "swipe up"), auto-dismisses once you switch back to your previous app, and closes on a tap anywhere; a "Return to [App]" link is still offered when available. / **冷启动返回引导**:交接页改为指向底部横条并配左向右滑动动画(不再是易误解的「向上滑」),切换回上一个 App 后自动消失,点按任意处即可关闭;可用时仍保留「返回 [App]」文本按钮。 +- **Voice session handoff robustness**: hardened the keyboard→app cold-start flow, including a clearer "Voice session disconnected" hint when the host session drops. / **语音会话交接健壮性**:强化键盘→主 App 的冷启动链路,宿主会话断开时给出更清晰的「语音会话已断开」提示。 + +### Fixed +- **Local ASR route-change crash**: dictating with the on-device engine no longer terminates the app with `Failed to create tap due to format mismatch`. On-device `SpeechAnalyzer` warmup reconfigures the shared audio session, which triggers a route change; the tap was reinstalled with a stale hardware format (48 kHz) that no longer matched the live node (24 kHz). The tap now binds to the node's live format (`installTap(format: nil)`) and the downsampling converter rebuilds itself from the actual buffer format, so route churn is handled without crashing. / **本地识别路由切换崩溃**:使用端侧引擎听写不再以 `Failed to create tap due to format mismatch` 崩溃退出。端侧 `SpeechAnalyzer` 预热会重配共享音频会话并触发路由变化,此前重装 tap 时用了过期的硬件采样率(48 kHz),与真实节点(24 kHz)不匹配。现在 tap 绑定节点实时格式(`installTap(format: nil)`),降采样转换器按实际缓冲区格式自适应重建,路由抖动不再导致崩溃。 +- **ASR fallback warning showed raw key**: the weak-network / missing-key fallback hint displayed its localization key (e.g. `flow.warning.polishDegraded`) instead of the translated sentence. These keys live in the `Shared.strings` table but were looked up via the main-app `Localizable` table; they are now resolved through `SharedL10n`. / **ASR 兜底提示显示变量名**:弱网 / 缺少密钥的兜底提示此前显示本地化键名(如 `flow.warning.polishDegraded`)而非译文。这些键位于 `Shared.strings`,却被按主 App 的 `Localizable` 表查找;现改为经 `SharedL10n` 解析。 +- **Flow multi-utterance recognition**: the second (and later) dictation in a session no longer returns "no speech". The session-long downsampling `AVAudioConverter` was being permanently locked by an `.endOfStream` tail-flush, starving every utterance after the first (both on-device and cloud). Trailing speech is still preserved by the live drain loop. Also added recovery from `mediaServicesWereReset`. / **Flow 连续多句识别**:同一会话内第二句及之后不再提示「未识别到语音」。此前会话级降采样 `AVAudioConverter` 被尾音冲刷的 `.endOfStream` 永久锁死,导致首句之后每句都拿不到音频(端侧与云端均受影响)。尾音仍由实时排空环节保留。并新增媒体服务重置(`mediaServicesWereReset`)后的自愈重建。 +- **Local ASR diagnostics**: add chunk-level `SpeechAnalyzer` logs and a settings switch to bypass the custom language model, so local recognition failures can be isolated between Apple assets, CLM attachment, and empty analyzer results. / **本地识别诊断**:新增 `SpeechAnalyzer` 分块级日志,并在设置中加入跳过自定义语言模型的诊断开关,用于区分 Apple 端侧资源、自定义语言模型挂载、以及分析器空结果三类问题。 + ## [0.4.1] - 2026-07-06 ### Added diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift index 7848f2d..916b2a6 100644 --- a/OSGKeyboard/Services/AppURLHandler.swift +++ b/OSGKeyboard/Services/AppURLHandler.swift @@ -1,29 +1,100 @@ // AppURLHandler.swift // OSGKeyboard · Main App // -// Captures `sourceApplication` from UIKit open-URL options (scheme D). +// iOS 26 URL handling via the UIScene lifecycle. `application(_:open:options:)` +// and `UIApplication.OpenURLOptionsKey.sourceApplication` are deprecated in +// iOS 26; the only supported way to read `sourceApplication` (scheme D +// host-return whitelist) is `UIOpenURLContext.options.sourceApplication` from a +// scene delegate — SwiftUI's `.onOpenURL` does not expose it. import UIKit import OSGKeyboardShared -extension Notification.Name { - static let osgKeyboardOpenURL = Notification.Name("osgkeyboard.openURL") +/// Buffers launch/open URLs until the SwiftUI root registers a handler. +/// +/// On a cold launch the scene delivers the URL in `scene(_:willConnectTo:)`, +/// which fires *before* the SwiftUI view hierarchy is on screen. Without +/// buffering, that first `osgkeyboard://startflow` (the keyboard → app +/// handoff) would be dropped. +@MainActor +final class AppOpenURLRouter { + static let shared = AppOpenURLRouter() + + private var handler: ((URL) -> Void)? + private var pending: [URL] = [] + + private init() {} + + /// Register the live handler and flush anything buffered before launch. + func register(_ handler: @escaping (URL) -> Void) { + self.handler = handler + let buffered = pending + pending.removeAll() + buffered.forEach(handler) + } + + func route(_ url: URL) { + if let handler { + handler(url) + } else { + pending.append(url) + } + } } final class AppURLHandler: NSObject, UIApplicationDelegate { + /// SwiftUI `@main` apps get no scene delegate by default. Attach ours so + /// scene-based URL delivery — the only iOS 26 path to `sourceApplication` — + /// reaches `AppSceneDelegate`. We deliberately do NOT create a window here; + /// SwiftUI's `WindowGroup` still owns the UI. func application( _ application: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - if let source = options[.sourceApplication] as? String { - FlowSessionBridge.setPendingHostBundleId(source) - } - NotificationCenter.default.post( - name: .osgKeyboardOpenURL, - object: nil, - userInfo: ["url": url] + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: nil, + sessionRole: connectingSceneSession.role ) - return true + configuration.delegateClass = AppSceneDelegate.self + return configuration + } +} + +final class AppSceneDelegate: NSObject, UIWindowSceneDelegate { + /// Cold launch: the URL arrives in the connection options. + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + handle(connectionOptions.urlContexts) + } + + /// Warm open while the app is already running or suspended in memory. + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + handle(URLContexts) + } + + private func handle(_ contexts: Set) { + // Extract Sendable primitives up front so we never hop a + // non-Sendable `UIOpenURLContext` across the actor boundary. + let items: [(url: URL, source: String?)] = contexts.map { + ($0.url, $0.options.sourceApplication) + } + guard !items.isEmpty else { return } + + // Scene delegate callbacks are delivered on the main thread. + MainActor.assumeIsolated { + 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 { + FlowSessionBridge.setPendingHostBundleId(source) + } + AppOpenURLRouter.shared.route(item.url) + } + } } } diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index fc6a54f..5096352 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -49,6 +49,8 @@ final class FlowSessionManager: ObservableObject { private var expiryTask: Task? private var levelTask: Task? private var startTask: Task? + /// Last recording state the poll loop observed — logs only on transition. + private var lastObservedRecordingState: FlowSessionKeys.RecordingState = .idle private var isUtteranceRecording = false /// True from `stopped` until the result/error is written back to App Group. private var isUtteranceProcessing = false @@ -86,6 +88,8 @@ final class FlowSessionManager: ObservableObject { isColdStartHandoff = true } + reconcilePersistedFlowStateBeforeStart() + if isActive { extendSession(duration: duration) if coldStart { @@ -105,6 +109,33 @@ final class FlowSessionManager: ObservableObject { } } + /// Clears App Group Flow state left behind when the host process was killed + /// or the device rebooted while the session flag was still set. + private func reconcilePersistedFlowStateBeforeStart() { + if FlowSessionBridge.isHostStale() { + if isActive { + endSession() + } else { + FlowSessionBridge.clearFlowState() + FlowLiveActivityController.endSession() + } + debug("reconciled zombie persisted Flow state") + return + } + + guard !isActive else { return } + + let orphaned = FlowSessionBridge.recordingState() + switch orphaned { + case .recording, .stopped, .processing: + FlowSessionBridge.setRecordingState(.idle) + FlowSessionBridge.clearPendingTranscription() + debug("cleared orphaned keyboard recording state: \(orphaned.rawValue)") + case .idle, .aborted: + break + } + } + /// Auto-start (or renew) the Flow session on every app foreground when /// permissions allow — the "always auto-open, no off switch" policy. Also /// clears any orphaned Live Activity a previously force-quit process left @@ -196,6 +227,9 @@ final class FlowSessionManager: ObservableObject { writeHeartbeatIfActive() case .background: setAppForeground(false) + if coldStartContext != nil { + dismissColdStartOverlay() + } beginBackgroundKeepAlive() @unknown default: break @@ -328,16 +362,13 @@ final class FlowSessionManager: ObservableObject { return } - coldStartContext = FlowColdStartContext( - hostEntry: hostEntry, - showReturnAlert: hostEntry != nil - ) + coldStartContext = FlowColdStartContext(hostEntry: hostEntry) } private func bindSessionASRIfNeeded(force: Bool = false) { let engineMode = store.engineMode if !force, - let sessionASR, + sessionASR != nil, sessionASREngineMode == engineMode { return } @@ -374,6 +405,11 @@ final class FlowSessionManager: ObservableObject { private func startPolling() { pollingTask?.cancel() + lastObservedRecordingState = FlowSessionBridge.recordingState() + FlowDiagnostics.log( + "polling started: initialRecordingState=\(lastObservedRecordingState.rawValue) " + + "container=\(AppGroup.containerPathForDiagnostics)" + ) pollingTask = Task { @MainActor [weak self] in while !Task.isCancelled { self?.handleKeyboardSignal() @@ -383,7 +419,17 @@ final class FlowSessionManager: ObservableObject { } private func handleKeyboardSignal() { - switch FlowSessionBridge.recordingState() { + let signal = FlowSessionBridge.recordingState() + if signal != lastObservedRecordingState { + // The single most important cross-process signal: proves whether the + // host actually SEES the keyboard's recording state writes. + FlowDiagnostics.log( + "poll observed recordingState \(lastObservedRecordingState.rawValue) → \(signal.rawValue) " + + "[rec=\(isUtteranceRecording) proc=\(isUtteranceProcessing) fg=\(isAppForeground)]" + ) + lastObservedRecordingState = signal + } + switch signal { case .recording: guard !isUtteranceRecording, !isUtteranceProcessing else { return } beginUtterance() @@ -431,7 +477,9 @@ final class FlowSessionManager: ObservableObject { FlowLiveActivityController.update(phase: .recording) FlowDiagnostics.log( "beginUtterance engine=\(store.engineMode) " + - "asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s" + "asrType=\(type(of: asr)) pipelined=true " + + "localCustomLM=\(store.localASRCustomLanguageModelEnabled) " + + "max=\(Int(FlowSessionKeys.maxUtteranceDuration))s" ) asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in @@ -638,12 +686,18 @@ final class FlowSessionManager: ObservableObject { // 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. - let warning = Self.warningFromPolishError(error, engineMode: engineMode) ?? chunkNote + let fallback = Self.makeFallbackDelivery( + rawText: text, + error: error, + engineMode: engineMode, + chunkWarning: chunkNote + ) FlowDiagnostics.log( "polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " + "\(error.localizedDescription)" ) - FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning) + delivered = fallback.text + FlowSessionBridge.storeTranscriptionResult(fallback.text, polishWarning: fallback.polishWarning) } SpeechHistoryStore.shared.recordUtterance( @@ -684,15 +738,41 @@ final class FlowSessionManager: ObservableObject { /// v0.2.0: surface the local-mode cloud-polish error path with a /// localised hint ("please fill in your DeepSeek key in Settings") /// rather than letting the keyboard show a generic network error. + static func makeFallbackDelivery( + rawText: String, + error: Error, + 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? { - guard let polishError = error as? PolishingService.PolishError, - polishError == .missingAPIKey else { - return nil + 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 engineMode == "local" { - return AppL10n.string("flow.warning.localPolishUnavailable") + if error is LLMError { + return polishDegradedWarning() } - return AppL10n.string("flow.warning.cloudPolishMissingKey") + return nil + } + + private static func polishDegradedWarning() -> String? { + SharedL10n.string("flow.warning.polishDegraded") } private func asrWaitTimeout() -> TimeInterval { diff --git a/OSGKeyboard/Services/SpeechHistoryStore.swift b/OSGKeyboard/Services/SpeechHistoryStore.swift index df13d66..ed61442 100644 --- a/OSGKeyboard/Services/SpeechHistoryStore.swift +++ b/OSGKeyboard/Services/SpeechHistoryStore.swift @@ -62,6 +62,12 @@ final class SpeechHistoryStore: ObservableObject { ) } + func delete(id: UUID) { + guard entries.contains(where: { $0.id == id }) else { return } + entries.removeAll { $0.id == id } + persist() + } + func clearAll() { entries.removeAll() persist() diff --git a/OSGKeyboard/Views/FlowColdStartOverlay.swift b/OSGKeyboard/Views/FlowColdStartOverlay.swift index 7b99170..90c5172 100644 --- a/OSGKeyboard/Views/FlowColdStartOverlay.swift +++ b/OSGKeyboard/Views/FlowColdStartOverlay.swift @@ -1,14 +1,13 @@ // FlowColdStartOverlay.swift // OSGKeyboard · Main App // -// Minimal cold-start handoff UI: swipe-back guidance and optional return alert. +// Minimal cold-start handoff UI: bottom-bar swipe guidance and optional return link. import SwiftUI import OSGKeyboardShared struct FlowColdStartContext: Equatable { let hostEntry: HostAppEntry? - var showReturnAlert: Bool } struct FlowColdStartOverlay: View { @@ -18,78 +17,62 @@ struct FlowColdStartOverlay: View { let onReturnToHost: () -> Void let onDismiss: () -> Void - @State private var showAlert: Bool + @State private var swipeOffset: CGFloat = 0 - init( - context: FlowColdStartContext, - onReturnToHost: @escaping () -> Void, - onDismiss: @escaping () -> Void - ) { - self.context = context - self.onReturnToHost = onReturnToHost - self.onDismiss = onDismiss - _showAlert = State(initialValue: context.showReturnAlert) - } + private let homeBarWidth: CGFloat = 134 var body: some View { ZStack { palette.background.opacity(0.96) .ignoresSafeArea() - VStack(spacing: Spacing.xl) { - Image("OSGBrandMark") - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 64, height: 64) - .foregroundStyle(palette.accent) - .accessibilityHidden(true) + VStack(spacing: 0) { + Spacer() - Text("flow.coldStart.title") - .font(TypeStyle.title3) - .foregroundStyle(palette.textPrimary) - .multilineTextAlignment(.center) - - Text(swipeHintKey) - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, Spacing.lg) - - swipeHintAnimation - .padding(.top, Spacing.md) - - Button(action: onDismiss) { - Text("flow.coldStart.dismiss") - .font(TypeStyle.body.weight(.semibold)) + VStack(spacing: Spacing.xl) { + Image("OSGBrandMark") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) .foregroundStyle(palette.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, Spacing.md) + .accessibilityHidden(true) + + Text("flow.coldStart.title") + .font(TypeStyle.title3) + .foregroundStyle(palette.textPrimary) + .multilineTextAlignment(.center) + + Text("flow.coldStart.swipeHint") + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.lg) + + if context.hostEntry != nil { + Button(action: onReturnToHost) { + Text(returnButtonTitle) + .font(TypeStyle.body.weight(.semibold)) + .foregroundStyle(palette.accent) + } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) .padding(.horizontal, Spacing.xl) - .padding(.top, Spacing.lg) - } - .padding(Spacing.xl) - } - .alert(alertTitle, isPresented: $showAlert) { - if context.hostEntry != nil { - Button(returnButtonTitle, action: onReturnToHost) - } - Button("flow.coldStart.dismiss", role: .cancel, action: onDismiss) - } message: { - Text("flow.coldStart.alert.message") - } - } - private var swipeHintKey: LocalizedStringKey { - context.hostEntry == nil - ? "flow.coldStart.swipeHint" - : "flow.coldStart.swipeHint.withSystemBack" - } + Spacer() - private var alertTitle: String { - AppL10n.string("flow.coldStart.alert.title") + bottomSwipeGuide + .padding(.bottom, Spacing.md) + + Text("flow.coldStart.tapToDismiss") + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .padding(.bottom, Spacing.xl) + } + } + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) } private var returnButtonTitle: String { @@ -100,15 +83,38 @@ struct FlowColdStartOverlay: View { return AppL10n.format("flow.coldStart.return.named", appName) } - private var swipeHintAnimation: some View { + private var bottomSwipeGuide: some View { VStack(spacing: Spacing.sm) { - Image(systemName: "chevron.up") - .font(.system(size: 20, weight: .semibold)) + Image(systemName: "arrow.down") + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(palette.textTertiary) - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(palette.textTertiary.opacity(0.5)) - .frame(width: 120, height: 5) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2.5, style: .continuous) + .fill(palette.textTertiary.opacity(0.35)) + .frame(width: homeBarWidth, height: 5) + + Circle() + .fill(palette.accent) + .frame(width: 8, height: 8) + .offset(x: swipeOffset) + } + .frame(width: homeBarWidth, height: 16) + + HStack(spacing: Spacing.xs) { + Image(systemName: "arrow.left") + .font(.system(size: 12, weight: .semibold)) + Image(systemName: "arrow.right") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(palette.textTertiary) } .accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility")) + .onAppear { + swipeOffset = 0 + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) { + swipeOffset = homeBarWidth - 8 + } + } } } diff --git a/OSGKeyboard/Views/HistoryView.swift b/OSGKeyboard/Views/HistoryView.swift index 7ed584e..f5c1e87 100644 --- a/OSGKeyboard/Views/HistoryView.swift +++ b/OSGKeyboard/Views/HistoryView.swift @@ -32,16 +32,7 @@ struct HistoryView: View { if store.entries.isEmpty { emptyState } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: Spacing.xl) { - ForEach(store.groupedByDay, id: \.day) { group in - daySection(day: group.day, items: group.items) - } - } - .padding(.horizontal, Spacing.lg) - .padding(.vertical, Spacing.md) - .tabBarScrollBottomPadding() - } + list } } .background(palette.background) @@ -74,6 +65,38 @@ struct HistoryView: View { } } + // MARK: - List + + private var list: some View { + List { + ForEach(store.groupedByDay, id: \.day) { group in + Section { + ForEach(group.items) { entry in + historyRow(entry) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(palette.surface) + .listRowSeparatorTint(palette.divider) + } + .onDelete { offsets in + delete(items: group.items, at: offsets) + } + } header: { + Text(Self.dayFormatter.string(from: group.day)) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .textCase(.uppercase) + .tracking(0.5) + } + } + } + .listStyle(.insetGrouped) + .listSectionSpacing(Spacing.lg) + .scrollContentBackground(.hidden) + .background(palette.background) + .contentMargins(.top, Spacing.md, for: .scrollContent) + .tabBarScrollBottomPadding() + } + private var emptyState: some View { VStack(spacing: Spacing.sm) { Spacer() @@ -88,30 +111,6 @@ struct HistoryView: View { .padding(.horizontal, Spacing.xl) } - private func daySection(day: Date, items: [SpeechHistoryEntry]) -> some View { - VStack(alignment: .leading, spacing: Spacing.sm) { - Text(Self.dayFormatter.string(from: day)) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .textCase(.uppercase) - .tracking(0.5) - - VStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in - historyRow(entry) - if index < items.count - 1 { - Divider().background(palette.divider) - } - } - } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - } - private func historyRow(_ entry: SpeechHistoryEntry) -> some View { VStack(alignment: .leading, spacing: Spacing.xxs) { Text(Self.timeFormatter.string(from: entry.createdAt)) @@ -126,4 +125,12 @@ struct HistoryView: View { .padding(Spacing.md) .frame(maxWidth: .infinity, alignment: .leading) } + + // MARK: - Mutations + + private func delete(items: [SpeechHistoryEntry], at offsets: IndexSet) { + for index in offsets { + store.delete(id: items[index].id) + } + } } diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index 2681243..c76d2ca 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -25,6 +25,8 @@ struct LocalModelsGroup: View { speechRow Divider().background(palette.divider) polishRow + Divider().background(palette.divider) + customLanguageModelDiagnosticRow } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( @@ -63,6 +65,25 @@ struct LocalModelsGroup: View { .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } + // MARK: Custom language model diagnostic row + + private var customLanguageModelDiagnosticRow: some View { + Toggle(isOn: $config.localASRCustomLanguageModelEnabled) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("settings.localModels.customLM.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Text("settings.localModels.customLM.subtitle") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + } + } + .tint(palette.accent) + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + } + // MARK: Helpers /// Accent badge naming the engine that backs each local-mode row diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index 31d7b9d..f10849c 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -38,14 +38,19 @@ struct MainAppRoot: View { .onAppear { flowManager.setAppForeground(scenePhase == .active) flowManager.activateOnForeground() - PersonalDictionaryCloudSync.shared.startObservingExternalChanges() + 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). + AppOpenURLRouter.shared.register { url in + handleIncomingURL(url) + } Task { - await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled() + await AppCloudSync.shared.pullAllIfEnabled() } } - .onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in - guard let url = notification.userInfo?["url"] as? URL else { return } - handleIncomingURL(url) + .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in + config.reloadFromPersistedStorage() } .onChange(of: config.hasCompletedOnboarding) { _, done in if done { @@ -59,7 +64,7 @@ struct MainAppRoot: View { flowManager.activateOnForeground() } Task { - await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled() + await AppCloudSync.shared.pullAllIfEnabled() } } } diff --git a/OSGKeyboard/Views/SettingsICloudSyncRow.swift b/OSGKeyboard/Views/SettingsICloudSyncRow.swift new file mode 100644 index 0000000..4da1ddf --- /dev/null +++ b/OSGKeyboard/Views/SettingsICloudSyncRow.swift @@ -0,0 +1,103 @@ +// SettingsICloudSyncRow.swift +// OSGKeyboard · Main App +// +// Settings-row toggle for mirroring user preferences through iCloud KVS. +// API keys remain in Keychain and are never uploaded. + +import SwiftUI +import OSGKeyboardShared + +@MainActor +struct SettingsICloudSyncRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @State private var isEnabled: Bool = AppGroupStore().settingsICloudSyncEnabled + @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) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("settings.appSettings.iCloudSync.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Text("settings.appSettings.iCloudSync.subtitle") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .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: .settingsDidSyncFromCloud) + ) { _ 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.settingsICloudSyncEnabled + } + + private func enableSync() { + isApplyingToggle = true + syncErrorMessage = nil + Task { + do { + try await SettingsCloudSync.shared.enableSync() + reloadFromStore() + } catch let error as SettingsCloudSyncError { + isEnabled = false + syncErrorMessage = localizedSyncError(error) + } catch { + isEnabled = false + syncErrorMessage = error.localizedDescription + } + isApplyingToggle = false + } + } + + private func disableSync() { + SettingsCloudSync.shared.disableSync() + isEnabled = false + syncErrorMessage = nil + } + + private func localizedSyncError(_ error: SettingsCloudSyncError) -> String { + switch error { + case .encodeFailed, .decodeFailed: + return AppL10n.string("settings.appSettings.iCloudSync.error.generic") + } + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index b49b0f8..0675d74 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -182,6 +182,10 @@ struct SettingsView: View { Divider().background(palette.divider) cursorDragNavigationToggleRow + + Divider().background(palette.divider) + + SettingsICloudSyncRow() } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .overlay( diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index a85d4a0..772c633 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -368,6 +368,11 @@ "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" = "Sync settings via iCloud"; +"settings.appSettings.iCloudSync.subtitle" = "Keep engine, language, polish, and Flow preferences in sync across your devices. API keys stay on each device."; +"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later."; + /* v0.3.0: Polish intensity */ "settings.polishIntensity.title" = "Polish intensity"; @@ -381,15 +386,14 @@ "settings.flow.inactivity.3h" = "3 hours"; "settings.flow.inactivity.12h" = "12 hours"; "settings.flow.inactivity.24h" = "24 hours"; +"settings.localModels.customLM.title" = "Use custom language model"; +"settings.localModels.customLM.subtitle" = "Diagnostic switch. Turn off to test pure Apple on-device recognition if local ASR gets stuck or returns no speech."; /* Cold-start handoff (scheme B) */ "flow.coldStart.title" = "Voice is ready"; -"flow.coldStart.swipeHint" = "Swipe up from the bottom edge to return to your previous app."; -"flow.coldStart.swipeHint.withSystemBack" = "Tap ‹ in the top-left or swipe up from the bottom to return."; -"flow.coldStart.dismiss" = "Got it"; -"flow.coldStart.swipeAccessibility" = "Swipe up from the bottom to return"; -"flow.coldStart.alert.title" = "Voice is ready"; -"flow.coldStart.alert.message" = "You can return to continue typing, or swipe up from the bottom."; +"flow.coldStart.swipeHint" = "Swipe right along the bar at the bottom to return to your previous app."; +"flow.coldStart.swipeAccessibility" = "Swipe right along the bottom bar to return"; +"flow.coldStart.tapToDismiss" = "Tap anywhere to close"; "flow.coldStart.return.named" = "Return to %@"; "flow.coldStart.return.generic" = "Return to app"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index c1d3f52..8c7f611 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -367,6 +367,11 @@ "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" = "在多台设备间同步引擎、语言、润色和 Flow 偏好。API 密钥仍保留在各设备本地。"; +"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。"; + /* v0.3.0: 润色强度 */ "settings.polishIntensity.title" = "润色强度"; @@ -380,15 +385,14 @@ "settings.flow.inactivity.3h" = "3 小时"; "settings.flow.inactivity.12h" = "12 小时"; "settings.flow.inactivity.24h" = "24 小时"; +"settings.localModels.customLM.title" = "使用自定义语言模型"; +"settings.localModels.customLM.subtitle" = "诊断开关。本地识别卡住或提示未识别时,可关闭它测试纯 Apple 端侧识别。"; /* 冷启动兜底(方案 B) */ "flow.coldStart.title" = "语音已就绪"; -"flow.coldStart.swipeHint" = "从屏幕底部边缘向上滑动,返回上一个 App。"; -"flow.coldStart.swipeHint.withSystemBack" = "点左上角 ‹ 或从底部向上滑动返回。"; -"flow.coldStart.dismiss" = "知道了"; -"flow.coldStart.swipeAccessibility" = "从底部向上滑动返回"; -"flow.coldStart.alert.title" = "语音已就绪"; -"flow.coldStart.alert.message" = "可返回继续输入,或从底部向上滑动返回。"; +"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动,返回上一个 App。"; +"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回"; +"flow.coldStart.tapToDismiss" = "点按屏幕关闭"; "flow.coldStart.return.named" = "返回%@"; "flow.coldStart.return.generic" = "返回 App"; diff --git a/OSGKeyboardExt/Services/KeyboardConfigSync.swift b/OSGKeyboardExt/Services/KeyboardConfigSync.swift index 2f4b8fe..1c4216e 100644 --- a/OSGKeyboardExt/Services/KeyboardConfigSync.swift +++ b/OSGKeyboardExt/Services/KeyboardConfigSync.swift @@ -72,7 +72,10 @@ final class KeyboardConfigSync { func syncOnboardingStateFromAppGroup() { let store = AppGroupStore() - state.hasCompletedOnboarding = store.hasCompletedOnboarding + // Fall back to the reboot-durable Keychain marker so a device restart + // does not resurrect the in-keyboard onboarding overlay when the App + // Group value transiently reads empty. + state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding() state.onboardingPage = store.onboardingPage } diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 7f11841..616d3cb 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -36,6 +36,8 @@ final class KeyboardFlowCoordinator { private var wasFlowSessionActive = false private var flowSessionMonitorTask: Task? private var isAwaitingFlowResult = false + private var lastFlowAutoStartAttempt: TimeInterval = 0 + private static let flowAutoStartCooldown: TimeInterval = 20 init( state: KeyboardState, @@ -87,10 +89,16 @@ final class KeyboardFlowCoordinator { refreshFlowPartialIfNeeded() consumePendingFlowDeliveryIfNeeded() - let active = FlowSessionBridge.isSessionActive() - state.flowSessionActive = active + recoverFromDeadHostIfNeeded() - if wasFlowSessionActive && !active && !isFlowRecording && !isPendingFlowStart { + if FlowSessionBridge.clearIfHostStale() { + debug("cleared zombie Flow session from App Group") + } + + let reachable = FlowSessionBridge.isHostReachable() + state.flowSessionActive = reachable + + if wasFlowSessionActive && !reachable && !isFlowRecording && !isPendingFlowStart { switch state.phase { case .recording, .processing: break @@ -98,7 +106,9 @@ final class KeyboardFlowCoordinator { showFlowSessionExpiredHint() } } - wasFlowSessionActive = active + wasFlowSessionActive = reachable + + maybeAutoStartFlowSession() } func toggleRecording() { @@ -135,7 +145,13 @@ final class KeyboardFlowCoordinator { detectAndStoreAppContext() - if FlowSessionBridge.isSessionActive() { + let reachable = FlowSessionBridge.isHostReachable() + debug( + "pressBegan hostReachable=\(reachable) " + + "staleness=\(FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil") " + + "container=\(AppGroup.containerPathForDiagnostics)" + ) + if reachable { startFlowRecording() } else { beginFlowStart() @@ -153,6 +169,7 @@ final class KeyboardFlowCoordinator { stopUtteranceCountdown() ExtensionScreenWakeLock.release() FlowSessionBridge.setRecordingState(.stopped) + debug("pressEnded wrote .stopped (readback=\(FlowSessionBridge.recordingState().rawValue))") state.phase = .processing state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") startFlowResultWatchdog() @@ -225,11 +242,63 @@ final class KeyboardFlowCoordinator { } } - if isPendingFlowStart, FlowSessionBridge.isSessionActive() { + if isPendingFlowStart, FlowSessionBridge.isHostReachable() { completeFlowStartHandoff() } } + /// When the host process died mid-utterance, abort local recording / waiting + /// so the user is not stuck until the long result watchdog fires. + private func recoverFromDeadHostIfNeeded() { + guard FlowSessionBridge.isHostStale() else { return } + + if isFlowRecording { + isFlowRecording = false + stopUtteranceCountdown() + ExtensionScreenWakeLock.release() + FlowSessionBridge.setRecordingState(.aborted) + stopFlowWatchdog() + state.level = 0 + state.phase = .idle + state.lastTranscript = "" + debug("aborted recording — host heartbeat zombie") + return + } + + if isAwaitingFlowResult { + failHostDisconnected() + } + } + + /// Restores the pre-ABCD behaviour: when the keyboard appears and the host + /// is not reachable, automatically jump to the main app to start Flow. + private func maybeAutoStartFlowSession() { + guard !FlowSessionBridge.isHostReachable() else { return } + guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return } + guard hasFullAccess(), AppGroup.isAvailable else { return } + guard case .idle = state.phase else { return } + + let now = Date().timeIntervalSince1970 + guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return } + lastFlowAutoStartAttempt = now + beginFlowStart() + } + + private func failHostDisconnected() { + isAwaitingFlowResult = false + isFlowRecording = false + isPendingFlowStart = false + stopUtteranceCountdown() + ExtensionScreenWakeLock.release() + FlowSessionBridge.setRecordingState(.aborted) + stopFlowWatchdog() + state.level = 0 + let message = ExtL10n.string("keyboard.flow.hostDisconnected") + state.phase = .error(.flowSessionExpired, message: message) + scheduleAutoClearError() + debug("host disconnected while awaiting Flow result") + } + private func showFlowSessionExpiredHint() { let message = ExtL10n.string("keyboard.flow.sessionExpired") state.phase = .error(.flowSessionExpired, message: message) @@ -252,6 +321,10 @@ final class KeyboardFlowCoordinator { } private func startFlowRecording() { + guard FlowSessionBridge.isHostReachable() else { + beginFlowStart() + return + } isPendingFlowStart = false flowStartDeadline = 0 stopFlowWatchdog() @@ -266,7 +339,9 @@ final class KeyboardFlowCoordinator { } startUtteranceCountdown() startFlowLevelWatchdog() - debug("startFlowRecording") + // Read back in-process to confirm the write landed before we rely on + // the host polling it out cross-process. + debug("startFlowRecording wrote .recording (readback=\(FlowSessionBridge.recordingState().rawValue))") } private func startUtteranceCountdown() { @@ -305,7 +380,7 @@ final class KeyboardFlowCoordinator { stopFlowWatchdog() flowWatchdogTask = Task { @MainActor [weak self] in while let self, !Task.isCancelled, self.isPendingFlowStart { - if FlowSessionBridge.isSessionActive() { + if FlowSessionBridge.isHostReachable() { self.completeFlowStartHandoff() return } @@ -362,17 +437,20 @@ final class KeyboardFlowCoordinator { isAwaitingFlowResult = true let startedAt = Date().timeIntervalSince1970 let resultTimeout = FlowWatchdog.resultTimeout(engineMode: state.engineMode) + debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)") flowWatchdogTask = Task { @MainActor [weak self] in while let self, !Task.isCancelled { if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() { self.isAwaitingFlowResult = false self.stopFlowWatchdog() + self.debug("resultWatchdog consumed delivery len=\(delivery.text.count)") self.textInserter.handleFlowTranscript(delivery) return } if let error = FlowSessionBridge.consumeTranscriptionError() { self.isAwaitingFlowResult = false self.stopFlowWatchdog() + self.debug("resultWatchdog consumed error kind=\(error.kind.rawValue)") self.state.phase = .error( .fromFlowTranscription(error), message: error.message @@ -382,9 +460,22 @@ final class KeyboardFlowCoordinator { } self.refreshFlowPartialIfNeeded() let now = Date().timeIntervalSince1970 + let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity + if staleness > FlowSessionKeys.heartbeatZombieInterval { + self.debug("resultWatchdog: host heartbeat zombie (staleness=\(String(format: "%.1f", staleness))s)") + self.failHostDisconnected() + return + } + if !FlowSessionBridge.isHostReachable(), + now - startedAt > FlowSessionKeys.keyboardHostDisconnectFailFast { + self.debug("resultWatchdog: host unreachable after \(String(format: "%.1f", now - startedAt))s") + self.failHostDisconnected() + return + } if now - startedAt > resultTimeout { self.isAwaitingFlowResult = false self.stopFlowWatchdog() + 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) self.scheduleAutoClearError() diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 704e997..581eb9d 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -139,6 +139,7 @@ "keyboard.flow.startingSession" = "Starting voice session…"; "keyboard.flow.transcribing" = "Transcribing…"; "keyboard.flow.resultTimeout" = "Timed out waiting for transcription. Try again."; +"keyboard.flow.hostDisconnected" = "Voice session disconnected. Open OSGKeyboard to restart."; "keyboard.flow.manualOpenHost" = "Could not auto-open the app. Open OSGKeyboard from the Home Screen, then return."; /* Keyboard status badges */ diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index 22c69bb..630ea1d 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -139,6 +139,7 @@ "keyboard.flow.startingSession" = "正在启动语音会话…"; "keyboard.flow.transcribing" = "识别中…"; "keyboard.flow.resultTimeout" = "等待识别结果超时,请重试"; +"keyboard.flow.hostDisconnected" = "语音会话已断开,请打开 OSGKeyboard 重新启动"; "keyboard.flow.manualOpenHost" = "无法自动跳转,请从主屏幕打开 OSGKeyboard 后返回"; /* Keyboard status badges */ diff --git a/OSGKeyboardShared/Constants/AppGroup.swift b/OSGKeyboardShared/Constants/AppGroup.swift index deb3eaa..b35a90f 100644 --- a/OSGKeyboardShared/Constants/AppGroup.swift +++ b/OSGKeyboardShared/Constants/AppGroup.swift @@ -23,6 +23,15 @@ public enum AppGroup { ) != nil }() + /// On-disk container path (or `"nil"`) for diagnostics. When this reads + /// `nil` in a process, that process cannot share App Group state — the + /// `CFPrefsPlistSource … Container: (null)` console warning comes from here. + public static var containerPathForDiagnostics: String { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: identifier + )?.path ?? "nil" + } + /// Shared UserDefaults when the App Group suite is available; `nil` otherwise. /// /// Prefer this in Release builds and in the keyboard extension so callers diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index 4730a02..6a1b802 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -32,10 +32,16 @@ public struct AppGroupConfiguration: Sendable, Equatable { public static let personalDictionary = "config.personalDictionary.v1" /// When true, the main app mirrors the personal dictionary via iCloud KVS. public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled" + /// When true, the main app mirrors user settings via iCloud KVS. + public static let settingsICloudSyncEnabled = "config.settings.iCloudSyncEnabled" + /// Wall-clock stamp of the last settings blob applied from iCloud KVS. + public static let settingsCloudUpdatedAt = "config.settings.cloudUpdatedAt" /// When true, the host app auto-returns to the source app after a cold-start handoff. public static let flowSkipAppSwitch = "config.flowSkipAppSwitch" /// Raw `FlowInactivityDuration` value; session expires after this idle window. public static let flowInactivityDuration = "config.flowInactivityDuration" + /// Diagnostic switch: when false, local ASR skips the custom language model. + public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled" } // MARK: - Stored fields @@ -57,10 +63,14 @@ public struct AppGroupConfiguration: Sendable, Equatable { public var personalDictionary: PersonalDictionary /// Opt-in iCloud KVS sync for the personal dictionary (main app only). public var personalDictionaryICloudSyncEnabled: Bool + /// Opt-in iCloud KVS sync for user settings (main app only). + public var settingsICloudSyncEnabled: Bool /// Auto-return to the host app after `startflow` cold start (default on). public var flowSkipAppSwitch: Bool /// Idle timeout before the Flow session ends; resets on each utterance. public var flowInactivityDuration: FlowInactivityDuration + /// Whether local `SpeechAnalyzer` should attach the prepared custom language model. + public var localASRCustomLanguageModelEnabled: Bool // MARK: - Derived @@ -164,6 +174,12 @@ public struct AppGroupConfiguration: Sendable, Equatable { } return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled) }(), + settingsICloudSyncEnabled: { + if defaults.object(forKey: Keys.settingsICloudSyncEnabled) == nil { + return true + } + return defaults.bool(forKey: Keys.settingsICloudSyncEnabled) + }(), flowSkipAppSwitch: { if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil { return true @@ -172,7 +188,13 @@ public struct AppGroupConfiguration: Sendable, Equatable { }(), flowInactivityDuration: FlowInactivityDuration.fromStored( defaults.string(forKey: Keys.flowInactivityDuration) - ) + ), + localASRCustomLanguageModelEnabled: { + if defaults.object(forKey: Keys.localASRCustomLanguageModelEnabled) == nil { + return true + } + return defaults.bool(forKey: Keys.localASRCustomLanguageModelEnabled) + }() ) let preset = LLMProvider.provider(id: config.providerId) @@ -222,7 +244,9 @@ public struct AppGroupConfiguration: Sendable, Equatable { defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity) defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch) defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration) + defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled) defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled) + defaults.set(settingsICloudSyncEnabled, forKey: Keys.settingsICloudSyncEnabled) Self.encodePersonalDictionary(personalDictionary, to: defaults) } diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index a49e571..07252f4 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -78,6 +78,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { guard !isApplyingConfiguration, hasCompletedOnboarding != configuration.hasCompletedOnboarding else { return } configuration.hasCompletedOnboarding = hasCompletedOnboarding + // Mirror to the reboot-durable Keychain marker so a device restart + // can never resurrect the onboarding flow (or lose a replay reset). + let newValue = hasCompletedOnboarding + OSGLog.config.info("[onboarding] didSet → \(newValue, privacy: .public), mirroring to Keychain") + Keychain.setOnboardingCompleted(hasCompletedOnboarding) if hasCompletedOnboarding { configuration.onboardingPage = 0 onboardingPage = 0 @@ -190,6 +195,19 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } } + /// Diagnostic switch: disable to isolate whether the custom language model + /// is causing local SpeechAnalyzer to return empty results. + @Published public var localASRCustomLanguageModelEnabled: Bool { + didSet { + guard !isApplyingConfiguration, + localASRCustomLanguageModelEnabled != configuration.localASRCustomLanguageModelEnabled else { + return + } + configuration.localASRCustomLanguageModelEnabled = localASRCustomLanguageModelEnabled + persistConfiguration() + } + } + 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. @@ -221,6 +239,28 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { self.defaults = resolvedDefaults self.configuration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults) + // Onboarding completion must survive a device reboot. App Group + // UserDefaults can transiently read empty right after boot, which would + // falsely re-show onboarding. Trust the durable Keychain marker when the + // App Group value looks unset, and backfill it once the App Group value + // is confirmed true (covers users onboarded before this safeguard). + let appGroupOnboarding = configuration.hasCompletedOnboarding + let keychainOnboarding = Keychain.hasCompletedOnboarding() + // Distinguish "key absent" (nil → plist not loaded / data-protection race) + // from "key present == false" (something actually wrote false). + let rawKeyPresent = resolvedDefaults.object(forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding) != nil + OSGLog.config.info( + "[onboarding] init: appGroup=\(appGroupOnboarding, privacy: .public) (keyPresent=\(rawKeyPresent, privacy: .public)), keychain=\(keychainOnboarding, privacy: .public)" + ) + if appGroupOnboarding { + Keychain.setOnboardingCompleted(true) + } else if keychainOnboarding { + configuration.hasCompletedOnboarding = true + OSGLog.config.info("[onboarding] init: App Group read false but Keychain true → restored to true") + } + let finalOnboarding = configuration.hasCompletedOnboarding + OSGLog.config.info("[onboarding] init: final=\(finalOnboarding, privacy: .public)") + isApplyingConfiguration = true providerId = configuration.providerId baseURL = configuration.baseURL @@ -239,6 +279,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { polishIntensity = configuration.polishIntensity flowSkipAppSwitch = configuration.flowSkipAppSwitch flowInactivityDuration = configuration.flowInactivityDuration + localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled isApplyingConfiguration = false } @@ -255,6 +296,55 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { if postConfigChanged { AppGroupConfigDarwin.postConfigChanged() } + scheduleSettingsCloudPushIfEnabled() + } + + /// Re-read App Group defaults after a cloud pull updates the cache. + public func reloadFromPersistedStorage() { + var fresh = AppGroupConfiguration.load(fromAvailable: defaults) + // Keep the reboot-durable onboarding marker authoritative across cloud + // pulls, matching the resilience applied at init. + let freshOnboarding = fresh.hasCompletedOnboarding + let keychainOnboarding = Keychain.hasCompletedOnboarding() + OSGLog.config.info( + "[onboarding] reload: appGroup=\(freshOnboarding, privacy: .public), keychain=\(keychainOnboarding, privacy: .public)" + ) + if freshOnboarding { + Keychain.setOnboardingCompleted(true) + } else if keychainOnboarding { + fresh.hasCompletedOnboarding = true + OSGLog.config.info("[onboarding] reload: App Group read false but Keychain true → restored to true") + } + isApplyingConfiguration = true + configuration = fresh + providerId = fresh.providerId + baseURL = fresh.baseURL + model = fresh.model + modeId = fresh.modeId + localeId = fresh.localeId + engineMode = fresh.engineMode + hasCompletedOnboarding = fresh.hasCompletedOnboarding + onboardingPage = fresh.onboardingPage + hasAcknowledgedCloudSharing = fresh.hasAcknowledgedCloudSharing + uiLanguage = fresh.uiLanguage + translationTargetLocaleId = fresh.translationTargetLocaleId + handednessPreference = fresh.handednessPreference + cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled + polishIntensity = fresh.polishIntensity + flowSkipAppSwitch = fresh.flowSkipAppSwitch + flowInactivityDuration = fresh.flowInactivityDuration + localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled + isSyncingProviderAPIKey = true + apiKey = fresh.apiKey + isSyncingProviderAPIKey = false + isApplyingConfiguration = false + } + + private func scheduleSettingsCloudPushIfEnabled() { + guard configuration.settingsICloudSyncEnabled else { return } + Task { @MainActor in + try? await SettingsCloudSync.shared.pushLocalIfEnabled() + } } public func apply(preset: LLMProvider) { @@ -284,11 +374,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { apiKey = "" model = preset.defaultModel handednessPreference = .left + localASRCustomLanguageModelEnabled = true hasAcknowledgedCloudSharing = false configuration.providerId = preset.id configuration.baseURL = preset.defaultBaseURL configuration.model = preset.defaultModel configuration.handednessPreference = .left + configuration.localASRCustomLanguageModelEnabled = true configuration.hasAcknowledgedCloudSharing = false isApplyingConfiguration = false persistConfiguration() diff --git a/OSGKeyboardShared/Models/SyncedAppSettings.swift b/OSGKeyboardShared/Models/SyncedAppSettings.swift new file mode 100644 index 0000000..aa5a8cd --- /dev/null +++ b/OSGKeyboardShared/Models/SyncedAppSettings.swift @@ -0,0 +1,107 @@ +// SyncedAppSettings.swift +// OSGKeyboard · Shared +// +// User-facing app settings mirrored through iCloud KVS. Excludes +// device-local state (onboarding progress, detected app context, +// personal dictionary blob, and API keys in Keychain). + +import Foundation + +public struct SyncedAppSettings: Codable, Sendable, Equatable { + public var updatedAt: Date + public var providerId: String + public var baseURL: String + public var model: String + public var modeId: String + public var localeId: String + public var engineMode: String + public var hasAcknowledgedCloudSharing: Bool + public var uiLanguage: AppUILanguage + public var translationTargetLocaleId: String + public var handednessPreference: HandednessPreference + public var cursorDragNavigationEnabled: Bool + public var polishIntensity: PolishIntensity + public var flowSkipAppSwitch: Bool + public var flowInactivityDuration: FlowInactivityDuration + + public init( + updatedAt: Date = Date(), + providerId: String, + baseURL: String, + model: String, + modeId: String, + localeId: String, + engineMode: String, + hasAcknowledgedCloudSharing: Bool, + uiLanguage: AppUILanguage, + translationTargetLocaleId: String, + handednessPreference: HandednessPreference, + cursorDragNavigationEnabled: Bool, + polishIntensity: PolishIntensity, + flowSkipAppSwitch: Bool, + flowInactivityDuration: FlowInactivityDuration + ) { + self.updatedAt = updatedAt + self.providerId = providerId + self.baseURL = baseURL + self.model = model + self.modeId = modeId + self.localeId = localeId + self.engineMode = engineMode + self.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing + self.uiLanguage = uiLanguage + self.translationTargetLocaleId = translationTargetLocaleId + self.handednessPreference = handednessPreference + self.cursorDragNavigationEnabled = cursorDragNavigationEnabled + self.polishIntensity = polishIntensity + self.flowSkipAppSwitch = flowSkipAppSwitch + self.flowInactivityDuration = flowInactivityDuration + } +} + +public extension SyncedAppSettings { + /// Build a cloud payload from the current App Group configuration. + static func from(configuration: AppGroupConfiguration, updatedAt: Date = Date()) -> SyncedAppSettings { + SyncedAppSettings( + updatedAt: updatedAt, + providerId: configuration.providerId, + baseURL: configuration.baseURL, + model: configuration.model, + modeId: configuration.modeId, + localeId: configuration.localeId, + engineMode: configuration.engineMode, + hasAcknowledgedCloudSharing: configuration.hasAcknowledgedCloudSharing, + uiLanguage: configuration.uiLanguage, + translationTargetLocaleId: configuration.translationTargetLocaleId, + handednessPreference: configuration.handednessPreference, + cursorDragNavigationEnabled: configuration.cursorDragNavigationEnabled, + polishIntensity: configuration.polishIntensity, + flowSkipAppSwitch: configuration.flowSkipAppSwitch, + flowInactivityDuration: configuration.flowInactivityDuration + ) + } + + /// Apply syncable fields onto a configuration, preserving device-local + /// fields such as onboarding progress and the personal dictionary. + func applying(to configuration: inout AppGroupConfiguration) { + configuration.providerId = providerId + configuration.baseURL = baseURL + configuration.model = model + configuration.modeId = modeId + configuration.localeId = localeId + configuration.engineMode = engineMode + configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing + configuration.uiLanguage = uiLanguage + configuration.translationTargetLocaleId = translationTargetLocaleId + configuration.handednessPreference = handednessPreference + configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled + configuration.polishIntensity = polishIntensity + configuration.flowSkipAppSwitch = flowSkipAppSwitch + configuration.flowInactivityDuration = flowInactivityDuration + } + + /// Last-write-wins merge for whole settings blobs. + static func merge(local: SyncedAppSettings, remote: SyncedAppSettings) -> SyncedAppSettings { + remote.updatedAt >= local.updatedAt ? remote : local + } +} diff --git a/OSGKeyboardShared/Services/ASRService.swift b/OSGKeyboardShared/Services/ASRService.swift index 8602358..0f068ae 100644 --- a/OSGKeyboardShared/Services/ASRService.swift +++ b/OSGKeyboardShared/Services/ASRService.swift @@ -198,28 +198,30 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { func warmup(locale: Locale) async { guard let resolvedLocale = await DictationTranscriber.supportedLocale(equivalentTo: locale) else { + Self.debug("warmup locale unsupported requested=\(locale.identifier(.bcp47))") return } let localeID = resolvedLocale.identifier(.bcp47) let cachedLocaleID = lock.withLock { chunkPreparedLocaleID } if cachedLocaleID == localeID, lock.withLock({ chunkAnalyzerFormat != nil }) { + Self.debug("warmup cache hit locale=\(localeID)") return } - let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription( - locale: resolvedLocale - ) - let transcriber = CustomLanguageModelManager.makeDictationTranscriber( - locale: resolvedLocale, - lmConfiguration: lmConfiguration + let setup = Self.makeDiagnosticTranscriber(locale: resolvedLocale) + Self.debug( + "warmup start locale=\(localeID) customLMEnabled=\(setup.customLanguageModelEnabled) " + + "customLMAttached=\(setup.usesCustomLanguageModel) " + + "clmState=\(Self.describeCLMState(setup.clmState))" ) do { - try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale) + try await Self.prepareAssetsIfNeeded(for: setup.transcriber, locale: resolvedLocale) guard let format = await SpeechAnalyzer.bestAvailableAudioFormat( - compatibleWith: [transcriber], + compatibleWith: [setup.transcriber], considering: Self.captureFormat ) else { + Self.debug("warmup format unsupported locale=\(localeID)") return } lock.withLock { @@ -236,13 +238,25 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { guard !samples.isEmpty else { return .success("") } if Task.isCancelled { return .cancelled } + let startedAt = Date() + let rms = Self.rms(of: samples) + Self.debug( + "chunk start samples=\(samples.count) rms=\(String(format: "%.4f", rms)) " + + "locale=\(locale.identifier(.bcp47))" + ) do { let text = try await transcribeSamples(samples, locale: locale, reuseChunkPrep: true) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + Self.debug( + "chunk success textLen=\(trimmed.count) elapsed=\(Self.elapsed(startedAt))s " + + "empty=\(trimmed.isEmpty)" + ) return trimmed.isEmpty ? .success("") : .success(trimmed) } catch is CancellationError { + Self.debug("chunk cancelled elapsed=\(Self.elapsed(startedAt))s") return .cancelled } catch { + Self.debug("chunk failed elapsed=\(Self.elapsed(startedAt))s error=\(error.localizedDescription)") return .failure(error.localizedDescription) } } @@ -257,12 +271,11 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { throw ASRChunkError.localeUnsupported } let localeID = resolvedLocale.identifier(.bcp47) - let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription( - locale: resolvedLocale - ) - let transcriber = CustomLanguageModelManager.makeDictationTranscriber( - locale: resolvedLocale, - lmConfiguration: lmConfiguration + let setup = Self.makeDiagnosticTranscriber(locale: resolvedLocale) + Self.debug( + "chunk setup locale=\(localeID) customLMEnabled=\(setup.customLanguageModelEnabled) " + + "customLMAttached=\(setup.usesCustomLanguageModel) " + + "clmState=\(Self.describeCLMState(setup.clmState))" ) let analyzerFormat: AVAudioFormat @@ -271,10 +284,14 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { cachedPrep.0 == localeID, let cached = cachedPrep.1 { analyzerFormat = cached + Self.debug( + "chunk using cached analyzer format sr=\(Int(cached.sampleRate)) " + + "channels=\(cached.channelCount) common=\(cached.commonFormat.rawValue)" + ) } else { - try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale) + try await Self.prepareAssetsIfNeeded(for: setup.transcriber, locale: resolvedLocale) guard let format = await SpeechAnalyzer.bestAvailableAudioFormat( - compatibleWith: [transcriber], + compatibleWith: [setup.transcriber], considering: Self.captureFormat ) else { throw ASRChunkError.formatUnsupported @@ -284,6 +301,10 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { chunkPreparedLocaleID = localeID chunkAnalyzerFormat = format } + Self.debug( + "chunk prepared analyzer format sr=\(Int(format.sampleRate)) " + + "channels=\(format.channelCount) common=\(format.commonFormat.rawValue)" + ) } let snapshot = AudioBufferSnapshot(samples: samples, sampleRate: 16_000) @@ -291,12 +312,12 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { throw ASRChunkError.formatUnsupported } - let analyzer = SpeechAnalyzer(modules: [transcriber]) + let analyzer = SpeechAnalyzer(modules: [setup.transcriber]) try await analyzer.prepareToAnalyze(in: analyzerFormat) let resultsTask = Task { var accumulator = ProgressiveDictationTranscriptAccumulator() - for try await result in transcriber.results { + for try await result in setup.transcriber.results { if Task.isCancelled { break } let text = String(result.text.characters) _ = accumulator.ingest(range: result.range, text: text) @@ -369,15 +390,15 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { } // Each pipelined chunk is ≤ 30 s; long dictation preset keeps a // single chunk coherent (Flow utterances run up to 3 min). - let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription( - locale: resolvedLocale - ) - let transcriber = CustomLanguageModelManager.makeDictationTranscriber( - locale: resolvedLocale, - lmConfiguration: lmConfiguration + let setup = Self.makeDiagnosticTranscriber(locale: resolvedLocale) + Self.debug( + "stream setup locale=\(resolvedLocale.identifier(.bcp47)) " + + "customLMEnabled=\(setup.customLanguageModelEnabled) " + + "customLMAttached=\(setup.usesCustomLanguageModel) " + + "clmState=\(Self.describeCLMState(setup.clmState))" ) do { - try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale) + try await Self.prepareAssetsIfNeeded(for: setup.transcriber, locale: resolvedLocale) } catch { Self.debug("asset prepare failed: \(error.localizedDescription)") continuation.yield(.error(SharedL10n.string("error.asr.assetsNotReady"))) @@ -385,11 +406,11 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { return } - let newAnalyzer = SpeechAnalyzer(modules: [transcriber]) + let newAnalyzer = SpeechAnalyzer(modules: [setup.transcriber]) self.lock.withLock { self.analyzer = newAnalyzer } guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat( - compatibleWith: [transcriber], + compatibleWith: [setup.transcriber], considering: Self.captureFormat ) else { continuation.yield(.error(SharedL10n.string("error.asr.formatUnsupported"))) @@ -405,7 +426,7 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { // while `analyzeSequence` drains the input stream. let resultsTask = Task { var accumulator = ProgressiveDictationTranscriptAccumulator() - for try await result in transcriber.results { + for try await result in setup.transcriber.results { if Task.isCancelled { break } let text = String(result.text.characters) guard let full = accumulator.ingest(range: result.range, text: text) else { @@ -457,23 +478,91 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { } } + private struct DiagnosticTranscriber { + let transcriber: DictationTranscriber + let customLanguageModelEnabled: Bool + let usesCustomLanguageModel: Bool + let clmState: CustomLanguageModelManager.PrepareState + } + + private static func makeDiagnosticTranscriber(locale: Locale) -> DiagnosticTranscriber { + let defaults = AppGroup.defaultsIfAvailable + let clmKey = AppGroupConfiguration.Keys.localASRCustomLanguageModelEnabled + let clmEnabled = defaults?.object(forKey: clmKey) == nil + ? true + : (defaults?.bool(forKey: clmKey) ?? true) + let clmState = CustomLanguageModelManager.shared.currentState() + let lmConfiguration = clmEnabled + ? CustomLanguageModelManager.shared.configurationForTranscription(locale: locale) + : nil + let transcriber = CustomLanguageModelManager.makeDictationTranscriber( + locale: locale, + lmConfiguration: lmConfiguration + ) + return DiagnosticTranscriber( + transcriber: transcriber, + customLanguageModelEnabled: clmEnabled, + usesCustomLanguageModel: lmConfiguration != nil, + clmState: clmState + ) + } + + private static func describeCLMState(_ state: CustomLanguageModelManager.PrepareState) -> String { + switch state { + case .idle: + return "idle" + case .preparing: + return "preparing" + case .ready: + return "ready" + case .failed(let message): + return "failed(\(message))" + } + } + + private static func rms(of samples: [Float]) -> Float { + guard !samples.isEmpty else { return 0 } + var sum: Float = 0 + for sample in samples { + sum += sample * sample + } + return sqrtf(sum / Float(samples.count)) + } + + private static func elapsed(_ start: Date) -> String { + String(format: "%.2f", Date().timeIntervalSince(start)) + } + private static func debug(_ message: String) { - #if DEBUG - print("🎙️[ASRService] \(message)") - #endif + OSGLog.asr.info("\(message, privacy: .public)") } private static func prepareAssetsIfNeeded( for transcriber: DictationTranscriber, locale: Locale ) async throws { + let localeID = locale.identifier(.bcp47) + let startedAt = Date() do { _ = try await AssetInventory.reserve(locale: locale) + Self.debug("asset reserve ok locale=\(localeID)") } catch { - // Reservation may already exist or slots are full; continue. + // Reservation may already exist or slots are full; continue, but + // log it so local-ASR setup failures are not hidden behind a later + // "no speech" timeout. + Self.debug("asset reserve non-fatal locale=\(localeID) error=\(error.localizedDescription)") } - if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { - try await request.downloadAndInstall() + do { + if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { + Self.debug("asset install required locale=\(localeID)") + try await request.downloadAndInstall() + Self.debug("asset install done locale=\(localeID) elapsed=\(elapsed(startedAt))s") + } else { + Self.debug("asset already installed locale=\(localeID) elapsed=\(elapsed(startedAt))s") + } + } catch { + Self.debug("asset prepare failed locale=\(localeID) elapsed=\(elapsed(startedAt))s error=\(error.localizedDescription)") + throw error } } diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 8553c3c..e9de78d 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -57,6 +57,7 @@ public struct AppGroupStore: @unchecked Sendable { public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline } public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride } public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput } + public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled } /// Whether the keyboard top-bar translation chip should render. public var isTranslationChipVisible: Bool { true } @@ -112,6 +113,10 @@ public struct AppGroupStore: @unchecked Sendable { mutateConfiguration { $0.polishIntensity = intensity } } + public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) { + mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled } + } + public var hasCompletedOnboarding: Bool { get { configuration.hasCompletedOnboarding } set { setHasCompletedOnboarding(newValue) } @@ -129,6 +134,8 @@ public struct AppGroupStore: @unchecked Sendable { config.onboardingPage = 0 } } + // Mirror to the reboot-durable Keychain marker (keyboard-side completion). + Keychain.setOnboardingCompleted(completed) } public func setOnboardingPage(_ page: Int) { @@ -167,6 +174,22 @@ public struct AppGroupStore: @unchecked Sendable { mutateConfiguration { $0.personalDictionaryICloudSyncEnabled = enabled } } + public var settingsICloudSyncEnabled: Bool { + get { configuration.settingsICloudSyncEnabled } + set { setSettingsICloudSyncEnabled(newValue) } + } + + public func setSettingsICloudSyncEnabled(_ enabled: Bool) { + mutateConfiguration { $0.settingsICloudSyncEnabled = enabled } + } + + /// Timestamp of the last settings blob applied from iCloud KVS. + public var settingsCloudUpdatedAt: Date? { + let raw = defaults.double(forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt) + guard raw > 0 else { return nil } + return Date(timeIntervalSince1970: raw) + } + // MARK: - Client public func makeClient() -> LLMClient { diff --git a/OSGKeyboardShared/Services/FlowContinuousCapture.swift b/OSGKeyboardShared/Services/FlowContinuousCapture.swift index ae52601..78db702 100644 --- a/OSGKeyboardShared/Services/FlowContinuousCapture.swift +++ b/OSGKeyboardShared/Services/FlowContinuousCapture.swift @@ -123,6 +123,48 @@ private final class FlowLevelStore: @unchecked Sendable { } } +/// Route-adaptive downsampling converter, safe to call from the realtime tap. +/// +/// `AVAudioEngine.installTap(format:)` traps with an **uncatchable** NSException +/// when the format passed to it does not match the input node's *live* format. +/// After an audio-route change — which the on-device `SpeechAnalyzer` triggers +/// during warmup by reconfiguring the shared `AVAudioSession` — the value +/// returned by `inputNode.outputFormat(forBus:)` can lag behind the real +/// hardware rate (e.g. it reports 48 kHz while the node has already switched to +/// 24 kHz). Installing a tap with that stale explicit format crashes the whole +/// app (`Failed to create tap due to format mismatch`). +/// +/// We therefore install the tap with `format: nil` (which always uses the +/// node's live format) and rebuild the sample-rate converter *here* whenever the +/// 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) + let targetFormat: AVAudioFormat + + 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 + } + guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else { + state = nil + return nil + } + state = (converter, sourceFormat) + return converter + } + } +} + @MainActor public final class FlowContinuousCapture { @@ -169,16 +211,18 @@ public final class FlowContinuousCapture { private let drainTracker = FlowCaptureDrainTracker() private let tailSampleCounter = OSAllocatedUnfairLock(initialState: 0) - private var audioConverter: AVAudioConverter? + private var downsampler: AdaptiveDownsampler? private var targetFormat: AVAudioFormat? private var hwFormat: AVAudioFormat? private var drainPolicy = FlowCaptureTailDrainPolicy.flowDefault private var didInstallTap = false private var isRunning = false + private var isRebuilding = false private var routeObserver: NSObjectProtocol? private var interruptionObserver: NSObjectProtocol? + private var mediaResetObserver: NSObjectProtocol? private let log = Logger(subsystem: "com.osgkeyboard.shared", category: "FlowCapture") public init() {} @@ -227,11 +271,11 @@ public final class FlowContinuousCapture { ) else { throw StartError.formatCreateFailed } - guard let converter = AVAudioConverter(from: hardwareFormat, to: resolvedTargetFormat) else { - throw StartError.converterCreateFailed - } - audioConverter = converter + // Route-adaptive converter: it rebuilds itself from the live buffer + // format inside the tap, so it never assumes a fixed hardware rate. + let downsampler = AdaptiveDownsampler(targetFormat: resolvedTargetFormat) + self.downsampler = downsampler targetFormat = resolvedTargetFormat hwFormat = hardwareFormat @@ -249,9 +293,7 @@ public final class FlowContinuousCapture { let tailCounter = tailSampleCounter let policy = drainPolicy let tap = Self.makeAudioTapBlock( - converter: converter, - targetFormat: resolvedTargetFormat, - hwFormat: hardwareFormat, + downsampler: downsampler, gate: gateLock, levelStore: levels, prerollStore: preroll, @@ -260,7 +302,10 @@ public final class FlowContinuousCapture { tailSampleCounter: tailCounter, drainPolicy: policy ) - inputNode.installTap(onBus: 0, bufferSize: 4096, format: hardwareFormat, block: tap) + // `format: nil` binds the tap to the input node's *live* format. Passing + // an explicit (possibly stale) format here is what crashed the app on a + // route change (48 kHz client vs 24 kHz hardware); nil can never mismatch. + inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil, block: tap) didInstallTap = true audioEngine.prepare() @@ -287,7 +332,7 @@ public final class FlowContinuousCapture { audioEngine.stop() } isRunning = false - audioConverter = nil + downsampler = nil targetFormat = nil hwFormat = nil try? AVAudioSession.sharedInstance().setActive( @@ -339,14 +384,35 @@ public final class FlowContinuousCapture { } } } + // Apple QA1749: when the system media server resets, the engine, + // converter and audio session all become orphaned and must be + // rebuilt from scratch — otherwise capture silently produces no + // audio (another cause of "waveform moves but ASR is empty"). + if mediaResetObserver == nil { + mediaResetObserver = center.addObserver( + forName: AVAudioSession.mediaServicesWereResetNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.handleMediaServicesReset() } + } + } } private func removeSessionObservers() { let center = NotificationCenter.default if let routeObserver { center.removeObserver(routeObserver) } if let interruptionObserver { center.removeObserver(interruptionObserver) } + if let mediaResetObserver { center.removeObserver(mediaResetObserver) } routeObserver = nil interruptionObserver = nil + mediaResetObserver = nil + } + + private func handleMediaServicesReset() { + guard isRunning else { return } + log.info("Media services were reset — rebuilding engine and converter") + rebuildEngine() } private func handleRouteChange(reasonRaw: UInt?) { @@ -388,7 +454,9 @@ public final class FlowContinuousCapture { /// Stop and rebuild the engine against the current route, keeping /// `isRunning` intact so the session survives the swap transparently. private func rebuildEngine() { - guard isRunning else { return } + guard isRunning, !isRebuilding else { return } + isRebuilding = true + defer { isRebuilding = false } if audioEngine.isRunning { audioEngine.stop() } @@ -436,9 +504,15 @@ public final class FlowContinuousCapture { try? await Task.sleep(nanoseconds: FlowCaptureConstants.drainPollIntervalNs) } - let flushSamples = flushConverterTailToStream() - tailSampleCounter.withLock { $0 += flushSamples } - + // NOTE: We intentionally do NOT signal `.endOfStream` to the shared + // downsampling converter here. `AVAudioConverter` is stateful: once its + // input block returns `.endOfStream`, the converter is permanently + // finished and every subsequent `.haveData` conversion (from the live + // tap) returns no data — which silently starved every utterance after + // the first (Apple docs + AVAudioConverter reuse guidance). Trailing + // speech is already preserved by the live `.draining` forwarding loop + // above; the converter's sub-millisecond internal filter tail is not + // worth poisoning a session-long converter for. streamRelay.finish() gate.withLock { $0 = .idle } @@ -466,58 +540,10 @@ public final class FlowContinuousCapture { levelStore.snapshot() } - // MARK: - Converter flush - - @discardableResult - private func flushConverterTailToStream() -> Int { - guard let converter = audioConverter, - let targetFormat, - let hwFormat else { - return 0 - } - - var flushedSamples = 0 - let capacity = AVAudioFrameCount(max(512, hwFormat.sampleRate / 20)) - guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { - return 0 - } - - var endOfStreamSignaled = false - while true { - outBuffer.frameLength = 0 - var error: NSError? - let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in - if endOfStreamSignaled { - outStatus.pointee = .noDataNow - return nil - } - endOfStreamSignaled = true - outStatus.pointee = .endOfStream - return nil - } - - if status == .error || error != nil { - break - } - guard status == .haveData, outBuffer.frameLength > 0 else { - break - } - - let snapshot = AudioBufferSnapshot(buffer: outBuffer) - guard !snapshot.samples.isEmpty else { break } - streamRelay.yield(snapshot) - flushedSamples += snapshot.samples.count - } - - return flushedSamples - } - // MARK: - Audio tap (nonisolated — runs on realtime thread) private nonisolated static func makeAudioTapBlock( - converter: AVAudioConverter, - targetFormat: AVAudioFormat, - hwFormat: AVAudioFormat, + downsampler: AdaptiveDownsampler, gate: OSAllocatedUnfairLock, levelStore: FlowLevelStore, prerollStore: FlowPrerollStore, @@ -529,8 +555,15 @@ public final class FlowContinuousCapture { return { buffer, _ in 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 / hwFormat.sampleRate + Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate ) guard outFrames > 0, let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames) diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift index 65fb653..4c54d1d 100644 --- a/OSGKeyboardShared/Services/FlowSessionBridge.swift +++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift @@ -117,9 +117,9 @@ public enum FlowSessionBridge { // MARK: - Session validity (keyboard) - /// True when the session contract is still valid (not expired). - /// Does not require a fresh heartbeat — the host may be suspended in - /// background while the continuous audio session is frozen. + /// True when the App Group session contract is still valid (not expired). + /// Does **not** mean the host process is alive — use `isHostReachable()` for + /// recording gates and "session ready" UI. public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool { let store = resolvedDefaults(defaults) guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false } @@ -128,19 +128,47 @@ public enum FlowSessionBridge { return expires > Date().timeIntervalSince1970 } + /// Seconds since the host last wrote `flowHeartbeat`; nil when never written. + public static func heartbeatStaleness(defaults: UserDefaults? = nil) -> TimeInterval? { + let store = resolvedDefaults(defaults) + let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat) + guard heartbeat > 0 else { return nil } + return Date().timeIntervalSince1970 - heartbeat + } + /// True when the host app recently wrote a heartbeat (foreground or - /// actively processing). Used for auto-start heuristics, not gating record. + /// actively processing). Gating record / "session ready" UI must use this, + /// not `isSessionActive()` alone. public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool { let store = resolvedDefaults(defaults) guard isSessionActive(defaults: store) else { return false } - - let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat) - guard heartbeat > 0 else { return false } - - let staleness = Date().timeIntervalSince1970 - heartbeat + guard let staleness = heartbeatStaleness(defaults: store) else { return false } return staleness <= FlowSessionKeys.heartbeatStaleInterval } + /// True when the session contract flag is still set but the host heartbeat + /// proves the process is gone (reboot, force-quit, long suspend). + public static func isHostStale( + staleAfter: TimeInterval = FlowSessionKeys.heartbeatZombieInterval, + defaults: UserDefaults? = nil + ) -> Bool { + let store = resolvedDefaults(defaults) + guard isSessionActive(defaults: store) else { return false } + guard let staleness = heartbeatStaleness(defaults: store) else { return true } + return staleness > staleAfter + } + + /// Clears orphaned App Group Flow state when the host is provably dead. + @discardableResult + public static func clearIfHostStale( + staleAfter: TimeInterval = FlowSessionKeys.heartbeatZombieInterval, + defaults: UserDefaults? = nil + ) -> Bool { + guard isHostStale(staleAfter: staleAfter, defaults: defaults) else { return false } + clearFlowState(defaults: defaults) + return true + } + public static func sessionExpiresAt(defaults: UserDefaults? = nil) -> TimeInterval? { let store = resolvedDefaults(defaults) let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires) diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift index b5a8856..7ea238e 100644 --- a/OSGKeyboardShared/Services/FlowSessionKeys.swift +++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift @@ -26,9 +26,16 @@ public enum FlowSessionKeys { /// Wall-clock timestamp of the last utterance completion or session start. public static let lastActivityAt = "flow.lastActivityAt" - /// Heartbeat older than this while the host is foreground → likely killed. + /// Heartbeat older than this → host is not actively reachable for recording. public static let heartbeatStaleInterval: TimeInterval = 3 + /// Session flag still set but heartbeat older than this → host process is + /// dead (force-quit, reboot). Keyboard / host should clear persisted state. + public static let heartbeatZombieInterval: TimeInterval = 60 + + /// After mic stop, fail fast when the host heartbeat is gone longer than this. + public static let keyboardHostDisconnectFailFast: TimeInterval = 15 + /// Legacy fixed session length — prefer `FlowSessionPolicy.sessionDuration()`. public static let defaultSessionDuration: TimeInterval = 480 diff --git a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift new file mode 100644 index 0000000..a186f84 --- /dev/null +++ b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift @@ -0,0 +1,70 @@ +// AppCloudSync.swift +// OSGKeyboard · Shared +// +// Single entry point for iCloud KVS sync in the main app: preferences +// toggles, settings payload, and personal dictionary. + +import Foundation + +@MainActor +public final class AppCloudSync { + public static let shared = AppCloudSync() + + private let kvs: UbiquitousKeyValueStoreing + private let makeStore: () -> AppGroupStore + private let settingsSync: SettingsCloudSync + private let dictionarySync: PersonalDictionaryCloudSync + private var externalChangeObserver: NSObjectProtocol? + + public init( + kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default, + makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }, + settingsSync: SettingsCloudSync? = nil, + dictionarySync: PersonalDictionaryCloudSync? = nil + ) { + self.kvs = kvs + self.makeStore = makeStore + self.settingsSync = settingsSync ?? SettingsCloudSync(kvs: kvs, makeStore: makeStore) + self.dictionarySync = dictionarySync ?? PersonalDictionaryCloudSync(kvs: kvs, makeStore: makeStore) + } + + public func startObservingExternalChanges() { + guard externalChangeObserver == nil else { return } + externalChangeObserver = NotificationCenter.default.addObserver( + forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.pullAllIfEnabled() + } + } + } + + public func stopObservingExternalChanges() { + if let externalChangeObserver { + NotificationCenter.default.removeObserver(externalChangeObserver) + self.externalChangeObserver = nil + } + } + + /// Launch / foreground: refresh KVS toggles, then pull payloads. + public func pullAllIfEnabled() async { + let store = makeStore() + ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store) + + let toggles = ICloudSyncPreferences.load(from: kvs, store: store) + ICloudSyncPreferences.cacheToAppGroup( + settingsEnabled: toggles.settings, + dictionaryEnabled: toggles.dictionary, + store: store + ) + + await settingsSync.pullAndMergeIfEnabled() + await dictionarySync.pullAndMergeIfEnabled() + } + + public var settingsSyncService: SettingsCloudSync { settingsSync } + public var dictionarySyncService: PersonalDictionaryCloudSync { dictionarySync } +} diff --git a/OSGKeyboardShared/Services/ICloudSync/ICloudSyncPreferences.swift b/OSGKeyboardShared/Services/ICloudSync/ICloudSyncPreferences.swift new file mode 100644 index 0000000..f3d6f05 --- /dev/null +++ b/OSGKeyboardShared/Services/ICloudSync/ICloudSyncPreferences.swift @@ -0,0 +1,53 @@ +// ICloudSyncPreferences.swift +// OSGKeyboard · Shared +// +// iCloud KVS is the source of truth for cross-device sync toggles +// (scheme A). App Group UserDefaults keeps a local cache so the +// keyboard extension and offline UI can read the last-known state. + +import Foundation + +public enum ICloudSyncPreferences { + public static let settingsEnabledKey = "iCloudSync.settingsEnabled" + public static let dictionaryEnabledKey = "personalDictionary.syncEnabled" + + /// Read sync toggles from KVS, falling back to the App Group cache + /// when a key has not been uploaded yet. + public static func load(from kvs: UbiquitousKeyValueStoreing, store: AppGroupStore) -> (settings: Bool, dictionary: Bool) { + let settings = kvs.object(forKey: settingsEnabledKey) as? Bool + ?? store.settingsICloudSyncEnabled + let dictionary = kvs.object(forKey: dictionaryEnabledKey) as? Bool + ?? store.personalDictionaryICloudSyncEnabled + return (settings, dictionary) + } + + /// Mirror KVS toggles into the App Group cache. + public static func cacheToAppGroup( + settingsEnabled: Bool, + dictionaryEnabled: Bool, + store: AppGroupStore + ) { + store.setSettingsICloudSyncEnabled(settingsEnabled) + store.setPersonalDictionaryICloudSyncEnabled(dictionaryEnabled) + } + + public static func pushSettingsEnabled(_ enabled: Bool, kvs: UbiquitousKeyValueStoreing) { + kvs.set(enabled, forKey: settingsEnabledKey) + _ = kvs.synchronize() + } + + public static func pushDictionaryEnabled(_ enabled: Bool, kvs: UbiquitousKeyValueStoreing) { + kvs.set(enabled, forKey: dictionaryEnabledKey) + _ = kvs.synchronize() + } + + /// One-time migration: upload locally cached toggles when KVS has no value yet. + public static func migrateLegacyTogglesIfNeeded(kvs: UbiquitousKeyValueStoreing, store: AppGroupStore) { + if kvs.object(forKey: dictionaryEnabledKey) == nil { + pushDictionaryEnabled(store.personalDictionaryICloudSyncEnabled, kvs: kvs) + } + if kvs.object(forKey: settingsEnabledKey) == nil { + pushSettingsEnabled(store.settingsICloudSyncEnabled, kvs: kvs) + } + } +} diff --git a/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift new file mode 100644 index 0000000..4d1e8d6 --- /dev/null +++ b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift @@ -0,0 +1,137 @@ +// SettingsCloudSync.swift +// OSGKeyboard · Shared +// +// Mirrors user-facing app settings through iCloud KVS. API keys stay +// in Keychain and are never uploaded. + +import Foundation + +public extension Notification.Name { + /// Posted after remote settings are applied to the App Group cache. + static let settingsDidSyncFromCloud = Notification.Name( + "com.osgkeyboard.settings.didSyncFromCloud" + ) +} + +public enum SettingsCloudSyncError: Error, Equatable, Sendable { + case encodeFailed + case decodeFailed +} + +@MainActor +public final class SettingsCloudSync { + public static let shared = SettingsCloudSync() + + public static let kvsKey = "appSettings.v1" + + private let kvs: UbiquitousKeyValueStoreing + private let makeStore: () -> AppGroupStore + + public init( + kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default, + makeStore: @escaping () -> AppGroupStore = { AppGroupStore() } + ) { + self.kvs = kvs + self.makeStore = makeStore + } + + public func pullAndMergeIfEnabled() async { + let store = makeStore() + guard store.settingsICloudSyncEnabled else { return } + await pullAndMerge(store: store) + } + + public func pushLocalIfEnabled() async throws { + let store = makeStore() + guard store.settingsICloudSyncEnabled else { return } + let local = SyncedAppSettings.from(configuration: store.configurationSnapshot()) + try push(local) + } + + public func enableSync() async throws { + let store = makeStore() + ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs) + ICloudSyncPreferences.cacheToAppGroup( + settingsEnabled: true, + dictionaryEnabled: store.personalDictionaryICloudSyncEnabled, + store: store + ) + + let local = SyncedAppSettings.from(configuration: store.configurationSnapshot()) + let remote = loadRemote() ?? local + let merged = SyncedAppSettings.merge(local: local, remote: remote) + apply(merged, to: store, postNotification: false) + try push(merged) + NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil) + } + + public func disableSync() { + let store = makeStore() + ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs) + store.setSettingsICloudSyncEnabled(false) + } + + public func pullAndMerge(store: AppGroupStore) async { + guard store.settingsICloudSyncEnabled else { return } + guard let remote = loadRemote() else { return } + + let local = SyncedAppSettings.from( + configuration: store.configurationSnapshot(), + updatedAt: store.settingsCloudUpdatedAt ?? .distantPast + ) + let merged = SyncedAppSettings.merge(local: local, remote: remote) + guard merged != local else { return } + + apply(merged, to: store, postNotification: true) + } + + public func push(_ settings: SyncedAppSettings) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(settings) else { + throw SettingsCloudSyncError.encodeFailed + } + kvs.set(data, forKey: Self.kvsKey) + _ = kvs.synchronize() + } + + public func loadRemote() -> SyncedAppSettings? { + guard let data = kvs.data(forKey: Self.kvsKey) else { return nil } + return try? decode(data) + } + + public func decode(_ data: Data) throws -> SyncedAppSettings { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let settings = try? decoder.decode(SyncedAppSettings.self, from: data) else { + throw SettingsCloudSyncError.decodeFailed + } + return settings + } + + private func apply( + _ settings: SyncedAppSettings, + to store: AppGroupStore, + postNotification: Bool + ) { + var config = store.configurationSnapshot() + settings.applying(to: &config) + store.saveConfiguration(config, settingsCloudUpdatedAt: settings.updatedAt) + if postNotification { + AppGroupConfigDarwin.postConfigChanged() + NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil) + } + } +} + +private extension AppGroupStore { + func configurationSnapshot() -> AppGroupConfiguration { + AppGroupConfiguration.load(fromAvailable: defaults) + } + + func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) { + let config = configuration + config.save(to: defaults) + defaults.set(settingsCloudUpdatedAt.timeIntervalSince1970, forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt) + } +} diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 3a9e8ee..0a21b78 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -80,7 +80,8 @@ public final class KeyboardState: ObservableObject { @Published public var onDeviceSupported: Bool = false /// Seconds remaining in the current utterance (Flow tap-to-talk). @Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration) - /// Whether the host app's Flow voice session is currently valid. + /// Whether the host app's Flow voice session is live and reachable (fresh + /// heartbeat). Do not use the App Group session flag alone for UI gating. @Published public var flowSessionActive: Bool = false /// When true, the mic is intentionally disabled (e.g. cloud engine /// selected but the provider-specific API key is missing). diff --git a/OSGKeyboardShared/Services/Keychain.swift b/OSGKeyboardShared/Services/Keychain.swift index 73b7967..3d6b670 100644 --- a/OSGKeyboardShared/Services/Keychain.swift +++ b/OSGKeyboardShared/Services/Keychain.swift @@ -180,4 +180,76 @@ public enum Keychain: @unchecked Sendable { throw KeychainError.unexpectedStatus(status) } } + + // MARK: - Onboarding completion (reboot-durable flag) + + // App Group UserDefaults can transiently read empty right after a device + // reboot (data protection / `cfprefsd` not warmed), which made the app + // falsely re-show onboarding. This Keychain marker uses the same + // `AfterFirstUnlockThisDeviceOnly` class — reliably readable once the app + // can run, device-local, never synced — so it stays a trustworthy fallback + // that survives the App Group read race. + private static let onboardingService = "com.osgkeyboard.onboarding" + private static let onboardingAccount = "hasCompletedOnboarding" + + /// Durable "user finished onboarding" marker. `false` when unset or unreadable. + public static func hasCompletedOnboarding() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: onboardingService, + kSecAttrAccount as String: onboardingAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let str = String(data: data, encoding: .utf8) else { + OSGLog.config.info("[onboarding] Keychain read: status=\(status, privacy: .public) → false") + return false + } + let completed = str == "1" + OSGLog.config.info( + "[onboarding] Keychain read: status=ok value=\(str, privacy: .public) → \(completed, privacy: .public)" + ) + return completed + } + + /// Mirror the onboarding-completed flag. Best-effort and idempotent — a + /// no-op when the stored value already matches, so it can be called from + /// frequently-saved config paths without Keychain churn. + public static func setOnboardingCompleted(_ completed: Bool) { + guard hasCompletedOnboarding() != completed else { + OSGLog.config.info("[onboarding] Keychain write skipped (already \(completed, privacy: .public))") + return + } + + let baseQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: onboardingService, + kSecAttrAccount as String: onboardingAccount, + ] + + guard completed else { + let delStatus = SecItemDelete(baseQuery as CFDictionary) + OSGLog.config.info("[onboarding] Keychain delete: status=\(delStatus, privacy: .public)") + return + } + + let data = Data("1".utf8) + let updateStatus = SecItemUpdate( + baseQuery as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecItemNotFound { + var addQuery = baseQuery + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + OSGLog.config.info("[onboarding] Keychain add: status=\(addStatus, privacy: .public)") + } else { + OSGLog.config.info("[onboarding] Keychain update: status=\(updateStatus, privacy: .public)") + } + } } diff --git a/OSGKeyboardShared/Services/LiveDictationController.swift b/OSGKeyboardShared/Services/LiveDictationController.swift index 8a82692..fa4d6ac 100644 --- a/OSGKeyboardShared/Services/LiveDictationController.swift +++ b/OSGKeyboardShared/Services/LiveDictationController.swift @@ -565,7 +565,12 @@ public final class LiveDictationController: ObservableObject { try? await Task.sleep(nanoseconds: 20_000_000) } - _ = flushConverterTailToStream() + // Trailing speech is preserved by the live `.draining` forwarding + // loop above. We deliberately do NOT signal `.endOfStream` to the + // converter to squeeze its internal filter tail: that both races the + // still-running audio-thread tap on the same non-thread-safe converter + // and (in reused-converter paths) permanently locks it. The dropped + // tail is sub-millisecond and inaudible. streamRelay.finish() teardownCaptureEngine() captureGate.withLock { $0 = .idle } @@ -580,50 +585,6 @@ public final class LiveDictationController: ObservableObject { ) } - @discardableResult - private func flushConverterTailToStream() -> Int { - guard let converter = audioConverter, - let targetFormat, - let hwFormat else { - return 0 - } - - var flushedSamples = 0 - let capacity = AVAudioFrameCount(max(512, hwFormat.sampleRate / 20)) - guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { - return 0 - } - - var endOfStreamSignaled = false - while true { - outBuffer.frameLength = 0 - var error: NSError? - let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in - if endOfStreamSignaled { - outStatus.pointee = .noDataNow - return nil - } - endOfStreamSignaled = true - outStatus.pointee = .endOfStream - return nil - } - - if status == .error || error != nil { - break - } - guard status == .haveData, outBuffer.frameLength > 0 else { - break - } - - let snapshot = AudioBufferSnapshot(buffer: outBuffer) - guard !snapshot.samples.isEmpty else { break } - streamRelay.yield(snapshot) - flushedSamples += snapshot.samples.count - } - - return flushedSamples - } - private func teardownCaptureEngine() { if didInstallTap { audioEngine.inputNode.removeTap(onBus: 0) diff --git a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift index 58261ba..b1aaeff 100644 --- a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift +++ b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.swift @@ -80,7 +80,12 @@ public final class PersonalDictionaryCloudSync { /// Enable sync: merge local + remote, persist locally, then upload. public func enableSync() async throws { let store = makeStore() - store.setPersonalDictionaryICloudSyncEnabled(true) + ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs) + ICloudSyncPreferences.cacheToAppGroup( + settingsEnabled: store.settingsICloudSyncEnabled, + dictionaryEnabled: true, + store: store + ) let local = store.personalDictionary let remote = loadRemote() ?? .empty @@ -90,7 +95,9 @@ public final class PersonalDictionaryCloudSync { } public func disableSync() { - makeStore().setPersonalDictionaryICloudSyncEnabled(false) + let store = makeStore() + ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs) + store.setPersonalDictionaryICloudSyncEnabled(false) } // MARK: - Core operations diff --git a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/UbiquitousKeyValueStoreing.swift b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/UbiquitousKeyValueStoreing.swift index 0581a78..5c5c767 100644 --- a/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/UbiquitousKeyValueStoreing.swift +++ b/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/UbiquitousKeyValueStoreing.swift @@ -8,6 +8,8 @@ import Foundation public protocol UbiquitousKeyValueStoreing: AnyObject { func data(forKey key: String) -> Data? func set(_ value: Data?, forKey key: String) + func object(forKey key: String) -> Any? + func set(_ value: Any?, forKey key: String) @discardableResult func synchronize() -> Bool } diff --git a/OSGKeyboardShared/Services/TranscriptPostProcessor.swift b/OSGKeyboardShared/Services/TranscriptPostProcessor.swift index 0831826..bf82888 100644 --- a/OSGKeyboardShared/Services/TranscriptPostProcessor.swift +++ b/OSGKeyboardShared/Services/TranscriptPostProcessor.swift @@ -43,6 +43,19 @@ public enum TranscriptPostProcessor: Sendable { text.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Conservative cleanup for raw ASR fallback delivery. This is used when + /// polish/translation cannot run, so it must not rewrite meaning or invent + /// punctuation; it only removes formatting artifacts that ASR/chunking can + /// introduce. + public static func cleanRawASRFallback(_ text: String) -> String { + var result = text.trimmingCharacters(in: .whitespacesAndNewlines) + result = repairMidSentenceLineBreaks(result) + result = collapseHorizontalWhitespace(result) + result = removeCJKBoundarySpaces(result) + result = normalizePunctuationSpacing(result) + return normalizeWhitespaceAndPunctuation(result) + } + // MARK: - Post-LLM pipeline /// Apply deterministic cleanup and quality gate to LLM output. @@ -220,6 +233,53 @@ public enum TranscriptPostProcessor: Sendable { return result.trimmingCharacters(in: .whitespacesAndNewlines) } + private static func collapseHorizontalWhitespace(_ text: String) -> String { + text.replacingOccurrences( + of: #"[^\S\r\n]+"#, + with: " ", + options: .regularExpression + ) + } + + private static func removeCJKBoundarySpaces(_ text: String) -> String { + var output = "" + let characters = Array(text) + for index in characters.indices { + let current = characters[index] + if current.isWhitespace, + let previous = previousNonWhitespace(in: characters, before: index), + let next = nextNonWhitespace(in: characters, after: index), + shouldDropSpaceBetween(previous: previous, next: next) { + continue + } + output.append(current) + } + return output + } + + private static func normalizePunctuationSpacing(_ text: String) -> String { + var output = "" + let characters = Array(text) + for index in characters.indices { + let current = characters[index] + if current.isWhitespace, + let next = nextNonWhitespace(in: characters, after: index), + isClosingPunctuation(next) { + continue + } + if isOpeningPunctuation(current), + let next = nextNonWhitespace(in: characters, after: index), + next.isWhitespace { + output.append(current) + continue + } + output.append(current) + } + return output + .replacingOccurrences(of: #"([(「“])\s+"#, with: "$1", options: .regularExpression) + .replacingOccurrences(of: #"\s+([,。!?;:、,.!?;:])"#, with: "$1", options: .regularExpression) + } + // MARK: - Prefix / quote cleanup public static func stripExplanatoryPrefix(from text: String) -> String { @@ -271,6 +331,41 @@ public enum TranscriptPostProcessor: Sendable { return (pAscii && nAscii) ? " " : "" } + private static func previousNonWhitespace(in characters: [Character], before index: Int) -> Character? { + guard index > characters.startIndex else { return nil } + for i in stride(from: index - 1, through: characters.startIndex, by: -1) { + if !characters[i].isWhitespace { return characters[i] } + } + return nil + } + + private static func nextNonWhitespace(in characters: [Character], after index: Int) -> Character? { + let nextIndex = index + 1 + guard nextIndex < characters.endIndex else { return nil } + for i in nextIndex.. Bool { + (isCJKCharacter(previous) && isCJKCharacter(next)) || isClosingPunctuation(next) + } + + private static func isCJKCharacter(_ character: Character) -> Bool { + character.unicodeScalars.contains(where: isCJKScalar) + } + + private static func isClosingPunctuation(_ character: Character) -> Bool { + let closing: Set = [",", "。", "!", "?", ";", ":", "、", ",", ".", "!", "?", ";", ":"] + return closing.contains(character) + } + + private static func isOpeningPunctuation(_ character: Character) -> Bool { + let opening: Set = ["(", "「", "“"] + return opening.contains(character) + } + private static func extractEmojis(from text: String) -> [String] { text.unicodeScalars.filter(isEmojiScalar).map { String($0) } } diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index 66d0528..4cdeddd 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -7,6 +7,7 @@ /* v0.2.0: flow-level warnings surfaced alongside the final transcript. */ "flow.warning.cloudPolishMissingKey" = "Cloud polish needs an API key in Settings. Inserted raw ASR text."; "flow.warning.localPolishUnavailable" = "Built-in polish is unavailable. Inserted raw ASR text."; +"flow.warning.polishDegraded" = "Weak network — inserted raw ASR text without polish."; /* LLM providers */ "provider.openai" = "OpenAI"; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index a1faf86..32b9cf3 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -7,6 +7,7 @@ /* v0.2.0: flow-level warnings surfaced alongside the final transcript. */ "flow.warning.cloudPolishMissingKey" = "云端润色需要先在设置中填写 API Key,本次已插入原始识别结果。"; "flow.warning.localPolishUnavailable" = "内置润色暂不可用,本次已插入原始识别结果。"; +"flow.warning.polishDegraded" = "弱网识别,本次未润色,已插入原始识别结果。"; /* LLM providers */ "provider.openai" = "OpenAI"; diff --git a/OSGKeyboardTests/FakeUbiquitousKeyValueStore.swift b/OSGKeyboardTests/FakeUbiquitousKeyValueStore.swift new file mode 100644 index 0000000..37bd616 --- /dev/null +++ b/OSGKeyboardTests/FakeUbiquitousKeyValueStore.swift @@ -0,0 +1,37 @@ +// FakeUbiquitousKeyValueStore.swift +// OSGKeyboardTests +// +// In-memory KVS fake for hermetic iCloud sync tests. + +import Foundation +@testable import OSGKeyboardShared + +final class FakeUbiquitousKeyValueStore: UbiquitousKeyValueStoreing, @unchecked Sendable { + private var storage: [String: Any] = [:] + + func data(forKey key: String) -> Data? { + storage[key] as? Data + } + + func set(_ value: Data?, forKey key: String) { + if let value { + storage[key] = value + } else { + storage.removeValue(forKey: key) + } + } + + func object(forKey key: String) -> Any? { + storage[key] + } + + func set(_ value: Any?, forKey key: String) { + if let value { + storage[key] = value + } else { + storage.removeValue(forKey: key) + } + } + + func synchronize() -> Bool { true } +} diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift index b544cfd..eae94bd 100644 --- a/OSGKeyboardTests/FlowSessionBridgeTests.swift +++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift @@ -23,6 +23,40 @@ final class FlowSessionBridgeTests: XCTestCase { defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat) XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults)) XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults)) + XCTAssertFalse(FlowSessionBridge.isHostStale(defaults: defaults)) + } + + func testHostStaleWhenHeartbeatVeryOld() { + let defaults = makeDefaults() + FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults) + let zombieHeartbeat = Date().timeIntervalSince1970 - 120 + defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat) + + XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults)) + XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults)) + XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults)) + } + + func testClearIfHostStaleRemovesZombieSession() { + let defaults = makeDefaults() + FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults) + FlowSessionBridge.setRecordingState(.stopped, defaults: defaults) + let zombieHeartbeat = Date().timeIntervalSince1970 - 120 + defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat) + + XCTAssertTrue(FlowSessionBridge.clearIfHostStale(defaults: defaults)) + XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults)) + XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle) + } + + func testHostStaleWhenSessionActiveButNoHeartbeat() { + let defaults = makeDefaults() + defaults.set(true, forKey: FlowSessionKeys.flowSessionActive) + defaults.set(Date().timeIntervalSince1970 + 3_600, forKey: FlowSessionKeys.flowSessionExpires) + + XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults)) + XCTAssertTrue(FlowSessionBridge.clearIfHostStale(defaults: defaults)) + XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults)) } func testSessionInactiveWhenExpired() { diff --git a/OSGKeyboardTests/IntelligentPolishTests.swift b/OSGKeyboardTests/IntelligentPolishTests.swift index 0ee0720..9f62a3e 100644 --- a/OSGKeyboardTests/IntelligentPolishTests.swift +++ b/OSGKeyboardTests/IntelligentPolishTests.swift @@ -326,6 +326,32 @@ final class IntelligentPolishTests: XCTestCase { XCTAssertEqual(output, "this is a broken sentence") } + func testCleanRawASRFallbackRemovesChineseInteriorSpaces() { + let input = " 你 是不是 已经 解决了 这个 问题 ? " + let output = TranscriptPostProcessor.cleanRawASRFallback(input) + XCTAssertEqual(output, "你是不是已经解决了这个问题?") + } + + func testCleanRawASRFallbackPreservesEnglishAndMixedSpaces() { + let input = "iOS 版本 uses Swift UI" + let output = TranscriptPostProcessor.cleanRawASRFallback(input) + XCTAssertEqual(output, "iOS 版本 uses Swift UI") + } + + @MainActor + func testFlowFallbackDeliveryCleansTextAndCarriesWeakNetworkWarning() { + let delivery = FlowSessionManager.makeFallbackDelivery( + rawText: " 你 是不是 已经 解决了 这个 问题 ? ", + error: LLMError.transport("offline"), + engineMode: "cloud", + chunkWarning: nil + ) + + XCTAssertEqual(delivery.text, "你是不是已经解决了这个问题?") + XCTAssertFalse(delivery.text.contains("未润色")) + XCTAssertEqual(delivery.polishWarning, SharedL10n.string("flow.warning.polishDegraded")) + } + func testHasStructureSignalDetectsChineseEnumeration() { XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "首先测试其次上线")) XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "第一点修复")) diff --git a/OSGKeyboardTests/PersonalDictionaryCloudSyncTests.swift b/OSGKeyboardTests/PersonalDictionaryCloudSyncTests.swift index b09fc2e..e6534dc 100644 --- a/OSGKeyboardTests/PersonalDictionaryCloudSyncTests.swift +++ b/OSGKeyboardTests/PersonalDictionaryCloudSyncTests.swift @@ -8,23 +8,7 @@ import XCTest // MARK: - Fake KVS -private final class FakeUbiquitousKeyValueStore: UbiquitousKeyValueStoreing, @unchecked Sendable { - private var storage: [String: Data] = [:] - - func data(forKey key: String) -> Data? { - storage[key] - } - - func set(_ value: Data?, forKey key: String) { - if let value { - storage[key] = value - } else { - storage.removeValue(forKey: key) - } - } - - func synchronize() -> Bool { true } -} +// See FakeUbiquitousKeyValueStore.swift // MARK: - Tests @@ -170,6 +154,7 @@ final class PersonalDictionaryCloudSyncTests: XCTestCase { try await sync.enableSync() XCTAssertTrue(store.personalDictionaryICloudSyncEnabled) + XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.dictionaryEnabledKey) as? Bool, true) XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalTerm", "RemoteTerm"])) XCTAssertNotNil(sync.loadRemote()) } diff --git a/OSGKeyboardTests/SettingsCloudSyncTests.swift b/OSGKeyboardTests/SettingsCloudSyncTests.swift new file mode 100644 index 0000000..3b3c836 --- /dev/null +++ b/OSGKeyboardTests/SettingsCloudSyncTests.swift @@ -0,0 +1,157 @@ +// SettingsCloudSyncTests.swift +// OSGKeyboardTests +// +// Hermetic tests for iCloud KVS settings sync + preference toggles. + +import XCTest +@testable import OSGKeyboardShared + +@MainActor +final class SettingsCloudSyncTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + private var store: AppGroupStore! + private var kvs: FakeUbiquitousKeyValueStore! + private var settingsSync: SettingsCloudSync! + + override func setUp() { + super.setUp() + suiteName = "group.com.osgkeyboard.shared.tests.settings.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + store = AppGroupStore(defaults: defaults) + kvs = FakeUbiquitousKeyValueStore() + settingsSync = SettingsCloudSync(kvs: kvs) { [unowned self] in store } + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + func testMergePrefersNewerUpdatedAt() { + let older = SyncedAppSettings( + updatedAt: Date(timeIntervalSince1970: 100), + providerId: "openai", + baseURL: "https://old.example", + model: "gpt-old", + modeId: "polish", + localeId: "auto", + engineMode: "cloud", + hasAcknowledgedCloudSharing: false, + uiLanguage: .english, + translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId, + handednessPreference: .left, + cursorDragNavigationEnabled: true, + polishIntensity: .medium, + flowSkipAppSwitch: true, + flowInactivityDuration: .twelveHours + ) + let newer = SyncedAppSettings( + updatedAt: Date(timeIntervalSince1970: 200), + providerId: "openai", + baseURL: "https://new.example", + model: "gpt-new", + modeId: "polish", + localeId: "zh-Hans", + engineMode: "local", + hasAcknowledgedCloudSharing: true, + uiLanguage: .chinese, + translationTargetLocaleId: "en", + handednessPreference: .right, + cursorDragNavigationEnabled: false, + polishIntensity: .light, + flowSkipAppSwitch: false, + flowInactivityDuration: .threeHours + ) + + let merged = SyncedAppSettings.merge(local: older, remote: newer) + XCTAssertEqual(merged.model, "gpt-new") + XCTAssertEqual(merged.localeId, "zh-Hans") + XCTAssertEqual(merged.engineMode, "local") + } + + func testEnableSyncUploadsMergedSettingsAndToggle() async throws { + store.setModeId("polish") + store.setLocaleId("zh-Hans") + + let remote = SyncedAppSettings( + updatedAt: Date().addingTimeInterval(3600), + providerId: "openai", + baseURL: "https://remote.example", + model: "remote-model", + modeId: "polish", + localeId: "en", + engineMode: "cloud", + hasAcknowledgedCloudSharing: true, + uiLanguage: .english, + translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId, + handednessPreference: .right, + cursorDragNavigationEnabled: true, + polishIntensity: .medium, + flowSkipAppSwitch: true, + flowInactivityDuration: .twelveHours + ) + try settingsSync.push(remote) + + try await settingsSync.enableSync() + + XCTAssertTrue(store.settingsICloudSyncEnabled) + XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.settingsEnabledKey) as? Bool, true) + XCTAssertEqual(settingsSync.loadRemote()?.localeId, "en") + } + + func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws { + store.setSettingsICloudSyncEnabled(true) + store.setLocaleId("auto") + + let remote = SyncedAppSettings( + updatedAt: Date(timeIntervalSince1970: 900), + providerId: "openai", + baseURL: "https://remote.example", + model: "remote-model", + modeId: "polish", + localeId: "ja", + engineMode: "cloud", + hasAcknowledgedCloudSharing: false, + uiLanguage: .english, + translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId, + handednessPreference: .left, + cursorDragNavigationEnabled: true, + polishIntensity: .medium, + flowSkipAppSwitch: true, + flowInactivityDuration: .twelveHours + ) + try settingsSync.push(remote) + + await settingsSync.pullAndMerge(store: store) + + XCTAssertEqual(store.localeId, "ja") + XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970, 900, accuracy: 1) + } + + func testPushLocalIfEnabledSkipsWhenDisabled() async throws { + store.setSettingsICloudSyncEnabled(false) + store.setLocaleId("ko") + + try await settingsSync.pushLocalIfEnabled() + + XCTAssertNil(settingsSync.loadRemote()) + } + + func testICloudSyncPreferencesMigrateLegacyToggles() { + store.setSettingsICloudSyncEnabled(false) + store.setPersonalDictionaryICloudSyncEnabled(true) + + ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store) + + XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.settingsEnabledKey) as? Bool, false) + XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.dictionaryEnabledKey) as? Bool, true) + } + + func testAppGroupConfigurationDefaultsSettingsICloudSyncToOn() { + let config = AppGroupConfiguration.load(fromAvailable: defaults) + XCTAssertTrue(config.settingsICloudSyncEnabled) + } +} diff --git a/project.yml b/project.yml index 3ce7a6e..383520f 100644 --- a/project.yml +++ b/project.yml @@ -35,9 +35,15 @@ settings: SWIFT_STRICT_CONCURRENCY: complete GENERATE_INFOPLIST_FILE: NO ENABLE_MODULE_VERIFIER: YES + MODULE_VERIFIER_SUPPORTED_LANGUAGES: "objective-c objective-c++" + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS: "gnu17 gnu++17" + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: YES + ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS: YES + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES + STRING_CATALOG_GENERATE_SYMBOLS: YES CLANG_CXX_LANGUAGE_STANDARD: c++17 - MARKETING_VERSION: "0.4.1" - CURRENT_PROJECT_VERSION: "17" + MARKETING_VERSION: "0.5.0" + CURRENT_PROJECT_VERSION: "18" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target @@ -325,10 +331,12 @@ targets: DYLIB_INSTALL_NAME_BASE: "@rpath" APPLICATION_EXTENSION_API_ONLY: YES ENABLE_MODULE_VERIFIER: YES - # Frameworks inherit the host app's signing identity; no profile - # is required, but pinning the team keeps the build reproducible. - CODE_SIGN_STYLE: Automatic - DEVELOPMENT_TEAM: X329MZU23S + # Embedded frameworks are re-signed by the containing app at package + # time. Signing this framework as a standalone build product is noisy + # and not recommended by Xcode. + CODE_SIGNING_ALLOWED: NO + CODE_SIGNING_REQUIRED: NO + CODE_SIGN_IDENTITY: "" dependencies: - sdk: Speech.framework - sdk: AVFoundation.framework