feat(ios): unify list cards and loading states

This commit is contained in:
chick
2026-09-10 01:35:12 +08:00
parent 995206ac23
commit c841748971
32 changed files with 1297 additions and 607 deletions
@@ -0,0 +1,7 @@
import Foundation
public protocol CredentialStore: Sendable {
func get(instanceID: UUID) throws -> String?
func set(instanceID: UUID, password: String) throws
func remove(instanceID: UUID) throws
}
@@ -1,6 +1,10 @@
import Foundation
enum DefaultContainer {
private static let credentials: any CredentialStore = FileCredentialStore(
location: documentFileURL("credentials.json")
)
private static let logStore: any LogStore = FileLogStore(
location: documentFileURL("log-center.json")
)
@@ -15,7 +19,7 @@ enum DefaultContainer {
FleetViewModel(
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
probe: ConnectionProbe(client: SimAdminClient()),
credentials: KeychainCredentialStore()
credentials: credentials
)
}
@@ -26,7 +30,7 @@ enum DefaultContainer {
service: MessageService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
credentials: credentials
)
)
}
@@ -38,7 +42,7 @@ enum DefaultContainer {
service: NotificationService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
credentials: credentials
)
)
}
@@ -51,7 +55,7 @@ enum DefaultContainer {
service: LogCenterService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore(),
credentials: credentials,
store: store
),
store: store
@@ -70,7 +74,7 @@ enum DefaultContainer {
service: DeviceService(
client: SimAdminClient(),
sessions: InstanceSessionStore(),
credentials: KeychainCredentialStore()
credentials: credentials
)
)
}
@@ -0,0 +1,52 @@
import Foundation
public final class FileCredentialStore: @unchecked Sendable, CredentialStore {
private struct FileModel: Codable {
var credentials: [String: String] = [:]
}
private let location: URL
private let lock = NSLock()
private var fileModel = FileModel()
public init(location: URL) {
self.location = location
lock.withLock {
if let data = try? Data(contentsOf: location),
let decoded = try? JSONDecoder().decode(FileModel.self, from: data) {
fileModel = decoded
}
}
}
public func get(instanceID: UUID) throws -> String? {
lock.withLock {
fileModel.credentials[instanceID.uuidString]
}
}
public func set(instanceID: UUID, password: String) throws {
try lock.withLock {
fileModel.credentials[instanceID.uuidString] = password
try write()
}
}
public func remove(instanceID: UUID) throws {
try lock.withLock {
fileModel.credentials.removeValue(forKey: instanceID.uuidString)
try write()
}
}
private func write() throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(fileModel)
try data.write(to: location, options: [.atomic, .completeFileProtection])
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: location.path
)
}
}
@@ -1,61 +0,0 @@
import Foundation
import Security
public protocol CredentialStore: Sendable {
func get(instanceID: UUID) throws -> String?
func set(instanceID: UUID, password: String) throws
func remove(instanceID: UUID) throws
}
public struct KeychainCredentialStore: CredentialStore {
private let service = "fun.chick.multi-simadmin.instance-password"
public init() {}
public func get(instanceID: UUID) throws -> String? {
var query = baseQuery(instanceID)
query[kSecReturnData as String] = true
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
if status == errSecItemNotFound { return nil }
throw CredentialStoreError.keychainFailed
}
guard let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
public func set(instanceID: UUID, password: String) throws {
try self.remove(instanceID: instanceID)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: instanceID.uuidString,
kSecValueData as String: Data(password.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else {
throw CredentialStoreError.keychainFailed
}
}
public func remove(instanceID: UUID) throws {
let status = SecItemDelete(baseQuery(instanceID) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw CredentialStoreError.keychainFailed
}
}
private func baseQuery(_ instanceID: UUID) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: instanceID.uuidString
]
}
}
public enum CredentialStoreError: Error, Equatable {
case keychainFailed
}