Connect macOS app to backend device bootstrap

This commit is contained in:
diyaa 2026-05-25 16:07:04 +02:00
parent 6d426f2a54
commit 1674174a4e
7 changed files with 724 additions and 44 deletions

View File

@ -10,6 +10,57 @@ struct MacLibraryView: View {
Text("Private Library Foundation") Text("Private Library Foundation")
.font(.largeTitle) .font(.largeTitle)
Text("Backend Connection")
.font(.title2)
TextField("http://localhost:3000", text: $viewModel.serverURLString)
.textFieldStyle(.roundedBorder)
.onSubmit {
viewModel.persistServerURLSelection()
}
VStack(alignment: .leading, spacing: 8) {
statusRow(title: "Server URL", value: viewModel.serverURLString)
statusRow(title: "Registration", value: viewModel.deviceRegistrationStatus)
statusRow(
title: "Device ID",
value: viewModel.registeredDeviceId ?? "Not registered yet."
)
statusRow(title: "Last heartbeat", value: viewModel.lastHeartbeatStatus)
statusRow(title: "Last bootstrap", value: viewModel.lastSyncBootstrapStatus)
if let lastBootstrapTrackCount = viewModel.lastBootstrapTrackCount {
statusRow(title: "Bootstrap tracks", value: "\(lastBootstrapTrackCount)")
}
if let lastBootstrapCursor = viewModel.lastBootstrapCursor {
statusRow(title: "Next cursor", value: lastBootstrapCursor)
}
}
HStack(spacing: 12) {
Button("Register this Mac") {
Task {
await viewModel.registerThisMac()
}
}
.disabled(viewModel.isRegisteringDevice)
Button("Send Heartbeat") {
Task {
await viewModel.sendHeartbeat()
}
}
.disabled(viewModel.registeredDeviceId == nil || viewModel.isSendingHeartbeat)
Button("Sync Bootstrap") {
Task {
await viewModel.syncBootstrap()
}
}
.disabled(viewModel.isRunningSyncBootstrap)
}
Text("Selected folder") Text("Selected folder")
.font(.headline) .font(.headline)
@ -93,4 +144,15 @@ struct MacLibraryView: View {
let seconds = totalSeconds % 60 let seconds = totalSeconds % 60
return String(format: "%d:%02d", minutes, seconds) return String(format: "%d:%02d", minutes, seconds)
} }
@ViewBuilder
private func statusRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline)
Text(value)
.textSelection(.enabled)
.foregroundStyle(.secondary)
}
}
} }

View File

