209 lines
6.9 KiB
Swift
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: "无法访问钥匙串。")
|
|
}
|
|
}
|
|
}
|