Changes
6 changed files (+525/-61)
-
-
@@ -0,0 +1,111 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import PlacKit import os class ConnectionErrorEvent { var ptr: UnsafeMutablePointer<PlacKit.plac_connection_connection_error_event> var code: PlacKit.plac_connection_connection_error { return ptr.pointee.code } init( ptr: UnsafeMutablePointer<PlacKit.plac_connection_connection_error_event> ) { self.ptr = ptr } deinit { PlacKit.plac_connection_connection_error_event_release(ptr) } } class ConnectedEvent { var ptr: UnsafeMutablePointer<PlacKit.plac_connection_connected_event> var token: String { return String(cString: ptr.pointee.token) } init(ptr: UnsafeMutablePointer<PlacKit.plac_connection_connected_event>) { self.ptr = ptr } deinit { PlacKit.plac_connection_connected_event_release(ptr) } } enum ConnectionEvent { case connectionError(ConnectionErrorEvent) case connected(ConnectedEvent) case zoneList(ZoneListEvent) } class Connection { let logger = Logger() var ptr: UnsafeMutablePointer<PlacKit.plac_connection> init(server: CoreServer) { self.ptr = PlacKit.plac_connection_make(server.ptr) } init(ptr: UnsafeMutablePointer<PlacKit.plac_connection>) { self.ptr = ptr } deinit { PlacKit.plac_connection_release(ptr) } func getEvent() -> ConnectionEvent? { guard let ev = PlacKit.plac_connection_get_event(ptr) else { logger.warning("plac_connection_get_event returned a null pointer") return nil } switch ev.pointee.kind { case PlacKit.PLAC_CONNECTION_EVENT_ERROR: let ptr = PlacKit.plac_connection_event_get_connection_error_event(ev) return .connectionError( ConnectionErrorEvent( ptr: PlacKit.plac_connection_connection_error_event_retain(ptr) ) ) case PlacKit.PLAC_CONNECTION_EVENT_CONNECTED: let ptr = PlacKit.plac_connection_event_get_connected_event(ev) return .connected( ConnectedEvent(ptr: PlacKit.plac_connection_connected_event_retain(ptr)) ) case PlacKit.PLAC_CONNECTION_EVENT_ZONE_LIST: let ptr = PlacKit.plac_connection_event_get_zone_list_event(ev) return .zoneList( ZoneListEvent(ptr: PlacKit.plac_transport_zone_list_event_retain(ptr)) ) default: // Unreachable, this is here due to Swift's poor exhaustive check logger.warning("Unknown event kind: \(ev.pointee.kind.rawValue)") return nil } } func subscribeZoneChanges() { PlacKit.plac_connection_subscribe_zones(ptr) } }
-
-
-
@@ -17,9 +17,13 @@import PlacKit import os let logger = Logger() protocol Server { var id: String { get } var name: String { get } var version: String { get } } class Server { class CoreServer: Identifiable, Server { var ptr: UnsafeMutablePointer<PlacKit.plac_discovery_server> var id: String {
-
@@ -43,19 +47,33 @@ PlacKit.plac_discovery_server_release(ptr)} } class MockServer: Identifiable, Server { var id: String var name: String var version: String init(id: String, name: String, version: String) { self.id = id self.name = name self.version = version } } class ScanResult { let logger = Logger() var ptr: UnsafeMutablePointer<PlacKit.plac_discovery_scan_result> var code: PlacKit.plac_discovery_scan_result_code { return ptr.pointee.code } var entries: [Server] var entries: [CoreServer] init(ptr: UnsafeMutablePointer<PlacKit.plac_discovery_scan_result>) { self.ptr = ptr var entries: [Server] = [] var entries: [CoreServer] = [] let current = ptr.pointee.servers_ptr?.pointee guard var current = current else {
-
@@ -65,7 +83,9 @@ return} for _ in 0..<ptr.pointee.servers_len { entries.append(Server(ptr: PlacKit.plac_discovery_server_retain(current))) entries.append( CoreServer(ptr: PlacKit.plac_discovery_server_retain(current)) ) current = current.successor() }
-
-
-
@@ -0,0 +1,106 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import PlacKit import os class Zone { var ptr: UnsafeMutablePointer<PlacKit.plac_transport_zone> var id: String { return String(cString: ptr.pointee.id) } var name: String { return String(cString: ptr.pointee.name) } var playback: PlacKit.plac_transport_playback_state { return ptr.pointee.playback } init(ptr: UnsafeMutablePointer<PlacKit.plac_transport_zone>) { self.ptr = ptr } deinit { PlacKit.plac_transport_zone_release(ptr) } } class ZoneListEvent { let logger = Logger() var ptr: UnsafeMutablePointer<PlacKit.plac_transport_zone_list_event> var addedZones: [Zone] var changedZones: [Zone] var removedZoneIds: [String] init(ptr: UnsafeMutablePointer<PlacKit.plac_transport_zone_list_event>) { self.ptr = ptr var addedZones: [Zone] = [] if var current = ptr.pointee.added_zones_ptr.pointee { for _ in 0..<ptr.pointee.added_zones_len { addedZones.append(Zone(ptr: PlacKit.plac_transport_zone_retain(current))) current = current.successor() } self.addedZones = addedZones } else { logger.warning("added_zones_ptr is null") self.addedZones = [] } var changedZones: [Zone] = [] if var current = ptr.pointee.changed_zones_ptr.pointee { for _ in 0..<ptr.pointee.changed_zones_len { changedZones.append(Zone(ptr: PlacKit.plac_transport_zone_retain(current))) current = current.successor() } self.changedZones = changedZones } else { logger.warning("changed_zones_ptr is null") self.changedZones = [] } var removedZoneIds: [String] = [] if var current = ptr.pointee.removed_zone_ids_ptr { for i in 0..<ptr.pointee.removed_zone_ids_len { if let strPtr = current.pointee { removedZoneIds.append(String(cString: strPtr)) } else { logger.warning("removed_zone_ids_ptr[\(i)] is null pointer") } current = current.successor() } self.removedZoneIds = removedZoneIds } else { logger.warning("removed_zone_ids_ptr is null") self.removedZoneIds = [] } } deinit { PlacKit.plac_transport_zone_list_event_release(ptr) } }
-
-
-
@@ -0,0 +1,31 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import SwiftUI struct MainView: View { var serverId: CoreServer.ID? var body: some View { VStack { if let id = serverId { Text(id) } else { Text("nil") } } } }
-
-
-
@@ -18,78 +18,266 @@ import PlacKitimport SwiftUI import os enum DiscoveryResult { struct ServerDiscovery: View { @State private var servers: [CoreServer] = [] @State private var status: ServerDiscoverySceneListStatus = .loading private let logger = Logger() private let queue = DispatchQueue(label: "plac.server-discovery") var body: some View { ServerDiscoverySceneList( servers: servers, status: status, onScan: { scan() } ) .onAppear { scan() } } private func scan() { status = .loading queue.async { let ptr = PlacKit.plac_discovery_scan() guard let ptr = ptr else { logger.error("Failed to scan servers: Out of memory") DispatchQueue.main.async { status = .null_pointer } return } let result = ScanResult(ptr: ptr) if result.code != PlacKit.PLAC_DISCOVERY_SCAN_RESULT_OK { DispatchQueue.main.async { status = .failed(result.code) } return } DispatchQueue.main.async { status = .loaded servers = result.entries } } } } enum ServerDiscoverySceneListStatus { case loading case loaded([Server]) case loaded case failed(PlacKit.plac_discovery_scan_result_code) case null_pointer } struct ServerDiscoveryScene: Scene { @Environment(\.scenePhase) private var scenePhase @State private var discovery: DiscoveryResult = .loading struct ServerDiscoverySceneList<TServer: Server & Identifiable>: View { @Environment(\.openWindow) private var openWindow @Environment(\.dismissWindow) private var dismissWindow var servers: [TServer] var status: ServerDiscoverySceneListStatus var onScan: (() -> Void)? = nil private let logger = Logger() private let queue = DispatchQueue(label: "plac.server-discovery") @ScaledMetric private var lineSpace = 12 private var busy: Bool { switch status { case .loading: true default: false } } var body: some Scene { WindowGroup { VStack { switch self.discovery { case .loading: Text("Scanning Roon Server on network...") case .loaded(let servers): List { ForEach(servers, id: \.id) { server in var body: some View { NavigationSplitView { List(servers) { (server: TServer) in NavigationLink { VStack(alignment: .leading, spacing: lineSpace) { HStack { Text(server.name) .font(.title) Spacer() Button { openWindow(id: "main-window", value: server.id) dismissWindow(id: "discovery-window") } label: { Text("Open") } .buttonStyle(.borderedProminent) } } case .failed(let plac_scan_result_code): switch plac_scan_result_code { case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_SOCKET_ERROR: Text("Unable to operate on network socket") case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_UNKNOWN: Text("Unexpected error") case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_OUT_OF_MEMORY: Text("Out of memory") case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_SOCKET_PERMISSION_DENIED: Text( "Permission rejected for network socket operation" ) case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_SOCKET_PERMISSION_DENIED: Text("Network unavailable") default: Text("Unexpected error (platform misbehaving)") VStack(alignment: .leading) { Text("Version") .font(.headline) Text(server.version) .font(.subheadline) } VStack(alignment: .leading) { Text("IP address") .font(.headline) // TODO: Display IP address Text(server.id) .font(.subheadline) } Spacer() } case .null_pointer: Text("Out of memory") .padding() } label: { Text(server.name) } } } .onChange(of: scenePhase, initial: true) { if self.scenePhase == .active { queue.async { let ptr = PlacKit.plac_discovery_scan() guard let ptr = ptr else { logger.error("Failed to scan servers: Out of memory") DispatchQueue.main.async { discovery = .null_pointer .navigationTitle("Servers") .toolbar { ToolbarItem(placement: .navigation) { Button { if let onScan = onScan { onScan() } return } label: { Label("Reload", systemImage: "arrow.trianglehead.clockwise") } .help("Reload server list") .disabled(busy) } } } detail: { switch status { case .loading: ContentUnavailableView { Label("Scanning", systemImage: "waveform.badge.magnifyingglass") .symbolEffect(.variableColor) } description: { Text("Scanning Roon servers on local network.") } case .loaded: if servers.isEmpty { ContentUnavailableView { Label("No servers", systemImage: "square.dashed") } description: { Text("No Roon server found on local network.") } } else { Text("Select a server from list") } case .null_pointer: ContentUnavailableView { Label( "Unable to scan", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text("Failed to allocate memory for scan operation.") } case .failed(let code): switch code { case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_SOCKET_ERROR: ContentUnavailableView { Label( "Unable to scan", systemImage: "wifi.exclamationmark" ) } description: { Text("Failed to scan Roon servers due to network error.") } case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_NETWORK_UNAVAILABLE: ContentUnavailableView { Label( "Network unavailable", systemImage: "wifi.exclamationmark" ) } description: { Text("Network access is required for scanning Roon servers.") } case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_UNKNOWN: ContentUnavailableView { Label( "Unable to scan", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text("An error occurred during a scan operation.") } case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_OUT_OF_MEMORY: ContentUnavailableView { Label( "Unable to scan", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text("Failed to allocate memory for scan operation.") } case PlacKit.PLAC_DISCOVERY_SCAN_RESULT_SOCKET_PERMISSION_DENIED: ContentUnavailableView { Label( "Unable to access network", systemImage: "wifi.exclamationmark" ) } description: { Text("Access to local network rejected.") } let result = ScanResult(ptr: ptr) if result.code != PlacKit.PLAC_DISCOVERY_SCAN_RESULT_OK { DispatchQueue.main.async { discovery = .failed(result.code) } return default: ContentUnavailableView { Label( "Unable to scan", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text("Unexpected error happened (platform misbehaving).") } discovery = .loaded(result.entries) } } } } } #Preview("Loaded") { let logger = Logger() let servers: [MockServer] = [ MockServer(id: "foo", name: "Foo", version: "foo-0.0"), MockServer(id: "bar", name: "Bar", version: "bar-0.0"), MockServer(id: "baz", name: "Baz", version: "baz-0.0"), ] ServerDiscoverySceneList(servers: servers, status: .loaded) .refreshable { logger.debug("ServerDiscoverySceneList.refreshable") } } #Preview("No servers") { ServerDiscoverySceneList<MockServer>(servers: [], status: .loaded) } #Preview("Loading") { ServerDiscoverySceneList<MockServer>(servers: [], status: .loading) } #Preview("OOM") { ServerDiscoverySceneList<MockServer>(servers: [], status: .null_pointer) } #Preview("SocketError") { ServerDiscoverySceneList<MockServer>( servers: [], status: .failed(PlacKit.PLAC_DISCOVERY_SCAN_RESULT_SOCKET_ERROR) ) } #Preview("NetworkUnavailable") { ServerDiscoverySceneList<MockServer>( servers: [], status: .failed(PlacKit.PLAC_DISCOVERY_SCAN_RESULT_NETWORK_UNAVAILABLE) ) }
-
-
-
@@ -19,6 +19,14 @@@main struct placApp: App { var body: some Scene { ServerDiscoveryScene() WindowGroup("Server Selection", id: "discovery-window") { ServerDiscovery() } // Usually, the number of Roon server on network is 1. .defaultSize(width: 600, height: 300) WindowGroup(id: "main-window", for: CoreServer.ID.self) { $serverId in MainView(serverId: serverId) } } }
-