c2f07bd8d2
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.
266 lines
10 KiB
Swift
266 lines
10 KiB
Swift
// Keychain.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Keychain helper for LLM API keys and onboarding markers.
|
|
//
|
|
// 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
|
|
|
|
public enum Keychain: @unchecked Sendable {
|
|
|
|
public enum KeychainError: Error, Sendable, Equatable {
|
|
case unexpectedStatus(OSStatus)
|
|
}
|
|
|
|
private static let service = "com.osgkeyboard.apikey"
|
|
private static let legacyAccount = "current"
|
|
private static let defaultProviderId = "openai"
|
|
|
|
private static func account(for providerId: String) -> String {
|
|
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
|
|
return "provider.\(normalized)"
|
|
}
|
|
|
|
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),
|
|
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 {
|
|
case errSecSuccess:
|
|
guard let data = result as? Data,
|
|
let str = String(data: data, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return str
|
|
case errSecItemNotFound:
|
|
return nil
|
|
default:
|
|
#if DEBUG
|
|
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); treating as no key.")
|
|
#endif
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public static func legacyAPIKey() -> String? {
|
|
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,
|
|
let data = result as? Data,
|
|
let str = String(data: data, encoding: .utf8)
|
|
else { return nil }
|
|
return str
|
|
}
|
|
|
|
// MARK: - Write
|
|
|
|
public static func setAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws {
|
|
if key.isEmpty {
|
|
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)
|
|
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:
|
|
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)
|
|
}
|
|
default:
|
|
throw KeychainError.unexpectedStatus(updateStatus)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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)
|
|
if status != errSecSuccess && status != errSecItemNotFound {
|
|
throw KeychainError.unexpectedStatus(status)
|
|
}
|
|
}
|
|
|
|
public static func deleteLegacyAPIKey() throws {
|
|
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)
|
|
|
|
private static let onboardingService = "com.osgkeyboard.onboarding"
|
|
private static let onboardingAccount = "hasCompletedOnboarding"
|
|
|
|
public static func hasCompletedOnboarding() -> Bool {
|
|
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,
|
|
let data = result as? Data,
|
|
let str = String(data: data, encoding: .utf8) else {
|
|
OSGLog.config.info("[onboarding] Keychain read: status=\(status, privacy: .public) → false")
|
|
return false
|
|
}
|
|
let completed = str == "1"
|
|
OSGLog.config.info(
|
|
"[onboarding] Keychain read: status=ok value=\(str, privacy: .public) → \(completed, privacy: .public)"
|
|
)
|
|
return completed
|
|
}
|
|
|
|
public static func setOnboardingCompleted(_ completed: Bool) {
|
|
guard hasCompletedOnboarding() != completed else {
|
|
OSGLog.config.info("[onboarding] Keychain write skipped (already \(completed, privacy: .public))")
|
|
return
|
|
}
|
|
|
|
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)
|
|
OSGLog.config.info("[onboarding] Keychain delete: status=\(delStatus, privacy: .public)")
|
|
return
|
|
}
|
|
|
|
let data = Data("1".utf8)
|
|
let updateStatus = SecItemUpdate(
|
|
baseQuery as CFDictionary,
|
|
[kSecValueData as String: data] as CFDictionary
|
|
)
|
|
if updateStatus == errSecItemNotFound {
|
|
var addQuery = baseQuery
|
|
addQuery[kSecValueData as String] = data
|
|
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
OSGLog.config.info("[onboarding] Keychain add: status=\(addStatus, privacy: .public)")
|
|
} else {
|
|
OSGLog.config.info("[onboarding] Keychain update: status=\(updateStatus, privacy: .public)")
|
|
}
|
|
}
|
|
}
|