feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree. - feat(keyboard): CursorNavigation + CursorDragPad for caret movement; KeyboardSoundFeedback for system key click sounds - feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet; TranscriptPostProcessor quality gate; retire DictionaryLearner - feat(ui): TabBarVisibility handling; drop PageHeaderRow / PageHeaderConfirmButton; refresh views and localizable strings - fix(security): move the hardcoded DeepSeek key out of PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift (seeded from .example by generate-xcodeproj.sh) - docs(agents): add Conventional Commits versioning + bilingual changelog rules - chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache Custom language model / lexicon work stays on feature/custom-language-model-asr. Changelog bullets added under [Unreleased]; no version bump.
This commit is contained in:
@@ -39,18 +39,25 @@ public enum Keychain: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private static let service = "com.osgkeyboard.apikey"
|
||||
private static let account = "current"
|
||||
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)"
|
||||
}
|
||||
|
||||
// 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() -> String? {
|
||||
public static func apiKey(for providerId: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
@@ -73,21 +80,45 @@ 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] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: legacyAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
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
|
||||
|
||||
/// 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) throws {
|
||||
public static func setAPIKey(_ key: String, for providerId: String) throws {
|
||||
if key.isEmpty {
|
||||
try deleteAPIKey()
|
||||
try deleteAPIKey(for: providerId)
|
||||
return
|
||||
}
|
||||
let data = Data(key.utf8)
|
||||
let baseQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
]
|
||||
// Try update first — covers the common path where the key already
|
||||
// exists (every settings edit after the first).
|
||||
@@ -112,13 +143,18 @@ 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() throws {
|
||||
public static func deleteAPIKey(for providerId: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
// `errSecItemNotFound` is success-from-the-user's-perspective — the
|
||||
@@ -127,4 +163,21 @@ public enum Keychain: @unchecked Sendable {
|
||||
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] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: legacyAccount,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user