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