import Foundation import MusubiCore import Network import Observation import OSLog /// Node side: advertises itself via Bonjour on the technical interface and accepts one control at a time. @MainActor @Observable public final class NodeServer { public private(set) var controlName: String? public private(set) var error: String? public private(set) var isListening = false /// Called for `apply`, `show` and `stop` from the control. public var onCommand: ((RemoteMessage) -> Void)? private let name: String private var interfaceName: String? private var retryTask: Task? private var listener: NWListener? private var link: Link? private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.node") private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote") public init(name: String) { self.name = name } public func start(interfaceName: String) async { stop() self.interfaceName = interfaceName guard let interface = await NetworkInterfaces.interface(named: interfaceName) else { error = "Technik-Schnittstelle \(interfaceName) nicht gefunden." log.error("Node: interface \(interfaceName, privacy: .public) not found") scheduleRetry() return } do { let listener = try NWListener(using: .musubi(interface: interface)) listener.service = NWListener.Service(name: name, type: RemoteProtocol.serviceType) listener.newConnectionHandler = { [weak self] connection in Task { @MainActor in self?.accept(connection) } } listener.stateUpdateHandler = { [weak self] state in Task { @MainActor in self?.listenerChanged(state) } } listener.start(queue: queue) self.listener = listener log.info("Node: advertising \(self.name, privacy: .public) on \(interfaceName, privacy: .public)") } catch { self.error = "Fernsteuerung lässt sich nicht starten." log.error("Node: listener failed: \(error.localizedDescription, privacy: .public)") } } public func stop() { retryTask?.cancel() retryTask = nil link?.cancel() link = nil listener?.cancel() listener = nil isListening = false controlName = nil } public func send(_ status: NodeStatus) { guard controlName != nil else { return } link?.send(.status(status)) } private func listenerChanged(_ state: NWListener.State) { switch state { case .ready: isListening = true error = nil case .failed(let failure): isListening = false if case .dns(let code) = failure, code == -65555 { // kDNSServiceErr_NoAuth: macOS has not (yet) allowed local network access. error = "macOS blockiert das lokale Netzwerk. Erlaube Musubi unter Systemeinstellungen › Datenschutz & Sicherheit › Lokales Netzwerk." } else { error = "Fernsteuerung gestört: \(failure.localizedDescription)" } log.error("Node: listener failed: \(failure.localizedDescription, privacy: .public)") scheduleRetry() case .cancelled: isListening = false default: break } } /// Tries again every 5 s, e.g. until local network access is granted or the cable is plugged in. private func scheduleRetry() { retryTask?.cancel() retryTask = Task { [weak self] in try? await Task.sleep(for: .seconds(5)) guard !Task.isCancelled, let self, let interfaceName else { return } await start(interfaceName: interfaceName) } } /// The newest control wins; the previous connection is closed. private func accept(_ connection: NWConnection) { link?.cancel() controlName = nil let link = Link(connection: connection, queue: queue) link.onMessage = { [weak self] in self?.received($0, from: link) } link.onState = { [weak self] state in guard let self, self.link === link else { return } switch state { case .ready: link.send(.hello(name: self.name, role: .node, version: RemoteProtocol.version)) case .failed, .cancelled: log.notice("Node: control \(self.controlName ?? "?", privacy: .public) disconnected") self.controlName = nil self.link = nil default: break } } self.link = link link.start() } private func received(_ message: RemoteMessage, from link: Link) { guard self.link === link else { return } switch message { case .hello(let name, _, _): controlName = name log.info("Node: controlled by \(name, privacy: .public)") case .apply, .show, .stop: onCommand?(message) case .status: break } } }