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:
Rocky
2026-07-08 18:13:56 +08:00
parent 128aab1b02
commit c2f07bd8d2
99 changed files with 6735 additions and 740 deletions
+1 -1
View File
@@ -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)
}
+26 -8
View File
@@ -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
+103 -93
View File
@@ -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)
@@ -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)"
}
}