Files
luma/Sources/LumaCore/TokenStore.swift
Ole André Vadla Ravnås 873f55d238 Tidy token storage layout
Rename the on-disk directory tokens to Tokens, matching its
PascalCase siblings (Projects, HookPacks). FileTokenStore
now names files <service> or <service>.<account>; the
.token suffix and luma.llm. / re.frida.Luma prefixes are
gone. Existing entries become orphaned and users will be
prompted to enter API keys and re-auth GitHub on next run.
2026-05-11 14:39:25 +02:00

115 lines
4.1 KiB
Swift

import Foundation
public protocol TokenStore: Sendable {
func get(service: String, account: String) async throws -> String?
func set(service: String, account: String, token: String) async throws
func delete(service: String, account: String) async throws
}
#if canImport(Security)
import Security
public struct KeychainTokenStore: TokenStore {
public init() {}
public func get(service: String, account: String) async throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status == errSecItemNotFound { return nil }
throw TokenStoreError.keychainError(status)
}
return String(data: data, encoding: .utf8)
}
public func set(service: String, account: String, token: String) async throws {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
]
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData as String] = data
status = SecItemAdd(addQuery as CFDictionary, nil)
}
guard status == errSecSuccess else {
throw TokenStoreError.keychainError(status)
}
}
public func delete(service: String, account: String) async throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw TokenStoreError.keychainError(status)
}
}
}
#endif
public struct FileTokenStore: TokenStore {
private let directory: URL
public init(directory: URL) {
self.directory = directory
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
private func fileURL(service: String, account: String) -> URL {
let name = account == "default" ? service : "\(service).\(account)"
return directory.appendingPathComponent(name)
}
public func get(service: String, account: String) async throws -> String? {
let url = fileURL(service: service, account: account)
guard let data = try? Data(contentsOf: url) else { return nil }
return String(data: data, encoding: .utf8)
}
public func set(service: String, account: String, token: String) async throws {
try Data(token.utf8).write(to: fileURL(service: service, account: account), options: .atomic)
}
public func delete(service: String, account: String) async throws {
try? FileManager.default.removeItem(at: fileURL(service: service, account: account))
}
}
public enum TokenStoreError: Error {
#if canImport(Security)
case keychainError(OSStatus)
#endif
case unavailable
}
@MainActor
public func defaultTokenStore(dataDirectory: URL) -> TokenStore {
// Keychain on macOS dev builds prompts on every launch (and silently
// fails if the user dismisses the prompt), so we persist tokens in a
// file under the app's data directory on Apple platforms too. iOS
// app-private storage is sandboxed already; macOS files inherit
// user-only permissions.
#if os(iOS) || os(visionOS)
return KeychainTokenStore()
#else
return FileTokenStore(directory: dataDirectory.appendingPathComponent("Tokens"))
#endif
}