@ -1,7 +1,9 @@
import Foundation import Foundation
import Observation import Observation
import VelodyDomain import VelodyDomain
import VelodyNetworking
import VelodyPersistence import VelodyPersistence
import VelodyUtilities
@MainActor @MainActor
@Observable @Observable
@ -12,12 +14,28 @@ final class MacLibraryViewModel {
var discoveredTrackCount = 0 var discoveredTrackCount = 0
var isScanning = false var isScanning = false
var serverURLString: String
var deviceRegistrationStatus = "Not registered."
var registeredDeviceId: String?
var lastHeartbeatStatus = "No heartbeat sent yet."
var lastSyncBootstrapStatus = "No sync bootstrap run yet."
var lastBootstrapTrackCount: Int?
var lastBootstrapCursor: String?
var isRegisteringDevice = false
var isSendingHeartbeat = false
var isRunningSyncBootstrap = false
private let folderAccessService: any VelodyPersistence.FolderAccessService private let folderAccessService: any VelodyPersistence.FolderAccessService
private let localMusicScanner: any LocalMusicScanner private let localMusicScanner: any LocalMusicScanner
private let keychainService: any KeychainService
private let userDefaults: UserDefaults
private let store = InMemoryLocalLibraryStore() private let store = InMemoryLocalLibraryStore()
private var hasLoaded = false private var hasLoaded = false
init() { init(
userDefaults: UserDefaults = .standard,
keychainService: any KeychainService = SystemKeychainService(service: "de.diyaa.velody.mac")
) {
let folderAccessService = FolderAccessService() let folderAccessService = FolderAccessService()
let localMusicScanner = FileSystemLocalMusicScanner( let localMusicScanner = FileSystemLocalMusicScanner(
metadataReader: AVFoundationMetadataReader() metadataReader: AVFoundationMetadataReader()
@ -25,6 +43,10 @@ final class MacLibraryViewModel {
self.folderAccessService = folderAccessService self.folderAccessService = folderAccessService
self.localMusicScanner = localMusicScanner self.localMusicScanner = localMusicScanner
self.keychainService = keychainService
self.userDefaults = userDefaults
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
if let url = folderAccessService.storedFolderURL() { if let url = folderAccessService.storedFolderURL() {
selectedFolderPath = url.path selectedFolderPath = url.path
@ -37,6 +59,7 @@ final class MacLibraryViewModel {
hasLoaded = true hasLoaded = true
tracks = await store.loadTracks() tracks = await store.loadTracks()
discoveredTrackCount = tracks.count discoveredTrackCount = tracks.count
await restoreDeviceIdentity()
} }
func chooseFolder() { func chooseFolder() {
@ -73,4 +96,199 @@ final class MacLibraryViewModel {
scanStatus = "Scan failed: \(error.localizedDescription)" scanStatus = "Scan failed: \(error.localizedDescription)"
} }
} }
func persistServerURLSelection() {
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
return
}
serverURLString = serverURL.absoluteString
userDefaults.set(serverURLString, forKey: Self.serverURLDefaultsKey)
}
func registerThisMac() async {
isRegisteringDevice = true
deviceRegistrationStatus = "Registering this Mac..."
defer {
isRegisteringDevice = false
}
do {
let environment = try currentEnvironment()
let payload = DeviceRegistrationPayload(
platform: .macos,
deviceName: Self.currentDeviceName,
appVersion: environment.appVersion
)
let response = try await makeAPIClient(for: environment).registerDevice(payload)
try await keychainService.save(response.deviceId, forKey: Self.deviceIdKey)
try await keychainService.save(response.bootstrapToken, forKey: Self.bootstrapTokenKey)
registeredDeviceId = response.deviceId
deviceRegistrationStatus = "Registered successfully at \(response.serverTime)."
} catch {
deviceRegistrationStatus = "Registration failed: \(error.localizedDescription)"
}
}
func sendHeartbeat() async {
isSendingHeartbeat = true
lastHeartbeatStatus = "Sending heartbeat..."
defer {
isSendingHeartbeat = false
}
do {
let environment = try currentEnvironment()
let deviceId = try await currentDeviceId()
let response = try await makeAPIClient(for: environment).sendHeartbeat(
DeviceHeartbeatPayload(
deviceId: deviceId,
appVersion: environment.appVersion
)
)
registeredDeviceId = deviceId
lastHeartbeatStatus = response.ok
? "Heartbeat succeeded at \(response.serverTime)."
: "Heartbeat did not succeed."
} catch {
lastHeartbeatStatus = "Heartbeat failed: \(error.localizedDescription)"
}
}
func syncBootstrap() async {
isRunningSyncBootstrap = true
lastSyncBootstrapStatus = "Running sync bootstrap..."
defer {
isRunningSyncBootstrap = false
}
do {
let environment = try currentEnvironment()
let response = try await makeAPIClient(for: environment).fetchSyncBootstrap()
lastBootstrapTrackCount = response.tracks.count
lastBootstrapCursor = response.nextCursor.value
lastSyncBootstrapStatus = "Sync bootstrap succeeded. Tracks: \(response.tracks.count). Next cursor: \(response.nextCursor.value)."
} catch {
lastSyncBootstrapStatus = "Sync bootstrap failed: \(error.localizedDescription)"
}
}
private func restoreDeviceIdentity() async {
do {
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
let bootstrapToken = try await keychainService.loadValue(forKey: Self.bootstrapTokenKey)
registeredDeviceId = deviceId
if let deviceId {
if let bootstrapToken, !bootstrapToken.isEmpty {
deviceRegistrationStatus = "Registered locally."
} else {
deviceRegistrationStatus = "Device ID restored (\(deviceId)). Bootstrap token is missing."
}
} else {
deviceRegistrationStatus = "Not registered."
}
} catch {
deviceRegistrationStatus = "Failed to load saved device identity: \(error.localizedDescription)"
}
}
private func currentEnvironment() throws -> ServerEnvironment {
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
throw BackendConnectionError.invalidServerURL
}
serverURLString = serverURL.absoluteString
userDefaults.set(serverURLString, forKey: Self.serverURLDefaultsKey)
return ServerEnvironment(
baseURL: serverURL,
appVersion: Self.currentAppVersion
)
}
private func currentDeviceId() async throws -> String {
if let registeredDeviceId, !registeredDeviceId.isEmpty {
return registeredDeviceId
}
if let savedDeviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey),
!savedDeviceId.isEmpty
{
registeredDeviceId = savedDeviceId
return savedDeviceId
}
throw BackendConnectionError.missingDeviceIdentity
}
private func makeAPIClient(for environment: ServerEnvironment) -> URLSessionVelodyAPIClient {
URLSessionVelodyAPIClient(environment: environment)
}
private static var currentAppVersion: String {
if let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
!shortVersion.isEmpty
{
return shortVersion
}
if let buildVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String,
!buildVersion.isEmpty
{
return buildVersion
}
return "0.1.0"
}
private static var currentDeviceName: String {
let localizedName = Host.current().localizedName?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let localizedName, !localizedName.isEmpty {
return localizedName
}
return "Velody Mac"
}
private static func normalizedServerURL(from rawValue: String) -> URL? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard
!trimmed.isEmpty,
let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
["http", "https"].contains(scheme),
url.host != nil
else {
return nil
}
return url
}
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
private static let deviceIdKey = "velody.device-id"
private static let bootstrapTokenKey = "velody.bootstrap-token"
}
private enum BackendConnectionError: LocalizedError {
case invalidServerURL
case missingDeviceIdentity
var errorDescription: String? {
switch self {
case .invalidServerURL:
return "Enter a valid backend URL, such as http://localhost:3000."
case .missingDeviceIdentity:
return "Register this Mac before sending a heartbeat."
}
}
} }

