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
@@ -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)
}
}
}
@@ -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."
}
}
}