import Foundation import MusubiCore import Network import Observation import OSLog /// Control side: finds nodes via Bonjour and keeps a connection to each of them. @MainActor @Observable public final class NodeBrowser { @MainActor @Observable public final class Node: Identifiable { public nonisolated let id: String // Bonjour service name = node name public fileprivate(set) var isConnected = false public fileprivate(set) var status: NodeStatus? fileprivate var link: Link? fileprivate var endpoint: NWEndpoint? fileprivate var retryTask: Task? init(name: String) { id = name } public var name: String { id } public func send(_ message: RemoteMessage) { link?.send(message) } } public private(set) var nodes: [Node] = [] public private(set) var error: String? /// A node said hello (first contact or after a reconnect). public var onNodeConnected: ((Node) -> Void)? private let name: String private var browser: NWBrowser? private var interface: NWInterface? private var interfaceName: String? private var retryTask: Task? private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.control") 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." scheduleRestart() return } self.interface = interface let browser = NWBrowser(for: .bonjour(type: RemoteProtocol.serviceType, domain: nil), using: .musubi(interface: interface)) browser.browseResultsChangedHandler = { [weak self] results, _ in let found = results.compactMap { result -> (String, NWEndpoint)? in guard case .service(let name, _, _, _) = result.endpoint else { return nil } return (name, result.endpoint) } Task { @MainActor in self?.update(found) } } browser.stateUpdateHandler = { [weak self] state in Task { @MainActor in self?.browserChanged(state) } } browser.start(queue: queue) self.browser = browser log.info("Control: browsing on \(interfaceName, privacy: .public)") } private func browserChanged(_ state: NWBrowser.State) { switch state { case .ready: error = nil case .failed(let failure), .waiting(let failure): if case .dns(let code) = failure, code == -65555 { error = "macOS blockiert das lokale Netzwerk. Erlaube Musubi unter Systemeinstellungen › Datenschutz & Sicherheit › Lokales Netzwerk." } else { error = "Knotensuche gestört: \(failure.localizedDescription)" } log.error("Control: browser \(String(describing: failure), privacy: .public)") if case .failed = state { scheduleRestart() } default: break } } private func scheduleRestart() { 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) } } public func stop() { retryTask?.cancel() retryTask = nil browser?.cancel() browser = nil for node in nodes { node.retryTask?.cancel() node.link?.cancel() } nodes = [] } public func node(named name: String) -> Node? { nodes.first { $0.id == name } } private func update(_ found: [(String, NWEndpoint)]) { for (name, endpoint) in found where name != self.name { let node = self.node(named: name) ?? { let node = Node(name: name) nodes.append(node) nodes.sort { $0.id.localizedStandardCompare($1.id) == .orderedAscending } return node }() node.endpoint = endpoint if node.link == nil { connect(node) } } // Nodes that vanished from Bonjour stay listed while connected; the connection decides. } private func connect(_ node: Node) { guard let endpoint = node.endpoint else { return } node.retryTask?.cancel() let link = Link(connection: NWConnection(to: endpoint, using: .musubi(interface: interface)), queue: queue) link.onMessage = { [weak self, weak node] message in guard let self, let node, node.link === link else { return } received(message, from: node) } link.onState = { [weak self, weak node] state in guard let self, let node, node.link === link else { return } switch state { case .ready: link.send(.hello(name: self.name, role: .control, version: RemoteProtocol.version)) case .failed, .cancelled: if node.isConnected { log.notice("Control: lost \(node.id, privacy: .public)") } node.isConnected = false node.link = nil scheduleRetry(node) case .waiting(let reason): log.notice("Control: \(node.id, privacy: .public) waiting: \(reason.localizedDescription, privacy: .public)") link.cancel() default: break } } node.link = link link.start() // A connection can hang in preparing/waiting (e.g. local network access pending); start over. Task { [weak self, weak node] in try? await Task.sleep(for: .seconds(6)) guard let self, let node, node.link === link, !node.isConnected else { return } log.notice("Control: \(node.id, privacy: .public) not ready after 6 s, retrying") link.cancel() node.link = nil scheduleRetry(node) } } private func scheduleRetry(_ node: Node) { node.retryTask = Task { [weak self, weak node] in try? await Task.sleep(for: .seconds(3)) guard !Task.isCancelled, let self, let node, node.link == nil else { return } connect(node) } } private func received(_ message: RemoteMessage, from node: Node) { switch message { case .hello(let name, let role, _): guard role == .node else { return } node.isConnected = true log.info("Control: connected to \(name, privacy: .public)") onNodeConnected?(node) case .status(let status): node.status = status case .apply, .show, .stop: break } } }