Files
1jehuang--jcode/ios/Sources/JCodeKit/CredentialStore.swift
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

152 lines
5.0 KiB
Swift

import Foundation
/// A saved server credential.
public struct ServerCredential: Codable, Equatable, Sendable, Identifiable {
public var id: String { "\(host):\(port)" }
public var host: String
public var port: UInt16
public var token: String
public var serverName: String
public var serverVersion: String
public var pairedAt: Date
public init(
host: String,
port: UInt16,
token: String,
serverName: String,
serverVersion: String,
pairedAt: Date = Date()
) {
self.host = host
self.port = port
self.token = token
self.serverName = serverName
self.serverVersion = serverVersion
self.pairedAt = pairedAt
}
public var gateway: Gateway {
Gateway(host: host, port: port)
}
}
/// Storage for paired-server credentials.
public protocol CredentialStore: Sendable {
func loadAll() -> [ServerCredential]
func save(_ credential: ServerCredential)
func remove(id: String)
}
/// Keychain-backed store used on device. All credentials live under one
/// keychain item as a JSON array; the auth token is the only secret and the
/// set is expected to stay tiny.
///
/// Falls back to a JSON file in Application Support when the keychain is
/// unavailable (e.g. unsigned simulator builds returning
/// `errSecMissingEntitlement`). Real signed builds use the keychain.
public struct KeychainCredentialStore: CredentialStore {
private static let service = "com.jcode.mobile.servers"
private static let account = "paired-servers"
public init() {}
public func loadAll() -> [ServerCredential] {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data {
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
}
if status != errSecItemNotFound {
NSLog("JCodeKit: keychain read failed (%d), trying file fallback", status)
}
if let data = try? Data(contentsOf: Self.fallbackURL) {
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
}
return []
}
public func save(_ credential: ServerCredential) {
var all = loadAll().filter { $0.id != credential.id }
all.append(credential)
persist(all)
}
public func remove(id: String) {
persist(loadAll().filter { $0.id != id })
}
private func persist(_ credentials: [ServerCredential]) {
guard let data = try? JSONEncoder().encode(credentials) else { return }
var query = baseQuery()
let attributes: [String: Any] = [kSecValueData as String: data]
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
status = SecItemAdd(query as CFDictionary, nil)
}
if status != errSecSuccess {
NSLog("JCodeKit: keychain write failed (%d), using file fallback", status)
persistToFile(data)
}
}
private func persistToFile(_ data: Data) {
let url = Self.fallbackURL
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: url, options: [.atomic, .completeFileProtection])
} catch {
NSLog("JCodeKit: file fallback write failed: %@", error.localizedDescription)
}
}
private static var fallbackURL: URL {
let base =
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first ?? FileManager.default.temporaryDirectory
return base.appendingPathComponent("jcode-servers.json")
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
]
}
}
/// In-memory store for tests and previews.
public final class InMemoryCredentialStore: CredentialStore, @unchecked Sendable {
private let lock = NSLock()
private var credentials: [ServerCredential] = []
public init() {}
public func loadAll() -> [ServerCredential] {
lock.withLock { credentials }
}
public func save(_ credential: ServerCredential) {
lock.withLock {
credentials.removeAll { $0.id == credential.id }
credentials.append(credential)
}
}
public func remove(id: String) {
lock.withLock {
credentials.removeAll { $0.id == id }
}
}
}