feat(macos): add macOS menu-bar app and harden cross-device iCloud sync
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.
This commit is contained in:
@@ -123,7 +123,7 @@ public enum ASREvent: Sendable, Equatable {
|
||||
public enum ASRServiceFactory {
|
||||
/// Returns on-device SpeechAnalyzer for `local`, or the user's cloud
|
||||
/// ASR provider when `engineMode == "cloud"`.
|
||||
public static func make(store: AppGroupStore = AppGroupStore()) -> ASRService {
|
||||
public static func make(store: any ConfigurationStore = AppGroupStore()) -> ASRService {
|
||||
if store.engineMode == "cloud" {
|
||||
return CloudASRService(store: store)
|
||||
}
|
||||
|
||||
@@ -16,15 +16,25 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
self.defaults = defaults
|
||||
return
|
||||
}
|
||||
guard let available = AppGroup.defaultsIfAvailable else {
|
||||
#if DEBUG
|
||||
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||
#else
|
||||
// Callers must check `AppGroup.isAvailable` before constructing.
|
||||
fatalError("App Group unavailable.")
|
||||
#endif
|
||||
if let available = AppGroup.defaultsIfAvailable {
|
||||
self.defaults = available
|
||||
return
|
||||
}
|
||||
self.defaults = available
|
||||
#if os(iOS)
|
||||
// iOS app + keyboard extension MUST share the App Group suite; a
|
||||
// silent `.standard` fallback would desync them. Keep this a hard
|
||||
// failure so a provisioning mistake is impossible to miss.
|
||||
#if DEBUG
|
||||
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||
#else
|
||||
fatalError("App Group unavailable.")
|
||||
#endif
|
||||
#else
|
||||
// macOS is a standalone menu-bar app with no keyboard extension to
|
||||
// stay in sync with, so a missing App Group container is expected;
|
||||
// fall back to the app's standard defaults.
|
||||
self.defaults = .standard
|
||||
#endif
|
||||
}
|
||||
|
||||
private var configuration: AppGroupConfiguration {
|
||||
@@ -165,6 +175,14 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
|
||||
mutateConfiguration { config in
|
||||
config.personalDictionary.entries.removeAll { $0.id == id }
|
||||
config.personalDictionary.deletedEntryIDs[id] = date
|
||||
}
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public var personalDictionaryICloudSyncEnabled: Bool {
|
||||
get { configuration.personalDictionaryICloudSyncEnabled }
|
||||
set { setPersonalDictionaryICloudSyncEnabled(newValue) }
|
||||
|
||||
@@ -16,7 +16,7 @@ public protocol CloudASRTranscribing: Sendable {
|
||||
}
|
||||
|
||||
public enum CloudASRClientFactory {
|
||||
public static func make(store: AppGroupStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
switch strategy {
|
||||
case .zhipuHotwords:
|
||||
@@ -29,7 +29,7 @@ public enum CloudASRClientFactory {
|
||||
return AlibabaFunASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
store: store,
|
||||
persistence: store.cloudASRPersistence,
|
||||
session: session
|
||||
)
|
||||
case .prompt:
|
||||
@@ -149,12 +149,12 @@ struct ZhipuCloudASRClient: CloudASRTranscribing {
|
||||
|
||||
// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context)
|
||||
|
||||
struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
/// `UserDefaults` is not `Sendable`; we only touch `persistence` on the
|
||||
/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder.
|
||||
struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
|
||||
let apiKey: String
|
||||
let model: String
|
||||
// Hold the (@unchecked Sendable) AppGroupStore rather than a raw
|
||||
// UserDefaults so this struct stays Sendable under strict concurrency.
|
||||
let store: AppGroupStore
|
||||
let persistence: UserDefaults
|
||||
let session: URLSession
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {
|
||||
@@ -162,7 +162,7 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: store.defaults,
|
||||
defaults: persistence,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
@@ -179,7 +179,7 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: store.defaults,
|
||||
defaults: persistence,
|
||||
session: session
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import Foundation
|
||||
import os
|
||||
|
||||
public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private let store: AppGroupStore
|
||||
private let store: any ConfigurationStore
|
||||
private let session: URLSession
|
||||
private let localFallback: ASRService
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
@@ -18,7 +18,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private var cancelled = false
|
||||
|
||||
public init(
|
||||
store: AppGroupStore = AppGroupStore(),
|
||||
store: any ConfigurationStore = AppGroupStore(),
|
||||
session: URLSession = .shared,
|
||||
localFallback: ASRService? = nil
|
||||
) {
|
||||
|
||||
@@ -123,6 +123,26 @@ private final class FlowLevelStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Last observed audio tap timestamp. This lets the host publish "ready"
|
||||
/// only after the microphone pipeline has produced real frames.
|
||||
private final class FlowAudioProofStore: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock(initialState: TimeInterval(0))
|
||||
|
||||
func markFrameReceived() {
|
||||
lock.withLock { $0 = Date().timeIntervalSince1970 }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lock.withLock { $0 = 0 }
|
||||
}
|
||||
|
||||
func hasRecentFrame(maxAge: TimeInterval) -> Bool {
|
||||
let timestamp = lock.withLock { $0 }
|
||||
guard timestamp > 0 else { return false }
|
||||
return Date().timeIntervalSince1970 - timestamp <= maxAge
|
||||
}
|
||||
}
|
||||
|
||||
/// Route-adaptive downsampling converter, safe to call from the realtime tap.
|
||||
///
|
||||
/// `AVAudioEngine.installTap(format:)` traps with an **uncatchable** NSException
|
||||
@@ -207,6 +227,7 @@ public final class FlowContinuousCapture {
|
||||
private let streamRelay = FlowCaptureStreamRelay()
|
||||
private let prerollStore = FlowPrerollStore()
|
||||
private let levelStore = FlowLevelStore(barCount: FlowCaptureConstants.levelBarCount)
|
||||
private let audioProofStore = FlowAudioProofStore()
|
||||
private let gate = OSAllocatedUnfairLock(initialState: UtteranceGatePhase.idle)
|
||||
private let drainTracker = FlowCaptureDrainTracker()
|
||||
private let tailSampleCounter = OSAllocatedUnfairLock(initialState: 0)
|
||||
@@ -229,12 +250,28 @@ public final class FlowContinuousCapture {
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
|
||||
/// True when the capture session flag, tap, and audio engine are all live.
|
||||
public var engineIsLive: Bool {
|
||||
isRunning && didInstallTap && audioEngine.isRunning
|
||||
}
|
||||
|
||||
/// True only when the engine is live and the input tap has recently
|
||||
/// delivered an actual audio frame.
|
||||
public func engineHasRecentAudio(maxAge: TimeInterval = 1) -> Bool {
|
||||
engineIsLive && audioProofStore.hasRecentFrame(maxAge: maxAge)
|
||||
}
|
||||
|
||||
/// Called on the main actor when `engineIsLive` may have changed.
|
||||
public var onEngineLiveChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
audioProofStore.reset()
|
||||
try activateEngine()
|
||||
isRunning = true
|
||||
installSessionObservers()
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
/// Bring up the audio session + engine for the *current* hardware route.
|
||||
@@ -289,6 +326,7 @@ public final class FlowContinuousCapture {
|
||||
let relay = streamRelay
|
||||
let preroll = prerollStore
|
||||
let levels = levelStore
|
||||
let proof = audioProofStore
|
||||
let tracker = drainTracker
|
||||
let tailCounter = tailSampleCounter
|
||||
let policy = drainPolicy
|
||||
@@ -296,6 +334,7 @@ public final class FlowContinuousCapture {
|
||||
downsampler: downsampler,
|
||||
gate: gateLock,
|
||||
levelStore: levels,
|
||||
audioProofStore: proof,
|
||||
prerollStore: preroll,
|
||||
streamRelay: relay,
|
||||
drainTracker: tracker,
|
||||
@@ -332,6 +371,7 @@ public final class FlowContinuousCapture {
|
||||
audioEngine.stop()
|
||||
}
|
||||
isRunning = false
|
||||
audioProofStore.reset()
|
||||
downsampler = nil
|
||||
targetFormat = nil
|
||||
hwFormat = nil
|
||||
@@ -339,6 +379,7 @@ public final class FlowContinuousCapture {
|
||||
false,
|
||||
options: .notifyOthersOnDeactivation
|
||||
)
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
/// Re-activate capture after returning from background without
|
||||
@@ -355,6 +396,21 @@ public final class FlowContinuousCapture {
|
||||
if !audioEngine.isRunning {
|
||||
try? audioEngine.start()
|
||||
}
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
public func awaitAudioFlowing(
|
||||
timeout: TimeInterval,
|
||||
recentFrameMaxAge: TimeInterval = 1
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
|
||||
}
|
||||
|
||||
// MARK: - Route / interruption recovery
|
||||
@@ -434,6 +490,7 @@ public final class FlowContinuousCapture {
|
||||
switch type {
|
||||
case .began:
|
||||
log.info("Audio interruption began")
|
||||
notifyEngineLiveChanged()
|
||||
case .ended:
|
||||
guard isRunning else { return }
|
||||
let shouldResume: Bool
|
||||
@@ -462,11 +519,17 @@ public final class FlowContinuousCapture {
|
||||
}
|
||||
do {
|
||||
try activateEngine()
|
||||
notifyEngineLiveChanged()
|
||||
} catch {
|
||||
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyEngineLiveChanged() {
|
||||
onEngineLiveChanged?(engineIsLive)
|
||||
}
|
||||
|
||||
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
||||
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
@@ -546,6 +609,7 @@ public final class FlowContinuousCapture {
|
||||
downsampler: AdaptiveDownsampler,
|
||||
gate: OSAllocatedUnfairLock<UtteranceGatePhase>,
|
||||
levelStore: FlowLevelStore,
|
||||
audioProofStore: FlowAudioProofStore,
|
||||
prerollStore: FlowPrerollStore,
|
||||
streamRelay: FlowCaptureStreamRelay,
|
||||
drainTracker: FlowCaptureDrainTracker,
|
||||
@@ -553,6 +617,7 @@ public final class FlowContinuousCapture {
|
||||
drainPolicy: FlowCaptureTailDrainPolicy
|
||||
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
return { buffer, _ in
|
||||
audioProofStore.markFrameReceived()
|
||||
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
|
||||
|
||||
// Derive the converter from the *live* buffer format so a mid-session
|
||||
|
||||
@@ -36,6 +36,15 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard/read side: refresh App Group defaults after the extension was
|
||||
/// suspended so decisions are not based on stale in-process caches.
|
||||
public static func reloadFromDisk(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if Thread.isMainThread {
|
||||
store.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle (host app)
|
||||
|
||||
public static func markSessionActive(
|
||||
@@ -62,12 +71,17 @@ public enum FlowSessionBridge {
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func writeHeartbeat(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
let now = Date().timeIntervalSince1970
|
||||
store.set(now, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
if store.bool(forKey: FlowSessionKeys.flowHostReady) {
|
||||
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
}
|
||||
flush(store)
|
||||
}
|
||||
|
||||
@@ -118,8 +132,7 @@ public enum FlowSessionBridge {
|
||||
// MARK: - Session validity (keyboard)
|
||||
|
||||
/// 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.
|
||||
/// Does **not** mean the host can accept utterances — use `isHostReady()`.
|
||||
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
|
||||
@@ -137,8 +150,8 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
|
||||
/// True when the host app recently wrote a heartbeat (foreground or
|
||||
/// actively processing). Gating record / "session ready" UI must use this,
|
||||
/// not `isSessionActive()` alone.
|
||||
/// actively processing). Use for zombie / disconnect detection — **not**
|
||||
/// for mic-ready UI; prefer `isHostReady()`.
|
||||
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard isSessionActive(defaults: store) else { return false }
|
||||
@@ -146,6 +159,44 @@ public enum FlowSessionBridge {
|
||||
return staleness <= FlowSessionKeys.heartbeatStaleInterval
|
||||
}
|
||||
|
||||
// MARK: - Host ready contract (host app → keyboard)
|
||||
|
||||
/// Host app: publish whether Flow can accept a new utterance right now.
|
||||
public static func setHostReady(
|
||||
_ ready: Bool,
|
||||
defaults: UserDefaults? = nil,
|
||||
notify: Bool = true
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if ready {
|
||||
let now = Date().timeIntervalSince1970
|
||||
store.set(true, forKey: FlowSessionKeys.flowHostReady)
|
||||
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
writeHeartbeat(defaults: store)
|
||||
} else {
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
}
|
||||
flush(store)
|
||||
if notify {
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
|
||||
public static func isHostReady(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard isHostReachable(defaults: store) else { return false }
|
||||
return store.bool(forKey: FlowSessionKeys.flowHostReady)
|
||||
}
|
||||
|
||||
private static func clearHostReady(defaults: UserDefaults, notify: Bool) {
|
||||
defaults.removeObject(forKey: FlowSessionKeys.flowHostReady)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
if notify {
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -346,6 +397,7 @@ public enum FlowSessionBridge {
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ public enum FlowSessionDarwin {
|
||||
public static let notificationName = "com.osgkeyboard.flow.session.changed"
|
||||
/// Posted when the host app writes a transcription result or error.
|
||||
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
|
||||
/// Posted when the host app publishes or clears the ready contract.
|
||||
public static let hostReadyNotificationName = "com.osgkeyboard.flow.host.ready.changed"
|
||||
|
||||
public static func postSessionChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
@@ -30,6 +32,16 @@ public enum FlowSessionDarwin {
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
public static func postHostReadyChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(hostReadyNotificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes Darwin notifications on a background thread; invokes
|
||||
|
||||
@@ -10,6 +10,10 @@ public enum FlowSessionKeys {
|
||||
public static let flowSessionActive = "flow.flowSessionActive"
|
||||
public static let flowSessionExpires = "flow.flowSessionExpires"
|
||||
public static let flowHeartbeat = "flow.flowHeartbeat"
|
||||
/// Host-published contract: capture + polling idle and able to accept utterances.
|
||||
public static let flowHostReady = "flow.flowHostReady"
|
||||
/// Wall-clock timestamp paired with `flowHostReady` (seconds since 1970).
|
||||
public static let flowHostReadyAt = "flow.flowHostReadyAt"
|
||||
public static let keyboardRecordingState = "flow.keyboardRecordingState"
|
||||
public static let transcriptionLanguage = "flow.transcriptionLanguage"
|
||||
public static let transcriptionResult = "flow.transcriptionResult"
|
||||
@@ -29,6 +33,9 @@ public enum FlowSessionKeys {
|
||||
/// Heartbeat older than this → host is not actively reachable for recording.
|
||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||
|
||||
/// `flowHostReadyAt` must be within this window of the latest heartbeat.
|
||||
public static let hostReadyMaxHeartbeatSkew: TimeInterval = 5
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single entry point for iCloud KVS sync in the main app: preferences
|
||||
// toggles, settings payload, and personal dictionary.
|
||||
// toggles, usage statistics, settings payload, speech history, and
|
||||
// personal dictionary.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -14,18 +15,28 @@ public final class AppCloudSync {
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let settingsSync: SettingsCloudSync
|
||||
private let dictionarySync: PersonalDictionaryCloudSync
|
||||
private let usageStatisticsSync: UsageStatisticsCloudSync
|
||||
private let speechHistorySync: SpeechHistoryCloudSync
|
||||
private var externalChangeObserver: NSObjectProtocol?
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard },
|
||||
settingsSync: SettingsCloudSync? = nil,
|
||||
dictionarySync: PersonalDictionaryCloudSync? = nil
|
||||
dictionarySync: PersonalDictionaryCloudSync? = nil,
|
||||
usageStatisticsSync: UsageStatisticsCloudSync? = nil,
|
||||
speechHistorySync: SpeechHistoryCloudSync? = nil
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.settingsSync = settingsSync ?? SettingsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.settingsSync = settingsSync
|
||||
?? SettingsCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
self.dictionarySync = dictionarySync ?? PersonalDictionaryCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.usageStatisticsSync = usageStatisticsSync
|
||||
?? UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.speechHistorySync = speechHistorySync
|
||||
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
}
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
@@ -62,9 +73,28 @@ public final class AppCloudSync {
|
||||
)
|
||||
|
||||
await settingsSync.pullAndMergeIfEnabled()
|
||||
await usageStatisticsSync.pullAndMergeIfEnabled()
|
||||
await speechHistorySync.pullAndMergeIfEnabled()
|
||||
await dictionarySync.pullAndMergeIfEnabled()
|
||||
}
|
||||
|
||||
/// Low-risk manual sync: pull remote changes, merge, then push local state.
|
||||
public func syncNow() async throws {
|
||||
let store = makeStore()
|
||||
await pullAllIfEnabled()
|
||||
|
||||
if store.settingsICloudSyncEnabled {
|
||||
try await settingsSync.pushLocalIfEnabled()
|
||||
try await usageStatisticsSync.pushLocalIfEnabled()
|
||||
try await speechHistorySync.pushLocalIfEnabled()
|
||||
}
|
||||
if store.personalDictionaryICloudSyncEnabled {
|
||||
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
public var settingsSyncService: SettingsCloudSync { settingsSync }
|
||||
public var dictionarySyncService: PersonalDictionaryCloudSync { dictionarySync }
|
||||
public var usageStatisticsSyncService: UsageStatisticsCloudSync { usageStatisticsSync }
|
||||
public var speechHistorySyncService: SpeechHistoryCloudSync { speechHistorySync }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// CloudSyncContext.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Injectable AppCloudSync instance so iOS and Mac share one sync graph.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public enum CloudSyncContext {
|
||||
private static var configured: AppCloudSync?
|
||||
|
||||
public static var shared: AppCloudSync {
|
||||
configured ?? AppCloudSync.shared
|
||||
}
|
||||
|
||||
public static func configure(_ sync: AppCloudSync) {
|
||||
configured = sync
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// SettingsCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors user-facing app settings through iCloud KVS. API keys stay
|
||||
// in Keychain and are never uploaded.
|
||||
// Mirrors user-facing app settings through iCloud KVS (`appSettings.v2`)
|
||||
// with per-field merge. API keys sync via iCloud Keychain — never KVS.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -22,17 +22,21 @@ public enum SettingsCloudSyncError: Error, Equatable, Sendable {
|
||||
public final class SettingsCloudSync {
|
||||
public static let shared = SettingsCloudSync()
|
||||
|
||||
public static let kvsKey = "appSettings.v1"
|
||||
public static let kvsKey = SyncedAppSettingsV2.kvsKey
|
||||
public static let legacyKVSKey = SyncedAppSettings.legacyKVSKey
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let historyDefaults: () -> UserDefaults
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.historyDefaults = historyDefaults
|
||||
}
|
||||
|
||||
public func pullAndMergeIfEnabled() async {
|
||||
@@ -44,8 +48,15 @@ public final class SettingsCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
try push(local)
|
||||
let deviceID = SyncDeviceID.current(defaults: store.defaults)
|
||||
let config = store.configurationSnapshot()
|
||||
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
|
||||
local = local.patchLocalChanges(from: config, deviceID: deviceID)
|
||||
saveLocalPayload(local, to: store.defaults)
|
||||
|
||||
let remote = loadRemote()
|
||||
let toPush = remote.map { SyncedAppSettingsV2.merge(local: local, remote: $0) } ?? local
|
||||
try push(toPush)
|
||||
}
|
||||
|
||||
public func enableSync() async throws {
|
||||
@@ -57,12 +68,26 @@ public final class SettingsCloudSync {
|
||||
store: store
|
||||
)
|
||||
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
Keychain.migrateLocalKeysToICloud()
|
||||
|
||||
let deviceID = SyncDeviceID.current(defaults: store.defaults)
|
||||
let config = store.configurationSnapshot()
|
||||
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
|
||||
local = local.patchLocalChanges(from: config, deviceID: deviceID)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedAppSettings.merge(local: local, remote: remote)
|
||||
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
|
||||
apply(merged, to: store, postNotification: false)
|
||||
try push(merged)
|
||||
|
||||
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
|
||||
let statisticsSync = UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
try await statisticsSync.mergeAndPushIfEnabled()
|
||||
let historySync = SpeechHistoryCloudSync(
|
||||
kvs: kvs,
|
||||
makeStore: makeStore,
|
||||
historyDefaults: historyDefaults
|
||||
)
|
||||
try await historySync.mergeAndPushIfEnabled()
|
||||
}
|
||||
|
||||
public func disableSync() {
|
||||
@@ -75,17 +100,19 @@ public final class SettingsCloudSync {
|
||||
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 }
|
||||
let deviceID = SyncDeviceID.current(defaults: store.defaults)
|
||||
let config = store.configurationSnapshot()
|
||||
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
|
||||
local = local.patchLocalChanges(from: config, deviceID: deviceID)
|
||||
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
|
||||
var trial = config
|
||||
merged.applying(to: &trial)
|
||||
guard trial != config else { return }
|
||||
|
||||
apply(merged, to: store, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ settings: SyncedAppSettings) throws {
|
||||
public func push(_ settings: SyncedAppSettingsV2) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(settings) else {
|
||||
@@ -95,12 +122,28 @@ public final class SettingsCloudSync {
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedAppSettings? {
|
||||
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
|
||||
return try? decode(data)
|
||||
public func loadRemote() -> SyncedAppSettingsV2? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decodeV2(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey),
|
||||
let legacy = try? decodeLegacy(legacyData) else {
|
||||
return nil
|
||||
}
|
||||
let deviceID = SyncDeviceID.current()
|
||||
return SyncedAppSettingsV2.migrated(from: legacy, deviceID: deviceID)
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> SyncedAppSettings {
|
||||
public func decodeV2(_ data: Data) throws -> SyncedAppSettingsV2 {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let settings = try? decoder.decode(SyncedAppSettingsV2.self, from: data) else {
|
||||
throw SettingsCloudSyncError.decodeFailed
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
public func decodeLegacy(_ data: Data) throws -> SyncedAppSettings {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let settings = try? decoder.decode(SyncedAppSettings.self, from: data) else {
|
||||
@@ -110,18 +153,42 @@ public final class SettingsCloudSync {
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ settings: SyncedAppSettings,
|
||||
_ settings: SyncedAppSettingsV2,
|
||||
to store: AppGroupStore,
|
||||
postNotification: Bool
|
||||
) {
|
||||
var config = store.configurationSnapshot()
|
||||
settings.applying(to: &config)
|
||||
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.updatedAt)
|
||||
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.latestUpdatedAt)
|
||||
saveLocalPayload(settings, to: store.defaults)
|
||||
if postNotification {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLocalPayload(
|
||||
from defaults: UserDefaults,
|
||||
configuration: AppGroupConfiguration,
|
||||
deviceID: String
|
||||
) -> SyncedAppSettingsV2 {
|
||||
if let data = defaults.data(forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2),
|
||||
let payload = try? JSONDecoder().decode(SyncedAppSettingsV2.self, from: data) {
|
||||
return payload
|
||||
}
|
||||
let stamp = defaults.object(forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt) as? TimeInterval
|
||||
let updatedAt = stamp.map { Date(timeIntervalSince1970: $0) } ?? .distantPast
|
||||
return SyncedAppSettingsV2.seeded(
|
||||
from: configuration,
|
||||
deviceID: deviceID,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private func saveLocalPayload(_ payload: SyncedAppSettingsV2, to defaults: UserDefaults) {
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
defaults.set(data, forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppGroupStore {
|
||||
@@ -132,6 +199,9 @@ private extension AppGroupStore {
|
||||
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
|
||||
let config = configuration
|
||||
config.save(to: defaults)
|
||||
defaults.set(settingsCloudUpdatedAt.timeIntervalSince1970, forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt)
|
||||
defaults.set(
|
||||
settingsCloudUpdatedAt.timeIntervalSince1970,
|
||||
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SpeechHistoryCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors speech history through iCloud KVS when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after remote speech history is applied locally.
|
||||
static let speechHistoryDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.speechHistory.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum SpeechHistoryCloudSyncError: Error, Equatable, Sendable {
|
||||
case payloadTooLarge(byteCount: Int)
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class SpeechHistoryCloudSync {
|
||||
public static let shared = SpeechHistoryCloudSync()
|
||||
|
||||
public static let kvsKey = SyncedSpeechHistory.kvsKey
|
||||
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let historyDefaults: () -> UserDefaults
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.historyDefaults = historyDefaults
|
||||
}
|
||||
|
||||
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 = SpeechHistoryStorage.load(from: historyDefaults())
|
||||
try push(local)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote history.
|
||||
public func mergeAndPushIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
||||
apply(merged, to: defaults, postNotification: false)
|
||||
try push(merged)
|
||||
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
apply(merged, to: defaults, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ history: SyncedSpeechHistory) throws {
|
||||
let data = try encode(history)
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedSpeechHistory? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decode(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
|
||||
return try? decode(legacyData)
|
||||
}
|
||||
|
||||
public func encode(_ history: SyncedSpeechHistory) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(history) else {
|
||||
throw SpeechHistoryCloudSyncError.encodeFailed
|
||||
}
|
||||
guard data.count <= Self.maxPayloadBytes else {
|
||||
throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: data.count)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> SyncedSpeechHistory {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let history = try? decoder.decode(SyncedSpeechHistory.self, from: data) else {
|
||||
throw SpeechHistoryCloudSyncError.decodeFailed
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ history: SyncedSpeechHistory,
|
||||
to defaults: UserDefaults,
|
||||
postNotification: Bool
|
||||
) {
|
||||
SpeechHistoryStorage.save(history, to: defaults)
|
||||
if postNotification {
|
||||
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SyncDeviceID.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Stable per-install identifier for per-field / per-device iCloud merge.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SyncDeviceID {
|
||||
private static let defaultsKey = "sync.deviceID.v1"
|
||||
|
||||
/// Returns a stable device id stored in the active defaults suite.
|
||||
public static func current(defaults: UserDefaults = AppGroupStore().defaults) -> String {
|
||||
if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let created = UUID().uuidString
|
||||
defaults.set(created, forKey: defaultsKey)
|
||||
return created
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// UsageStatisticsCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors cumulative usage statistics through iCloud KVS (`usageStatistics.v2`)
|
||||
// using per-device G-Counter merge when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after remote usage statistics are applied locally.
|
||||
static let usageStatisticsDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.usageStatistics.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum UsageStatisticsCloudSyncError: Error, Equatable, Sendable {
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class UsageStatisticsCloudSync {
|
||||
public static let shared = UsageStatisticsCloudSync()
|
||||
|
||||
public static let kvsKey = SyncedUsageStatisticsV2.kvsKey
|
||||
public static let legacyKVSKey = SyncedUsageStatisticsStorage.legacyStorageKey
|
||||
|
||||
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 = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
try push(local)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote totals.
|
||||
public func mergeAndPushIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
|
||||
apply(merged, to: store.defaults, postNotification: false)
|
||||
try push(merged)
|
||||
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
apply(merged, to: store.defaults, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ stats: SyncedUsageStatisticsV2) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(stats) else {
|
||||
throw UsageStatisticsCloudSyncError.encodeFailed
|
||||
}
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
/// Removes the cumulative-stats payload from iCloud KVS. Used by the
|
||||
/// one-time cleanup that clears data corrupted by the pre-fix
|
||||
/// double-counting bug so it can't be pulled back onto other devices.
|
||||
public func purgeRemote() {
|
||||
kvs.set(Data?.none, forKey: Self.kvsKey)
|
||||
kvs.set(Data?.none, forKey: Self.legacyKVSKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedUsageStatisticsV2? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decodeV2(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
|
||||
let deviceID = SyncDeviceID.current()
|
||||
guard let legacy = try? decodeLegacy(legacyData) else { return nil }
|
||||
return SyncedUsageStatisticsV2.migrated(from: legacy, deviceID: deviceID)
|
||||
}
|
||||
|
||||
public func decodeV2(_ data: Data) throws -> SyncedUsageStatisticsV2 {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let stats = try? decoder.decode(SyncedUsageStatisticsV2.self, from: data) else {
|
||||
throw UsageStatisticsCloudSyncError.decodeFailed
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
public func decodeLegacy(_ data: Data) throws -> UsageStatistics {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let stats = try? decoder.decode(UsageStatistics.self, from: data) else {
|
||||
throw UsageStatisticsCloudSyncError.decodeFailed
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ stats: SyncedUsageStatisticsV2,
|
||||
to defaults: UserDefaults,
|
||||
postNotification: Bool
|
||||
) {
|
||||
SyncedUsageStatisticsStorage.save(stats, to: defaults)
|
||||
if postNotification {
|
||||
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,10 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
/// 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.
|
||||
/// Prefer `micVoiceAvailability` for mic color and tap behavior.
|
||||
@Published public var flowSessionActive: Bool = false
|
||||
/// Unified mic color / tap / hint source for the keyboard extension.
|
||||
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
|
||||
/// When true, the mic is intentionally disabled (e.g. cloud engine
|
||||
/// selected but the provider-specific API key is missing).
|
||||
@Published public var micDisabled: Bool = false
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
// Keychain.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single-purpose Keychain helper for the user's LLM API key.
|
||||
// Keychain helper for LLM API keys and onboarding markers.
|
||||
//
|
||||
// Why this exists
|
||||
// ---------------
|
||||
// Both the host app and the keyboard extension need to read the same API
|
||||
// key (the host writes it in Settings; the extension uses it to
|
||||
// authenticate LLM requests). Storing it in App Group `UserDefaults` is
|
||||
// plaintext on disk and shows up in any unencrypted backup. The Keychain
|
||||
// gives us at-rest encryption and proper lifecycle.
|
||||
//
|
||||
// Cross-process sharing
|
||||
// ---------------------
|
||||
// App and extension have different bundle IDs, so their default Keychain
|
||||
// access groups differ and they cannot see each other's items out of the
|
||||
// box. We add `com.apple.security.keychain-access-groups` to both
|
||||
// targets' entitlements with the entry `com.osgkeyboard.shared`; this
|
||||
// becomes each process's *first* (and therefore default) access group, so
|
||||
// we never need to specify `kSecAttrAccessGroup` in queries — the system
|
||||
// resolves it for us.
|
||||
//
|
||||
// Accessibility class
|
||||
// -------------------
|
||||
// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
|
||||
// - Available after the user unlocks the device at least once after
|
||||
// boot (so background jobs work even with a locked phone).
|
||||
// - "ThisDeviceOnly" — does not migrate to a restored device and is
|
||||
// NOT included in iCloud Keychain. API keys should not sync.
|
||||
// API keys:
|
||||
// - Local (device-only) items use `AfterFirstUnlockThisDeviceOnly`.
|
||||
// - When settings iCloud sync is enabled, keys are stored as synchronizable
|
||||
// generic passwords (`kSecAttrSynchronizable = true`) and replicate through
|
||||
// the user's iCloud Keychain — never through KVS JSON.
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
@@ -48,19 +28,42 @@ public enum Keychain: @unchecked Sendable {
|
||||
return "provider.\(normalized)"
|
||||
}
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
/// Read the stored API key. Returns `nil` when nothing is stored,
|
||||
/// or when the underlying call returns a non-success status we can't
|
||||
/// usefully surface (e.g. transient `errSecInteractionNotAllowed`).
|
||||
public static func apiKey(for providerId: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
private static func baseQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
|
||||
]
|
||||
#if os(macOS)
|
||||
query[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
return query
|
||||
}
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
public static func apiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
|
||||
if preferICloudSync, let synced = readKey(providerId: providerId, synchronizable: true) {
|
||||
return synced
|
||||
}
|
||||
if let local = readKey(providerId: providerId, synchronizable: false) {
|
||||
return local
|
||||
}
|
||||
if preferICloudSync {
|
||||
return readKey(providerId: providerId, synchronizable: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func apiKey() -> String? {
|
||||
apiKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
private static func readKey(providerId: String, synchronizable: Bool) -> String? {
|
||||
var query = baseQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
switch status {
|
||||
@@ -80,21 +83,17 @@ public enum Keychain: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible shorthand for the default cloud provider.
|
||||
public static func apiKey() -> String? {
|
||||
apiKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
/// Legacy account used by older builds before provider-scoped keys.
|
||||
/// New code should avoid this and use `apiKey(for:)`.
|
||||
public static func legacyAPIKey() -> String? {
|
||||
let query: [String: Any] = [
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: legacyAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
#if os(macOS)
|
||||
query[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
@@ -106,35 +105,37 @@ public enum Keychain: @unchecked Sendable {
|
||||
|
||||
// MARK: - Write
|
||||
|
||||
/// Store (or update) the API key. An empty string deletes the entry,
|
||||
/// so clearing the field in the UI removes the key from the Keychain
|
||||
/// rather than leaving an empty-string placeholder.
|
||||
public static func setAPIKey(_ key: String, for providerId: String) throws {
|
||||
public static func setAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws {
|
||||
if key.isEmpty {
|
||||
try deleteAPIKey(for: providerId)
|
||||
try deleteAPIKey(for: providerId, useICloudSync: useICloudSync)
|
||||
return
|
||||
}
|
||||
if useICloudSync {
|
||||
try writeKey(key, providerId: providerId, synchronizable: true)
|
||||
try? deleteKey(providerId: providerId, synchronizable: false)
|
||||
} else {
|
||||
try writeKey(key, providerId: providerId, synchronizable: false)
|
||||
}
|
||||
}
|
||||
|
||||
public static func setAPIKey(_ key: String) throws {
|
||||
try setAPIKey(key, for: defaultProviderId, useICloudSync: false)
|
||||
}
|
||||
|
||||
private static func writeKey(_ key: String, providerId: String, synchronizable: Bool) throws {
|
||||
let data = Data(key.utf8)
|
||||
let baseQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
]
|
||||
// Try update first — covers the common path where the key already
|
||||
// exists (every settings edit after the first).
|
||||
let updateAttrs: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
var baseQuery = baseQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
let updateAttrs: [String: Any] = [kSecValueData as String: data]
|
||||
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
|
||||
switch updateStatus {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
// No existing item — add one with our accessibility class.
|
||||
var addQuery = baseQuery
|
||||
addQuery[kSecValueData as String] = data
|
||||
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
baseQuery[kSecValueData as String] = data
|
||||
baseQuery[kSecAttrAccessible as String] = synchronizable
|
||||
? kSecAttrAccessibleAfterFirstUnlock
|
||||
: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(baseQuery as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
throw KeychainError.unexpectedStatus(addStatus)
|
||||
}
|
||||
@@ -143,64 +144,73 @@ public enum Keychain: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible shorthand for the default cloud provider.
|
||||
public static func setAPIKey(_ key: String) throws {
|
||||
try setAPIKey(key, for: defaultProviderId)
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
public static func deleteAPIKey(for providerId: String, useICloudSync: Bool = false) throws {
|
||||
try deleteKey(providerId: providerId, synchronizable: false)
|
||||
if useICloudSync {
|
||||
try deleteKey(providerId: providerId, synchronizable: true)
|
||||
}
|
||||
}
|
||||
|
||||
public static func deleteAPIKey(for providerId: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
]
|
||||
try deleteAPIKey(for: providerId, useICloudSync: false)
|
||||
}
|
||||
|
||||
public static func deleteAPIKey() throws {
|
||||
try deleteAPIKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
private static func deleteKey(providerId: String, synchronizable: Bool) throws {
|
||||
let query = baseQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
// `errSecItemNotFound` is success-from-the-user's-perspective — the
|
||||
// desired end state is "no key", which is what we already have.
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible shorthand for the default cloud provider.
|
||||
public static func deleteAPIKey() throws {
|
||||
try deleteAPIKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
public static func deleteLegacyAPIKey() throws {
|
||||
let query: [String: Any] = [
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: legacyAccount,
|
||||
]
|
||||
#if os(macOS)
|
||||
query[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy non-empty local keys into synchronizable Keychain items.
|
||||
public static func migrateLocalKeysToICloud() {
|
||||
for provider in LLMProvider.presets {
|
||||
guard let local = readKey(providerId: provider.id, synchronizable: false), !local.isEmpty else {
|
||||
continue
|
||||
}
|
||||
try? writeKey(local, providerId: provider.id, synchronizable: true)
|
||||
try? deleteKey(providerId: provider.id, synchronizable: false)
|
||||
}
|
||||
}
|
||||
|
||||
// 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] = [
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: onboardingService,
|
||||
kSecAttrAccount as String: onboardingAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
#if os(macOS)
|
||||
query[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
@@ -216,20 +226,20 @@ public enum Keychain: @unchecked Sendable {
|
||||
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] = [
|
||||
var baseQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: onboardingService,
|
||||
kSecAttrAccount as String: onboardingAccount,
|
||||
]
|
||||
#if os(macOS)
|
||||
baseQuery[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
|
||||
guard completed else {
|
||||
let delStatus = SecItemDelete(baseQuery as CFDictionary)
|
||||
|
||||
+7
-3
@@ -24,7 +24,8 @@ public enum PersonalDictionaryCloudSyncError: Error, Equatable, Sendable {
|
||||
public final class PersonalDictionaryCloudSync {
|
||||
public static let shared = PersonalDictionaryCloudSync()
|
||||
|
||||
public static let kvsKey = "personalDictionary.v1"
|
||||
public static let kvsKey = PersonalDictionary.kvsKeyV2
|
||||
public static let legacyKVSKey = PersonalDictionary.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
|
||||
@@ -124,8 +125,11 @@ public final class PersonalDictionaryCloudSync {
|
||||
}
|
||||
|
||||
public func loadRemote() -> PersonalDictionary? {
|
||||
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
|
||||
return try? decode(data)
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decode(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
|
||||
return try? decode(legacyData)
|
||||
}
|
||||
|
||||
// MARK: - Encoding
|
||||
|
||||
@@ -48,7 +48,7 @@ public actor PolishingService {
|
||||
case translate(targetLocaleId: String)
|
||||
}
|
||||
|
||||
private let store: AppGroupStore
|
||||
private let store: any ConfigurationStore
|
||||
private let timeout: TimeInterval
|
||||
/// Optional injected client (mostly for testing). When nil we build
|
||||
/// one from `store.makeClient()` per call.
|
||||
@@ -60,7 +60,7 @@ public actor PolishingService {
|
||||
/// own slack on top of the length-scaled budget in `polishRemote`, so
|
||||
/// no `+1` is baked in here.
|
||||
public init(
|
||||
store: AppGroupStore = AppGroupStore(),
|
||||
store: any ConfigurationStore = AppGroupStore(),
|
||||
client: LLMClient? = nil,
|
||||
timeout: TimeInterval? = nil
|
||||
) {
|
||||
@@ -346,7 +346,7 @@ public actor PolishingService {
|
||||
}
|
||||
|
||||
internal static func resolvedProviderId(
|
||||
store: AppGroupStore,
|
||||
store: any ConfigurationStore,
|
||||
providerIdOverride: String?
|
||||
) -> String {
|
||||
if let providerIdOverride {
|
||||
@@ -360,7 +360,7 @@ public actor PolishingService {
|
||||
}
|
||||
|
||||
internal static func resolveLLMEndpoint(
|
||||
store: AppGroupStore,
|
||||
store: any ConfigurationStore,
|
||||
preset: LLMProvider,
|
||||
providerIdOverride: String?
|
||||
) -> (baseURL: String, model: String) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// SpeechHistoryStorage.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Local persistence for the speech history payload (entries + tombstones).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SpeechHistoryStorage {
|
||||
public static let storageKey = SyncedSpeechHistory.kvsKey
|
||||
/// Pre-unification iOS history in `UserDefaults.standard`.
|
||||
public static let legacyIOSEntriesKey = "speechHistory.entries.v1"
|
||||
/// Pre-unification macOS history in `UserDefaults.standard`.
|
||||
public static let legacyMacHistoryKey = "mac.history"
|
||||
|
||||
public static func load(from defaults: UserDefaults) -> SyncedSpeechHistory {
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let history = try? JSONDecoder().decode(SyncedSpeechHistory.self, from: data) {
|
||||
return history
|
||||
}
|
||||
return migrateLegacyIfNeeded(into: defaults)
|
||||
}
|
||||
|
||||
public static func save(_ history: SyncedSpeechHistory, to defaults: UserDefaults) {
|
||||
guard let data = try? JSONEncoder().encode(history) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
/// Import older per-platform keys once, then persist the unified payload.
|
||||
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> SyncedSpeechHistory {
|
||||
var entries: [SpeechHistoryEntry] = []
|
||||
|
||||
if let data = defaults.data(forKey: legacyIOSEntriesKey),
|
||||
let legacy = try? JSONDecoder().decode([LegacyIOSHistoryEntry].self, from: data) {
|
||||
entries.append(contentsOf: legacy.map {
|
||||
SpeechHistoryEntry(
|
||||
id: $0.id,
|
||||
text: $0.text,
|
||||
createdAt: $0.createdAt,
|
||||
engineMode: $0.engineMode
|
||||
)
|
||||
})
|
||||
defaults.removeObject(forKey: legacyIOSEntriesKey)
|
||||
}
|
||||
|
||||
if let data = defaults.data(forKey: legacyMacHistoryKey),
|
||||
let legacy = try? JSONDecoder().decode([LegacyMacHistoryRecord].self, from: data) {
|
||||
entries.append(contentsOf: legacy.map {
|
||||
SpeechHistoryEntry(id: $0.id, text: $0.text, createdAt: $0.date, engineMode: nil)
|
||||
})
|
||||
defaults.removeObject(forKey: legacyMacHistoryKey)
|
||||
}
|
||||
|
||||
guard !entries.isEmpty else { return .empty }
|
||||
|
||||
var history = SyncedSpeechHistory(updatedAt: Date(), entries: [])
|
||||
for entry in entries {
|
||||
history.entries.append(entry)
|
||||
}
|
||||
history.entries.sort { $0.createdAt > $1.createdAt }
|
||||
history.trimEntries()
|
||||
save(history, to: defaults)
|
||||
return history
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy decoding
|
||||
|
||||
private struct LegacyIOSHistoryEntry: Codable {
|
||||
let id: UUID
|
||||
let text: String
|
||||
let createdAt: Date
|
||||
let engineMode: String
|
||||
}
|
||||
|
||||
private struct LegacyMacHistoryRecord: Codable {
|
||||
let id: UUID
|
||||
let text: String
|
||||
let date: Date
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SpeechHistoryStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Observable store for voice transcription history. Mirrored through
|
||||
// iCloud KVS when settings sync is enabled.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class SpeechHistoryStore: ObservableObject {
|
||||
public static let shared = SpeechHistoryStore()
|
||||
|
||||
@Published public private(set) var entries: [SpeechHistoryEntry] = []
|
||||
|
||||
public let defaults: UserDefaults
|
||||
private var payload: SyncedSpeechHistory = .empty
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
reloadFromDisk()
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .speechHistoryDidSyncFromCloud,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func append(text: String, engineMode: String? = nil) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
|
||||
payload.entries.insert(entry, at: 0)
|
||||
payload.trimEntries()
|
||||
payload.updatedAt = Date()
|
||||
applyPayload(postCloudPush: true)
|
||||
}
|
||||
|
||||
public func delete(id: UUID) {
|
||||
guard payload.entries.contains(where: { $0.id == id }) else { return }
|
||||
payload.deletedEntryIDs[id] = Date()
|
||||
payload.entries.removeAll { $0.id == id }
|
||||
payload.updatedAt = Date()
|
||||
payload.pruneTombstonesIfNeeded()
|
||||
applyPayload(postCloudPush: true)
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
payload.recordClearAll()
|
||||
payload.updatedAt = Date()
|
||||
payload.pruneTombstonesIfNeeded()
|
||||
applyPayload(postCloudPush: true)
|
||||
}
|
||||
|
||||
public func snapshot() -> SyncedSpeechHistory {
|
||||
payload
|
||||
}
|
||||
|
||||
public func apply(_ history: SyncedSpeechHistory) {
|
||||
payload = history
|
||||
entries = history.entries.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
public func reloadFromDisk() {
|
||||
payload = SpeechHistoryStorage.load(from: defaults)
|
||||
entries = payload.entries.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
/// Entries grouped by calendar day (newest day first).
|
||||
public var groupedByDay: [(day: Date, items: [SpeechHistoryEntry])] {
|
||||
let calendar = Calendar.current
|
||||
var buckets: [Date: [SpeechHistoryEntry]] = [:]
|
||||
for entry in entries {
|
||||
let day = calendar.startOfDay(for: entry.createdAt)
|
||||
buckets[day, default: []].append(entry)
|
||||
}
|
||||
return buckets.keys.sorted(by: >).map { day in
|
||||
(day, buckets[day]!.sorted { $0.createdAt > $1.createdAt })
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPayload(postCloudPush: Bool) {
|
||||
entries = payload.entries.sorted { $0.createdAt > $1.createdAt }
|
||||
SpeechHistoryStorage.save(payload, to: defaults)
|
||||
guard postCloudPush else { return }
|
||||
Task {
|
||||
try? await SpeechHistoryCloudSync.shared.pushLocalIfEnabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// UsageStatisticsStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Observable store for cumulative usage metrics. Updated after each
|
||||
// successful dictation on iOS Flow and macOS menu-bar capture.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class UsageStatisticsStore: ObservableObject {
|
||||
public static let shared = UsageStatisticsStore()
|
||||
|
||||
@Published public private(set) var dictationDurationSeconds: TimeInterval = 0
|
||||
@Published public private(set) var dictationCharacterCount: Int = 0
|
||||
@Published public private(set) var translationCharacterCount: Int = 0
|
||||
|
||||
public let defaults: UserDefaults
|
||||
|
||||
/// Marks the one-time purge of statistics corrupted by the pre-fix
|
||||
/// double-counting bug (see `purgeCorruptedStatsIfNeeded`).
|
||||
private static let dirtyResetFlagKey = "usageStatistics.dirtyReset.v1"
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
self.defaults = defaults ?? AppGroupStore().defaults
|
||||
purgeCorruptedStatsIfNeeded()
|
||||
reloadFromDisk()
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .usageStatisticsDidSyncFromCloud,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func recordUtterance(text: String, duration: TimeInterval, wasTranslation: Bool) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let count = Self.characterCount(for: trimmed)
|
||||
|
||||
// Increment ONLY this device's own slice. The displayed totals are the
|
||||
// cross-device *sum* (see `reloadFromDisk`), so incrementing in-memory
|
||||
// display state and writing it back as this device's slice would fold
|
||||
// every other device's total into this one and double-count on the
|
||||
// next reload — the bug that inflated one slice to ~8× the real usage.
|
||||
let deviceID = SyncDeviceID.current(defaults: defaults)
|
||||
var slice = SyncedUsageStatisticsStorage.currentDeviceSlice(from: defaults, deviceID: deviceID)
|
||||
if wasTranslation {
|
||||
slice.translationCharacterCount += count
|
||||
} else {
|
||||
slice.dictationCharacterCount += count
|
||||
}
|
||||
slice.dictationDurationSeconds += max(0, duration)
|
||||
slice.updatedAt = Date()
|
||||
SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(slice, defaults: defaults, deviceID: deviceID)
|
||||
|
||||
reloadFromDisk()
|
||||
|
||||
Task {
|
||||
try? await UsageStatisticsCloudSync.shared.pushLocalIfEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the published totals from disk. Display-only: it reads the
|
||||
/// aggregated cross-device sum and NEVER writes it back (writing would
|
||||
/// corrupt the per-device slices — see `recordUtterance`).
|
||||
public func reloadFromDisk() {
|
||||
let aggregated = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
|
||||
dictationDurationSeconds = aggregated.dictationDurationSeconds
|
||||
dictationCharacterCount = aggregated.dictationCharacterCount
|
||||
translationCharacterCount = aggregated.translationCharacterCount
|
||||
}
|
||||
|
||||
/// One-time cleanup: the pre-fix code overwrote a device slice with the
|
||||
/// cross-device *sum*, so every reload/record re-added the other devices'
|
||||
/// totals and one slice ballooned to ~8× the true usage. We can't recover
|
||||
/// the true per-device split from corrupted data, so wipe local + remote
|
||||
/// once and let the corrected per-device accounting re-accumulate cleanly.
|
||||
private func purgeCorruptedStatsIfNeeded() {
|
||||
guard !defaults.bool(forKey: Self.dirtyResetFlagKey) else { return }
|
||||
defaults.set(true, forKey: Self.dirtyResetFlagKey)
|
||||
|
||||
defaults.removeObject(forKey: SyncedUsageStatisticsStorage.storageKey)
|
||||
defaults.removeObject(forKey: UsageStatisticsStorage.storageKey)
|
||||
defaults.removeObject(forKey: UsageStatisticsStorage.legacyMacTotalWordsKey)
|
||||
|
||||
UsageStatisticsCloudSync.shared.purgeRemote()
|
||||
}
|
||||
|
||||
public static func characterCount(for text: String) -> Int {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).count
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
public static func formatDuration(_ seconds: TimeInterval, language: AppUILanguage) -> String {
|
||||
let total = max(0, Int(seconds.rounded()))
|
||||
if total < 60 {
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(total)秒"
|
||||
: "\(total)s"
|
||||
}
|
||||
let hours = total / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
if hours > 0 {
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(hours)小时\(minutes)分"
|
||||
: "\(hours)h \(minutes)m"
|
||||
}
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(minutes)分"
|
||||
: "\(minutes)m"
|
||||
}
|
||||
|
||||
public static func formatCount(_ value: Int, language: AppUILanguage) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: language.resolvedLanguageCode())
|
||||
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user