Connect macOS app to backend device bootstrap
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user