|
|
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<Void, Never>?
|
|
|
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
|
|
|
}
|
|
|
}
|
|
|
}
|