9f308fadd2
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
993 lines
39 KiB
Swift
993 lines
39 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)
|
|
}
|
|
|
|
public enum CredentialMigrationError: Error, Sendable, Equatable {
|
|
case unavailable
|
|
case conflict
|
|
case verificationFailed
|
|
}
|
|
|
|
typealias CredentialRead = () throws -> String?
|
|
typealias CredentialWrite = (String) throws -> Void
|
|
|
|
private static let service = "com.osgkeyboard.apikey"
|
|
private static let legacyAccount = "current"
|
|
/// Must match `AppGroupConfiguration.defaultPolishProviderId` so bare
|
|
/// `apiKey()` / `setAPIKey(_:)` hit the same account the host/ext use.
|
|
private static let defaultProviderId = AppGroupConfiguration.defaultPolishProviderId
|
|
|
|
// MARK: - XCTest unsigned-host fallback
|
|
//
|
|
// `CODE_SIGNING_ALLOWED=NO` (historical test runner) omits entitlements, so
|
|
// SecItem returns `errSecMissingEntitlement` (-34018). That aborts Keychain
|
|
// tests and turns "missing key" into `keychainLocked`. While XCTest is
|
|
// loaded, fall back to a process-local map so the hermetic suite stays
|
|
// deterministic; production / signed hosts never take this path.
|
|
|
|
private static let memoryLock = NSLock()
|
|
/// Protected by `memoryLock`; marked unsafe for Swift 6 global mutability rules.
|
|
nonisolated(unsafe) private static var memoryStore: [String: String] = [:]
|
|
|
|
private static var isRunningUnderXCTest: Bool {
|
|
NSClassFromString("XCTestCase") != nil
|
|
}
|
|
|
|
private static func memorySlot(account: String, synchronizable: Bool) -> String {
|
|
"\(synchronizable ? "s" : "l")|\(service)|\(account)"
|
|
}
|
|
|
|
private static func memoryRead(account: String, synchronizable: Bool) -> String? {
|
|
memoryLock.lock()
|
|
defer { memoryLock.unlock() }
|
|
return memoryStore[memorySlot(account: account, synchronizable: synchronizable)]
|
|
}
|
|
|
|
private static func memoryWrite(_ value: String, account: String, synchronizable: Bool) {
|
|
memoryLock.lock()
|
|
defer { memoryLock.unlock() }
|
|
memoryStore[memorySlot(account: account, synchronizable: synchronizable)] = value
|
|
}
|
|
|
|
private static func memoryDelete(account: String, synchronizable: Bool) {
|
|
memoryLock.lock()
|
|
defer { memoryLock.unlock() }
|
|
memoryStore.removeValue(forKey: memorySlot(account: account, synchronizable: synchronizable))
|
|
}
|
|
|
|
/// Clears the XCTest in-memory Keychain map. Call from test `setUp` so
|
|
/// provider-scoped leftovers (sync + local) cannot leak across cases.
|
|
public static func resetTestMemoryStore() {
|
|
guard isRunningUnderXCTest else { return }
|
|
memoryLock.lock()
|
|
defer { memoryLock.unlock() }
|
|
memoryStore.removeAll()
|
|
}
|
|
|
|
private static func shouldUseMemoryFallback(for status: OSStatus) -> Bool {
|
|
#if DEBUG
|
|
isRunningUnderXCTest && status == errSecMissingEntitlement
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
private static func normalizedProviderId(_ providerId: String) -> String {
|
|
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
|
|
}
|
|
|
|
/// LLM polish credentials (`provider.<id>`).
|
|
private static func account(for providerId: String) -> String {
|
|
"provider.\(normalizedProviderId(providerId))"
|
|
}
|
|
|
|
/// Cloud ASR credentials (`asr.<id>`), independent from polish keys.
|
|
private static func asrAccount(for providerId: String) -> String {
|
|
"asr.\(normalizedProviderId(providerId))"
|
|
}
|
|
|
|
// NOTE on kSecAttrAccessGroup: we deliberately rely on the DEFAULT
|
|
// access group (the first entry in each target's keychain-access-groups,
|
|
// which project.yml pins to `$(AppIdentifierPrefix)com.osgkeyboard.shared`
|
|
// for every target). Setting the attribute explicitly would require the
|
|
// team-prefixed string at runtime, which is not portably available
|
|
// without injecting TeamID through the build system. If a SECOND access
|
|
// group is ever added to any target, revisit this — reordered groups
|
|
// would silently change which store these queries hit.
|
|
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
|
|
|
|
// MARK: - ASR keys
|
|
|
|
public static func asrApiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
|
|
if preferICloudSync, let synced = readASRKey(providerId: providerId, synchronizable: true) {
|
|
return synced
|
|
}
|
|
if let local = readASRKey(providerId: providerId, synchronizable: false) {
|
|
return local
|
|
}
|
|
if preferICloudSync {
|
|
return readASRKey(providerId: providerId, synchronizable: true)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public static func asrApiKeyOutcome(
|
|
for providerId: String,
|
|
preferICloudSync: Bool = false
|
|
) -> ReadOutcome {
|
|
let first = readASRKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
|
|
if case .found = first { return first }
|
|
let second = readASRKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
|
|
if case .found = second { return second }
|
|
if case .unavailable = first { return first }
|
|
if case .unavailable = second { return second }
|
|
return .notFound
|
|
}
|
|
|
|
public static func setASRAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws {
|
|
if key.isEmpty {
|
|
try deleteASRAPIKey(for: providerId, useICloudSync: useICloudSync)
|
|
return
|
|
}
|
|
if useICloudSync {
|
|
try writeMirroredCredential(
|
|
key,
|
|
readLocal: {
|
|
try migrationValue(
|
|
from: readASRKeyOutcome(
|
|
providerId: providerId,
|
|
synchronizable: false,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
)
|
|
},
|
|
readSynchronizable: {
|
|
try migrationValue(
|
|
from: readASRKeyOutcome(
|
|
providerId: providerId,
|
|
synchronizable: true,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
)
|
|
},
|
|
writeLocal: { try writeASRKey($0, providerId: providerId, synchronizable: false) },
|
|
writeSynchronizable: {
|
|
try writeASRKey($0, providerId: providerId, synchronizable: true)
|
|
},
|
|
deleteLocal: { try deleteASRKey(providerId: providerId, synchronizable: false) },
|
|
deleteSynchronizable: {
|
|
try deleteASRKey(providerId: providerId, synchronizable: true)
|
|
}
|
|
)
|
|
} else {
|
|
try writeASRKey(key, providerId: providerId, synchronizable: false)
|
|
}
|
|
}
|
|
|
|
public static func deleteASRAPIKey(for providerId: String, useICloudSync: Bool = false) throws {
|
|
try deleteASRKey(providerId: providerId, synchronizable: false)
|
|
if useICloudSync {
|
|
try deleteASRKey(providerId: providerId, synchronizable: true)
|
|
}
|
|
}
|
|
|
|
private static func readASRKey(providerId: String, synchronizable: Bool) -> String? {
|
|
if case .found(let value) = readASRKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func readASRKeyOutcome(
|
|
providerId: String,
|
|
synchronizable: Bool,
|
|
fallbackToLegacyProviderAccount: Bool = true
|
|
) -> ReadOutcome {
|
|
var query = baseASRQuery(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 .notFound
|
|
}
|
|
return .found(str)
|
|
case errSecItemNotFound:
|
|
// Pre-split installs stored one key under `provider.<id>` for both stages.
|
|
return fallbackToLegacyProviderAccount
|
|
? readKeyOutcome(providerId: providerId, synchronizable: synchronizable)
|
|
: .notFound
|
|
default:
|
|
if shouldUseMemoryFallback(for: status) {
|
|
if let value = memoryRead(account: asrAccount(for: providerId), synchronizable: synchronizable) {
|
|
return .found(value)
|
|
}
|
|
// Pre-split installs: fall through to polish-key account.
|
|
return fallbackToLegacyProviderAccount
|
|
? readKeyOutcome(providerId: providerId, synchronizable: synchronizable)
|
|
: .notFound
|
|
}
|
|
#if DEBUG
|
|
print("⚠️ [OSGKeyboard] ASR Keychain read returned OSStatus \(status); reporting unavailable.")
|
|
#endif
|
|
return .unavailable(status)
|
|
}
|
|
}
|
|
|
|
private static func baseASRQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: asrAccount(for: providerId),
|
|
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
|
|
]
|
|
#if os(macOS)
|
|
query[kSecUseDataProtectionKeychain as String] = true
|
|
#endif
|
|
return query
|
|
}
|
|
|
|
private static func writeASRKey(_ key: String, providerId: String, synchronizable: Bool) throws {
|
|
let data = Data(key.utf8)
|
|
var baseQuery = baseASRQuery(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 { return }
|
|
if shouldUseMemoryFallback(for: addStatus) {
|
|
memoryWrite(key, account: asrAccount(for: providerId), synchronizable: synchronizable)
|
|
return
|
|
}
|
|
throw KeychainError.unexpectedStatus(addStatus)
|
|
default:
|
|
if shouldUseMemoryFallback(for: updateStatus) {
|
|
memoryWrite(key, account: asrAccount(for: providerId), synchronizable: synchronizable)
|
|
return
|
|
}
|
|
throw KeychainError.unexpectedStatus(updateStatus)
|
|
}
|
|
}
|
|
|
|
private static func deleteASRKey(providerId: String, synchronizable: Bool) throws {
|
|
let query = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
if status == errSecSuccess || status == errSecItemNotFound { return }
|
|
if shouldUseMemoryFallback(for: status) {
|
|
memoryDelete(account: asrAccount(for: providerId), synchronizable: synchronizable)
|
|
return
|
|
}
|
|
throw KeychainError.unexpectedStatus(status)
|
|
}
|
|
|
|
// MARK: - LLM keys
|
|
|
|
/// Reads synchronizable then local when iCloud is preferred (retrying sync
|
|
/// after local miss); otherwise reads local only. This optional API folds
|
|
/// locked/unavailable into nil—use `apiKeyOutcome` when that distinction matters.
|
|
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)
|
|
}
|
|
|
|
/// Distinguishes "no key stored" from "keychain temporarily unreadable".
|
|
public enum ReadOutcome: Equatable {
|
|
case found(String)
|
|
case notFound
|
|
/// The keychain could not be read (e.g. `errSecInteractionNotAllowed`
|
|
/// while the device is locked before first unlock). NOT the same as
|
|
/// "no key configured" — telling the user to re-enter their key in
|
|
/// this state would be wrong; the read succeeds once unlocked.
|
|
case unavailable(OSStatus)
|
|
}
|
|
|
|
/// Like `apiKey(for:)`, but reports WHY a key was not returned so
|
|
/// callers can distinguish a missing key (user action needed) from a
|
|
/// transiently locked keychain (retry later).
|
|
public static func apiKeyOutcome(
|
|
for providerId: String,
|
|
preferICloudSync: Bool = false
|
|
) -> ReadOutcome {
|
|
let first = readKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
|
|
if case .found = first { return first }
|
|
let second = readKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
|
|
if case .found = second { return second }
|
|
// Neither store had it: surface "unavailable" when either read was
|
|
// blocked, since the key may well exist behind the lock.
|
|
if case .unavailable = first { return first }
|
|
if case .unavailable = second { return second }
|
|
return .notFound
|
|
}
|
|
|
|
private static func readKey(providerId: String, synchronizable: Bool) -> String? {
|
|
if case .found(let value) = readKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func readKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome {
|
|
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 .notFound
|
|
}
|
|
return .found(str)
|
|
case errSecItemNotFound:
|
|
return .notFound
|
|
default:
|
|
if shouldUseMemoryFallback(for: status) {
|
|
if let value = memoryRead(account: account(for: providerId), synchronizable: synchronizable) {
|
|
return .found(value)
|
|
}
|
|
return .notFound
|
|
}
|
|
#if DEBUG
|
|
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); reporting unavailable.")
|
|
#endif
|
|
return .unavailable(status)
|
|
}
|
|
}
|
|
|
|
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)
|
|
if status == errSecSuccess,
|
|
let data = result as? Data,
|
|
let str = String(data: data, encoding: .utf8) {
|
|
return str
|
|
}
|
|
if shouldUseMemoryFallback(for: status) {
|
|
return memoryRead(account: legacyAccount, synchronizable: false)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Write
|
|
|
|
/// An empty value deletes the selected storage. A synchronized write
|
|
/// removes its local counterpart; a local write leaves any synchronized
|
|
/// counterpart intact until an explicit sync migration or deletion.
|
|
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 writeMirroredCredential(
|
|
key,
|
|
readLocal: {
|
|
try migrationValue(
|
|
from: readKeyOutcome(providerId: providerId, synchronizable: false)
|
|
)
|
|
},
|
|
readSynchronizable: {
|
|
try migrationValue(
|
|
from: readKeyOutcome(providerId: providerId, synchronizable: true)
|
|
)
|
|
},
|
|
writeLocal: { try writeKey($0, providerId: providerId, synchronizable: false) },
|
|
writeSynchronizable: {
|
|
try writeKey($0, providerId: providerId, synchronizable: true)
|
|
},
|
|
deleteLocal: { try deleteKey(providerId: providerId, synchronizable: false) },
|
|
deleteSynchronizable: {
|
|
try deleteKey(providerId: providerId, synchronizable: true)
|
|
}
|
|
)
|
|
} 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 { return }
|
|
if shouldUseMemoryFallback(for: addStatus) {
|
|
memoryWrite(key, account: account(for: providerId), synchronizable: synchronizable)
|
|
return
|
|
}
|
|
throw KeychainError.unexpectedStatus(addStatus)
|
|
default:
|
|
if shouldUseMemoryFallback(for: updateStatus) {
|
|
memoryWrite(key, account: account(for: providerId), synchronizable: synchronizable)
|
|
return
|
|
}
|
|
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 { return }
|
|
if shouldUseMemoryFallback(for: status) {
|
|
memoryDelete(account: account(for: providerId), synchronizable: synchronizable)
|
|
return
|
|
}
|
|
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 { return }
|
|
if shouldUseMemoryFallback(for: status) {
|
|
memoryDelete(account: legacyAccount, synchronizable: false)
|
|
return
|
|
}
|
|
throw KeychainError.unexpectedStatus(status)
|
|
}
|
|
|
|
// MARK: - Credential migration
|
|
|
|
/// Writes the same explicit user value to device-only and synchronizable
|
|
/// stores. Any failure restores both previous values before returning.
|
|
static func writeMirroredCredential(
|
|
_ value: String,
|
|
readLocal: CredentialRead,
|
|
readSynchronizable: CredentialRead,
|
|
writeLocal: CredentialWrite,
|
|
writeSynchronizable: CredentialWrite,
|
|
deleteLocal: () throws -> Void,
|
|
deleteSynchronizable: () throws -> Void
|
|
) throws {
|
|
let previousLocal = try migrationOperation(readLocal)
|
|
let previousSynchronizable = try migrationOperation(readSynchronizable)
|
|
do {
|
|
try migrationOperation { try writeLocal(value) }
|
|
guard try migrationOperation(readLocal) == value else {
|
|
throw CredentialMigrationError.verificationFailed
|
|
}
|
|
try migrationOperation { try writeSynchronizable(value) }
|
|
guard try migrationOperation(readSynchronizable) == value else {
|
|
throw CredentialMigrationError.verificationFailed
|
|
}
|
|
} catch {
|
|
let originalError = (error as? CredentialMigrationError) ?? .unavailable
|
|
var restoreFailed = false
|
|
do {
|
|
try restoreCredential(
|
|
previousLocal,
|
|
write: writeLocal,
|
|
delete: deleteLocal
|
|
)
|
|
} catch {
|
|
restoreFailed = true
|
|
}
|
|
do {
|
|
try restoreCredential(
|
|
previousSynchronizable,
|
|
write: writeSynchronizable,
|
|
delete: deleteSynchronizable
|
|
)
|
|
} catch {
|
|
restoreFailed = true
|
|
}
|
|
if restoreFailed {
|
|
throw CredentialMigrationError.unavailable
|
|
}
|
|
throw originalError
|
|
}
|
|
}
|
|
|
|
/// Pure copy/verify/delete transaction. Tests inject each operation so
|
|
/// failure paths never need to manipulate or expose real credentials.
|
|
@discardableResult
|
|
static func copyCredentialTransaction(
|
|
source: CredentialRead,
|
|
destination: CredentialRead,
|
|
writeDestination: CredentialWrite,
|
|
readbackDestination: CredentialRead,
|
|
deleteSource: () throws -> Void
|
|
) throws -> String? {
|
|
let sourceValue = try migrationOperation(source)
|
|
guard let sourceValue, !sourceValue.isEmpty else { return nil }
|
|
|
|
if let destinationValue = try migrationOperation(destination),
|
|
destinationValue != sourceValue {
|
|
throw CredentialMigrationError.conflict
|
|
}
|
|
|
|
try migrationOperation {
|
|
try writeDestination(sourceValue)
|
|
}
|
|
let readback = try migrationOperation(readbackDestination)
|
|
guard readback == sourceValue else {
|
|
throw CredentialMigrationError.verificationFailed
|
|
}
|
|
try migrationOperation(deleteSource)
|
|
return sourceValue
|
|
}
|
|
|
|
/// Copy non-empty local LLM and ASR keys into synchronizable items.
|
|
/// Device-only shadow copies are retained for a safe sync disable.
|
|
public static func migrateLocalKeysToICloud() throws {
|
|
try migrateAllCredentials(
|
|
sourceSynchronizable: false,
|
|
destinationSynchronizable: true,
|
|
deleteSourcesAfterVerification: false
|
|
)
|
|
}
|
|
|
|
/// Copy synchronizable LLM and ASR keys back to device-only items before
|
|
/// settings sync is disabled.
|
|
public static func migrateICloudKeysToLocal() throws {
|
|
try migrateAllCredentials(
|
|
sourceSynchronizable: true,
|
|
destinationSynchronizable: false,
|
|
deleteSourcesAfterVerification: true
|
|
)
|
|
}
|
|
|
|
/// Stores a legacy plaintext value in the selected provider account and
|
|
/// verifies it. The caller remains responsible for deleting its source.
|
|
static func copyAPIKeyToSelectedStorage(
|
|
_ key: String,
|
|
providerId: String,
|
|
useICloudSync: Bool
|
|
) throws {
|
|
_ = try copyCredentialTransaction(
|
|
source: { key },
|
|
destination: {
|
|
try migrationValue(
|
|
from: readKeyOutcome(providerId: providerId, synchronizable: useICloudSync)
|
|
)
|
|
},
|
|
writeDestination: { value in
|
|
try setAPIKey(value, for: providerId, useICloudSync: useICloudSync)
|
|
},
|
|
readbackDestination: {
|
|
try migrationValue(
|
|
from: readKeyOutcome(providerId: providerId, synchronizable: useICloudSync)
|
|
)
|
|
},
|
|
deleteSource: {}
|
|
)
|
|
}
|
|
|
|
/// Safely migrates the legacy `current` account into the selected provider
|
|
/// account. A failed write or unavailable Keychain leaves the source intact.
|
|
static func migrateLegacyAPIKey(
|
|
to providerId: String,
|
|
useICloudSync: Bool
|
|
) throws {
|
|
_ = try copyCredentialTransaction(
|
|
source: { legacyAPIKey() },
|
|
destination: {
|
|
try migrationValue(
|
|
from: readKeyOutcome(providerId: providerId, synchronizable: useICloudSync)
|
|
)
|
|
},
|
|
writeDestination: { value in
|
|
try setAPIKey(value, for: providerId, useICloudSync: useICloudSync)
|
|
},
|
|
readbackDestination: {
|
|
try migrationValue(
|
|
from: readKeyOutcome(providerId: providerId, synchronizable: useICloudSync)
|
|
)
|
|
},
|
|
deleteSource: {
|
|
try deleteLegacyAPIKey()
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Copies the retired qwen ASR credential into bailian without deleting
|
|
/// either qwen account, which may still be needed by LLM or rollback paths.
|
|
static func copyQwenASRKeyToBailian(useICloudSync: Bool) throws {
|
|
_ = try copyCredentialTransaction(
|
|
source: {
|
|
if let dedicated = try preferredMigrationValue(
|
|
providerId: "qwen",
|
|
synchronizable: useICloudSync,
|
|
asr: true
|
|
) {
|
|
return dedicated
|
|
}
|
|
return try preferredMigrationValue(
|
|
providerId: "qwen",
|
|
synchronizable: useICloudSync,
|
|
asr: false
|
|
)
|
|
},
|
|
destination: {
|
|
try migrationValue(
|
|
from: readASRKeyOutcome(
|
|
providerId: "bailian",
|
|
synchronizable: useICloudSync,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
)
|
|
},
|
|
writeDestination: { value in
|
|
try setASRAPIKey(value, for: "bailian", useICloudSync: useICloudSync)
|
|
},
|
|
readbackDestination: {
|
|
try migrationValue(
|
|
from: readASRKeyOutcome(
|
|
providerId: "bailian",
|
|
synchronizable: useICloudSync,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
)
|
|
},
|
|
deleteSource: {}
|
|
)
|
|
}
|
|
|
|
private static func migrateAllCredentials(
|
|
sourceSynchronizable: Bool,
|
|
destinationSynchronizable: Bool,
|
|
deleteSourcesAfterVerification: Bool
|
|
) throws {
|
|
var verifiedSources: [(providerId: String, asr: Bool)] = []
|
|
for providerId in Set(LLMProvider.presets.map(\.id)).sorted() {
|
|
if try copyCredential(
|
|
providerId: providerId,
|
|
sourceSynchronizable: sourceSynchronizable,
|
|
destinationSynchronizable: destinationSynchronizable,
|
|
asr: false
|
|
) {
|
|
verifiedSources.append((providerId, false))
|
|
}
|
|
}
|
|
let asrProviderIds = Set(
|
|
(LLMProvider.presets + LLMProvider.asrSelectablePresets).map(\.id)
|
|
)
|
|
for providerId in asrProviderIds.sorted() {
|
|
if try copyCredential(
|
|
providerId: providerId,
|
|
sourceSynchronizable: sourceSynchronizable,
|
|
destinationSynchronizable: destinationSynchronizable,
|
|
asr: true
|
|
) {
|
|
verifiedSources.append((providerId, true))
|
|
}
|
|
}
|
|
guard deleteSourcesAfterVerification else { return }
|
|
var cleanupFailed = false
|
|
for source in verifiedSources {
|
|
do {
|
|
if source.asr {
|
|
try deleteASRKey(
|
|
providerId: source.providerId,
|
|
synchronizable: sourceSynchronizable
|
|
)
|
|
} else {
|
|
try deleteKey(
|
|
providerId: source.providerId,
|
|
synchronizable: sourceSynchronizable
|
|
)
|
|
}
|
|
} catch {
|
|
cleanupFailed = true
|
|
// Every destination has already been verified, so a retained
|
|
// source is a harmless shadow that a later migration can retry.
|
|
OSGLog.config.warning(
|
|
"credential source cleanup deferred provider=\(source.providerId, privacy: .public)"
|
|
)
|
|
}
|
|
}
|
|
if cleanupFailed {
|
|
throw CredentialMigrationError.unavailable
|
|
}
|
|
}
|
|
|
|
private static func copyCredential(
|
|
providerId: String,
|
|
sourceSynchronizable: Bool,
|
|
destinationSynchronizable: Bool,
|
|
asr: Bool
|
|
) throws -> Bool {
|
|
let copied = try copyCredentialTransaction(
|
|
source: {
|
|
try migrationValue(
|
|
from: asr
|
|
? readASRKeyOutcome(
|
|
providerId: providerId,
|
|
synchronizable: sourceSynchronizable,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
: readKeyOutcome(providerId: providerId, synchronizable: sourceSynchronizable)
|
|
)
|
|
},
|
|
destination: {
|
|
try migrationValue(
|
|
from: asr
|
|
? readASRKeyOutcome(
|
|
providerId: providerId,
|
|
synchronizable: destinationSynchronizable,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
: readKeyOutcome(providerId: providerId, synchronizable: destinationSynchronizable)
|
|
)
|
|
},
|
|
writeDestination: { value in
|
|
if asr {
|
|
try writeASRKey(
|
|
value,
|
|
providerId: providerId,
|
|
synchronizable: destinationSynchronizable
|
|
)
|
|
} else {
|
|
try writeKey(
|
|
value,
|
|
providerId: providerId,
|
|
synchronizable: destinationSynchronizable
|
|
)
|
|
}
|
|
},
|
|
readbackDestination: {
|
|
try migrationValue(
|
|
from: asr
|
|
? readASRKeyOutcome(
|
|
providerId: providerId,
|
|
synchronizable: destinationSynchronizable,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
: readKeyOutcome(providerId: providerId, synchronizable: destinationSynchronizable)
|
|
)
|
|
},
|
|
deleteSource: {}
|
|
)
|
|
return copied != nil
|
|
}
|
|
|
|
private static func preferredMigrationValue(
|
|
providerId: String,
|
|
synchronizable: Bool,
|
|
asr: Bool
|
|
) throws -> String? {
|
|
let preferred = try migrationValue(
|
|
from: asr
|
|
? readASRKeyOutcome(
|
|
providerId: providerId,
|
|
synchronizable: synchronizable,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
: readKeyOutcome(providerId: providerId, synchronizable: synchronizable)
|
|
)
|
|
if let preferred, !preferred.isEmpty { return preferred }
|
|
return try migrationValue(
|
|
from: asr
|
|
? readASRKeyOutcome(
|
|
providerId: providerId,
|
|
synchronizable: !synchronizable,
|
|
fallbackToLegacyProviderAccount: false
|
|
)
|
|
: readKeyOutcome(providerId: providerId, synchronizable: !synchronizable)
|
|
)
|
|
}
|
|
|
|
private static func migrationValue(from outcome: ReadOutcome) throws -> String? {
|
|
switch outcome {
|
|
case .found(let value):
|
|
return value
|
|
case .notFound:
|
|
return nil
|
|
case .unavailable:
|
|
throw CredentialMigrationError.unavailable
|
|
}
|
|
}
|
|
|
|
private static func migrationOperation<T>(_ operation: () throws -> T) throws -> T {
|
|
do {
|
|
return try operation()
|
|
} catch let error as CredentialMigrationError {
|
|
throw error
|
|
} catch {
|
|
throw CredentialMigrationError.unavailable
|
|
}
|
|
}
|
|
|
|
private static func restoreCredential(
|
|
_ previousValue: String?,
|
|
write: CredentialWrite,
|
|
delete: () throws -> Void
|
|
) throws {
|
|
if let previousValue {
|
|
try write(previousValue)
|
|
} else {
|
|
try delete()
|
|
}
|
|
}
|
|
|
|
// MARK: - Onboarding completion (reboot-durable flag)
|
|
|
|
private static let onboardingService = "com.osgkeyboard.onboarding"
|
|
private static let onboardingAccount = "hasCompletedOnboarding"
|
|
/// Survives reboots but is wiped with the app container (unlike Keychain).
|
|
private static let installIdentityKey = "osgkeyboard.installIdentity"
|
|
|
|
/// Call once at config init. Returns `true` when this is a brand-new app
|
|
/// container (first launch or reinstall after delete). Clears a stale
|
|
/// Keychain onboarding flag so deleted installs show the welcome flow again.
|
|
@discardableResult
|
|
public static func beginInstallIdentityIfNeeded() -> Bool {
|
|
let standard = UserDefaults.standard
|
|
if standard.string(forKey: installIdentityKey) != nil {
|
|
return false
|
|
}
|
|
standard.set(UUID().uuidString, forKey: installIdentityKey)
|
|
if hasCompletedOnboarding() {
|
|
setOnboardingCompleted(false)
|
|
OSGLog.config.info("[onboarding] fresh install: cleared stale Keychain onboarding flag")
|
|
} else {
|
|
OSGLog.config.info("[onboarding] fresh install: install identity created")
|
|
}
|
|
return true
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
}
|