You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift

148 lines
5.2 KiB

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<Void, Never>?
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 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()
guard let interface = await NetworkInterfaces.interface(named: interfaceName) else {
error = "Technik-Schnittstelle \(interfaceName) nicht gefunden."
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
if case .failed(let failure) = state {
Task { @MainActor in self?.error = "Knotensuche gestört: \(failure.localizedDescription)" }
}
}
browser.start(queue: queue)
self.browser = browser
error = nil
log.info("Control: browsing on \(interfaceName, privacy: .public)")
}
public func stop() {
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:
link.cancel()
default:
break
}
}
node.link = link
link.start()
}
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
}
}
}

Powered by TurnKey Linux.