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

188 lines
6.9 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 var interfaceName: String?
private var retryTask: Task<Void, Never>?
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
}
}
}

Powered by TurnKey Linux.