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