From 1674174a4e6acf2319ced812e2c1574ef55064e0 Mon Sep 17 00:00:00 2001 From: diyaa Date: Mon, 25 May 2026 16:07:04 +0200 Subject: [PATCH] Connect macOS app to backend device bootstrap --- .../VelodyMac/Sources/MacLibraryView.swift | 62 +++++ .../Sources/MacLibraryViewModel.swift | 220 ++++++++++++++++- .../Sources/iPhoneLibraryViewModel.swift | 5 +- .../Sources/VelodyDomain/Models.swift | 141 ++++++++++- .../VelodyNetworking/VelodyAPIClient.swift | 230 ++++++++++++++++-- .../PlaceholderSyncCoordinator.swift | 25 +- .../VelodyUtilities/KeychainService.swift | 85 ++++++- 7 files changed, 724 insertions(+), 44 deletions(-) diff --git a/apps/apple/VelodyMac/Sources/MacLibraryView.swift b/apps/apple/VelodyMac/Sources/MacLibraryView.swift index b015059..eaff576 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryView.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryView.swift @@ -10,6 +10,57 @@ struct MacLibraryView: View { Text("Private Library Foundation") .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") .font(.headline) @@ -93,4 +144,15 @@ struct MacLibraryView: View { let seconds = totalSeconds % 60 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) + } + } } diff --git a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift index c86d61c..1057a55 100644 --- a/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift +++ b/apps/apple/VelodyMac/Sources/MacLibraryViewModel.swift @@ -1,7 +1,9 @@ import Foundation import Observation import VelodyDomain +import VelodyNetworking import VelodyPersistence +import VelodyUtilities @MainActor @Observable @@ -12,12 +14,28 @@ final class MacLibraryViewModel { var discoveredTrackCount = 0 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 localMusicScanner: any LocalMusicScanner + private let keychainService: any KeychainService + private let userDefaults: UserDefaults private let store = InMemoryLocalLibraryStore() private var hasLoaded = false - init() { + init( + userDefaults: UserDefaults = .standard, + keychainService: any KeychainService = SystemKeychainService(service: "de.diyaa.velody.mac") + ) { let folderAccessService = FolderAccessService() let localMusicScanner = FileSystemLocalMusicScanner( metadataReader: AVFoundationMetadataReader() @@ -25,6 +43,10 @@ final class MacLibraryViewModel { self.folderAccessService = folderAccessService self.localMusicScanner = localMusicScanner + self.keychainService = keychainService + self.userDefaults = userDefaults + self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey) + ?? ServerEnvironment.defaultLocalBaseURL.absoluteString if let url = folderAccessService.storedFolderURL() { selectedFolderPath = url.path @@ -37,6 +59,7 @@ final class MacLibraryViewModel { hasLoaded = true tracks = await store.loadTracks() discoveredTrackCount = tracks.count + await restoreDeviceIdentity() } func chooseFolder() { @@ -73,4 +96,199 @@ final class MacLibraryViewModel { 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." + } + } } diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 7b31a4d..1c12ca0 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -4,7 +4,6 @@ import VelodyDomain import VelodyNetworking import VelodyPersistence import VelodySync -import VelodyUtilities @MainActor @Observable @@ -13,13 +12,12 @@ final class iPhoneLibraryViewModel { var syncStatus = "Offline library not synced yet" private let store = InMemoryLocalLibraryStore() - private let keychainService = MemoryKeychainService() private let syncCoordinator: PlaceholderSyncCoordinator private var hasLoaded = false init() { let environment = ServerEnvironment( - baseURL: URL(string: "http://localhost:3000")!, + baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "0.1.0" ) let apiClient = StubVelodyAPIClient(environment: environment) @@ -32,7 +30,6 @@ final class iPhoneLibraryViewModel { func loadIfNeeded() async { guard !hasLoaded else { return } hasLoaded = true - await keychainService.save("placeholder-bootstrap-token", forKey: "bootstrap-token") await refreshSync() } diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift index 98d35d2..4d666fd 100644 --- a/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/Models.swift @@ -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 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 static let defaultLocalBaseURL = URL(string: "http://localhost:3000")! + public var baseURL: URL public var appVersion: String public init( - baseURL: URL, + baseURL: URL = ServerEnvironment.defaultLocalBaseURL, appVersion: String ) { self.baseURL = baseURL diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift index 5a3b4e2..1d55f80 100644 --- a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift @@ -1,21 +1,189 @@ import Foundation import VelodyDomain -public struct HealthStatusSummary: Hashable, Sendable { - public var databaseStatus: String - public var storageStatus: String +public enum VelodyAPIError: LocalizedError, Sendable { + case invalidResponse + case invalidServerURL(String) + case requestFailed(String) + case server(statusCode: Int, message: String?) + case decodingFailed(String) - public init( - databaseStatus: String, - storageStatus: String - ) { - self.databaseStatus = databaseStatus - self.storageStatus = storageStatus + public var errorDescription: String? { + switch self { + case .invalidResponse: + return "The server returned an invalid response." + case let .invalidServerURL(value): + 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 { - 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( + 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( + 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( + _ 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 { @@ -25,11 +193,45 @@ public struct StubVelodyAPIClient: VelodyAPIClient { 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 - return HealthStatusSummary( - databaseStatus: "placeholder", - storageStatus: "placeholder" + + return SyncBootstrapResponse( + nextCursor: SyncCursor(value: "0"), + tracks: [ + LibraryTrack( + title: "Velody Placeholder", + artist: "Private Library", + album: "Phase 1", + localFilePath: "" + ), + ], + events: [], + deletedTrackIds: [], + serverTime: ISO8601DateFormatter().string(from: .now) ) } } diff --git a/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift b/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift index a09f08c..38c2945 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/PlaceholderSyncCoordinator.swift @@ -36,28 +36,13 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator { } public func performInitialSync() async throws -> SyncResult { - let health = try await apiClient.fetchHealthStatus() - let existingTracks = await store.loadTracks() - 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 - } + let bootstrap = try await apiClient.fetchSyncBootstrap() + await store.replaceTracks(bootstrap.tracks) return SyncResult( - statusMessage: "Sync placeholder ready (\(health.databaseStatus)/\(health.storageStatus))", - tracks: tracks, - cursor: SyncCursor(value: "0") + statusMessage: "Sync bootstrap completed. Tracks: \(bootstrap.tracks.count). Next cursor: \(bootstrap.nextCursor.value).", + tracks: bootstrap.tracks, + cursor: bootstrap.nextCursor ) } } diff --git a/packages/apple/VelodyUtilities/Sources/VelodyUtilities/KeychainService.swift b/packages/apple/VelodyUtilities/Sources/VelodyUtilities/KeychainService.swift index 42ca9b5..998ee13 100644 --- a/packages/apple/VelodyUtilities/Sources/VelodyUtilities/KeychainService.swift +++ b/packages/apple/VelodyUtilities/Sources/VelodyUtilities/KeychainService.swift @@ -1,8 +1,9 @@ import Foundation +import Security public protocol KeychainService: Sendable { - func save(_ value: String, forKey key: String) async - func loadValue(forKey key: String) async -> String? + func save(_ value: String, forKey key: String) async throws + func loadValue(forKey key: String) async throws -> String? } public actor MemoryKeychainService: KeychainService { @@ -10,11 +11,87 @@ public actor MemoryKeychainService: KeychainService { public init() {} - public func save(_ value: String, forKey key: String) async { + public func save(_ value: String, forKey key: String) async throws { storage[key] = value } - public func loadValue(forKey key: String) async -> String? { + public func loadValue(forKey key: String) async throws -> String? { 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, + ] + } +}