Zum Inhalt

apple/PhysarumApp/Sources/Remote/RemoteDiscovery.swift

import Foundation
import Network

/// The Bonjour service the ATV advertises and the phone browses for. Shared by both sides.
enum RemoteService {
    static let type = "_physarum._tcp"
}

/// A discovered Apple TV, surfaced to the picker UI.
struct RemoteEndpoint: Identifiable, Hashable {
    let id: String          // stable identity for the browse result (the Bonjour instance name)
    let name: String        // display name shown in the picker
    let endpoint: NWEndpoint
}

/// Health of an ongoing browse, so the UI can show searching vs. a real failure instead of an
/// indefinite spinner.
enum DiscoveryState: Equatable { case searching, ready, failed }

/// Abstracts how the phone finds an ATV. The iOS impl below browses the Bonjour service with
/// `NWBrowser`; a system-picker (DeviceDiscoveryUI is built for the opposite tvOS→phone direction)
/// or a macOS variant can swap in behind this protocol later.
protocol RemoteDiscovering: AnyObject {
    func start(onResults: @escaping ([RemoteEndpoint]) -> Void, onState: @escaping (DiscoveryState) -> Void)
    func connect(to endpoint: RemoteEndpoint) -> NWConnection
    func stop()
}

/// Browses `_physarum._tcp` over the local network and yields the ATVs found. Results + state arrive
/// on the main queue. Requires `NSLocalNetworkUsageDescription` + `NSBonjourServices` (the system
/// shows the local-network consent prompt on first browse).
final class BonjourDiscovery: RemoteDiscovering {
    private var browser: NWBrowser?

    func start(onResults: @escaping ([RemoteEndpoint]) -> Void, onState: @escaping (DiscoveryState) -> Void) {
        let browser = NWBrowser(for: .bonjour(type: RemoteService.type, domain: nil), using: .tcp)
        browser.stateUpdateHandler = { state in
            // .failed = the browse can't run (e.g. local-network denied) → surface it so the UI stops
            // spinning. .waiting is transient (waiting for connectivity); leave it as still-searching.
            let ds: DiscoveryState?
            switch state {
            case .ready: ds = .ready
            case .failed: ds = .failed
            default: ds = nil
            }
            if let ds { DispatchQueue.main.async { onState(ds) } }
        }
        browser.browseResultsChangedHandler = { results, _ in
            let endpoints = results.compactMap { (result: NWBrowser.Result) -> RemoteEndpoint? in
                guard case let .service(name, _, _, _) = result.endpoint else { return nil }
                return RemoteEndpoint(id: name, name: name, endpoint: result.endpoint)
            }
            DispatchQueue.main.async { onResults(endpoints) }
        }
        browser.start(queue: .main)
        self.browser = browser
    }

    func connect(to endpoint: RemoteEndpoint) -> NWConnection {
        NWConnection(to: endpoint.endpoint, using: .tcp)
    }

    func stop() {
        browser?.cancel()
        browser = nil
    }
}