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
@@ -36,6 +36,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let settingsICloudSyncEnabled = "config.settings.iCloudSyncEnabled"
/// Wall-clock stamp of the last settings blob applied from iCloud KVS.
public static let settingsCloudUpdatedAt = "config.settings.cloudUpdatedAt"
/// Cached per-field settings merge payload (`SyncedAppSettingsV2`).
public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2"
/// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
@@ -104,8 +106,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
/// API key lives in the Keychain (cross-process, encrypted at rest).
/// When settings iCloud sync is on, reads synchronizable Keychain items first.
public var apiKey: String {
Keychain.apiKey(for: providerId) ?? ""
Self.resolveAPIKey(
defaults: nil,
providerId: providerId,
preferICloudSync: settingsICloudSyncEnabled
)
}
public func makeClient() -> LLMClient {
@@ -206,7 +213,11 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
// One-shot legacy migration: plaintext apiKey in UserDefaults Keychain.
_ = resolveAPIKey(defaults: defaults, providerId: config.providerId)
_ = resolveAPIKey(
defaults: defaults,
providerId: config.providerId,
preferICloudSync: config.settingsICloudSyncEnabled
)
// Cloud no longer exposes off/transcribe; migrate legacy values.
if config.engineMode == "cloud", config.modeId != "polish" {
@@ -295,19 +306,23 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
static func resolveAPIKey(defaults: UserDefaults?, providerId: String) -> String {
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
static func resolveAPIKey(
defaults: UserDefaults?,
providerId: String,
preferICloudSync: Bool = false
) -> String {
if let stored = Keychain.apiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
return stored
}
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
try? Keychain.deleteLegacyAPIKey()
return legacyKeychain
}
if let defaults,
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
!legacy.isEmpty {
try? Keychain.setAPIKey(legacy, for: providerId)
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
defaults.removeObject(forKey: Keys.apiKeyLegacy)
return legacy
}
@@ -0,0 +1,45 @@
// MicVoiceAvailability+Keyboard.swift
// OSGKeyboard · Shared
//
// Derives keyboard mic availability from pipeline phase and host readiness.
import Foundation
public enum MicVoiceAvailabilityResolver {
public static func resolve(
phase: KeyboardState.Phase,
micDisabled: Bool,
hasFullAccess: Bool,
appGroupAvailable: Bool,
hostReady: Bool,
isPreparingSession: Bool
) -> MicVoiceAvailability {
switch phase {
case .recording:
return .recording
case .processing, .requestingPermissions:
return .processing
case .error, .denied:
return .unavailable(.hostNotReady)
case .idle:
break
}
if !appGroupAvailable {
return .unavailable(.appGroupUnavailable)
}
if !hasFullAccess {
return .unavailable(.noFullAccess)
}
if micDisabled {
return .unavailable(.missingAPIKey)
}
if isPreparingSession {
return .unavailable(.preparingSession)
}
if hostReady {
return .ready
}
return .unavailable(.hostNotReady)
}
}
@@ -0,0 +1,37 @@
// MicVoiceAvailability.swift
// OSGKeyboard · Shared
//
// Single source of truth for keyboard mic color, hint text, and tap behavior.
import Foundation
/// Whether the keyboard mic can start a Flow utterance right now.
public enum MicVoiceAvailability: Equatable, Sendable {
/// Green tap records immediately without opening the host app.
case ready
/// Orange voice input blocked; see `Reason` for hint copy.
case unavailable(Reason)
/// Red user is actively recording.
case recording
/// White waiting for ASR / cloud polish after stop.
case processing
public enum Reason: Equatable, Sendable {
case missingAPIKey
case hostNotReady
case noFullAccess
case appGroupUnavailable
/// User tapped mic; host app jump in progress, awaiting ready contract.
case preparingSession
}
public var isReady: Bool {
if case .ready = self { return true }
return false
}
public var isUnavailable: Bool {
if case .unavailable = self { return true }
return false
}
}
@@ -7,17 +7,36 @@
import Foundation
extension PersonalDictionary {
public static let kvsKeyV2 = "personalDictionary.v2"
public static let legacyKVSKey = "personalDictionary.v1"
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
/// Merges two dictionary snapshots for cross-device sync.
///
/// Rules:
/// - Apply `clearedAt` and deletion tombstones before entry union.
/// - Same `id`: keep the entry with the newer `updatedAt`.
/// - Same canonical term (case-insensitive) but different `id`: union
/// aliases, take max `usageCount`, keep the newer entry's fields.
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
var deletedIDs = local.deletedEntryIDs
for (id, date) in remote.deletedEntryIDs {
if let existing = deletedIDs[id] {
deletedIDs[id] = max(existing, date)
} else {
deletedIDs[id] = date
}
}
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
var mergedByID: [UUID: Entry] = [:]
var canonicalOwner: [String: UUID] = [:]
func insertOrMerge(_ candidate: Entry) {
if deletedIDs[candidate.id] != nil { return }
if let clearedAt, candidate.createdAt <= clearedAt { return }
let key = candidate.term.lowercased()
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
if candidate.id == existingID {
@@ -56,10 +75,51 @@ extension PersonalDictionary {
return PersonalDictionary(
entries: mergedEntries,
version: max(local.version, remote.version) + 1,
lastSyncedAt: lastSyncedAt
lastSyncedAt: lastSyncedAt,
deletedEntryIDs: deletedIDs,
clearedAt: clearedAt
)
}
public mutating func recordDeletion(of entryID: UUID, at date: Date = Date()) {
deletedEntryIDs[entryID] = date
entries.removeAll { $0.id == entryID }
}
public mutating func recordClearAll(at date: Date = Date()) {
entries.removeAll()
clearedAt = date
}
public mutating func pruneTombstonesIfNeeded() {
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
}
private static func pruneTombstones(
_ tombstones: [UUID: Date],
clearedAt: Date?
) -> [UUID: Date] {
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
return tombstones.filter { _, deletedAt in
if deletedAt < cutoff { return false }
if let clearedAt, deletedAt <= clearedAt { return false }
return true
}
}
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
switch (lhs, rhs) {
case let (left?, right?):
return max(left, right)
case (nil, let right?):
return right
case (let left?, nil):
return left
case (nil, nil):
return nil
}
}
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
incoming.updatedAt >= existing.updatedAt ? incoming : existing
}
@@ -23,17 +23,31 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
public var version: Int
/// When this dictionary blob was last successfully pushed to iCloud KVS.
public var lastSyncedAt: Date?
/// Tombstones for deleted entries prevents remote resurrections.
public var deletedEntryIDs: [UUID: Date]
/// When set, entries created at or before this instant are excluded from merge.
public var clearedAt: Date?
public init(entries: [Entry] = [], version: Int = 1, lastSyncedAt: Date? = nil) {
public init(
entries: [Entry] = [],
version: Int = 1,
lastSyncedAt: Date? = nil,
deletedEntryIDs: [UUID: Date] = [:],
clearedAt: Date? = nil
) {
self.entries = entries
self.version = version
self.lastSyncedAt = lastSyncedAt
self.deletedEntryIDs = deletedEntryIDs
self.clearedAt = clearedAt
}
private enum CodingKeys: String, CodingKey {
case entries
case version
case lastSyncedAt
case deletedEntryIDs
case clearedAt
}
public init(from decoder: Decoder) throws {
@@ -41,6 +55,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
deletedEntryIDs = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) ?? [:]
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
}
public func encode(to encoder: Encoder) throws {
@@ -48,6 +64,10 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
try container.encode(entries, forKey: .entries)
try container.encode(version, forKey: .version)
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
if !deletedEntryIDs.isEmpty {
try container.encode(deletedEntryIDs, forKey: .deletedEntryIDs)
}
try container.encodeIfPresent(clearedAt, forKey: .clearedAt)
}
public struct Entry: Codable, Sendable, Equatable, Identifiable {
@@ -36,7 +36,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
didSet {
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
do {
try Keychain.setAPIKey(apiKey, for: providerId)
try Keychain.setAPIKey(
apiKey,
for: providerId,
useICloudSync: configuration.settingsICloudSyncEnabled
)
} catch {
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
}
@@ -0,0 +1,24 @@
// ProviderLogo.swift
// OSGKeyboard · Shared
//
// Maps a provider id to its asset-catalog logo name. Shared by the iOS
// app and the macOS menu-bar app so both show identical brand marks.
import Foundation
public enum ProviderLogo {
/// Asset name for the provider's logo, or `nil` when there is no bundled logo.
public static func assetName(for providerId: String) -> String? {
switch providerId {
case "openai": return "openai"
case "deepseek": return "deepseek"
case "qwen": return "qwen"
case "moonshot": return "moonshot"
case "zhipu": return "zhipu"
case "mimo": return "mimo"
case "apple": return "apple"
case "custom": return "custom"
default: return nil
}
}
}
@@ -0,0 +1,32 @@
// SpeechHistoryEntry.swift
// OSGKeyboard · Shared
//
// A single voice transcription in the cross-device history log.
import Foundation
public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
public let id: UUID
public let text: String
public let createdAt: Date
/// iOS Flow engine mode; nil on macOS captures.
public let engineMode: String?
public init(
id: UUID = UUID(),
text: String,
createdAt: Date = Date(),
engineMode: String? = nil
) {
self.id = id
self.text = text
self.createdAt = createdAt
self.engineMode = engineMode
}
/// First-line preview for compact list rows (macOS history sidebar).
public var previewTitle: String {
let firstLine = text.split(separator: "\n").first.map(String.init) ?? text
return firstLine.count > 36 ? String(firstLine.prefix(36)) + "" : firstLine
}
}
@@ -1,9 +1,8 @@
// SyncedAppSettings.swift
// OSGKeyboard · Shared
//
// User-facing app settings mirrored through iCloud KVS. Excludes
// device-local state (onboarding progress, detected app context,
// personal dictionary blob, and API keys in Keychain).
// Legacy v1 settings blob (read-only migration input). New sync uses
// `SyncedAppSettingsV2`. API keys never belong in KVS payloads.
import Foundation
@@ -23,6 +22,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
public var polishIntensity: PolishIntensity
public var flowSkipAppSwitch: Bool
public var flowInactivityDuration: FlowInactivityDuration
/// Deprecated decoded for backward compatibility only; never applied.
public var providerAPIKeys: [String: String]
public init(
updatedAt: Date = Date(),
@@ -39,7 +40,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
cursorDragNavigationEnabled: Bool,
polishIntensity: PolishIntensity,
flowSkipAppSwitch: Bool,
flowInactivityDuration: FlowInactivityDuration
flowInactivityDuration: FlowInactivityDuration,
providerAPIKeys: [String: String] = [:]
) {
self.updatedAt = updatedAt
self.providerId = providerId
@@ -56,11 +58,51 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
self.polishIntensity = polishIntensity
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration
self.providerAPIKeys = providerAPIKeys
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
providerId = try container.decode(String.self, forKey: .providerId)
baseURL = try container.decode(String.self, forKey: .baseURL)
model = try container.decode(String.self, forKey: .model)
modeId = try container.decode(String.self, forKey: .modeId)
localeId = try container.decode(String.self, forKey: .localeId)
engineMode = try container.decode(String.self, forKey: .engineMode)
hasAcknowledgedCloudSharing = try container.decode(Bool.self, forKey: .hasAcknowledgedCloudSharing)
uiLanguage = try container.decode(AppUILanguage.self, forKey: .uiLanguage)
translationTargetLocaleId = try container.decode(String.self, forKey: .translationTargetLocaleId)
handednessPreference = try container.decode(HandednessPreference.self, forKey: .handednessPreference)
cursorDragNavigationEnabled = try container.decode(Bool.self, forKey: .cursorDragNavigationEnabled)
polishIntensity = try container.decode(PolishIntensity.self, forKey: .polishIntensity)
flowSkipAppSwitch = try container.decode(Bool.self, forKey: .flowSkipAppSwitch)
flowInactivityDuration = try container.decode(FlowInactivityDuration.self, forKey: .flowInactivityDuration)
providerAPIKeys = try container.decodeIfPresent([String: String].self, forKey: .providerAPIKeys) ?? [:]
}
/// Apply legacy scalar fields only never touches Keychain.
func applyingScalars(to configuration: inout AppGroupConfiguration) {
configuration.providerId = providerId
configuration.baseURL = baseURL
configuration.model = model
configuration.modeId = modeId
configuration.localeId = localeId
configuration.engineMode = engineMode
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
configuration.uiLanguage = uiLanguage
configuration.translationTargetLocaleId = translationTargetLocaleId
configuration.handednessPreference = handednessPreference
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
configuration.polishIntensity = polishIntensity
configuration.flowSkipAppSwitch = flowSkipAppSwitch
configuration.flowInactivityDuration = flowInactivityDuration
}
}
public extension SyncedAppSettings {
/// Build a cloud payload from the current App Group configuration.
static let legacyKVSKey = "appSettings.v1"
static func from(configuration: AppGroupConfiguration, updatedAt: Date = Date()) -> SyncedAppSettings {
SyncedAppSettings(
updatedAt: updatedAt,
@@ -80,28 +122,4 @@ public extension SyncedAppSettings {
flowInactivityDuration: configuration.flowInactivityDuration
)
}
/// Apply syncable fields onto a configuration, preserving device-local
/// fields such as onboarding progress and the personal dictionary.
func applying(to configuration: inout AppGroupConfiguration) {
configuration.providerId = providerId
configuration.baseURL = baseURL
configuration.model = model
configuration.modeId = modeId
configuration.localeId = localeId
configuration.engineMode = engineMode
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
configuration.uiLanguage = uiLanguage
configuration.translationTargetLocaleId = translationTargetLocaleId
configuration.handednessPreference = handednessPreference
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
configuration.polishIntensity = polishIntensity
configuration.flowSkipAppSwitch = flowSkipAppSwitch
configuration.flowInactivityDuration = flowInactivityDuration
}
/// Last-write-wins merge for whole settings blobs.
static func merge(local: SyncedAppSettings, remote: SyncedAppSettings) -> SyncedAppSettings {
remote.updatedAt >= local.updatedAt ? remote : local
}
}
@@ -0,0 +1,237 @@
// SyncedAppSettingsV2.swift
// OSGKeyboard · Shared
//
// Versioned settings payload with per-field merge metadata. API keys are
// intentionally excluded they sync through iCloud Keychain.
import Foundation
public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public static let schemaVersion = 2
public static let kvsKey = "appSettings.v2"
public var schemaVersion: Int
public var providerId: SyncedField<String>
public var baseURL: SyncedField<String>
public var model: SyncedField<String>
public var modeId: SyncedField<String>
public var localeId: SyncedField<String>
public var engineMode: SyncedField<String>
public var hasAcknowledgedCloudSharing: SyncedField<Bool>
public var uiLanguage: SyncedField<AppUILanguage>
public var translationTargetLocaleId: SyncedField<String>
public var handednessPreference: SyncedField<HandednessPreference>
public var cursorDragNavigationEnabled: SyncedField<Bool>
public var polishIntensity: SyncedField<PolishIntensity>
public var flowSkipAppSwitch: SyncedField<Bool>
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
public init(
schemaVersion: Int = Self.schemaVersion,
providerId: SyncedField<String>,
baseURL: SyncedField<String>,
model: SyncedField<String>,
modeId: SyncedField<String>,
localeId: SyncedField<String>,
engineMode: SyncedField<String>,
hasAcknowledgedCloudSharing: SyncedField<Bool>,
uiLanguage: SyncedField<AppUILanguage>,
translationTargetLocaleId: SyncedField<String>,
handednessPreference: SyncedField<HandednessPreference>,
cursorDragNavigationEnabled: SyncedField<Bool>,
polishIntensity: SyncedField<PolishIntensity>,
flowSkipAppSwitch: SyncedField<Bool>,
flowInactivityDuration: SyncedField<FlowInactivityDuration>
) {
self.schemaVersion = schemaVersion
self.providerId = providerId
self.baseURL = baseURL
self.model = model
self.modeId = modeId
self.localeId = localeId
self.engineMode = engineMode
self.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
self.uiLanguage = uiLanguage
self.translationTargetLocaleId = translationTargetLocaleId
self.handednessPreference = handednessPreference
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
self.polishIntensity = polishIntensity
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration
}
/// Monotonic stamp used for `settingsCloudUpdatedAt` bookkeeping.
public var latestUpdatedAt: Date {
[
providerId.updatedAt,
baseURL.updatedAt,
model.updatedAt,
modeId.updatedAt,
localeId.updatedAt,
engineMode.updatedAt,
hasAcknowledgedCloudSharing.updatedAt,
uiLanguage.updatedAt,
translationTargetLocaleId.updatedAt,
handednessPreference.updatedAt,
cursorDragNavigationEnabled.updatedAt,
polishIntensity.updatedAt,
flowSkipAppSwitch.updatedAt,
flowInactivityDuration.updatedAt,
].max() ?? .distantPast
}
}
public extension SyncedAppSettingsV2 {
static func from(configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
seeded(from: configuration, deviceID: deviceID, updatedAt: Date())
}
/// Build a payload from configuration using one shared timestamp (for merge bookkeeping).
static func seeded(
from configuration: AppGroupConfiguration,
deviceID: String,
updatedAt: Date
) -> SyncedAppSettingsV2 {
func field<T>(_ value: T) -> SyncedField<T> {
SyncedField(value: value, updatedAt: updatedAt, deviceID: deviceID)
}
return SyncedAppSettingsV2(
providerId: field(configuration.providerId),
baseURL: field(configuration.baseURL),
model: field(configuration.model),
modeId: field(configuration.modeId),
localeId: field(configuration.localeId),
engineMode: field(configuration.engineMode),
hasAcknowledgedCloudSharing: field(configuration.hasAcknowledgedCloudSharing),
uiLanguage: field(configuration.uiLanguage),
translationTargetLocaleId: field(configuration.translationTargetLocaleId),
handednessPreference: field(configuration.handednessPreference),
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
polishIntensity: field(configuration.polishIntensity),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowInactivityDuration: field(configuration.flowInactivityDuration)
)
}
/// Upgrade a legacy v1 blob into per-field metadata on this device.
static func migrated(from legacy: SyncedAppSettings, deviceID: String) -> SyncedAppSettingsV2 {
let stamp = legacy.updatedAt
func field<T>(_ value: T) -> SyncedField<T> {
SyncedField(value: value, updatedAt: stamp, deviceID: deviceID)
}
return SyncedAppSettingsV2(
providerId: field(legacy.providerId),
baseURL: field(legacy.baseURL),
model: field(legacy.model),
modeId: field(legacy.modeId),
localeId: field(legacy.localeId),
engineMode: field(legacy.engineMode),
hasAcknowledgedCloudSharing: field(legacy.hasAcknowledgedCloudSharing),
uiLanguage: field(legacy.uiLanguage),
translationTargetLocaleId: field(legacy.translationTargetLocaleId),
handednessPreference: field(legacy.handednessPreference),
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
polishIntensity: field(legacy.polishIntensity),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowInactivityDuration: field(legacy.flowInactivityDuration)
)
}
static func merge(local: SyncedAppSettingsV2, remote: SyncedAppSettingsV2) -> SyncedAppSettingsV2 {
SyncedAppSettingsV2(
providerId: .merge(local: local.providerId, remote: remote.providerId),
baseURL: .merge(local: local.baseURL, remote: remote.baseURL),
model: .merge(local: local.model, remote: remote.model),
modeId: .merge(local: local.modeId, remote: remote.modeId),
localeId: .merge(local: local.localeId, remote: remote.localeId),
engineMode: .merge(local: local.engineMode, remote: remote.engineMode),
hasAcknowledgedCloudSharing: .merge(
local: local.hasAcknowledgedCloudSharing,
remote: remote.hasAcknowledgedCloudSharing
),
uiLanguage: .merge(local: local.uiLanguage, remote: remote.uiLanguage),
translationTargetLocaleId: .merge(
local: local.translationTargetLocaleId,
remote: remote.translationTargetLocaleId
),
handednessPreference: .merge(local: local.handednessPreference, remote: remote.handednessPreference),
cursorDragNavigationEnabled: .merge(
local: local.cursorDragNavigationEnabled,
remote: remote.cursorDragNavigationEnabled
),
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
flowInactivityDuration: .merge(
local: local.flowInactivityDuration,
remote: remote.flowInactivityDuration
)
)
}
func applying(to configuration: inout AppGroupConfiguration) {
configuration.providerId = providerId.value
configuration.baseURL = baseURL.value
configuration.model = model.value
configuration.modeId = modeId.value
configuration.localeId = localeId.value
configuration.engineMode = engineMode.value
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing.value
configuration.uiLanguage = uiLanguage.value
configuration.translationTargetLocaleId = translationTargetLocaleId.value
configuration.handednessPreference = handednessPreference.value
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
configuration.polishIntensity = polishIntensity.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowInactivityDuration = flowInactivityDuration.value
}
/// Stamp fields whose values differ from `configuration` with this device id.
func patchLocalChanges(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
var copy = self
func patch<T: Equatable>(_ field: inout SyncedField<T>, value: T) {
guard field.value != value else { return }
field = .make(value: value, deviceID: deviceID)
}
patch(&copy.providerId, value: configuration.providerId)
patch(&copy.baseURL, value: configuration.baseURL)
patch(&copy.model, value: configuration.model)
patch(&copy.modeId, value: configuration.modeId)
patch(&copy.localeId, value: configuration.localeId)
patch(&copy.engineMode, value: configuration.engineMode)
patch(&copy.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
patch(&copy.uiLanguage, value: configuration.uiLanguage)
patch(&copy.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
patch(&copy.handednessPreference, value: configuration.handednessPreference)
patch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
patch(&copy.polishIntensity, value: configuration.polishIntensity)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
/// Refresh only fields owned by `deviceID` from the current local configuration.
func refreshedLocalFields(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
var copy = self
let now = Date()
func touch<T>(_ field: inout SyncedField<T>, value: T) {
guard field.deviceID == deviceID else { return }
field.value = value
field.updatedAt = now
}
touch(&copy.providerId, value: configuration.providerId)
touch(&copy.baseURL, value: configuration.baseURL)
touch(&copy.model, value: configuration.model)
touch(&copy.modeId, value: configuration.modeId)
touch(&copy.localeId, value: configuration.localeId)
touch(&copy.engineMode, value: configuration.engineMode)
touch(&copy.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
touch(&copy.uiLanguage, value: configuration.uiLanguage)
touch(&copy.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
touch(&copy.handednessPreference, value: configuration.handednessPreference)
touch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
touch(&copy.polishIntensity, value: configuration.polishIntensity)
touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
}
@@ -0,0 +1,29 @@
// SyncedField.swift
// OSGKeyboard · Shared
//
// Per-field metadata for conflict-free settings merge across devices.
import Foundation
public struct SyncedField<T: Codable & Equatable & Sendable>: Codable, Equatable, Sendable {
public var value: T
public var updatedAt: Date
public var deviceID: String
public init(value: T, updatedAt: Date = Date(), deviceID: String) {
self.value = value
self.updatedAt = updatedAt
self.deviceID = deviceID
}
/// Pick the field with the newer `updatedAt`; ties break lexicographically on `deviceID`.
public static func merge(local: SyncedField<T>, remote: SyncedField<T>) -> SyncedField<T> {
if remote.updatedAt > local.updatedAt { return remote }
if local.updatedAt > remote.updatedAt { return local }
return remote.deviceID >= local.deviceID ? remote : local
}
public static func make(value: T, deviceID: String) -> SyncedField<T> {
SyncedField(value: value, updatedAt: Date(), deviceID: deviceID)
}
}
@@ -0,0 +1,140 @@
// SyncedSpeechHistory.swift
// OSGKeyboard · Shared
//
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
// propagate single-entry deletes and "clear all" across devices.
import Foundation
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
public static let schemaVersion = 2
public static let kvsKey = "speechHistory.v2"
public static let legacyKVSKey = "speechHistory.v1"
public static let maxEntries = 300
/// Tombstones older than this window may be pruned during merge.
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
public var schemaVersion: Int
public var updatedAt: Date
public var entries: [SpeechHistoryEntry]
/// Entry IDs deleted on any device, with deletion timestamps.
public var deletedEntryIDs: [UUID: Date]
/// When set, entries created at or before this instant are excluded.
public var clearedAt: Date?
public init(
schemaVersion: Int = Self.schemaVersion,
updatedAt: Date = Date(),
entries: [SpeechHistoryEntry] = [],
deletedEntryIDs: [UUID: Date] = [:],
clearedAt: Date? = nil
) {
self.schemaVersion = schemaVersion
self.updatedAt = updatedAt
self.entries = entries
self.deletedEntryIDs = deletedEntryIDs
self.clearedAt = clearedAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
deletedEntryIDs = map
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
let stamp = Date()
deletedEntryIDs = Dictionary(uniqueKeysWithValues: legacyIDs.map { ($0, stamp) })
} else {
deletedEntryIDs = [:]
}
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
}
public static let empty = SyncedSpeechHistory(updatedAt: .distantPast)
/// Union entries by id (newer `createdAt` wins), apply tombstones and clear.
public static func merge(local: SyncedSpeechHistory, remote: SyncedSpeechHistory) -> SyncedSpeechHistory {
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
var deletedIDs = local.deletedEntryIDs
for (id, date) in remote.deletedEntryIDs {
if let existing = deletedIDs[id] {
deletedIDs[id] = max(existing, date)
} else {
deletedIDs[id] = date
}
}
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
var byID: [UUID: SpeechHistoryEntry] = [:]
for entry in local.entries + remote.entries {
if deletedIDs[entry.id] != nil { continue }
if let clearedAt, entry.createdAt <= clearedAt { continue }
if let existing = byID[entry.id] {
byID[entry.id] = entry.createdAt >= existing.createdAt ? entry : existing
} else {
byID[entry.id] = entry
}
}
var entries = Array(byID.values).sorted { $0.createdAt > $1.createdAt }
if entries.count > maxEntries {
entries = Array(entries.prefix(maxEntries))
}
return SyncedSpeechHistory(
updatedAt: max(local.updatedAt, remote.updatedAt),
entries: entries,
deletedEntryIDs: deletedIDs,
clearedAt: clearedAt
)
}
/// Trim to the newest `maxEntries` rows (call after local-only appends).
public mutating func trimEntries() {
guard entries.count > Self.maxEntries else { return }
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
updatedAt = Date()
}
public mutating func pruneTombstonesIfNeeded() {
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
}
private static func pruneTombstones(
_ tombstones: [UUID: Date],
clearedAt: Date?
) -> [UUID: Date] {
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
return tombstones.filter { _, deletedAt in
if deletedAt < cutoff {
return false
}
if let clearedAt, deletedAt <= clearedAt {
return false
}
return true
}
}
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
switch (lhs, rhs) {
case let (left?, right?):
return max(left, right)
case (nil, let right?):
return right
case (let left?, nil):
return left
case (nil, nil):
return nil
}
}
}
extension SyncedSpeechHistory {
mutating func recordClearAll(at date: Date = Date()) {
entries.removeAll()
clearedAt = date
}
}
@@ -0,0 +1,148 @@
// SyncedUsageStatisticsV2.swift
// OSGKeyboard · Shared
//
// Per-device grow-only counters (G-Counter) for cumulative usage stats.
import Foundation
public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
public var updatedAt: Date
public var dictationDurationSeconds: TimeInterval
public var dictationCharacterCount: Int
public var translationCharacterCount: Int
public init(
updatedAt: Date = Date(),
dictationDurationSeconds: TimeInterval = 0,
dictationCharacterCount: Int = 0,
translationCharacterCount: Int = 0
) {
self.updatedAt = updatedAt
self.dictationDurationSeconds = dictationDurationSeconds
self.dictationCharacterCount = dictationCharacterCount
self.translationCharacterCount = translationCharacterCount
}
public static func merge(local: UsageStatisticsDeviceSlice, remote: UsageStatisticsDeviceSlice) -> UsageStatisticsDeviceSlice {
UsageStatisticsDeviceSlice(
updatedAt: max(local.updatedAt, remote.updatedAt),
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
)
}
public var totals: UsageStatistics {
UsageStatistics(
updatedAt: updatedAt,
dictationDurationSeconds: dictationDurationSeconds,
dictationCharacterCount: dictationCharacterCount,
translationCharacterCount: translationCharacterCount
)
}
}
public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
public static let schemaVersion = 2
public static let kvsKey = "usageStatistics.v2"
public var schemaVersion: Int
public var devices: [String: UsageStatisticsDeviceSlice]
public init(schemaVersion: Int = Self.schemaVersion, devices: [String: UsageStatisticsDeviceSlice] = [:]) {
self.schemaVersion = schemaVersion
self.devices = devices
}
public static let empty = SyncedUsageStatisticsV2()
public var aggregated: UsageStatistics {
var duration: TimeInterval = 0
var dictation = 0
var translation = 0
var latest = Date.distantPast
for slice in devices.values {
duration += slice.dictationDurationSeconds
dictation += slice.dictationCharacterCount
translation += slice.translationCharacterCount
latest = max(latest, slice.updatedAt)
}
return UsageStatistics(
updatedAt: latest,
dictationDurationSeconds: duration,
dictationCharacterCount: dictation,
translationCharacterCount: translation
)
}
public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 {
var mergedDevices = local.devices
for (deviceID, remoteSlice) in remote.devices {
if let localSlice = mergedDevices[deviceID] {
mergedDevices[deviceID] = .merge(local: localSlice, remote: remoteSlice)
} else {
mergedDevices[deviceID] = remoteSlice
}
}
return SyncedUsageStatisticsV2(devices: mergedDevices)
}
public static func migrated(from legacy: UsageStatistics, deviceID: String) -> SyncedUsageStatisticsV2 {
guard legacy != .zero else { return .empty }
return SyncedUsageStatisticsV2(devices: [
deviceID: UsageStatisticsDeviceSlice(
updatedAt: legacy.updatedAt,
dictationDurationSeconds: legacy.dictationDurationSeconds,
dictationCharacterCount: legacy.dictationCharacterCount,
translationCharacterCount: legacy.translationCharacterCount
),
])
}
}
public enum SyncedUsageStatisticsStorage {
public static let storageKey = SyncedUsageStatisticsV2.kvsKey
public static let legacyStorageKey = "usageStatistics.v1"
public static func load(from defaults: UserDefaults) -> SyncedUsageStatisticsV2 {
if let data = defaults.data(forKey: storageKey),
let payload = try? JSONDecoder().decode(SyncedUsageStatisticsV2.self, from: data) {
return payload
}
return migrateLegacyIfNeeded(into: defaults)
}
public static func save(_ payload: SyncedUsageStatisticsV2, to defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: storageKey)
}
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> SyncedUsageStatisticsV2 {
let deviceID = SyncDeviceID.current(defaults: defaults)
let legacy = UsageStatisticsStorage.migrateLegacyIfNeeded(into: defaults)
let migrated = SyncedUsageStatisticsV2.migrated(from: legacy, deviceID: deviceID)
if migrated != .empty {
save(migrated, to: defaults)
}
return migrated
}
public static func currentDeviceSlice(
from defaults: UserDefaults,
deviceID: String? = nil
) -> UsageStatisticsDeviceSlice {
let id = deviceID ?? SyncDeviceID.current(defaults: defaults)
return load(from: defaults).devices[id] ?? UsageStatisticsDeviceSlice()
}
public static func upsertCurrentDeviceSlice(
_ slice: UsageStatisticsDeviceSlice,
defaults: UserDefaults,
deviceID: String? = nil
) {
let id = deviceID ?? SyncDeviceID.current(defaults: defaults)
var payload = load(from: defaults)
payload.devices[id] = slice
save(payload, to: defaults)
}
}
@@ -0,0 +1,86 @@
// UsageStatistics.swift
// OSGKeyboard · Shared
//
// Cumulative dictation metrics shown on the home / dashboard stats cards.
// Mirrored through iCloud KVS when settings sync is enabled.
import Foundation
public struct UsageStatistics: Codable, Equatable, Sendable {
public var updatedAt: Date
public var dictationDurationSeconds: TimeInterval
public var dictationCharacterCount: Int
public var translationCharacterCount: Int
public init(
updatedAt: Date = Date(),
dictationDurationSeconds: TimeInterval = 0,
dictationCharacterCount: Int = 0,
translationCharacterCount: Int = 0
) {
self.updatedAt = updatedAt
self.dictationDurationSeconds = dictationDurationSeconds
self.dictationCharacterCount = dictationCharacterCount
self.translationCharacterCount = translationCharacterCount
}
public static let zero = UsageStatistics(updatedAt: .distantPast)
/// Combine lifetime totals from two devices. After merge, each device
/// continues accumulating locally so `max` converges to the union.
public static func merge(local: UsageStatistics, remote: UsageStatistics) -> UsageStatistics {
UsageStatistics(
updatedAt: max(local.updatedAt, remote.updatedAt),
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
)
}
}
public enum UsageStatisticsStorage {
public static let storageKey = "usageStatistics.v1"
/// Legacy macOS dashboard counter (word split); migrated on first load.
public static let legacyMacTotalWordsKey = "mac.totalWords"
/// PreApp Group iOS storage in `UserDefaults.standard`.
public static let legacyStandardDefaultsKey = "usageStatistics.v1"
public static func load(from defaults: UserDefaults) -> UsageStatistics {
if let data = defaults.data(forKey: storageKey),
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data) {
return stats
}
return .zero
}
public static func save(_ stats: UsageStatistics, to defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(stats) else { return }
defaults.set(data, forKey: storageKey)
}
/// One-time imports from older per-platform keys.
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> UsageStatistics {
var stats = load(from: defaults)
guard stats == .zero else { return stats }
let legacyWords = defaults.integer(forKey: legacyMacTotalWordsKey)
if legacyWords > 0 {
stats.dictationCharacterCount = legacyWords
stats.updatedAt = Date()
save(stats, to: defaults)
return stats
}
#if os(iOS)
if let data = UserDefaults.standard.data(forKey: legacyStandardDefaultsKey),
let legacy = try? JSONDecoder().decode(UsageStatistics.self, from: data),
legacy != .zero {
stats = legacy
stats.updatedAt = Date()
save(stats, to: defaults)
}
#endif
return stats
}
}