View File

@ -4,7 +4,6 @@ import VelodyDomain
import VelodyNetworking import VelodyNetworking
import VelodyPersistence import VelodyPersistence
import VelodySync import VelodySync
import VelodyUtilities
@MainActor @MainActor
@Observable @Observable
@ -13,13 +12,12 @@ final class iPhoneLibraryViewModel {
var syncStatus = "Offline library not synced yet" var syncStatus = "Offline library not synced yet"
private let store = InMemoryLocalLibraryStore() private let store = InMemoryLocalLibraryStore()
private let keychainService = MemoryKeychainService()
private let syncCoordinator: PlaceholderSyncCoordinator private let syncCoordinator: PlaceholderSyncCoordinator
private var hasLoaded = false private var hasLoaded = false
init() { init() {
let environment = ServerEnvironment( let environment = ServerEnvironment(
baseURL: URL(string: "http://localhost:3000")!, baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "0.1.0" appVersion: "0.1.0"
) )
let apiClient = StubVelodyAPIClient(environment: environment) let apiClient = StubVelodyAPIClient(environment: environment)
@ -32,7 +30,6 @@ final class iPhoneLibraryViewModel {
func loadIfNeeded() async { func loadIfNeeded() async {
guard !hasLoaded else { return } guard !hasLoaded else { return }
hasLoaded = true hasLoaded = true
await keychainService.save("placeholder-bootstrap-token", forKey: "bootstrap-token")
await refreshSync() await refreshSync()
} }

View File

@ -49,6 +49,48 @@ public struct DeviceRegistrationPayload: Codable, Hashable, Sendable {
} }
} }
public struct DeviceRegistrationResponse: Codable, Hashable, Sendable {
public var deviceId: String
public var bootstrapToken: String
public var serverTime: String
public init(
deviceId: String,
bootstrapToken: String,
serverTime: String
) {
self.deviceId = deviceId
self.bootstrapToken = bootstrapToken
self.serverTime = serverTime
}
}
public struct DeviceHeartbeatPayload: Codable, Hashable, Sendable {
public var deviceId: String
public var appVersion: String
public init(
deviceId: String,
appVersion: String
) {
self.deviceId = deviceId
self.appVersion = appVersion
}
}
public struct DeviceHeartbeatResponse: Codable, Hashable, Sendable {
public var ok: Bool
public var serverTime: String
public init(
ok: Bool,
serverTime: String
) {
self.ok = ok
self.serverTime = serverTime
}
}
public struct SyncCursor: Codable, Hashable, Sendable { public struct SyncCursor: Codable, Hashable, Sendable {
public var value: String public var value: String
@ -57,12 +99,109 @@ public struct SyncCursor: Codable, Hashable, Sendable {
} }
} }
public struct SyncEvent: Codable, Hashable, Sendable {
public var entityType: String
public var entityId: String
public var action: String
public var eventId: String
public init(
entityType: String,
entityId: String,
action: String,
eventId: String
) {
self.entityType = entityType
self.entityId = entityId
self.action = action
self.eventId = eventId
}
}
public struct SyncBootstrapResponse: Codable, Hashable, Sendable {
public var nextCursor: SyncCursor
public var tracks: [LibraryTrack]
public var events: [SyncEvent]
public var deletedTrackIds: [String]
public var serverTime: String
public init(
nextCursor: SyncCursor,
tracks: [LibraryTrack],
events: [SyncEvent],
deletedTrackIds: [String],
serverTime: String
) {
self.nextCursor = nextCursor
self.tracks = tracks
self.events = events
self.deletedTrackIds = deletedTrackIds
self.serverTime = serverTime
}
private enum CodingKeys: String, CodingKey {
case nextCursor
case tracks
case events
case deletedTrackIds
case serverTime
}
private struct WireTrack: Codable {
var id: String?
var title: String?
var artist: String?
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let wireTracks = try container.decode([WireTrack].self, forKey: .tracks)
nextCursor = SyncCursor(
value: try container.decode(String.self, forKey: .nextCursor)
)
tracks = wireTracks.map { track in
LibraryTrack(
id: track.id ?? UUID().uuidString,
title: track.title ?? "Unknown Title",
artist: track.artist ?? "Unknown Artist",
album: nil,
durationSeconds: nil,
localFilePath: "",
sha256: nil
)
}
events = try container.decode([SyncEvent].self, forKey: .events)
deletedTrackIds = try container.decode([String].self, forKey: .deletedTrackIds)
serverTime = try container.decode(String.self, forKey: .serverTime)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let wireTracks = tracks.map { track in
WireTrack(
id: track.id,
title: track.title,
artist: track.artist
)
}
try container.encode(nextCursor.value, forKey: .nextCursor)
try container.encode(wireTracks, forKey: .tracks)
try container.encode(events, forKey: .events)
try container.encode(deletedTrackIds, forKey: .deletedTrackIds)
try container.encode(serverTime, forKey: .serverTime)
}
}
public struct ServerEnvironment: Codable, Hashable, Sendable { public struct ServerEnvironment: Codable, Hashable, Sendable {
public static let defaultLocalBaseURL = URL(string: "http://localhost:3000")!
public var baseURL: URL public var baseURL: URL
public var appVersion: String public var appVersion: String
public init( public init(
baseURL: URL, baseURL: URL = ServerEnvironment.defaultLocalBaseURL,
appVersion: String appVersion: String
) { ) {
self.baseURL = baseURL self.baseURL = baseURL

View File

@ -1,21 +1,189 @@
import Foundation import Foundation
import VelodyDomain import VelodyDomain
public struct HealthStatusSummary: Hashable, Sendable { public enum VelodyAPIError: LocalizedError, Sendable {
public var databaseStatus: String case invalidResponse
public var storageStatus: String case invalidServerURL(String)
case requestFailed(String)
case server(statusCode: Int, message: String?)
case decodingFailed(String)
public init( public var errorDescription: String? {
databaseStatus: String, switch self {
storageStatus: String case .invalidResponse:
) { return "The server returned an invalid response."
self.databaseStatus = databaseStatus case let .invalidServerURL(value):
self.storageStatus = storageStatus return "The server URL is invalid: \(value)"
case let .requestFailed(message):
return "The request failed: \(message)"
case let .server(statusCode, message):
if let message, !message.isEmpty {
return "The server returned \(statusCode): \(message)"
}
return "The server returned HTTP \(statusCode)."
case let .decodingFailed(message):
return "The response could not be decoded: \(message)"
}
} }
} }
public protocol VelodyAPIClient: Sendable { public protocol VelodyAPIClient: Sendable {
func fetchHealthStatus() async throws -> HealthStatusSummary func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse
func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
}
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
public let environment: ServerEnvironment
private let session: URLSession
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
environment: ServerEnvironment,
session: URLSession = .shared
) {
self.environment = environment
self.session = session
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
}
public func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "devices", "register"],
body: payload,
responseType: DeviceRegistrationResponse.self
)
}
public func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "devices", "heartbeat"],
body: payload,
responseType: DeviceHeartbeatResponse.self
)
}
public func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "sync", "bootstrap"],
responseType: SyncBootstrapResponse.self
)
}
private func sendRequest<Response: Decodable>(
method: String,
pathComponents: [String],
responseType: Response.Type
) async throws -> Response {
let request = try buildRequest(
method: method,
pathComponents: pathComponents,
bodyData: nil
)
return try await execute(request, responseType: responseType)
}
private func sendRequest<Body: Encodable, Response: Decodable>(
method: String,
pathComponents: [String],
body: Body,
responseType: Response.Type
) async throws -> Response {
let bodyData: Data
do {
bodyData = try encoder.encode(body)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
let request = try buildRequest(
method: method,
pathComponents: pathComponents,
bodyData: bodyData
)
return try await execute(request, responseType: responseType)
}
private func buildRequest(
method: String,
pathComponents: [String],
bodyData: Data?
) throws -> URLRequest {
guard let url = endpointURL(pathComponents: pathComponents) else {
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let bodyData {
request.httpBody = bodyData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func execute<Response: Decodable>(
_ request: URLRequest,
responseType: Response.Type
) async throws -> Response {
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw VelodyAPIError.invalidResponse
}
guard (200 ..< 300).contains(httpResponse.statusCode) else {
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
throw VelodyAPIError.server(
statusCode: httpResponse.statusCode,
message: message?.isEmpty == true ? nil : message
)
}
do {
return try decoder.decode(responseType, from: data)
} catch {
throw VelodyAPIError.decodingFailed(error.localizedDescription)
}
}
private func endpointURL(pathComponents: [String]) -> URL? {
pathComponents.reduce(environment.baseURL) { partialURL, component in
partialURL.appendingPathComponent(component, isDirectory: false)
}
}
} }
public struct StubVelodyAPIClient: VelodyAPIClient { public struct StubVelodyAPIClient: VelodyAPIClient {
@ -25,11 +193,45 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
self.environment = environment self.environment = environment
} }
public func fetchHealthStatus() async throws -> HealthStatusSummary { public func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
bootstrapToken: "stub-bootstrap-token",
serverTime: ISO8601DateFormatter().string(from: .now)
)
}
public func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse {
_ = payload
return DeviceHeartbeatResponse(
ok: true,
serverTime: ISO8601DateFormatter().string(from: .now)
)
}
public func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
_ = environment _ = environment
return HealthStatusSummary(
databaseStatus: "placeholder", return SyncBootstrapResponse(
storageStatus: "placeholder" nextCursor: SyncCursor(value: "0"),
tracks: [
LibraryTrack(
title: "Velody Placeholder",
artist: "Private Library",
album: "Phase 1",
localFilePath: ""
),
],
events: [],
deletedTrackIds: [],
serverTime: ISO8601DateFormatter().string(from: .now)
) )
} }
} }

