Changes
33 changed files (+3257/-1069)
-
-
@@ -20,3 +20,9 @@# What: Clang header maps. # Why: https://github.com/github/gitignore/blob/main/Swift.gitignore *.hmap # What: Swift Package Manager directories. /*/Packages DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
-
-
-
@@ -18,17 +18,8 @@ --># Plac for macOS Plac's macOS native application, using SwiftUI for GUI. Plac's macOS native application, using SwiftUI. ## Development Building of Plac for macOS requires Xcode and Xcode Command Line Tools ### Building C API Before compiling in Xcode, build core library: ``` cd ../core zig build apple ``` Building of Plac for macOS requires Xcode.
-
-
-
@@ -1,6 +1,11 @@version = 1 [[annotations]] path = ["plac/**/*.json", "plac/**/*.entitlements", "plac.xcodeproj/**/*"] path = [ "plac/**/*.json", "plac/**/*.entitlements", "plac.xcodeproj/**/*", "*/.swiftpm/**/*", ] SPDX-FileCopyrightText = "Shota FUJI" SPDX-License-Identifier = "Apache-2.0"
-
-
-
@@ -0,0 +1,79 @@<?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1640" version = "1.7"> <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES" buildArchitectures = "Automatic"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "RoonKit" BuildableName = "RoonKit" BlueprintName = "RoonKit" ReferencedContainer = "container:"> </BuildableReference> </BuildActionEntry> </BuildActionEntries> </BuildAction> <TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> <Testables> <TestableReference skipped = "NO"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "RoonKitTests" BuildableName = "RoonKitTests" BlueprintName = "RoonKitTests" ReferencedContainer = "container:"> </BuildableReference> </TestableReference> </Testables> </TestAction> <LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> </LaunchAction> <ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "RoonKit" BuildableName = "RoonKit" BlueprintName = "RoonKit" ReferencedContainer = "container:"> </BuildableReference> </MacroExpansion> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> </AnalyzeAction> <ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"> </ArchiveAction> </Scheme>
-
-
-
@@ -0,0 +1,53 @@// swift-tools-version: 6.1 // 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 PackageDescription let package = Package( name: "RoonKit", platforms: [ .macOS(.v14), .iOS(.v16) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "RoonKit", targets: ["RoonKit"] ) ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "RoonKit", dependencies: [ .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOFoundationCompat", package: "swift-nio"), ] ), .testTarget( name: "RoonKitTests", dependencies: ["RoonKit"] ), ] )
-
-
-
@@ -0,0 +1,427 @@// 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 Network import OSLog import Observation public actor Connection { private let logger = Logger(subsystem: subsystem, category: "Connection") public let serverID: String public let host: String public let port: UInt16 public let ext: RegistryService.Extension public private(set) var token: String? = nil private var conn: NWConnection private var writeContext: NWConnection.ContentContext private let queue = DispatchQueue(label: "RoonKit.Connection.network") private var requestID: RequestId = 0 private var messageContinuationID: UInt = 0 private var messageContinuations: [UInt: AsyncStream<Moo>.Continuation] = [:] private var readLoop: Task<Void, any Error>? = nil public enum InitError: Error { case invalidHost } public init( id: String, host: String, port: UInt16, ext: RegistryService.Extension ) async throws { self.serverID = id self.host = host self.port = port self.ext = ext self.token = ext.token let metadata = NWProtocolWebSocket.Metadata(opcode: .binary) self.writeContext = NWConnection.ContentContext( identifier: "writeBinaryContext", metadata: [metadata] ) var url = URLComponents() url.scheme = "ws" url.host = host url.port = Int(port) url.path = "/api" guard let url = url.url else { throw InitError.invalidHost } let options = NWProtocolWebSocket.Options.init() options.autoReplyPing = true let parameters = NWParameters.tcp parameters.defaultProtocolStack.applicationProtocols.insert(options, at: 0) self.conn = NWConnection.init(to: .url(url), using: parameters) try await self.connect() } deinit { logger.info("Disconnecting... (automatic)") if let readLoop = readLoop { readLoop.cancel() self.readLoop = nil } self.messageContinuations = [:] if conn.state != .cancelled { conn.cancel() } } public enum ConnectError: Error { case alreadyStarted case unexpectedInfoReceived case serverIDMismatch } public func connect() async throws { switch conn.state { case .failed(let error): throw error case .cancelled, .setup: break default: throw ConnectError.alreadyStarted } logger.debug("Starting WebSocket connection") conn.start(queue: queue) try await conn.ready() logger.debug("WebSocket connection is ready") self.readLoop = startReadLoop() conn.stateUpdateHandler = { [weak self] (state: NWConnection.State) in switch state { case .cancelled, .failed(_), .waiting(_): Task { self?.logger.info("Network connection closed") await self?.disconnect() } case .preparing: self?.logger.debug("Network is in preparing state") case .setup: self?.logger.debug("Network has reset to setup state") default: break } } do { logger.debug("Querying server info") let res = try await request(Moo(info: .init()), timeout: .seconds(3)) let info = try RegistryService.InfoResponse(res) guard info.coreId == serverID else { throw ConnectError.serverIDMismatch } } do { logger.debug("Registering extension") // This stays pending until user accepts the extension on Roon settings page. // Because of this, setting timeout does not make sense here. let res = try await request(Moo(register: ext)) let result = try RegistryService.RegisterResponse(res) token = result.token } } private func clearMessageContinuations() { for (_, continuation) in messageContinuations { continuation.finish() } messageContinuations.removeAll() } private func startReadLoop() -> Task<Void, any Error> { // This loop task is a field of the actor containing it--every reference to // the actor (self) should be weak otherwise they form circular reference // and the actor will not be released via ARC. Task { [weak self] in while true { try Task.checkCancellation() do { guard let self = self else { return } let (data, context, _) = try await conn.receiveMessage() guard let data = data else { continue } guard let metadata = context?.protocolMetadata.first as? NWProtocolWebSocket.Metadata else { logger.warning( "Received non-WebSocket message on WebSocket connection" ) continue } switch metadata.opcode { case .binary, .text: guard let msg = Moo.init(String(data: data, encoding: .utf8)!) else { logger.warning("Received invalid MOO message") continue } if msg.service == PingService.Ping.service { Task { [weak self] in var res = Moo(verb: "COMPLETE", service: "Success") res.requestId = msg.requestId guard let self = self else { return } try await send(res) } continue } for (_, continuation) in await messageContinuations { continuation.yield(msg) } case .close: logger.info("Connection closed, releasing resources") return default: break } } catch { if let logger = self?.logger { logger.warning("Receive failed: \(error)") } return } } } } public func disconnect() { logger.info("Disconnecting... (manual)") if let readLoop = readLoop { readLoop.cancel() self.readLoop = nil } self.messageContinuations = [:] if conn.state != .cancelled { conn.cancel() } } /// A stream of incoming messages. Roon API uses WebSocket for most of the operation, /// even including request-response methods. Due to this nature, caller must check /// "Request-Id" header field to determine if a message is a response for the request. /// /// The stream will end when the underlying connection was closed. /// /// This is low-level API. Only use for continuous reading, such as change subscription. public var messages: AsyncStream<Moo> { let id = messageContinuationID messageContinuationID += 1 return AsyncStream { cont in messageContinuations[id] = cont cont.onTermination = { @Sendable _ in Task.detached { await self.deleteMessageContinuation(id) } } } } public enum MessageReadError: Error { case connectionClosed case timeout } /// Send a MOO message to connected Roon server and returns a response for the message. /// /// This function sets "Request-Id" header if the message does not have one. /// Unless you have a specific reason, let this function set it so the ID will be unique among other requests. /// /// Throws when send failed or connection closed during the read. /// /// If you set `timeout` parameter and no response was made during the duration, `MessageReadError.timeout` /// error will be thrown. public func request( _ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration? = nil ) async throws -> Moo { let requestID: RequestId if let givenID = msg.requestId { requestID = givenID } else { requestID = self.requestID msg.requestId = requestID self.requestID += 1 } let timeoutTask: Task<Void, any Error>? if let timeout = timeout { timeoutTask = Task { try await Task.sleep(for: timeout) throw MessageReadError.timeout } } else { timeoutTask = nil } let read = Task { for await msg in self.messages { guard msg.requestId == .some(requestID) else { continue } timeoutTask?.cancel() return msg } throw MessageReadError.connectionClosed } try await self.send(msg) return try await read.value } public enum MessageWriteError: Error { case connectionNotReady } /// Sends a MOO message to the connected Roon server, then returns. /// This function does not waits for a response message. /// /// Throws when a network error occured during send operation. public func send(_ msg: consuming Moo) async throws { let req = String(msg).data(using: .utf8)! try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in if conn.state != .ready { cont.resume(throwing: MessageWriteError.connectionNotReady) return } conn.send( content: req, contentContext: writeContext, completion: .contentProcessed({ error in if let error = error { cont.resume(throwing: error) return } cont.resume() }) ) } } private func deleteMessageContinuation(_ id: UInt) { self.messageContinuations[id] = nil } } extension NWConnection { enum ConnectionNotReadyError: Error { case failed(NWError) case cancelled } func ready() async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in self.stateUpdateHandler = { state in switch state { case .failed(let error), .waiting(let error): cont.resume(throwing: ConnectionNotReadyError.failed(error)) self.stateUpdateHandler = nil case .cancelled: cont.resume(throwing: ConnectionNotReadyError.cancelled) self.stateUpdateHandler = nil case .ready: cont.resume() self.stateUpdateHandler = nil default: break } } } } func receiveMessage() async throws -> (Data?, ContentContext?, Bool) { return try await withCheckedThrowingContinuation { cont in self.receiveMessage { (data, context, received, error) in if let error = error { cont.resume(throwing: error) return } cont.resume(returning: (data, context, received)) } } } func send( _ data: Data, contentContext: ContentContext = ContentContext.defaultMessage ) async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, any Error>) in self.send( content: data, contentContext: contentContext, completion: .contentProcessed({ error in if let error = error { cont.resume(throwing: error) return } cont.resume() }) ) } } }
-
-
-
@@ -0,0 +1,350 @@// 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 Foundation public typealias Headers = [String: String] let signature = "MOO/" enum MooMessageDecodeError: Error { case signatureMismatch case invalidVersion case missingVerb case missingService case malformedHeaderLine } /// MOO is a message format represented as a newline-delimited UTF-8 encoded text. /// A client (Roon Extension) and Roon Server communicates via exchanging MOO message over /// WebSocket connection. /// /// MOO message has medatada, headers, and optional body. /// /// Headers is a list of key-value, where key and value is separated by `:` (colon.) /// /// If there is a body, typical MOO message has `Content-Type` header and its value describes /// the type of the body content. E.g. `application/json` public struct Moo: LosslessStringConvertible, Sendable, Copyable, Escapable { /// Message schema version described in metadata. /// This field will be ignored as I have no idea how to use and validate this field. public let schemaVersion: UInt /// Describes semantic role of the message, such as `REQUEST`, `CONTINUE` and `COMPLETE`. /// It's totally unclear why they insists on WebSocket when normal HTTP natively supports these /// semantics and many clients support in platform level. public let verb: String /// Name (actually identifier) of a Roon API "Service" if `verb` is `REQUEST`. /// Otherwise, this will be abused as a response status field, such as `Success` or `~Error`. public let service: String /// A MOO message has HTTP-like headers. Every request has `Request-Id` header, which identifies which request /// the message corresponds to. Most of the time, request ID is number. However, Roon Server seems to just return /// whatever is in the request message. public internal(set) var headers: Headers public let body: String? public var description: String { var description = "\(signature)\(String(self.schemaVersion)) \(self.verb) \(self.service)\n" for (key, value) in self.headers { description += "\(key): \(value)\n" } if self.headers.count > 0 { description += "\n" } if let body = self.body { description += body } return description } public var requestId: RequestId? { get { guard let value = headers["Request-Id"] else { return nil } return RequestId(value) } set { headers["Request-Id"] = newValue.map { String($0) } } } public init( schemaVersion: UInt = 1, verb: String = "REQUEST", service: String, headers: Headers = [:], body: Data? = nil, requestId: RequestId? = nil, ) { self.schemaVersion = schemaVersion self.verb = verb self.service = service self.headers = headers self.body = body.flatMap { String(data: $0, encoding: .utf8) } self.requestId = requestId } public init?(_ from: String) { try? self.init(from: from) } public init<T>( jsonBody: T, schemaVersion: UInt = 1, verb: String = "REQUEST", service: String, headers: Headers = [:], requestId: RequestId? = nil ) throws where T: Encodable { let body = try JSONEncoder().encode(jsonBody) var headers = headers headers["Content-Type"] = "application/json" headers["Content-Length"] = String(body.count, radix: 10) self.init( schemaVersion: schemaVersion, verb: verb, service: service, headers: headers, body: body, requestId: requestId ) } private enum HeaderParseState { case key(found: String) case beforeValue(key: String) case value(key: String, found: String) } private enum ParseState { case signature(expecting: Character, remaining: Substring) case schemaVersion(found: String) case verb(found: String) case service(found: String) case headers(parsing: HeaderParseState, parsed: Headers) case body(found: String) } init(from: String) throws(MooMessageDecodeError) { var state: ParseState = .signature( expecting: signature.first!, remaining: signature.dropFirst() ) var schemaVersion: UInt? = nil var verb: String? = nil var service: String? = nil var headers: Headers = [:] if from.count == 0 { throw .signatureMismatch } for char in from { switch state { case .signature(let expecting, let remaining): if char != expecting { throw .signatureMismatch } guard let next = remaining.first else { state = .schemaVersion(found: "") break } state = .signature(expecting: next, remaining: remaining.dropFirst()) break case .schemaVersion(let found): if char.isWhitespace && !char.isNewline { if found == "" { throw .invalidVersion } schemaVersion = UInt(found, radix: 10) state = .verb(found: "") break } if !char.isNumber || (found == "" && char == "0") { throw .invalidVersion } state = .schemaVersion(found: found + String(char)) break case .verb(let found): if char.isNewline { throw .missingService } if char.isWhitespace { verb = found state = .service(found: "") break } state = .verb(found: found + String(char)) break case .service(let found): if char.isNewline { service = found state = .headers(parsing: .key(found: ""), parsed: [:]) break } state = .service(found: found + String(char)) break case .headers(let parsing, let parsed): switch parsing { case .key(let found): if found == "" && char.isNewline { headers = parsed state = .body(found: "") break } if char == ":" { if found == "" { throw .malformedHeaderLine } state = .headers(parsing: .beforeValue(key: found), parsed: parsed) break } if !char.isASCII { throw .malformedHeaderLine } state = .headers( parsing: .key(found: found + String(char)), parsed: parsed ) break case .beforeValue(let key): if char.isNewline { throw .malformedHeaderLine } if char.isWhitespace { break } state = .headers( parsing: .value(key: key, found: String(char)), parsed: parsed ) break case .value(let key, let found): if char.isNewline { var parsed = parsed _ = parsed.updateValue(found, forKey: key) state = .headers(parsing: .key(found: ""), parsed: parsed) break } state = .headers( parsing: .value(key: key, found: found + String(char)), parsed: parsed ) break } case .body(let found): state = .body(found: found + String(char)) break } } guard let schemaVersion = schemaVersion else { throw .invalidVersion } guard let verb = verb, verb.count > 0 else { throw .missingVerb } guard let service = service, service.count > 0 else { throw .missingService } self.schemaVersion = schemaVersion self.verb = verb self.service = service self.headers = headers if case .body(let found) = state, found.count > 0 { self.body = found } else { self.body = nil } } public enum JsonBodyError: Error { case decodeError(any Error) case contentTypeMismatch(got: String?) case invalidContentLength(found: String?) case contentLengthMismatch(told: Int, found: Int) case emptyBody } public func json<T>(type: T.Type) throws(JsonBodyError) -> T where T: Decodable { guard let body = self.body else { throw .emptyBody } guard let contentType = self.headers["Content-Type"] else { throw .contentTypeMismatch(got: nil) } guard contentType == "application/json" || contentType.starts(with: "application/json;") else { throw .contentTypeMismatch(got: contentType) } guard let contentLength = self.headers["Content-Length"].flatMap({ Int($0, radix: 10) }) else { throw .invalidContentLength(found: self.headers["Content-Length"]) } let data = body.data(using: .utf8)! guard data.count == contentLength else { throw .contentLengthMismatch(told: contentLength, found: data.count) } do { return try JSONDecoder().decode(type, from: data) } catch { throw .decodeError(error) } } }
-
-
-
@@ -0,0 +1,21 @@// 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 os public typealias RequestId = UInt internal let subsystem = "jp.pocka.plac.RoonKit"
-
-
-
@@ -0,0 +1,222 @@// 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 Foundation import NIOCore import NIOFoundationCompat import NIOPosix import OSLog private let multicastAddr = ("239.255.90.90", 9003) private let logger = Logger(subsystem: subsystem, category: "ServerDiscovery") public enum SocketError: Error { case failedToGetDescriptor case setReuseAddrFailed(Int32) case setReceiveTimeoutFailed(Int32) case setBroadcastOptionFailed(Int32) } public enum ServerLookupError: Error { case notFound case unableToGetAddress case connectionClosed case unableToBuildUrl case socketError(SocketError) } public nonisolated struct Server: Identifiable, Sendable { public let host: String public let port: UInt16 public let id: String public let name: String public let version: String public init(id: String) async throws { self.id = id let queue = DispatchQueue(label: "RoonKit.Server.init") let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) defer { group.shutdownGracefully(queue: queue) { _ in } } let channel = try await DatagramBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .bind(host: "0.0.0.0", port: 0) { channel in channel.eventLoop.makeCompletedFuture { return try NIOAsyncChannel< AddressedEnvelope<ByteBuffer>, AddressedEnvelope<ByteBuffer> >( wrappingChannelSynchronously: channel, ) } } async let read = try await channel.executeThenClose { inbound, outbound in for try await envelope in inbound { let res = try ServerDiscoveryResponse( from: Data(buffer: envelope.data) ) logger.debug("Received a valid SOOD message") guard res.uniqueId == id, let ipAddress = envelope.remoteAddress.ipAddress else { continue } logger.debug("Found a server at \(envelope.remoteAddress)") return (ipAddress, res) } // Once the below "executeThenClose" completed, channel will be closed and // the iterator ("inbound") exits, and program reaches this line. throw ServerLookupError.notFound } async let _ = channel.executeThenClose { inbound, outbound in let req = ServerDiscoveryRequest().data() for _ in 1...4 { do { logger.debug( "Sending a discovery query to \(multicastAddr.0):\(multicastAddr.1)" ) try await outbound.write( AddressedEnvelope( remoteAddress: .init( ipAddress: multicastAddr.0, port: multicastAddr.1 ), data: ByteBuffer(data: req), ) ) } catch { logger.warning("Unable to send discovery query: \(error)") } try await Task.sleep(for: .seconds(3)) } } let (host, msg) = try await read self.host = host self.name = msg.displayName self.version = msg.version self.port = msg.httpPort } public init( id: String, name: String, version: String, host: String, port: UInt16 ) { self.id = id self.name = name self.version = version self.host = host self.port = port } public static func list() async throws -> [Server] { let queue = DispatchQueue(label: "RoonKit.Server.list") let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) defer { group.shutdownGracefully(queue: queue) { _ in } } let channel = try await DatagramBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .bind(host: "0.0.0.0", port: 0) { channel in channel.eventLoop.makeCompletedFuture { return try NIOAsyncChannel< AddressedEnvelope<ByteBuffer>, AddressedEnvelope<ByteBuffer> >( wrappingChannelSynchronously: channel, ) } } async let read = try await channel.executeThenClose { inbound, outbound in var found: [String: Server] = [:] for try await envelope in inbound { do { let res = try ServerDiscoveryResponse( from: Data(buffer: envelope.data) ) logger.debug("Received a valid SOOD message") guard let ipAddress = envelope.remoteAddress.ipAddress else { continue } logger.debug("Found a server at \(envelope.remoteAddress)") found[res.uniqueId] = Server( id: res.uniqueId, name: res.displayName, version: res.version, host: ipAddress, port: res.httpPort ) } catch { logger.warning("Received an invalid SOOD message") } } return found } async let _ = channel.executeThenClose { inbound, outbound in let req = ServerDiscoveryRequest().data() for _ in 1...4 { do { logger.debug( "Sending a discovery query to \(multicastAddr.0):\(multicastAddr.1)" ) try await outbound.write( AddressedEnvelope( remoteAddress: .init( ipAddress: multicastAddr.0, port: multicastAddr.1 ), data: ByteBuffer(data: req), ) ) } catch { logger.warning("Unable to send discovery query: \(error)") } try await Task.sleep(for: .seconds(3)) } } let found = try await read return found.values.sorted(by: { a, b in a.id.hash < b.id.hash }) } }
-
-
-
@@ -0,0 +1,79 @@// 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 Foundation enum ServerDiscoveryResponseProperty: Sendable, Equatable { case uniqueId case displayName case version case httpPort } enum ServerDiscoveryResponseParseError: Error, Equatable { case invalidMessage(SoodDecodeError) case unexpectedMessageKind case missingProperty(ServerDiscoveryResponseProperty) case illegalHttpPort } struct ServerDiscoveryResponse { let uniqueId: String let displayName: String let version: String let httpPort: UInt16 init(from: Data) throws(ServerDiscoveryResponseParseError) { let msg: Sood do { msg = try Sood(from: from) } catch { throw .invalidMessage(error) } if msg.kind != .response { throw .unexpectedMessageKind } if let uniqueId = msg.properties["unique_id"] { self.uniqueId = uniqueId } else { throw .missingProperty(.uniqueId) } if let displayName = msg.properties["name"] { self.displayName = displayName } else { throw .missingProperty(.displayName) } if let version = msg.properties["display_version"] { self.version = version } else { throw .missingProperty(.version) } if let httpPort = msg.properties["http_port"] { guard let httpPort = UInt16(httpPort, radix: 10) else { throw .illegalHttpPort } self.httpPort = httpPort } else { throw .missingProperty(.httpPort) } } }
-
-
-
@@ -0,0 +1,338 @@// 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 public struct BrowseService { public static let id = "com.roonlabs.browse:1" public enum ItemHint: LosslessStringConvertible, Codable { case unknown(String) case action, actionList case list case header public var description: String { switch self { case .action: "action" case .actionList: "action_list" case .header: "header" case .list: "list" case .unknown(let string): string } } public init(_ description: String) { switch description { case "action": self = .action case "action_list": self = .actionList case "header": self = .header case "list": self = .list default: self = .unknown(description) } } public init(from decoder: any Decoder) throws { let value = try decoder.singleValueContainer() let string = try value.decode(String.self) self.init(string) } public func encode(to encoder: any Encoder) throws { var value = encoder.singleValueContainer() try value.encode(self.description) } } public struct InputPrompt: Codable { public let prompt: String public let action: String public let value: String? let _isPassword: Bool? public var isPassword: Bool { _isPassword ?? false } public enum CodingKeys: String, CodingKey { case prompt = "prompt" case action = "action" case value = "value" case _isPassword = "is_password" } } public struct Item: Codable { public let title: String public let subtitle: String? public let imageKey: ImageService.Key? public let itemKey: String? public let hint: ItemHint? public let inputPrompt: InputPrompt? public enum CodingKeys: String, CodingKey { case title = "title" case subtitle = "subtitle" case imageKey = "image_key" case itemKey = "item_key" case hint = "hint" case inputPrompt = "input_prompt" } } public enum ListHint: LosslessStringConvertible, Codable { case unknown(String) case actionList public var description: String { switch self { case .actionList: "action_list" case .unknown(let string): string } } public init(_ description: String) { switch description { case "action_list": self = .actionList default: self = .unknown(description) } } public init(from decoder: any Decoder) throws { let value = try decoder.singleValueContainer() let string = try value.decode(String.self) self.init(string) } public func encode(to encoder: any Encoder) throws { var value = encoder.singleValueContainer() try value.encode(self.description) } } public struct List: Codable { public let title: String public let subtitle: String? public let imageKey: ImageService.Key public let displayOffset: Int? public let hint: ListHint? let _count: UInt? let _level: UInt? public var count: UInt { _count ?? 0 } public var level: UInt { _level ?? 0 } public enum CodingKeys: String, CodingKey { case title = "title" case _count = "count" case subtitle = "subtitle" case imageKey = "image_key" case _level = "level" case displayOffset = "display_offset" case hint = "hint" } } public enum Hierarchy: String, Codable { case browse, playlists, settings, albums case artists, genres, composers, search case internetRadio = "internet_radio" } } extension BrowseService { public enum BrowseAction: String, Codable { case message, none, list case replaceItem = "replace_item" case removeItem = "remove_item" } public struct BrowseRequest: Codable { public let hierarchy: Hierarchy public let itemKey: String? public let input: String? public let zoneOrOutputID: String? public let popAll: Bool? public let popLevels: UInt? public let refreshList: Bool? public enum CodingKeys: String, CodingKey { case hierarchy = "hierarchy" case itemKey = "item_key" case input = "input" case zoneOrOutputID = "zone_or_output_id" case popAll = "pop_all" case popLevels = "pop_levels" case refreshList = "refresh_list" } public init( hierarchy: Hierarchy, itemKey: String? = nil, input: String? = nil, zoneOrOutputID: String? = nil, popAll: Bool? = nil, popLevels: UInt? = nil, refreshList: Bool? = nil ) { self.hierarchy = hierarchy self.itemKey = itemKey self.input = input self.zoneOrOutputID = zoneOrOutputID self.popAll = popAll self.popLevels = popLevels self.refreshList = refreshList } } public struct BrowseResponse: Codable { public let action: BrowseAction public let item: Item? public let list: List? public let message: String? let _isError: Bool? public var isError: Bool { _isError ?? false } public enum CodingKeys: String, CodingKey { case action = "action" case item = "item" case list = "list" case message = "message" case _isError = "is_error" } public enum DecodeError: Error { case nonSuccess case invalidBody(Moo.JsonBodyError) } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { public init(browse: BrowseService.BrowseRequest) throws { try self.init(jsonBody: browse, service: "\(BrowseService.id)/browse") } } extension BrowseService { public struct LoadRequest: Codable { public let hierarchy: Hierarchy public let displayOffset: Int? public let level: UInt? public let offset: Int? public let count: UInt? public let multiSessionKey: String? public enum CodingKeys: String, CodingKey { case hierarchy = "hierarchy" case displayOffset = "display_offset" case level = "level" case offset = "offset" case count = "count" case multiSessionKey = "multi_session_key" } public init( hierarchy: Hierarchy, displayOffset: Int? = nil, level: UInt? = 0, offset: Int? = 0, count: UInt? = nil, multiSessionKey: String? = nil ) { self.hierarchy = hierarchy self.displayOffset = displayOffset self.level = level self.offset = offset self.count = count self.multiSessionKey = multiSessionKey } } public struct LoadResponse: Codable { public let items: [Item] let _offset: Int? public let list: List public var offset: Int { _offset ?? 0 } public enum CodingKeys: String, CodingKey { case items = "items" case _offset = "offset" case list = "list" } public enum DecodeError: Error { case nonSuccess case invalidBody(Moo.JsonBodyError) } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { public init(load: BrowseService.LoadRequest) throws { try self.init(jsonBody: load, service: "\(BrowseService.id)/load") } }
-
-
-
@@ -0,0 +1,93 @@// 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 Foundation public struct ImageService { public static let id = "com.roonlabs.image:1" public typealias Key = String public enum Format: String, Codable { case jpeg = "image/jpeg" case png = "image/png" } public enum ScalingMethod: String, Codable { case fit, fill, stretch } public struct GetRequest { public let key: Key public let format: Format? public let scale: ScalingMethod? public let width: UInt? public let height: UInt? public init( key: Key, format: Format? = nil, scale: ScalingMethod? = nil, width: UInt? = nil, height: UInt? = nil ) { self.key = key self.format = format self.scale = scale self.width = width self.height = height } } } extension URL { public init?(roonImage: ImageService.GetRequest, host: String, port: UInt16) { var queries: [URLQueryItem] = [] if let scale = roonImage.scale { queries.append(.init(name: "scale", value: scale.rawValue)) } if let width = roonImage.width { queries.append(.init(name: "width", value: String(width, radix: 10))) } if let height = roonImage.height { queries.append(.init(name: "height", value: String(height, radix: 10))) } if let format = roonImage.format { queries.append(.init(name: "format", value: format.rawValue)) } var components = URLComponents() components.host = host components.port = Int(port) components.path = "/api/image/\(roonImage.key)" components.scheme = "http" if queries.count > 0 { components.queryItems = queries } if let url = components.url { self = url } else { return nil } } }
-
-
-
@@ -0,0 +1,23 @@// 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 public struct PingService { public static let id = "com.roonlabs.ping:1" public struct Ping { public static let service = "\(id)/ping" } }
-
-
-
@@ -0,0 +1,135 @@// 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 Foundation public struct RegistryService { static let id = "com.roonlabs.registry:1" } extension RegistryService { struct InfoRequest { init() {} } struct InfoResponse: Codable { let coreId: String let displayName: String let version: String enum CodingKeys: String, CodingKey { case coreId = "core_id" case displayName = "display_name" case version = "display_version" } enum DecodeError: Error { case invalidBody(Moo.JsonBodyError) } init(_ msg: Moo) throws(DecodeError) { do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { init(info: RegistryService.InfoRequest) { self.init(service: "\(RegistryService.id)/info") } } extension RegistryService { public struct RegisterRequest: Codable { public let id: String public let displayName: String public let version: String public let publisher: String public let email: String public let requiredServices: [String] public let optionalServices: [String] public let providedServices: [String] public let token: String? public init( id: String, displayName: String, version: String, publisher: String, email: String, requiredServices: [String] = [], optionalServices: [String] = [], providedServices: [String] = [PingService.id], token: String? = nil ) { self.id = id self.displayName = displayName self.version = version self.publisher = publisher self.email = email self.requiredServices = requiredServices self.optionalServices = optionalServices self.providedServices = providedServices self.token = token } public enum CodingKeys: String, CodingKey { case id = "extension_id" case displayName = "display_name" case version = "display_version" case publisher = "publisher" case email = "email" case requiredServices = "required_services" case optionalServices = "optional_services" case providedServices = "provided_services" case token = "token" } } public typealias Extension = RegisterRequest struct RegisterResponse: Decodable { let token: String enum DecodeError: Error { case unexpectedStatus(String) case invalidBody(Moo.JsonBodyError) } init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Registered" else { throw .unexpectedStatus(msg.service) } do { self = try msg.json(type: Self.self) } catch { throw .invalidBody(error) } } } } extension Moo { init(register: RegistryService.RegisterRequest) throws { try self.init(jsonBody: register, service: "\(RegistryService.id)/register") } }
-
-
-
@@ -0,0 +1,350 @@// 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 Foundation public struct TransportService { public static let id = "com.roonlabs.transport:2" public struct SingleLineDisplay: Decodable, Hashable { public let line: String public enum CodingKeys: String, CodingKey { case line = "line1" } } public struct DoubleLineDisplay: Decodable, Hashable { public let line1: String public let line2: String? } public struct TripleLineDisplay: Decodable, Hashable { public let line1: String public let line2: String? public let line3: String? } public struct NowPlaying: Decodable, Hashable { public let seekPosition: UInt64? public let length: UInt64? public let imageKey: String? public let singleLine: SingleLineDisplay public let doubleLine: DoubleLineDisplay public let tripleLine: TripleLineDisplay public enum CodingKeys: String, CodingKey { case seekPosition = "seek_position" case length = "length" case imageKey = "image_key" case singleLine = "one_line" case doubleLine = "two_line" case tripleLine = "three_line" } } public enum PlaybackState: String, Decodable { case playing case paused case loading case stopped } public struct OutputVolume: Decodable, Hashable { public let type: String public let min: Float64? public let max: Float64? public let value: Float64? public let step: Float64? public let isMuted: Bool? public enum CodingKeys: String, CodingKey { case type = "type" case min = "min" case max = "max" case value = "value" case step = "step" case isMuted = "is_muted" } } public struct Output: Decodable, Identifiable, Hashable { public let id: String public let displayName: String public let volume: OutputVolume? public enum CodingKeys: String, CodingKey { case id = "output_id" case displayName = "display_name" case volume = "volume" } } public struct Zone: Decodable, Identifiable, Hashable { public let id: String public let displayName: String public let outputs: [Output] public let nowPlaying: NowPlaying? public let state: PlaybackState public let isPreviousAllowed: Bool? public let isNextAllowed: Bool? public let isPauseAllowed: Bool? public let isPlayAllowed: Bool? public let isSeekAllowed: Bool? public enum CodingKeys: String, CodingKey { case id = "zone_id" case displayName = "display_name" case outputs = "outputs" case nowPlaying = "now_playing" case state = "state" case isPreviousAllowed = "is_previous_allowed" case isNextAllowed = "is_next_allowed" case isPauseAllowed = "is_pause_allowed" case isPlayAllowed = "is_play_allowed" case isSeekAllowed = "is_seek_allowed" } } public struct SeekChangeEvent: Decodable { public let zoneID: String public let seekPosition: UInt64? public enum CodingKeys: String, CodingKey { case zoneID = "zone_id" case seekPosition = "seek_position" } } public struct ZoneChangeEvent: Decodable { private let _removedZoneIDs: [String]? private let _addedZones: [Zone]? private let _changedZones: [Zone]? private let _seekChanges: [SeekChangeEvent]? public var removedZoneIDs: [String] { _removedZoneIDs ?? [] } public var addedZones: [Zone] { _addedZones ?? [] } public var changedZones: [Zone] { _changedZones ?? [] } public var seekChanges: [SeekChangeEvent] { _seekChanges ?? [] } public enum CodingKeys: String, CodingKey { case _removedZoneIDs = "zones_removed" case _addedZones = "zones_added" case _changedZones = "zones_changed" case _seekChanges = "zones_seek_changed" } public init?(_ msg: Moo) { do { self = try msg.json(type: Self.self) } catch { return nil } } } } extension TransportService { public struct SubscribeZoneChangesRequest: Encodable { public let subscriptionID: String public init(subscriptionID: String) { self.subscriptionID = subscriptionID } public init(subscriptionID: any BinaryInteger) { self.subscriptionID = String(subscriptionID, radix: 10) } public enum CodingKeys: String, CodingKey { case subscriptionID = "subscription_key" } } public struct SubscribeZoneChangesResponse: Decodable { public enum DecodeError: Error { case unexpectedStatus(String) case bodyError(Moo.JsonBodyError) } public let zones: [Zone] public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Subscribed" else { throw .unexpectedStatus(msg.service) } do { self = try msg.json(type: Self.self) } catch { throw .bodyError(error) } } } } extension Moo { public init( subscribeZoneChange: TransportService.SubscribeZoneChangesRequest ) throws { try self.init( jsonBody: subscribeZoneChange, service: "\(TransportService.id)/subscribe_zones" ) } } extension TransportService { public enum ControlAction: String, Codable { case play, pause, playpause, stop, previous, next } public struct ControlRequest: Codable { public let zoneOrOutputID: String public let control: ControlAction public enum CodingKeys: String, CodingKey { case zoneOrOutputID = "zone_or_output_id" case control = "control" } public init(zoneID: String, control: ControlAction) { self.zoneOrOutputID = zoneID self.control = control } } public struct ControlResponse: Codable { public enum DecodeError: Error { case nonSuccess } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } } } } extension Moo { public init(control: TransportService.ControlRequest) throws { try self.init(jsonBody: control, service: "\(TransportService.id)/control") } } extension TransportService { public enum SeekMode: String, Codable { case relative, absolute } public struct SeekRequest: Codable { public let zoneOrOutputID: String public let mode: SeekMode public let seconds: Int64 public enum CodingKeys: String, CodingKey { case zoneOrOutputID = "zone_or_output_id" case mode = "how" case seconds = "seconds" } public init(zoneID: String, seconds: Int64, mode: SeekMode = .absolute) { self.zoneOrOutputID = zoneID self.mode = mode self.seconds = seconds } } public struct SeekResponse: Codable { public enum DecodeError: Error { case nonSuccess } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } } } } extension Moo { public init(seek: TransportService.SeekRequest) throws { try self.init(jsonBody: seek, service: "\(TransportService.id)/seek") } } extension TransportService { public enum ChangeVolumeMode: String, Codable { case absolute, relative case relativeStep = "relative_step" } public struct ChangeVolumeRequest: Codable { public let outputID: String public let mode: ChangeVolumeMode public let value: Float64 public enum CodingKeys: String, CodingKey { case outputID = "output_id" case mode = "how" case value = "value" } public init( outputID: String, value: Float64, mode: ChangeVolumeMode = .absolute ) { self.outputID = outputID self.value = value self.mode = mode } } public struct ChangeVolumeResponse: Codable { public enum DecodeError: Error { case nonSuccess } public init(_ msg: Moo) throws(DecodeError) { guard msg.service == "Success" else { throw .nonSuccess } } } } extension Moo { public init(changeVolume: TransportService.ChangeVolumeRequest) throws { try self.init( jsonBody: changeVolume, service: "\(TransportService.id)/change_volume" ) } }
-
-
-
@@ -0,0 +1,250 @@// 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 Foundation enum SoodDecodeError: Error { case signatureMismatch case missingMessageKind case propertyKeySizeIsZero case incompletePropertyKey case missingPropertyValueSize case incompletePropertyValue case invalidPropertyKey case invalidPropertyValue } enum SoodMessageKind: Equatable, LosslessStringConvertible { /// Unknown, or unsupported message kind. Sorely exists for forward compatibility. case unknown(String) /// Discovery query. Client (Roon Extension) sends this via UDP multicast and/or broadcast. case query /// Response to a query. Roon Server sends this in response to a query SOOD message. case response var description: String { switch self { case .query: return "Q" case .response: return "R" case .unknown(let original): return original } } init(_ from: String) { switch from { case "R": self = .response case "Q": self = .query default: self = .unknown(from) } } func data() -> Data { return String(self).data(using: .utf8)! } } /// SOOD message is a binary data format used for Roon Server Discovery, a process for finding /// Roon Servers in a network. SOOD messages are sent over UDP via multicast and broadcast. struct Sood { private static let signature = "SOOD\u{2}" let kind: SoodMessageKind let properties: [String: String] init(kind: SoodMessageKind, properties: [String: String] = [:]) { self.kind = kind self.properties = properties } init?(_ from: String) { try? self.init(from: from.data(using: .utf8)!) } private enum PropertyParseState { case keySize case key(read: [UInt8], remainingBytes: UInt8) case valueSizeHi(key: String) case valueSizeLo(key: String, hi: UInt8) case value(key: String, read: [UInt8], remainingBytes: UInt16) } private enum ParseState { case signature(expecting: Character, remaining: Substring) case kind case properties( SoodMessageKind, parsing: PropertyParseState, parsed: [String: String] ) } init(from: Data) throws(SoodDecodeError) { var state: ParseState = .signature( expecting: Sood.signature.first!, remaining: Sood.signature.dropFirst() ) for byte in from { switch state { case .signature(let expecting, let remaining): if byte != expecting.asciiValue { throw .signatureMismatch } guard let next = remaining.first else { state = .kind break } state = .signature(expecting: next, remaining: remaining.dropFirst()) break case .kind: guard let char = String(bytes: [byte], encoding: .ascii) else { throw .missingMessageKind } state = .properties(.init(char), parsing: .keySize, parsed: [:]) break case .properties(let kind, let parsing, let parsed): switch parsing { case .keySize: if byte == 0 { throw .propertyKeySizeIsZero } state = .properties( kind, parsing: .key(read: [], remainingBytes: byte), parsed: parsed ) break case .key(let read, let remainingBytes): let read = read + [byte] if remainingBytes > 1 { state = .properties( kind, parsing: .key(read: read, remainingBytes: remainingBytes - 1), parsed: parsed ) break } guard let key = String(bytes: read, encoding: .utf8) else { throw .invalidPropertyKey } state = .properties( kind, parsing: .valueSizeHi(key: key), parsed: parsed ) break case .valueSizeHi(let key): state = .properties( kind, parsing: .valueSizeLo(key: key, hi: byte), parsed: parsed ) break case .valueSizeLo(let key, let hi): let hi = UInt16(hi) let lo = UInt16(byte) let size = UInt16(bigEndian: (hi.bigEndian << 8) | lo.bigEndian) state = .properties( kind, parsing: .value(key: key, read: [], remainingBytes: size), parsed: parsed ) break case .value(let key, let read, let remainingBytes): let read = read + [byte] if remainingBytes > 1 { state = .properties( kind, parsing: .value( key: key, read: read, remainingBytes: remainingBytes - 1 ), parsed: parsed ) break } guard let value = String(bytes: read, encoding: .utf8) else { throw .invalidPropertyValue } var parsed = parsed _ = parsed.updateValue(value, forKey: key) state = .properties(kind, parsing: .keySize, parsed: parsed) break } } } switch state { case .signature(_, _): throw .signatureMismatch case .kind: throw .missingMessageKind case .properties(let kind, parsing: .keySize, let parsed): self.kind = kind self.properties = parsed return case .properties(_, parsing: .key(_, _), parsed: _): throw .invalidPropertyKey case .properties(_, parsing: .valueSizeHi(_), parsed: _): throw .missingPropertyValueSize case .properties(_, parsing: .valueSizeLo(_, _), parsed: _): throw .missingPropertyValueSize case .properties(_, parsing: .value(_, _, _), parsed: _): throw .incompletePropertyValue } } func data() -> Data { var data = Sood.signature.data(using: .utf8)! data.append(self.kind.data()) for (key, value) in self.properties { let key = key.data(using: .utf8)! data.append(contentsOf: [UInt8(key.count)]) data.append(key) let value = value.data(using: .utf8)! let hi = UInt8((UInt16(value.count).bigEndian & 0b11111111).littleEndian) let lo = UInt8( ((UInt16(value.count).bigEndian & 0b11111111_00000000) >> 8) .littleEndian ) data.append(contentsOf: [hi, lo]) data.append(value) } return data } }
-
-
-
@@ -0,0 +1,123 @@// 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 Testing @testable import RoonKit @Suite("MOO message decode tests") struct MooMessageDecodeTests { @Test func acceptsMetadataOnlyMessage() throws { let msg = try Moo(from: "MOO/1 REQUEST foo/bar\n") #expect(msg.schemaVersion == 1) #expect(msg.verb == "REQUEST") #expect(msg.service == "foo/bar") #expect(msg.headers == [:]) #expect(msg.body == .none) } @Test func rejectsEmptyMessage() { #expect(throws: MooMessageDecodeError.signatureMismatch) { let _ = try Moo(from: "") } } @Test func rejectsInvalidSignature() { #expect(throws: MooMessageDecodeError.signatureMismatch) { let _ = try Moo(from: "MOO1 REQUEST foo/bar\n") } } @Test func rejectsInvalidVersion() { #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/1.0 REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/-1 REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/ REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/0x1 REQUEST foo/bar\n") } #expect(throws: MooMessageDecodeError.invalidVersion) { let _ = try Moo(from: "MOO/one REQUEST foo/bar\n") } } @Test func acceptsMessageWithoutBody() throws { let msg = try Moo( from: "MOO/1 REQUEST foo/bar\nRequest-Id: 5\nAccept-Encoding:application/json;charset=utf-8\n\n" ) #expect(msg.schemaVersion == 1) #expect(msg.verb == "REQUEST") #expect(msg.service == "foo/bar") #expect(msg.headers.count == 2) #expect(msg.headers["Request-Id"] == .some("5")) #expect( msg.headers["Accept-Encoding"] == .some("application/json;charset=utf-8") ) #expect(msg.body == .none) } @Test func acceptsFullMessage() throws { let msg = try Moo( from: "MOO/1 REQUEST foo/bar\nRequest-Id: 5\nContent-Type:application/json;charset=utf-8\n\n{\"foo\":\"bar\"}" ) #expect(msg.schemaVersion == 1) #expect(msg.verb == "REQUEST") #expect(msg.service == "foo/bar") #expect(msg.headers.count == 2) #expect(msg.headers["Request-Id"] == .some("5")) #expect( msg.headers["Content-Type"] == .some("application/json;charset=utf-8") ) #expect(msg.body == .some("{\"foo\":\"bar\"}")) } @Test func encodeThenDecode() { let msg = Moo( schemaVersion: 2, verb: "OPTIONS", service: "foo/bar", headers: ["Language": "Esperanto"], body: "FOO BAR".data(using: .utf8)! ) let encoded = String(msg) guard let decoded = Moo(encoded) else { #expect(Bool(false), "Unable to encode") return } #expect(decoded.schemaVersion == 2) #expect(decoded.verb == "OPTIONS") #expect(decoded.service == "foo/bar") #expect(decoded.headers.count == 1) #expect(decoded.headers["Language"] == .some("Esperanto")) #expect(decoded.body == .some("FOO BAR")) } }
-
-
-
@@ -0,0 +1,67 @@// 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 Testing @testable import RoonKit @Suite("Discovery response parsing tests") struct ServerDiscoveryResponseTests { @Test func rejectsMissingProperties() { #expect( throws: ServerDiscoveryResponseParseError.missingProperty(.uniqueId) ) { let _ = try ServerDiscoveryResponse( from: "SOOD\u{2}R\u{3}foo\u{0}\u{3}bar".data(using: .utf8)! ) } } @Test func rejectsNonResponseMessage() { let msg = Sood( kind: .query, properties: [ "http_port": "9999", "name": "Foo", "display_version": "devel", "unique_id": "i-am-foo", ] ) #expect(throws: ServerDiscoveryResponseParseError.unexpectedMessageKind) { let _ = try ServerDiscoveryResponse(from: msg.data()) } } @Test func decode() throws { let msg = Sood( kind: .response, properties: [ "http_port": "9999", "name": "Foo", "display_version": "devel", "unique_id": "i-am-foo", ] ) let decoded = try ServerDiscoveryResponse(from: msg.data()) #expect(decoded.displayName == "Foo") #expect(decoded.version == "devel") #expect(decoded.uniqueId == "i-am-foo") #expect(decoded.httpPort == 9999) } }
-
-
-
@@ -0,0 +1,48 @@// 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 Foundation import Testing @testable import RoonKit @Suite("ImageService URL construction tests") struct ImageServiceURLConstructionTests { @Test func buildsKeyOnlyURL() async throws { let url = URL(roonImage: .init(key: "foo"), host: "127.0.0.1", port: 8080)! #expect(url.absoluteString == "http://127.0.0.1:8080/api/image/foo") } @Test func buildsFullParameters() async throws { let url = URL( roonImage: .init( key: "foo", format: .png, scale: .fit, width: 100, height: 200, ), host: "127.0.0.1", port: 8080 )! #expect( url.absoluteString == "http://127.0.0.1:8080/api/image/foo?scale=fit&width=100&height=200&format=image/png" ) } }
-
-
-
@@ -0,0 +1,88 @@// 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 Testing @testable import RoonKit @Suite("SOOD message decode tests") struct SoodMessageDecodeTests { @Test func rejectsPngHeader() { let msg = "\u{89}PNG\r\n\u{1a}\n" #expect(throws: SoodDecodeError.signatureMismatch) { let _ = try Sood(from: msg.data(using: .utf8)!) } } @Test func rejectsNoSoodSignatureDelimiter() { let msg = "SOODR\u{2}foo" #expect(throws: SoodDecodeError.signatureMismatch) { let _ = try Sood(from: msg.data(using: .utf8)!) } } @Test func rejectsSoodMissingKind() { let msg = "SOOD\u{2}" #expect(throws: SoodDecodeError.missingMessageKind) { let _ = try Sood(from: msg.data(using: .utf8)!) } } @Test func acceptsUnknownSoodMessageKind() throws { let msg = try Sood(from: "SOOD\u{2}r".data(using: .utf8)!) #expect(msg.kind == .unknown("r")) } @Test func acceptsNonPrintableSoodMessageKind() throws { let msg = try Sood(from: "SOOD\u{2}\u{2}".data(using: .utf8)!) #expect(msg.kind == .unknown("\u{2}")) } @Test func acceptsSoodMessageWithoutProperties() throws { let msg = try Sood(from: "SOOD\u{2}Q".data(using: .utf8)!) #expect(msg.kind == .query) #expect(msg.properties.count == 0) } @Test func acceptsSoodMessageWithProperties() throws { let msg = try Sood( from: "SOOD\u{2}R\u{3}foo\u{0}\u{3}bar".data(using: .utf8)! ) #expect(msg.kind == .response) #expect(msg.properties.count == 1) #expect(msg.properties["foo"] == .some("bar")) } } @Suite("SOOD encode tests") struct SoodMessageEncodeTests { @Test func encodeAndDecode() throws { let original = Sood(kind: .response, properties: ["foo": "bar"]) let decoded = try Sood(from: original.data()) #expect(decoded.kind == .response) #expect(decoded.properties.count == 1) #expect(decoded.properties["foo"] == .some("bar")) } }
-
-
-
@@ -7,7 +7,7 @@ objectVersion = 77;objects = { /* Begin PBXBuildFile section */ C328BE522E497E7A006A8C07 /* PlacCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = C328BE512E497E7A006A8C07 /* PlacCore.xcframework */; }; C3BD03932E4F079E006AF103 /* RoonKit in Frameworks */ = {isa = PBXBuildFile; productRef = C3BD03922E4F079E006AF103 /* RoonKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */
-
@@ -28,7 +28,7 @@ };/* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ C328BE512E497E7A006A8C07 /* PlacCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = PlacCore.xcframework; path = "zig-out/xcframeworks/PlacCore.xcframework"; sourceTree = "<group>"; }; C3BD03912E4EB8F3006AF103 /* RoonKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RoonKit; sourceTree = "<group>"; }; C3EABC712DB1170400F786D6 /* plac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = plac.app; sourceTree = BUILT_PRODUCTS_DIR; }; C3EABC7F2DB1170700F786D6 /* placTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = placTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C3EABC892DB1170700F786D6 /* placUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = placUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
-
@@ -57,7 +57,7 @@ C3EABC6E2DB1170400F786D6 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( C328BE522E497E7A006A8C07 /* PlacCore.xcframework in Frameworks */, C3BD03932E4F079E006AF103 /* RoonKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; };
-
@@ -81,7 +81,6 @@ /* Begin PBXGroup section */C3490F952DB36DC30030476A /* Frameworks */ = { isa = PBXGroup; children = ( C328BE512E497E7A006A8C07 /* PlacCore.xcframework */, ); name = Frameworks; sourceTree = "<group>";
-
@@ -89,6 +88,7 @@ };C3EABC682DB1170400F786D6 = { isa = PBXGroup; children = ( C3BD03912E4EB8F3006AF103 /* RoonKit */, C3EABC732DB1170400F786D6 /* plac */, C3EABC822DB1170700F786D6 /* placTests */, C3EABC8C2DB1170700F786D6 /* placUITests */,
-
@@ -127,6 +127,7 @@ C3EABC732DB1170400F786D6 /* plac */,); name = plac; packageProductDependencies = ( C3BD03922E4F079E006AF103 /* RoonKit */, ); productName = plac; productReference = C3EABC712DB1170400F786D6 /* plac.app */;
-
@@ -210,6 +211,8 @@ Base,); mainGroup = C3EABC682DB1170400F786D6; minimizedProjectReferenceProxies = 1; packageReferences = ( ); preferredProjectObjectVersion = 77; productRefGroup = C3EABC722DB1170400F786D6 /* Products */; projectDirPath = "";
-
@@ -616,6 +619,13 @@ defaultConfigurationIsVisible = 0;defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ C3BD03922E4F079E006AF103 /* RoonKit */ = { isa = XCSwiftPackageProductDependency; productName = RoonKit; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C3EABC692DB1170400F786D6 /* Project object */; }
-
-
-
@@ -0,0 +1,42 @@{ "originHash" : "d7c8df497b27c4731b91c7c9b5f25500d28e0fd99bf3f6080a72b02c267be0c4", "pins" : [ { "identity" : "swift-atomics", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { "revision" : "cd142fd2f64be2100422d658e7411e39489da985", "version" : "1.2.0" } }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", "version" : "1.1.4" } }, { "identity" : "swift-nio", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { "revision" : "1c30f0f2053b654e3d1302492124aa6d242cdba7", "version" : "2.86.0" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", "version" : "1.4.2" } } ], "version" : 3 }
-
-
macos/plac/Core/Connection.swift (deleted)
-
@@ -1,135 +0,0 @@// 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 Observation 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) } @Observable class Connection { let logger = Logger() var ptr: UnsafeMutablePointer<PlacKit.plac_connection> init(server: CoreServer, token: String? = nil) { self.ptr = PlacKit.plac_connection_make(server.ptr, token) } 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 control(_ zone: CoreZone, _ action: PlaybackAction) { let value: UInt16 switch action { case .Next: value = UInt16(PlacKit.PLAC_TRANSPORT_ACTION_NEXT) case .Pause: value = UInt16(PlacKit.PLAC_TRANSPORT_ACTION_PAUSE) case .Play: value = UInt16(PlacKit.PLAC_TRANSPORT_ACTION_PLAY) case .Prev: value = UInt16(PlacKit.PLAC_TRANSPORT_ACTION_PREV) case .Seek: value = UInt16(PlacKit.PLAC_TRANSPORT_ACTION_SEEK) } PlacKit.plac_connection_control(ptr, zone.ptr, value) } func subscribeZoneChanges() { PlacKit.plac_connection_subscribe_zones(ptr) } func close() { PlacKit.plac_connection_disconnect(ptr) } }
-
-
macos/plac/Core/Discovery.swift (deleted)
-
@@ -1,104 +0,0 @@// 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 protocol Server { var id: String { get } var name: String { get } var version: String { get } } class CoreServer: Identifiable, Server { var ptr: UnsafeMutablePointer<PlacKit.plac_discovery_server> var id: String { return String(cString: ptr.pointee.id) } var name: String { return String(cString: ptr.pointee.name) } var version: String { return String(cString: ptr.pointee.version) } var ipAddress: String { return String(cString: ptr.pointee.ip_addr) } var port: UInt16 { return ptr.pointee.http_port } init(ptr: UnsafeMutablePointer<PlacKit.plac_discovery_server>) { self.ptr = ptr } deinit { 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: [CoreServer] init(ptr: UnsafeMutablePointer<PlacKit.plac_discovery_scan_result>) { self.ptr = ptr var entries: [CoreServer] = [] if ptr.pointee.code == PlacKit.PLAC_DISCOVERY_SCAN_RESULT_OK { for i in 0..<ptr.pointee.servers_len { entries.append( CoreServer( ptr: PlacKit.plac_discovery_server_retain( ptr.pointee.servers_ptr[i] ) ) ) } } self.entries = entries } deinit { PlacKit.plac_discovery_scan_result_release(ptr) } }
-
-
macos/plac/Core/Transport.swift (deleted)
-
@@ -1,258 +0,0 @@// 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 protocol NowPlaying { var oneline: String { get } var twoline: (String, String?) { get } var threeline: (String, String?, String?) { get } /// Seek information. The first element represents current seek position and the second represents current song's length. /// Both numbers are in seconds, starts from 0. var seek: (UInt64, UInt64)? { get } var imageKey: String? { get } } class CoreNowPlaying: NowPlaying { var ptr: UnsafeMutablePointer<PlacKit.plac_transport_now_playing> var oneline: String { return String(cString: ptr.pointee.one_line_line1) } var twoline: (String, String?) { return ( String(cString: ptr.pointee.two_line_line1), ptr.pointee.two_line_line2.map { String(cString: $0) } ) } var threeline: (String, String?, String?) { return ( String(cString: ptr.pointee.three_line_line1), ptr.pointee.three_line_line2.map { String(cString: $0) }, ptr.pointee.three_line_line3.map { String(cString: $0) }, ) } var seek: (UInt64, UInt64)? { if !ptr.pointee.has_length || !ptr.pointee.has_seek_position { return nil } return (ptr.pointee.seek_position, ptr.pointee.length) } var imageKey: String? { return ptr.pointee.image_key.map { String(cString: $0) } } init(ptr: UnsafeMutablePointer<PlacKit.plac_transport_now_playing>) { self.ptr = ptr } deinit { PlacKit.plac_transport_now_playing_release(ptr) } } class MockNowPlaying: NowPlaying { var oneline: String var twoline: (String, String?) var threeline: (String, String?, String?) var seek: (UInt64, UInt64)? var imageKey: String? init( line1: String, line2: String? = nil, line3: String? = nil, seek: (UInt64, UInt64)? = nil, imageKey: String? = nil ) { self.oneline = line1 self.twoline = (line1, line2) self.threeline = (line1, line2, line3) self.seek = seek self.imageKey = imageKey } } enum PlaybackAction { case Next case Prev case Pause case Play case Seek } protocol Zone { var id: String { get } var name: String { get } var playback: PlacKit.plac_transport_playback_state { get } var nowPlaying: NowPlaying? { get } func isAllowedTo(_ action: PlaybackAction) -> Bool } class CoreZone: Identifiable, Zone { var ptr: UnsafeMutablePointer<PlacKit.plac_transport_zone> var nowPlaying: (any NowPlaying)? 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 self.nowPlaying = ptr.pointee.now_playing.map { CoreNowPlaying(ptr: $0) } } deinit { PlacKit.plac_transport_zone_release(ptr) } func isAllowedTo(_ action: PlaybackAction) -> Bool { switch action { case .Next: return (ptr.pointee.allowed_action & UInt16(PlacKit.PLAC_TRANSPORT_ACTION_NEXT)) != 0 case .Prev: return (ptr.pointee.allowed_action & UInt16(PlacKit.PLAC_TRANSPORT_ACTION_PREV)) != 0 case .Pause: return (ptr.pointee.allowed_action & UInt16(PlacKit.PLAC_TRANSPORT_ACTION_PAUSE)) != 0 case .Play: return (ptr.pointee.allowed_action & UInt16(PlacKit.PLAC_TRANSPORT_ACTION_PLAY)) != 0 case .Seek: return (ptr.pointee.allowed_action & UInt16(PlacKit.PLAC_TRANSPORT_ACTION_SEEK)) != 0 } } } class MockZone: Identifiable, Zone { var id: String var name: String var playback: plac_transport_playback_state var nowPlaying: (any NowPlaying)? var allowedActions: [PlaybackAction] init( id: String, name: String, playback: plac_transport_playback_state, nowPlaying: (any NowPlaying)? = nil, allowedActions: [PlaybackAction] = [], ) { self.id = id self.name = name self.playback = playback self.nowPlaying = nowPlaying self.allowedActions = allowedActions } func isAllowedTo(_ action: PlaybackAction) -> Bool { return self.allowedActions.firstIndex(of: action) != nil } } class ZoneListEvent { let logger = Logger() var ptr: UnsafeMutablePointer<PlacKit.plac_transport_zone_list_event> var addedZones: [CoreZone] var changedZones: [CoreZone] var removedZoneIds: [String] init(ptr: UnsafeMutablePointer<PlacKit.plac_transport_zone_list_event>) { self.ptr = ptr self.addedZones = [] if ptr.pointee.added_zones_len > 0 { var addedZones: [CoreZone] = [] for i in 0..<ptr.pointee.added_zones_len { addedZones.append( CoreZone( ptr: PlacKit.plac_transport_zone_retain( ptr.pointee.added_zones_ptr[i] ) ) ) } self.addedZones = addedZones } self.changedZones = [] if ptr.pointee.changed_zones_len > 0 { var changedZones: [CoreZone] = [] for i in 0..<ptr.pointee.changed_zones_len { changedZones.append( CoreZone( ptr: PlacKit.plac_transport_zone_retain( ptr.pointee.changed_zones_ptr[i] ) ) ) } self.changedZones = changedZones } self.removedZoneIds = [] if ptr.pointee.removed_zone_ids_len > 0 { var removedZoneIds: [String] = [] for i in 0..<ptr.pointee.removed_zone_ids_len { if let strPtr = ptr.pointee.removed_zone_ids_ptr[i] { removedZoneIds.append(String(cString: strPtr)) } else { logger.warning("removed_zone_ids_ptr[\(i)] is null pointer") } } self.removedZoneIds = removedZoneIds } } deinit { PlacKit.plac_transport_zone_list_event_release(ptr) } }
-
-
-
@@ -0,0 +1,176 @@// 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 Foundation import RoonKit import SwiftUI enum ConnectionState { case loading case open(Connection) case closed case error(any Error) } private enum ServerQuery { case id(String) case info(Server) } @Observable final class ConnectionDataModel { @ObservationIgnored @AppStorage("PlacApp.connectedServerId") private var connectedServerID: String? @ObservationIgnored @AppStorage("PlacApp.token") private var savedToken: String? @ObservationIgnored @AppStorage("PlacApp.serverIp") private var serverIp: String? @ObservationIgnored @AppStorage("PlacApp.serverPort") private var serverPort: Int? var state: ConnectionState = .closed var disconnectable: Bool { switch state { case .open(_): true default: false } } private let server: ServerQuery /// Connection address as a local property. /// /// Since `RoonKit.Connection` is an actor, accessing `host` and `port` of that /// requires `async` access, which incurs context switches. This stored propery is both for /// performance and for convenience. private var address: (String, UInt16)? = nil /// Initialize data model with server ID, let the model resolve host and port. /// /// Model might use cached host/port to skip UDP mutlicast. init(serverID: String) { self.server = .id(serverID) } /// Initialize data model with resolved server. /// /// Model do not perform server discovery. init(server: Server) { self.server = .info(server) } func connect() async { if case .loading = state { return } do { state = .loading address = nil let serverID: String let host: String let port: UInt16 switch server { case .id(let id): serverID = id if connectedServerID != .some(id) { clearCache() } else if let serverIp = serverIp, let serverPort = serverPort { host = serverIp port = UInt16(serverPort) break } let server = try await Server(id: id) host = server.host port = server.port case .info(let server): if connectedServerID != .some(server.id) { clearCache() } serverID = server.id host = server.host port = server.port } let ext = RegistryService.Extension( id: "jp.pocka.plac.macos", displayName: "Plac macOS", version: "0.0.1", publisher: "Shota FUJI", email: "pockawoooh@gmail.com", requiredServices: [ TransportService.id, BrowseService.id, ImageService.id, ], token: savedToken, ) let conn = try await Connection( id: serverID, host: host, port: port, ext: ext ) state = .open(conn) address = (host, port) connectedServerID = serverID serverIp = host serverPort = Int(port) savedToken = await conn.token } catch { state = .error(error) } } func disconnect() async { switch state { case .open(let conn): await conn.disconnect() clearCache() state = .closed case .loading: return default: state = .closed } } func imageURL(req: ImageService.GetRequest) -> URL? { address.flatMap { (host, port) in URL(roonImage: req, host: host, port: port) } } private func clearCache() { connectedServerID = nil savedToken = nil serverIp = nil serverPort = nil } }
-
-
-
@@ -0,0 +1,83 @@// 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 RoonKit import SwiftUI @Observable final class ZoneDataModel { let conn: Connection private var zoneID: TransportService.Zone.ID? = nil private var _zones: [TransportService.Zone.ID: TransportService.Zone] = [:] private(set) var zones: [TransportService.Zone.ID: TransportService.Zone] { get { _zones } set { _zones = newValue if let zoneID = zoneID, _zones[zoneID] != nil { return } zoneID = _zones.first?.key } } var zone: TransportService.Zone? { get { if let zoneID = zoneID { zones[zoneID] } else { nil } } set { zoneID = newValue?.id } } init(conn: Connection) { self.conn = conn } func watchChanges() async throws { let msg = try await conn.request( Moo(subscribeZoneChange: .init(subscriptionID: UUID().uuidString)) ) let body = try TransportService.SubscribeZoneChangesResponse(msg) zones = body.zones.reduce(into: [:]) { dict, zone in dict[zone.id] = zone } for try await message in await conn.messages.compactMap({ TransportService.ZoneChangeEvent($0) }) { for added in message.addedZones { zones[added.id] = added } for changed in message.changedZones { zones[changed.id] = changed } for removedID in message.removedZoneIDs { zones[removedID] = nil } } } }
-
-
macos/plac/Environments/placConnection.swift > macos/RoonKit/Sources/RoonKit/ServerDiscoveryRequest.swift
-
@@ -14,15 +14,17 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 import SwiftUI import Foundation extension EnvironmentValues { /// Connection to Roon server, if established. @Entry var placConnection: Connection? = nil } struct ServerDiscoveryRequest { let queryServiceId: String = "00720724-5143-4a9b-abac-0e50cba674bb" extension View { func placConnection(_ connection: Connection) -> some View { environment(\.placConnection, connection) func data() -> Data { let msg = Sood( kind: .query, properties: ["query_service_id": queryServiceId] ) return msg.data() } }
-
-
-
@@ -14,358 +14,104 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 import PlacKit import Network import RoonKit import SwiftUI import os enum MainViewState { case loading case found(Connection) case findError(PlacKit.plac_discovery_scan_result_code) case null_pointer case not_found case found(RoonKit.Connection) case error(any Error) } struct MainView: View { var serverId: CoreServer.ID? @State private var conn: ConnectionDataModel @State private var state: MainViewState = .loading @AppStorage("PlacApp.token") private var savedToken: String? @AppStorage("PlacApp.serverIp") private var serverIp: String? @AppStorage("PlacApp.serverPort") private var serverPort: Int? private let logger = Logger() private let queue = DispatchQueue(label: "plac.server-find") init(serverID: String) { self.conn = ConnectionDataModel(serverID: serverID) } var body: some View { VStack { switch state { case .loading: switch conn.state { case .loading, .closed: // TODO: Create view for closed state ProgressView() case .found(let conn): WithServer() .onAuthorize { token in savedToken = token } .placConnection(conn) case .findError(_): case .open(let conn): ConnectedView(conn: conn) .environment(self.conn) case .error(RoonKit.ServerLookupError.notFound): ContentUnavailableView { Label( "Server not found", systemImage: "exclamationmark.magnifyingglass" ) } description: { // TODO: Tidy // TODO: Display ID Text("Server not found") } case .not_found: case .error(_): ContentUnavailableView { Label( "Server not found", "Failed to connect", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text("Selected server does not found on local network.") } case .null_pointer: ContentUnavailableView { Label( "Unable to connect", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text( "Failed to retrieve server connection information. Out of memory." ) } } } .onAppear { if let serverId = serverId { queue.async { let ptr = findServer(serverId) guard let ptr = ptr else { logger.error("Failed to scan servers: Out of memory") DispatchQueue.main.async { state = .null_pointer } return } let result = ScanResult(ptr: ptr) if result.code != PlacKit.PLAC_DISCOVERY_SCAN_RESULT_OK { DispatchQueue.main.async { state = .findError(result.code) } return } if result.entries.count < 1 { DispatchQueue.main.async { state = .not_found } return } DispatchQueue.main.async { let entry = result.entries[0] serverIp = entry.ipAddress serverPort = Int(entry.port) state = .found( Connection(server: entry, token: savedToken) ) } // TODO: Display error? Text("Server not found") } } } } func findServer(_ serverId: String) -> UnsafeMutablePointer< PlacKit.plac_discovery_scan_result >? { if let ip = serverIp, let port = serverPort { return PlacKit.plac_discovery_resolve(serverId, ip, UInt16(port)) } else { return PlacKit.plac_discovery_find(serverId) .task { await conn.connect() } .focusedValue(conn) } } enum ConnectionState<TZone: Zone & Identifiable> { case authorizing case authorized(zone_id: String?, zones: [String: TZone]) } struct WithServer: View { @Environment(\.placConnection) var conn: Connection? @State private var zone_id: String? = nil @State private var zones: [String: CoreZone] = [:] private let queue = DispatchQueue(label: "plac.event-loop") private let actionQueue = DispatchQueue(label: "plac.WithServer.actionQueue") var onAuthorizeAction: ((_ token: String) -> Void)? var body: some View { let state: Binding<ConnectionState<CoreZone>> = .init( get: { if let zone_id = zone_id { .authorized(zone_id: zone_id, zones: zones) } else { .authorizing } }, set: { newValue in switch newValue { case .authorizing: zone_id = nil case .authorized(let newZoneId, _): zone_id = newZoneId } } ) ConnectedView(state: state) .task { do { try await startLoop() } catch { Logger().info("Cancelled message loop") } } .focusedSceneValue(conn) } func startLoop() async throws { try await withTaskCancellationHandler( operation: { try await withUnsafeThrowingContinuation { (cont: UnsafeContinuation<Void, any Error>) in queue.async { let logger = Logger() logger.debug("Starting getEvent loop...") while true { if Task.isCancelled { cont.resume(throwing: CancellationError()) return } let event = conn?.getEvent() if Task.isCancelled { cont.resume(throwing: CancellationError()) return } guard let event = event else { logger.debug("Got null event, quitting") cont.resume() return } struct ConnectedView: View { @State private var model: ZoneDataModel switch event { case .connected(let ev): onAuthorizeAction?(ev.token) conn?.subscribeZoneChanges() break case .connectionError(let ev): logger.error("Failed to connect: \(ev.code.rawValue)") break case .zoneList(let ev): DispatchQueue.main.async { for zone in ev.addedZones { zones[zone.id] = zone } for zone in ev.changedZones { zones[zone.id] = zone } for id in ev.removedZoneIds { zones.removeValue(forKey: id) if zone_id == id { zone_id = nil } } if zone_id == nil { zone_id = zones.first?.value.id } } break } } } } }, onCancel: { actionQueue.sync { NSApp.terminate(nil) } // FIXME: The below "close()" crashes. Once fixed, remove the above "terminate()" call. // actionQueue.async { // conn?.close() // } } ) init(conn: Connection) { model = ZoneDataModel(conn: conn) } } extension WithServer { func onAuthorize(_ handler: @escaping (String) -> Void) -> WithServer { var new = self new.onAuthorizeAction = handler return new } } struct ConnectedView<TZone: Zone & Identifiable>: View { @Environment(\.placConnection) var conn: Connection? @Binding var state: ConnectionState<TZone> private let actionQueue = DispatchQueue( label: "plac.ConnectedView.actionQueue" ) var body: some View { switch state { case .authorized(let zone_id, let zones): let zoneIdProxy: Binding<String?> = .init( get: { zone_id }, set: { newId in state = .authorized(zone_id: newId, zones: zones) } ) VStack(spacing: 0) { NavigationSplitView { List { NavigationLink { Text("LIBRARY") } label: { Text("Library") } VStack(spacing: 0) { NavigationSplitView { List { NavigationLink { Text("LIBRARY") } label: { Text("Library") } NavigationLink { Text("DISCOVER") } label: { Text("Discover") } NavigationLink { Text("DISCOVER") } label: { Text("Discover") } } detail: { } } detail: { } Divider() Divider() PlaybackBar<TZone>(zone_id: zoneIdProxy, zones: zones) .onPlaybackAction { action in guard let zone_id = zone_id, let conn = conn else { return } guard let zone = zones[zone_id] as? CoreZone else { return } await withUnsafeContinuation { cont in actionQueue.async { conn.control(zone, action) cont.resume() } } } .frame(maxWidth: .infinity) } case .authorizing: ContentUnavailableView { Label( "Requesting Access", systemImage: "lock.circle.dotted" ) .symbolEffect(.pulse) } description: { Text( "Connecting to your Roon Server. Enable access at \"Settings > Extensions\" in official Roon Client application." ) PlaybackBar() .frame(maxWidth: .infinity) .environment(model) } .task { do { try await model.watchChanges() } catch { Logger().error("Failed to watch zone changes: \(error)") } } } } #Preview("Layout") { @Previewable @State var state: ConnectionState<MockZone> = .authorized( zone_id: "foo", zones: [ "foo": MockZone( id: "foo", name: "Foo", playback: PlacKit.PLAC_TRANSPORT_PLAYBACK_PLAYING, nowPlaying: MockNowPlaying( line1: "My great song", line2: "Some untrusted artist", seek: (5, 3 * 60 + 30) ) ), "bar": MockZone( id: "bar", name: "Bar", playback: PlacKit.PLAC_TRANSPORT_PLAYBACK_STOPPED ), ] ) ConnectedView<MockZone>(state: $state) } #Preview("Authorizing") { @Previewable @State var state: ConnectionState<MockZone> = .authorizing ConnectedView<MockZone>(state: $state) }
-
-
-
@@ -14,15 +14,15 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 import PlacKit import RoonKit import SwiftUI import os struct ServerDiscovery: View { @State private var servers: [CoreServer] = [] @State private var servers: [RoonKit.Server] = [] @State private var status: ServerDiscoverySceneListStatus = .loading var onConnectAction: ((CoreServer) -> Void)? var onConnectAction: ((RoonKit.Server) -> Void)? private let logger = Logger() private let queue = DispatchQueue(label: "plac.server-discovery")
-
@@ -32,49 +32,33 @@ ServerDiscoverySceneList(servers: servers, status: status, onScan: { scan() Task { await scan() } } ) .onConnect { server in onConnectAction?(server) } .onAppear { scan() .task { await scan() } } private func scan() { private func scan() async { 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 } do { servers = try await RoonKit.Server.list() status = .loaded } catch { status = .failed(error) } } } extension ServerDiscovery { func onConnect(_ handler: @escaping (CoreServer) -> Void) func onConnect(_ handler: @escaping (RoonKit.Server) -> Void) -> ServerDiscovery { var new = self
-
@@ -86,15 +70,14 @@enum ServerDiscoverySceneListStatus { case loading case loaded case failed(PlacKit.plac_discovery_scan_result_code) case null_pointer case failed(any Error) } struct ServerDiscoverySceneList<TServer: Server & Identifiable>: View { var servers: [TServer] struct ServerDiscoverySceneList: View { var servers: [RoonKit.Server] var status: ServerDiscoverySceneListStatus var onConnectAction: ((TServer) -> Void)? var onConnectAction: ((RoonKit.Server) -> Void)? var onScan: (() -> Void)? = nil @ScaledMetric private var lineSpace = 12
-
@@ -110,7 +93,7 @@ }var body: some View { NavigationSplitView { List(servers) { (server: TServer) in List(servers) { server in NavigationLink { VStack(alignment: .leading, spacing: lineSpace) { HStack {
-
@@ -139,8 +122,7 @@ VStack(alignment: .leading) {Text("IP address") .font(.headline) // TODO: Display IP address Text(server.id) Text("\(server.host):\(String(server.port, radix: 10))") .font(.subheadline) }
-
@@ -184,18 +166,10 @@ }} 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: case .failed(let error): switch error { case RoonKit.ServerLookupError.socketError(_), RoonKit.ServerLookupError.connectionClosed: ContentUnavailableView { Label( "Unable to scan",
-
@@ -204,16 +178,7 @@ )} 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: default: ContentUnavailableView { Label( "Unable to scan",
-
@@ -222,33 +187,6 @@ )} 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.") } default: ContentUnavailableView { Label( "Unable to scan", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text("Unexpected error happened (platform misbehaving).") } } } }
-
@@ -256,7 +194,7 @@ }} extension ServerDiscoverySceneList { func onConnect(_ handler: @escaping (TServer) -> Void) func onConnect(_ handler: @escaping (RoonKit.Server) -> Void) -> ServerDiscoverySceneList { var new = self
-
@@ -268,10 +206,28 @@#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"), let servers: [RoonKit.Server] = [ .init( id: "foo", name: "Foo", version: "foo-0.0", host: "192.0.2.10", port: 9003 ), .init( id: "bar", name: "Bar", version: "bar-0.0", host: "198.51.100.8", port: 9003 ), .init( id: "baz", name: "Baz", version: "baz-0.0", host: "203.0.113.100", port: 8000 ), ] ServerDiscoverySceneList(servers: servers, status: .loaded)
-
@@ -280,26 +236,31 @@ logger.debug("ServerDiscoverySceneList.refreshable")} } #Preview("No servers") { ServerDiscoverySceneList<MockServer>(servers: [], status: .loaded) ServerDiscoverySceneList(servers: [], status: .loaded) } #Preview("Loading") { ServerDiscoverySceneList<MockServer>(servers: [], status: .loading) } #Preview("OOM") { ServerDiscoverySceneList<MockServer>(servers: [], status: .null_pointer) ServerDiscoverySceneList(servers: [], status: .loading) } #Preview("SocketError") { ServerDiscoverySceneList<MockServer>( ServerDiscoverySceneList( servers: [], status: .failed(PlacKit.PLAC_DISCOVERY_SCAN_RESULT_SOCKET_ERROR) status: .failed( RoonKit.ServerLookupError.socketError( RoonKit.SocketError.failedToGetDescriptor ) ) ) } #Preview("NetworkUnavailable") { ServerDiscoverySceneList<MockServer>( private enum MockUnknownError: Error { case doNotUseThisInApplicationCode } #Preview("Unknown error") { ServerDiscoverySceneList( servers: [], status: .failed(PlacKit.PLAC_DISCOVERY_SCAN_RESULT_NETWORK_UNAVAILABLE) status: .failed(MockUnknownError.doNotUseThisInApplicationCode) ) }
-
-
-
@@ -14,49 +14,26 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 import PlacKit import RoonKit import SwiftUI struct Artwork: View { @Environment(\.placConnection) var conn: Connection? @Environment(ConnectionDataModel.self) var conn let imageKey: String let width: CGFloat let height: CGFloat private var url: URL? { guard let conn = conn else { return nil } let req = PlacKit.plac_image_get_options_make() defer { PlacKit.plac_image_get_options_release(req) } PlacKit.plac_image_get_options_set_content_type( req, PlacKit.PLAC_IMAGE_CONTENT_TYPE_PNG conn.imageURL( req: .init( key: imageKey, format: .png, scale: .fit, width: UInt(width), height: UInt(height) ) ) PlacKit.plac_image_get_options_set_size( req, PlacKit.PLAC_IMAGE_SCALING_METHOD_FIT, Int(width), Int(height) ) guard let builtUrl = PlacKit.plac_connection_get_image_url( conn.ptr, imageKey, req ) else { return nil } defer { builtUrl.deallocate() } return URL(string: String(cString: builtUrl)) } var body: some View {
-
-
-
@@ -14,23 +14,18 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 import PlacKit import OSLog import RoonKit import SwiftUI struct PlaybackBar<TZone: Zone & Identifiable>: View { @Binding var zone_id: String? struct PlaybackBar: View { @Environment(ZoneDataModel.self) var model: ZoneDataModel var zones: [String: TZone] = [:] @State private var isPerformingAction = false private var zone: TZone? { zones.first(where: { _, zone in zone.id == zone_id })?.value private var zone: TransportService.Zone? { model.zone } var onPlaybackActionHandler: ((PlaybackAction) async -> Void)? @State private var isPerformingAction = false var body: some View { VStack(alignment: .leading, spacing: 14) {
-
@@ -41,8 +36,8 @@ .frame(width: 48, height: 48)} VStack(alignment: .leading, spacing: 2) { let line1 = zone?.nowPlaying?.twoline.0 ?? " " let line2 = zone?.nowPlaying?.twoline.1 ?? " " let line1 = zone?.nowPlaying?.doubleLine.line1 ?? " " let line2 = zone?.nowPlaying?.doubleLine.line2 ?? " " Text(line1) .font(.headline)
-
@@ -59,63 +54,64 @@ HStack {HStack { Button { Task { await performAction(.Prev) await performAction(.previous) } } label: { Label("Previous", systemImage: "backward.end.fill") } .font(.title3) .disabled(isPerformingAction) .disabled(zone?.isAllowedTo(.Prev) != true) .disabled(!(zone?.isPreviousAllowed ?? false)) if zone?.playback == PlacKit.PLAC_TRANSPORT_PLAYBACK_PLAYING { if zone?.state == .playing { Button { Task { await performAction(.Pause) await performAction(.pause) } } label: { Label("Pause", systemImage: "pause.fill") } .font(.title) .disabled(isPerformingAction) .disabled(zone?.isAllowedTo(.Pause) != true) .disabled(!(zone?.isPauseAllowed ?? false)) } else { Button { Task { await performAction(.Play) await performAction(.play) } } label: { Label("Play", systemImage: "play.fill") } .font(.title) .disabled(isPerformingAction) .disabled(zone?.isAllowedTo(.Play) != true) .disabled(!(zone?.isPlayAllowed ?? false)) } Button { Task { await performAction(.Next) await performAction(.next) } } label: { Label("Next", systemImage: "forward.end.fill") } .font(.title3) .disabled(isPerformingAction) .disabled(zone?.isAllowedTo(.Next) != true) .disabled(!(zone?.isNextAllowed ?? false)) } .labelStyle(.iconOnly) .buttonStyle(.borderless) Spacer() if zones.count > 0 && zone_id != nil { Picker("Zone", selection: $zone_id) { if model.zones.count > 0 && zone != nil { @Bindable var model = model Picker("Zone", selection: $model.zone) { ForEach( zones.values.sorted(by: { model.zones.values.sorted(by: { $0.id < $1.id }) ) { zone in Text(zone.name).tag(zone.id as String?) ) { (zone: TransportService.Zone) in Text(zone.displayName).tag(zone) } } .scaledToFit()
-
@@ -126,58 +122,24 @@ .padding([.horizontal], 12).padding([.vertical], 10) } private func performAction(_ action: PlaybackAction) async { guard let handler = onPlaybackActionHandler else { private func performAction(_ action: TransportService.ControlAction) async { guard let zone = zone else { return } isPerformingAction = true await handler(action) do { let msg = try await model.conn.request( try Moo(control: .init(zoneID: zone.id, control: action)), timeout: .seconds(5) ) isPerformingAction = false } } _ = try TransportService.ControlResponse(msg) } catch { Logger().warning("Failed to send control message: \(error)") } extension PlaybackBar { func onPlaybackAction(_ handler: @escaping (PlaybackAction) async -> Void) -> PlaybackBar { var new = self new.onPlaybackActionHandler = handler return new isPerformingAction = false } } #Preview("PlaybackBar") { @Previewable @State var zone_id: String? = "foo" let zones: [String: MockZone] = [ "foo": MockZone( id: "foo", name: "Foo", playback: PlacKit.PLAC_TRANSPORT_PLAYBACK_PLAYING, nowPlaying: MockNowPlaying( line1: "My great song", line2: "Some untrusted artist", seek: (5, 3 * 60 + 30), imageKey: "foo" ), allowedActions: [.Play, .Pause, .Prev] ), "bar": MockZone( id: "bar", name: "Bar", playback: PlacKit.PLAC_TRANSPORT_PLAYBACK_STOPPED, allowedActions: [.Play, .Pause, .Prev, .Next] ), ] PlaybackBar(zone_id: $zone_id, zones: zones) .onPlaybackAction { action in do { try await Task.sleep(for: .seconds(1)) print(action) } catch {} } }
-
-
-
@@ -14,32 +14,24 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 import RoonKit import SwiftUI struct ConnectionCommands: Commands { var onDisconnect: (() -> Void)? @FocusedValue(Connection.self) private var conn: Connection? @FocusedValue(ConnectionDataModel.self) private var conn: ConnectionDataModel? var body: some Commands { CommandGroup(after: .appInfo) { Button("Disconnect", systemImage: "powercode") { // FIXME: "disconnect" call panics with "incorrect alignment" error (at call to "deinitListeners") if let conn = conn { conn.close() guard let conn = conn else { return } onDisconnect?() } .disabled(true) } CommandGroup(before: .appTermination) { Button("Disconnect and Quit Plac") { onDisconnect?() NSApp.terminate(nil) Task { await conn.disconnect() } } .disabled(!(conn?.disconnectable ?? false)) } } }
-
@@ -49,34 +41,20 @@ struct placApp: App {@AppStorage("PlacApp.connectedServerId") private var connectedServerId: String? @AppStorage("PlacApp.token") private var savedToken: String? @AppStorage("PlacApp.serverIp") private var serverIp: String? @AppStorage("PlacApp.serverPort") private var serverPort: Int? var body: some Scene { WindowGroup("Plac", id: "main-window") { if let connectedServerId = connectedServerId { MainView(serverId: connectedServerId) MainView(serverID: connectedServerId) } else { ServerDiscovery() .onConnect { server in connectedServerId = server.id clearConnectionState() } } } .commands { ConnectionCommands(onDisconnect: { connectedServerId = nil clearConnectionState() }) ConnectionCommands() } .defaultSize(width: 800, height: 600) } func clearConnectionState() { savedToken = nil serverIp = nil serverPort = nil } }
-