Changes
5 changed files (+286/-174)
-
-
@@ -110,6 +110,8 @@ public enum ConnectError: Error {case alreadyStarted case unexpectedInfoReceived case serverIDMismatch case cancelled case connectionNotReady(any Error) } public func connect() async throws {
-
@@ -125,7 +127,18 @@logger.debug("Starting WebSocket connection") conn.start(queue: queue) try await conn.ready() do { try await conn.ready() } catch { switch error { case NWConnection.ConnectionNotReadyError.cancelled: throw ConnectError.cancelled case NWConnection.ConnectionNotReadyError.failed(let error): throw ConnectError.connectionNotReady(error) default: throw error } } logger.debug("WebSocket connection is ready") self.readLoop = startReadLoop()
-
-
-
@@ -15,19 +15,16 @@ //// SPDX-License-Identifier: Apache-2.0 import Foundation import Network import OSLog import RoonKit import SwiftUI enum ConnectionState { case loading case open(Communicatable & Connectable, (String, UInt16)) case closed case error(any Error) } private enum ServerQuery { case id(String) case info(Server) case connecting case connected(Communicatable & Connectable) case waitingToReconnect case failed(any Error) } #if os(macOS)
-
@@ -36,145 +33,161 @@ #elselet systemName = UIDevice.current.systemName #endif @MainActor @Observable final class ConnectionDataModel { private static let reconnectionWindow: ContinuousClock.Instant.Duration = .seconds(5) private(set) var state: ConnectionState = .connecting @ObservationIgnored @AppStorage("PlacApp.connectedServerId") private var connectedServerID: String? private var loop: Task<Void, Never>? = nil @ObservationIgnored @AppStorage("PlacApp.token") private var savedToken: String? let serverID: String let host: String let port: UInt16 @ObservationIgnored @AppStorage("PlacApp.serverIp") private var serverIp: String? private var token: String? = nil @ObservationIgnored @AppStorage("PlacApp.serverPort") private var serverPort: Int? /// Initialize data model with resolved server. convenience init( server: Server, onConnect: ((_ token: String) -> Void)? = nil ) { self.init( serverID: server.id, host: server.host, port: server.port, onConnect: onConnect ) } var state: ConnectionState = .closed var disconnectable: Bool { switch state { case .open(_, _): true default: false } convenience init( _ other: ConnectionDataModel, onConnect: ((_ token: String) -> Void)? = nil ) { self.init( serverID: other.serverID, host: other.host, port: other.port, token: other.token, onConnect: onConnect ) } private let server: ServerQuery init( serverID: String, host: String, port: UInt16, token: String? = nil, onConnect: ((_ token: String) -> Void)? = nil ) { self.serverID = serverID self.host = host self.port = port self.loop = Task { [weak self] in while true { do { let conn: Connection let connectionStartsAt: ContinuousClock.Instant /// 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) } if let self = self { state = .connecting /// Initialize data model with resolved server. /// /// Model do not perform server discovery. init(server: Server) { self.server = .info(server) } let ext = RegistryService.Extension( id: "jp.pocka.plac.apple", displayName: "Plac for \(systemName)", version: "0.0.1", publisher: "Shota FUJI", email: "pockawoooh@gmail.com", requiredServices: [ TransportService.id, BrowseService.id, ImageService.id, ], token: token, ) func connect() async { if case .loading = state { return } conn = try await Connection( id: serverID, host: host, port: port, ext: ext ) do { state = .loading state = .connected(conn) let serverID: String let host: String let port: UInt16 switch server { case .id(let id): serverID = id connectionStartsAt = ContinuousClock.now if connectedServerID != .some(id) { clearCache() } else if let serverIp = serverIp, let serverPort = serverPort { host = serverIp port = UInt16(serverPort) break } self.token = await conn.token let server = try await Server(id: id) if let token = self.token { onConnect?(token) } } else { return } host = server.host port = server.port case .info(let server): if connectedServerID != .some(server.id) { clearCache() } await conn.lifetime() serverID = server.id host = server.host port = server.port } let connectionEndsAt = ContinuousClock.now let ext = RegistryService.Extension( id: "jp.pocka.plac.apple", displayName: "Plac for \(systemName)", version: "0.0.1", publisher: "Shota FUJI", email: "pockawoooh@gmail.com", requiredServices: [ TransportService.id, BrowseService.id, ImageService.id, ], token: savedToken, ) if let self = self { state = .waitingToReconnect } let conn = try await Connection( id: serverID, host: host, port: port, ext: ext ) Logger().debug("Connection closed, reconnecting after interval") state = .open(conn, (host, port)) try await Task.sleep( for: Self.reconnectionWindow - (connectionEndsAt - connectionStartsAt) ) } catch { switch error { // These error happens when a target server is down. // This does not catch ETIMEDOUT; we don't want our app to // unnecessarily access network interface. Most of the time // that error code returned, is server being down or rebooting. case Connection.ConnectError.connectionNotReady( NWError.posix(.ECONNREFUSED) ), Connection.ConnectError.connectionNotReady( NWError.posix(.ECONNABORTED) ): Logger().info("Connection refused, retrying after 3 seconds") connectedServerID = serverID serverIp = host serverPort = Int(port) savedToken = await conn.token if let self = self { state = .waitingToReconnect } await conn.lifetime() do { try await Task.sleep(for: .seconds(3)) continue } catch { // Abort loop on cancellation return } default: Logger().warning("Connection error: \(error)") if let self = self { state = .failed(error) } } state = .closed } catch { state = .error(error) return } } } } func disconnect() async { switch state { case .open(let conn, _): await conn.disconnect() clearCache() state = .closed case .loading: return default: state = .closed deinit { Logger().debug("Deinitializing ConnectionDataModel") if let loop = self.loop { loop.cancel() } } func imageURL(req: ImageService.GetRequest) -> URL? { switch state { case .open(_, let (host, port)): URL(roonImage: req, host: host, port: port) default: nil } } private func clearCache() { connectedServerID = nil savedToken = nil serverIp = nil serverPort = nil URL(roonImage: req, host: host, port: port) } }
-
-
-
@@ -17,38 +17,115 @@import RoonKit import SwiftUI struct ConnectionCommands: Commands { @FocusedValue(ConnectionDataModel.self) private var conn: ConnectionDataModel? /// SwitUI's Commands are not designed to pass State or such via initializer. /// In doing that so, closures capture the environment thus leaks passed States. /// As `FocusedValue` cannot mutate the value itself, this class wraps the model /// object and mimics state manipulation. @MainActor @Observable private final class ConnectionDataModelWrapper { var model: ConnectionDataModel? init(_ model: ConnectionDataModel? = nil) { self.model = model } } private struct ConnectionCommands: Commands { @FocusedValue(ConnectionDataModelWrapper.self) private var model: ConnectionDataModelWrapper? var body: some Commands { CommandGroup(after: .appInfo) { Button("Disconnect", systemImage: "powercode") { guard let conn = conn else { return } Task { await conn.disconnect() } model?.model = nil } .disabled(!(conn?.disconnectable ?? false)) .disabled(model?.model == nil) } } } @main struct PlacApp: App { @AppStorage("PlacApp.connectedServerId") private var connectedServerId: String? @State private var model: ConnectionDataModelWrapper = ConnectionDataModelWrapper(Self.storedConnection()) private static func storedConnection() -> ConnectionDataModel? { let port = UserDefaults.standard.integer(forKey: StorageKeys.serverPort) guard let serverID = UserDefaults.standard.string( forKey: StorageKeys.serverID ), let host = UserDefaults.standard.string(forKey: StorageKeys.serverHost), port > 0 else { return nil } return ConnectionDataModel( serverID: serverID, host: host, port: UInt16(port), token: UserDefaults.standard.string(forKey: StorageKeys.extensionToken), onConnect: { token in Self.saveToken(token) } ) } private static func saveConnection( serverID: String? = nil, host: String? = nil, port: UInt16? = nil ) { UserDefaults.standard.set(serverID, forKey: StorageKeys.serverID) UserDefaults.standard.set(host, forKey: StorageKeys.serverHost) UserDefaults.standard.set(Int(port ?? 0), forKey: StorageKeys.serverPort) } private static func saveToken(_ token: String? = nil) { UserDefaults.standard.set(token, forKey: StorageKeys.extensionToken) } var body: some Scene { WindowGroup("Plac", id: "main-window") { if let connectedServerId = connectedServerId { ConnectionScreen(serverID: connectedServerId) if let model = model.model { ConnectionScreen( model: model, onDisconnect: { self.model.model = nil Self.saveToken() Self.saveConnection() }, onReconnect: { self.model.model = ConnectionDataModel( model, onConnect: { token in Self.saveToken(token) } ) } ) .focusedSceneValue(self.model) } else { ServerDiscoveryScreen() .onConnect { server in connectedServerId = server.id self.model.model = ConnectionDataModel( serverID: server.id, host: server.host, port: server.port, onConnect: { token in Self.saveToken(token) } ) Self.saveToken() Self.saveConnection( serverID: server.id, host: server.host, port: server.port ) } } }
-
-
-
@@ -0,0 +1,22 @@// 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 struct StorageKeys { static let serverID = "Plac.serverID" static let serverHost = "Plac.serverHost" static let serverPort = "Plac.serverPort" static let extensionToken = "Plac.token" }
-
-
-
@@ -18,66 +18,59 @@ import RoonKitimport SwiftUI struct ConnectionScreen: View { @State private var serverID: String @State private var conn: ConnectionDataModel private let conn: ConnectionDataModel private var onDisconnect: (() -> Void) private var onReconnect: (() -> Void) init(serverID: String) { self.conn = ConnectionDataModel(serverID: serverID) self.serverID = serverID init( model: ConnectionDataModel, onDisconnect: (@escaping () -> Void), onReconnect: (@escaping () -> Void) ) { self.conn = model self.onDisconnect = onDisconnect self.onReconnect = onReconnect } var body: some View { VStack { switch conn.state { case .closed: ContentUnavailableView { Label( "Connection closed", systemImage: "play.slash" ) } description: { Text("Connection to Roon server closed.") } actions: { Button { Task { await conn.connect() } case .connecting, .waitingToReconnect: VStack { Loading() Button(role: .cancel) { onDisconnect() } label: { Text("Connect") Text("Cancel") } } case .loading: Loading() case .open(let conn, let (host, port)): case .connected(let conn): ConnectedScreen(conn: conn) .environment(self.conn) .environment(ImageURLBuilderDataModel(host: host, port: port)) case .error(RoonKit.ServerLookupError.notFound): .environment( ImageURLBuilderDataModel(host: self.conn.host, port: self.conn.port) ) case .failed(RoonKit.ServerLookupError.notFound): ContentUnavailableView { Label( "Server not found", systemImage: "exclamationmark.magnifyingglass" ) } description: { Text("Server of ID=\(serverID) does not found.") Text("Server of ID=\(self.conn.serverID) does not found.") } actions: { Button(role: .cancel) { Task { await conn.disconnect() } onDisconnect() } label: { Text("Cancel") } Button { Task { await conn.connect() } onReconnect() } label: { Text("Retry") } } case .error(_): case .failed(_): ContentUnavailableView { Label( "Failed to connect",
-
@@ -87,19 +80,13 @@ } description: {Text("Unable to connect to server due to error.") } actions: { Button { Task { await conn.connect() } onReconnect() } label: { Text("Connect") } } } } .task { await conn.connect() } .focusedValue(conn) } }
-