View File

@ -36,28 +36,13 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator {
} }
public func performInitialSync() async throws -> SyncResult { public func performInitialSync() async throws -> SyncResult {
let health = try await apiClient.fetchHealthStatus() let bootstrap = try await apiClient.fetchSyncBootstrap()
let existingTracks = await store.loadTracks() await store.replaceTracks(bootstrap.tracks)
let tracks: [LibraryTrack]
if existingTracks.isEmpty {
tracks = [
LibraryTrack(
title: "Velody Placeholder",
artist: "Private Library",
album: "Phase 1",
localFilePath: ""
),
]
await store.replaceTracks(tracks)
} else {
tracks = existingTracks
}
return SyncResult( return SyncResult(
statusMessage: "Sync placeholder ready (\(health.databaseStatus)/\(health.storageStatus))", statusMessage: "Sync bootstrap completed. Tracks: \(bootstrap.tracks.count). Next cursor: \(bootstrap.nextCursor.value).",
tracks: tracks, tracks: bootstrap.tracks,
cursor: SyncCursor(value: "0") cursor: bootstrap.nextCursor
) )
} }
} }

View File

@ -1,8 +1,9 @@
import Foundation import Foundation
import Security
public protocol KeychainService: Sendable { public protocol KeychainService: Sendable {
func save(_ value: String, forKey key: String) async func save(_ value: String, forKey key: String) async throws
func loadValue(forKey key: String) async -> String? func loadValue(forKey key: String) async throws -> String?
} }
public actor MemoryKeychainService: KeychainService { public actor MemoryKeychainService: KeychainService {
@ -10,11 +11,87 @@ public actor MemoryKeychainService: KeychainService {
public init() {} public init() {}
public func save(_ value: String, forKey key: String) async { public func save(_ value: String, forKey key: String) async throws {
storage[key] = value storage[key] = value
} }
public func loadValue(forKey key: String) async -> String? { public func loadValue(forKey key: String) async throws -> String? {
storage[key] storage[key]
} }
} }
public enum KeychainServiceError: LocalizedError, Sendable {
case invalidValue
case unexpectedStatus(OSStatus)
public var errorDescription: String? {
switch self {
case .invalidValue:
return "The keychain returned invalid data."
case let .unexpectedStatus(status):
if let message = SecCopyErrorMessageString(status, nil) as String? {
return message
}
return "The keychain returned status \(status)."
}
}
}
public actor SystemKeychainService: KeychainService {
private let service: String
public init(service: String = "de.diyaa.velody") {
self.service = service
}
public func save(_ value: String, forKey key: String) async throws {
let query = baseQuery(forKey: key)
let deleteStatus = SecItemDelete(query as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw KeychainServiceError.unexpectedStatus(deleteStatus)
}
var addQuery = query
addQuery[kSecValueData as String] = Data(value.utf8)
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainServiceError.unexpectedStatus(addStatus)
}
}
public func loadValue(forKey key: String) async throws -> String? {
var query = baseQuery(forKey: key)
query[kSecReturnData as String] = kCFBooleanTrue
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard
let data = result as? Data,
let value = String(data: data, encoding: .utf8)
else {
throw KeychainServiceError.invalidValue
}
return value
case errSecItemNotFound:
return nil
default:
throw KeychainServiceError.unexpectedStatus(status)
}
}
private func baseQuery(forKey key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
}
}