|
|
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
|
|
|
}
|
|
|
}
|
|
|
}
|