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
@@ -0,0 +1,10 @@
// AppGroupStore+ConfigurationStore.swift
// OSGKeyboard · Shared
//
// iOS / keyboard-extension configuration backed by the App Group suite.
import Foundation
extension AppGroupStore: ConfigurationStore {
public var cloudASRPersistence: UserDefaults { defaults }
}
@@ -0,0 +1,31 @@
// ConfigurationStore.swift
// OSGKeyboard · Shared
//
// Cross-platform read facade for the dictation pipeline (ASR polish).
// iOS implements this via `AppGroupStore` (App Group UserDefaults + Keychain).
// macOS will gain a separate implementation (standard UserDefaults + Keychain)
// without pulling in keyboard-extension-only APIs.
import Foundation
/// Read-only configuration surface consumed by ASR, cloud ASR, and polish services.
///
/// Keep this protocol narrow: only what the shared pipeline needs today.
/// Platform-specific settings UI and iCloud sync stay on concrete stores.
public protocol ConfigurationStore: Sendable {
var providerId: String { get }
var baseURL: String { get }
var apiKey: String { get }
var model: String { get }
var engineMode: String { get }
var polishIntensity: PolishIntensity { get }
var personalDictionary: PersonalDictionary { get }
/// Foreground-app context for polish prompts (keyboard extension publishes this).
var detectedAppContext: (context: AppContext, observedAt: Date)? { get }
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
var cloudASRPersistence: UserDefaults { get }
func makeClient() -> LLMClient
}
@@ -10,7 +10,10 @@ public struct RecordButton: View {
@Environment(\.themePalette) private var palette: ThemePalette
public enum Phase: Equatable {
case idle
/// Green host ready; tap records immediately.
case idleReady
/// Orange voice input unavailable (missing key, session not ready, etc.).
case idleUnavailable
case recording
case processing
case error
@@ -75,7 +78,7 @@ public struct RecordButton: View {
.animation(Motion.soft, value: level)
Circle()
.stroke(Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5)
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
.frame(width: Layout.outerRing, height: Layout.outerRing)
ZStack {
@@ -87,7 +90,7 @@ public struct RecordButton: View {
Group {
switch phase {
case .idle:
case .idleReady, .idleUnavailable:
Image(systemName: "mic.fill")
.font(.system(size: 36, weight: .medium))
.foregroundStyle(.white)
@@ -128,9 +131,9 @@ public struct RecordButton: View {
.animation(Motion.soft, value: remainingSeconds)
}
.contentShape(Circle())
.opacity(isEnabled ? 1 : 0.45)
.onTapGesture {
guard isEnabled, phase != .processing else { return }
guard phase != .processing else { return }
guard isEnabled || phase == .idleUnavailable else { return }
onToggle()
}
.onAppear { breath = (phase == .recording) }
@@ -140,6 +143,15 @@ public struct RecordButton: View {
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
}
private var isIdle: Bool {
switch phase {
case .idleReady, .idleUnavailable:
return true
case .recording, .processing, .error:
return false
}
}
private func formatRemaining(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainder = seconds % 60
@@ -159,13 +171,13 @@ public struct RecordButton: View {
startPoint: .top,
endPoint: .bottom
)
case .error:
case .error, .idleUnavailable:
return LinearGradient(
colors: [palette.warning.opacity(0.85), palette.warning.opacity(0.55)],
startPoint: .top,
endPoint: .bottom
)
case .idle:
case .idleReady:
return LinearGradient(
colors: [palette.accent.opacity(0.95), palette.accent.opacity(0.75)],
startPoint: .top,
@@ -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
}
}
+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)"
}
}
+102
View File
@@ -99,3 +99,105 @@
"keyboard.translation.offMenu" = "Don't translate";
"keyboard.translation.a11y" = "Translation";
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
/* macOS app */
"mac.section.dashboard" = "Dashboard";
"mac.section.history" = "History";
"mac.section.dictionary" = "Personal Dictionary";
"mac.section.settings" = "Settings";
"mac.brand.subtitle" = "AI DICTATION";
"mac.devices" = "Devices";
"mac.status.ready" = "Ready to dictate…";
"mac.status.listening" = "Listening…";
"mac.status.transcribing" = "Transcribing…";
"mac.status.copied" = "Copied to clipboard";
"mac.status.pasted" = "Inserted into front app";
"mac.status.copiedAndPasted" = "Copied and inserted";
"mac.stat.dictationTime" = "Dictation Time";
"mac.stat.words" = "Dictation Chars";
"mac.stat.translation" = "Translation Chars";
"mac.stat.dictionary" = "Dictionary";
"mac.stat.cumulativeDuration" = "Total time";
"mac.stat.transcribed" = "Transcribed";
"mac.stat.cumulativeTranslation" = "Translated";
"mac.stat.customTerms" = "Custom terms";
"mac.status.chipReady" = "Ready";
"mac.status.chipProcessing" = "Processing";
"mac.record.start" = "Record";
"mac.record.stop" = "Stop";
"mac.record.pressStop" = "Press Stop";
"mac.copy" = "Copy";
"mac.openWindow" = "Open Window";
"mac.quit" = "Quit";
"mac.mode.cloud" = "Cloud Mode";
"mac.mode.local" = "Local Mode";
"mac.connected" = "Connected";
"mac.offline" = "Offline";
"mac.history.recent" = "Recent";
"mac.history.empty" = "No voice transcripts yet.";
"mac.history.select" = "Select a dictation";
"mac.history.clearTitle" = "Clear all history?";
"mac.history.clearMessage" = "This cannot be undone.";
"mac.history.clearConfirm" = "Clear all";
"mac.dict.health" = "Vocabulary Health";
"mac.dict.healthDesc" = "Custom terms that bias recognition and are never rewritten.";
"mac.dict.search" = "Search words";
"mac.dict.empty" = "No words yet";
"mac.dict.emptyBody" = "Add words on your iPhone or iPad to improve recognition accuracy. They sync here via iCloud.";
"mac.dict.noMatch" = "No matches";
"mac.cancel" = "Cancel";
"mac.delete" = "Delete";
"mac.dict.deleteTitle" = "Delete this word?";
"mac.dict.deleteMessage" = "This cannot be undone.";
"mac.hint.holdOption" = "Hold Option to dictate";
"mac.settings.cloudProvider" = "CLOUD PROVIDER";
"mac.settings.service" = "Service";
"mac.settings.apiKey" = "API Key";
"mac.settings.model" = "Model";
"mac.settings.recognition" = "RECOGNITION METHOD";
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
"mac.settings.localEngine" = "Local Recognition (Qwen3-ASR)";
"mac.settings.localEngineDesc" = "On-device ASR with Qwen3-ASR 1.7B (MLX). High privacy, zero latency.";
"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
"mac.settings.about" = "About";
"mac.settings.general" = "General";
"mac.settings.input" = "Input & Shortcuts";
"mac.settings.recognitionLanguage" = "Recognition Language";
"mac.settings.interfaceLanguage" = "Interface Language";
"mac.settings.autoPaste" = "Auto-paste after dictation";
"mac.settings.autoPasteDesc" = "Simulate ⌘V in the front app after transcription (requires Accessibility).";
"mac.settings.hotkey" = "Global shortcut";
"mac.settings.hotkeyDesc" = "Hold Option (⌥) to dictate from any app.";
"mac.settings.qwen3Model" = "Qwen3 model folder";
"mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt.";
"mac.settings.qwen3Browse" = "Choose folder…";
"mac.settings.qwen3Missing" = "Qwen3 model not found — using Apple Speech for now.";
"mac.settings.accessibility" = "Accessibility";
"mac.settings.accessibilityDesc" = "Required for global shortcut and auto-paste.";
"mac.settings.openAccessibility" = "Open System Settings";
"mac.settings.appearance" = "Appearance";
"mac.settings.appearanceDesc" = "Match the system, or force a light or dark look.";
"mac.appearance.system" = "System";
"mac.appearance.light" = "Light";
"mac.appearance.dark" = "Dark";
"mac.error.noAudio" = "No audio captured";
"mac.error.noCloudASR" = "Selected provider has no cloud ASR";
"mac.error.emptyTranscript" = "No speech recognized";
"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
"mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings";
"mac.foregroundApp" = "Front app: %@";
"mac.sync.settingsTitle" = "iCloud Sync";
"mac.sync.settingsSubtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud.";
"mac.sync.syncNow" = "Sync Now";
"mac.sync.dictTitle" = "Personal dictionary iCloud sync";
"mac.sync.dictSubtitle" = "Keep your dictionary in sync across all devices.";
"mac.sync.error.generic" = "Could not sync with iCloud. Try again later.";
"mac.sync.error.dictTooLarge" = "Dictionary is too large to sync via iCloud.";
"settings.appLanguage.auto" = "Auto";
"settings.appLanguage.english" = "English";
"settings.appLanguage.chinese" = "Chinese";
@@ -99,3 +99,105 @@
"keyboard.translation.offMenu" = "不翻译";
"keyboard.translation.a11y" = "翻译";
"keyboard.translation.a11yHint" = "切换翻译或更改目标语言。";
/* macOS 应用 */
"mac.section.dashboard" = "仪表盘";
"mac.section.history" = "历史";
"mac.section.dictionary" = "个性词库";
"mac.section.settings" = "设置";
"mac.brand.subtitle" = "AI 听写";
"mac.devices" = "设备";
"mac.status.ready" = "准备听写…";
"mac.status.listening" = "录音中…";
"mac.status.transcribing" = "识别中…";
"mac.status.copied" = "已复制到剪贴板";
"mac.status.pasted" = "已插入前台应用";
"mac.status.copiedAndPasted" = "已复制并插入";
"mac.stat.dictationTime" = "听写时长";
"mac.stat.words" = "听写字数";
"mac.stat.translation" = "翻译字数";
"mac.stat.dictionary" = "词库";
"mac.stat.cumulativeDuration" = "累计时长";
"mac.stat.transcribed" = "累计转写";
"mac.stat.cumulativeTranslation" = "累计翻译";
"mac.stat.customTerms" = "自定义词条";
"mac.status.chipReady" = "就绪";
"mac.status.chipProcessing" = "处理中";
"mac.record.start" = "开始录音";
"mac.record.stop" = "停止";
"mac.record.pressStop" = "点击停止";
"mac.copy" = "复制";
"mac.openWindow" = "打开主窗口";
"mac.quit" = "退出";
"mac.mode.cloud" = "云端模式";
"mac.mode.local" = "本地模式";
"mac.connected" = "已连接";
"mac.offline" = "离线";
"mac.history.recent" = "最近";
"mac.history.empty" = "还没有语音识别记录。";
"mac.history.select" = "选择一条记录";
"mac.history.clearTitle" = "清空全部历史?";
"mac.history.clearMessage" = "此操作无法撤销。";
"mac.history.clearConfirm" = "全部清空";
"mac.dict.health" = "词库健康度";
"mac.dict.healthDesc" = "影响识别偏置且润色时不会被改写的自定义词条。";
"mac.dict.search" = "搜索词条";
"mac.dict.empty" = "还没有词条";
"mac.dict.emptyBody" = "在 iPhone 或 iPad 上添加词条以提升识别准确性,它们会通过 iCloud 同步到这里。";
"mac.dict.noMatch" = "无匹配结果";
"mac.cancel" = "取消";
"mac.delete" = "删除";
"mac.dict.deleteTitle" = "删除该词条?";
"mac.dict.deleteMessage" = "此操作无法撤销。";
"mac.hint.holdOption" = "长按 Option 开始听写";
"mac.settings.cloudProvider" = "云端服务商";
"mac.settings.service" = "服务商";
"mac.settings.apiKey" = "API 密钥";
"mac.settings.model" = "模型";
"mac.settings.recognition" = "识别方式";
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
"mac.settings.localEngine" = "本地识别(Qwen3-ASR";
"mac.settings.localEngineDesc" = "使用 Qwen3-ASR 1.7BMLX)本地转写,高隐私、低延迟。";
"mac.settings.localSpeechFallback" = "本地识别(Apple Speech";
"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
"mac.settings.about" = "关于";
"mac.settings.general" = "通用";
"mac.settings.input" = "输入与快捷键";
"mac.settings.recognitionLanguage" = "识别语言";
"mac.settings.interfaceLanguage" = "界面语言";
"mac.settings.autoPaste" = "听写后自动粘贴";
"mac.settings.autoPasteDesc" = "转写完成后向前台应用模拟 ⌘V(需辅助功能权限)。";
"mac.settings.hotkey" = "全局快捷键";
"mac.settings.hotkeyDesc" = "按住 Option (⌥) 键即可从任意应用开始听写。";
"mac.settings.qwen3Model" = "Qwen3 模型目录";
"mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。";
"mac.settings.qwen3Browse" = "选择文件夹…";
"mac.settings.qwen3Missing" = "未找到 Qwen3 模型,暂时使用 Apple Speech。";
"mac.settings.accessibility" = "辅助功能";
"mac.settings.accessibilityDesc" = "全局快捷键与自动粘贴需要此权限。";
"mac.settings.openAccessibility" = "打开系统设置";
"mac.settings.appearance" = "外观";
"mac.settings.appearanceDesc" = "跟随系统,或强制使用浅色 / 深色。";
"mac.appearance.system" = "跟随系统";
"mac.appearance.light" = "浅色";
"mac.appearance.dark" = "深色";
"mac.error.noAudio" = "没有捕获到音频";
"mac.error.noCloudASR" = "当前服务商不支持云端语音识别";
"mac.error.emptyTranscript" = "没有识别到语音";
"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
"mac.error.accessibilityRequired" = "请在系统设置中为 OSGKeyboard 启用辅助功能";
"mac.foregroundApp" = "前台应用:%@";
"mac.sync.settingsTitle" = "iCloud 同步";
"mac.sync.settingsSubtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。";
"mac.sync.syncNow" = "立即同步";
"mac.sync.dictTitle" = "个人词库 iCloud 同步";
"mac.sync.dictSubtitle" = "在所有设备间同步个人词库。";
"mac.sync.error.generic" = "无法与 iCloud 同步,请稍后重试。";
"mac.sync.error.dictTooLarge" = "词库过大,无法通过 iCloud 同步。";
"settings.appLanguage.auto" = "自动";
"settings.appLanguage.english" = "英文";
"settings.appLanguage.chinese" = "中文";