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