c2f07bd8d2
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
84 lines
3.0 KiB
Swift
84 lines
3.0 KiB
Swift
// MainAppRoot.swift
|
||
// OSGKeyboard · Main App
|
||
//
|
||
// Host-app shell that owns `ProviderConfig` and `FlowSessionManager`.
|
||
// Only constructed when `AppGroup.isAvailable` so the error path never
|
||
// touches App Group–backed singletons.
|
||
|
||
import SwiftUI
|
||
import OSGKeyboardShared
|
||
|
||
struct MainAppRoot: View {
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
|
||
@StateObject private var config = ProviderConfig.shared
|
||
@StateObject private var flowManager = FlowSessionManager()
|
||
|
||
var body: some View {
|
||
Group {
|
||
if config.hasCompletedOnboarding {
|
||
MainTabView()
|
||
} else {
|
||
OnboardingView(config: config)
|
||
}
|
||
}
|
||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||
.environmentObject(flowManager)
|
||
.overlay {
|
||
if let context = flowManager.coldStartContext {
|
||
FlowColdStartOverlay(
|
||
context: context,
|
||
onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
|
||
onDismiss: { flowManager.dismissColdStartOverlay() },
|
||
onRetry: { flowManager.retryColdStartReadiness() },
|
||
onOpenSettings: { flowManager.openColdStartPermissionSettings() }
|
||
)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
|
||
.onAppear {
|
||
flowManager.setAppForeground(scenePhase == .active)
|
||
flowManager.activateOnForeground()
|
||
AppCloudSync.shared.startObservingExternalChanges()
|
||
// Registering here also flushes any URL buffered during a cold
|
||
// launch (the keyboard → app `startflow` handoff arrives via the
|
||
// scene delegate before this view is on screen).
|
||
AppOpenURLRouter.shared.register { url in
|
||
handleIncomingURL(url)
|
||
}
|
||
Task {
|
||
await AppCloudSync.shared.pullAllIfEnabled()
|
||
}
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||
config.reloadFromPersistedStorage()
|
||
}
|
||
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||
if done {
|
||
flowManager.activateOnForeground()
|
||
}
|
||
}
|
||
.onChange(of: scenePhase) { _, phase in
|
||
flowManager.handleScenePhase(phase)
|
||
guard phase == .active else { return }
|
||
if config.hasCompletedOnboarding {
|
||
flowManager.activateOnForeground()
|
||
}
|
||
Task {
|
||
await AppCloudSync.shared.pullAllIfEnabled()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleIncomingURL(_ url: URL) {
|
||
guard url.scheme == "osgkeyboard" else { return }
|
||
switch url.host {
|
||
case "startflow":
|
||
flowManager.startSession(coldStart: true)
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|