Connect macOS app to backend device bootstrap
This commit is contained in:
parent
6d426f2a54
commit
1674174a4e
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user