feat: iCloud settings sync and cold-start return redesign
- Add iCloud key-value settings sync (engine/language/polish/Flow prefs); API keys stay on-device. New "Sync settings via iCloud" toggle. - Redesign cold-start handoff: bottom-bar left-to-right swipe guidance, auto-dismiss on app switch, tap-anywhere to close, retained return link. - Harden keyboard->app handoff with host-disconnected hint. - Include prior Unreleased ASR fixes (route-change crash, fallback warning, multi-utterance recognition, local ASR diagnostics). Release 0.5.0 (build 18).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<UIOpenURLContext>) {
|
||||
handle(URLContexts)
|
||||
}
|
||||
|
||||
private func handle(_ contexts: Set<UIOpenURLContext>) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var expiryTask: Task<Void, Never>?
|
||||
private var levelTask: Task<Void, Never>?
|
||||
private var startTask: Task<Void, Never>?
|
||||
/// 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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Bool> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ final class KeyboardFlowCoordinator {
|
||||
private var wasFlowSessionActive = false
|
||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||
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()
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"keyboard.flow.startingSession" = "正在启动语音会话…";
|
||||
"keyboard.flow.transcribing" = "识别中…";
|
||||
"keyboard.flow.resultTimeout" = "等待识别结果超时,请重试";
|
||||
"keyboard.flow.hostDisconnected" = "语音会话已断开,请打开 OSGKeyboard 重新启动";
|
||||
"keyboard.flow.manualOpenHost" = "无法自动跳转,请从主屏幕打开 OSGKeyboard 后返回";
|
||||
|
||||
/* Keyboard status badges */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String, Error> {
|
||||
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<String, Error> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<UtteranceGatePhase>,
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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..<characters.endIndex {
|
||||
if !characters[i].isWhitespace { return characters[i] }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func shouldDropSpaceBetween(previous: Character, next: Character) -> 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<Character> = [",", "。", "!", "?", ";", ":", "、", ",", ".", "!", "?", ";", ":"]
|
||||
return closing.contains(character)
|
||||
}
|
||||
|
||||
private static func isOpeningPunctuation(_ character: Character) -> Bool {
|
||||
let opening: Set<Character> = ["(", "「", "“"]
|
||||
return opening.contains(character)
|
||||
}
|
||||
|
||||
private static func extractEmojis(from text: String) -> [String] {
|
||||
text.unicodeScalars.filter(isEmojiScalar).map { String($0) }
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: "第一点修复"))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user