Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

209 lines
6.9 KiB
Swift

import Foundation
import LocalAuthentication
import MacToolsPluginKit
import Security
protocol TranslatorSecretStoring: Sendable {
func containsAPIKey() throws -> Bool
func containsAPIKey(forProfileID profileID: String) throws -> Bool
func loadAPIKey() throws -> String?
func loadAPIKey(forProfileID profileID: String) throws -> String?
func saveAPIKey(_ apiKey: String) throws
func saveAPIKey(_ apiKey: String, forProfileID profileID: String) throws
func deleteAPIKey() throws
func deleteAPIKey(forProfileID profileID: String) throws
}
extension TranslatorSecretStoring {
func containsAPIKey(forProfileID profileID: String) throws -> Bool {
try containsAPIKey()
}
func loadAPIKey(forProfileID profileID: String) throws -> String? {
try loadAPIKey()
}
func saveAPIKey(_ apiKey: String, forProfileID profileID: String) throws {
try saveAPIKey(apiKey)
}
func deleteAPIKey(forProfileID profileID: String) throws {
try deleteAPIKey()
}
}
struct OpenAICompatibleSecretStore: TranslatorSecretStoring {
static let defaultService = "cc.ggbond.mactools.translator"
static let defaultAccount = "translator.openai.api-key"
static func account(profileID: String) -> String {
"translator.provider.\(profileID).api-key"
}
let service: String
let account: String
init(
service: String = Self.defaultService,
account: String = Self.defaultAccount
) {
self.service = service
self.account = account
}
func containsAPIKey() throws -> Bool {
try containsAPIKey(account: account)
}
func containsAPIKey(forProfileID profileID: String) throws -> Bool {
try containsAPIKey(account: Self.account(profileID: profileID))
}
func loadAPIKey() throws -> String? {
try loadAPIKey(account: account)
}
func loadAPIKey(forProfileID profileID: String) throws -> String? {
try loadAPIKey(account: Self.account(profileID: profileID))
}
func saveAPIKey(_ apiKey: String) throws {
try saveAPIKey(apiKey, account: account)
}
func saveAPIKey(_ apiKey: String, forProfileID profileID: String) throws {
try saveAPIKey(apiKey, account: Self.account(profileID: profileID))
}
func deleteAPIKey() throws {
try deleteAPIKey(account: account)
}
func deleteAPIKey(forProfileID profileID: String) throws {
try deleteAPIKey(account: Self.account(profileID: profileID))
}
private func containsAPIKey(account: String) throws -> Bool {
var query = baseQuery
query[kSecAttrAccount as String] = account
let context = LAContext()
context.interactionNotAllowed = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnAttributes as String] = true
query[kSecUseAuthenticationContext as String] = context
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return true
case errSecItemNotFound:
return false
case errSecInteractionNotAllowed:
// The item likely exists but cannot be inspected without user interaction.
// Treat it as present so a blank settings save preserves it.
return true
default:
throw OpenAICompatibleSecretStoreError.security(status)
}
}
private func loadAPIKey(account: String) throws -> String? {
var query = baseQuery
query[kSecAttrAccount as String] = account
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 else {
throw OpenAICompatibleSecretStoreError.unexpectedItemData
}
guard let apiKey = String(data: data, encoding: .utf8) else {
throw OpenAICompatibleSecretStoreError.unexpectedItemData
}
return apiKey
case errSecItemNotFound:
return nil
default:
throw OpenAICompatibleSecretStoreError.security(status)
}
}
private func saveAPIKey(_ apiKey: String, account: String) throws {
let trimmedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedAPIKey.isEmpty else {
try deleteAPIKey(account: account)
return
}
let data = Data(trimmedAPIKey.utf8)
var attributes = baseQuery
attributes[kSecAttrAccount as String] = account
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(attributes as CFDictionary, nil)
switch status {
case errSecSuccess:
return
case errSecDuplicateItem:
var updateAttributes: [String: Any] = [:]
updateAttributes[kSecValueData as String] = data
updateAttributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
var updateQuery = baseQuery
updateQuery[kSecAttrAccount as String] = account
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
guard updateStatus == errSecSuccess else {
throw OpenAICompatibleSecretStoreError.security(updateStatus)
}
default:
throw OpenAICompatibleSecretStoreError.security(status)
}
}
private func deleteAPIKey(account: String) throws {
var query = baseQuery
query[kSecAttrAccount as String] = account
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw OpenAICompatibleSecretStoreError.security(status)
}
}
private var baseQuery: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
}
}
enum OpenAICompatibleSecretStoreError: Error, Equatable, Sendable {
case unexpectedItemData
case security(OSStatus)
}
extension OpenAICompatibleSecretStoreError: LocalizedError {
var errorDescription: String? {
errorDescription()
}
func errorDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String {
switch self {
case .unexpectedItemData:
return localization.string("secretStore.error.unexpectedItemData", defaultValue: "API Key 数据无效。")
case .security:
return localization.string("secretStore.error.security", defaultValue: "无法访问钥匙串。")
}
}
}