// // LocalNetworkService.swift // Yattee // // Handles local network discovery and communication for remote control. // Uses Bonjour/mDNS for discovery and TCP for message exchange. // import Foundation import Network #if os(iOS) import UIKit #endif /// Error thrown when a connection attempt times out. struct ConnectionTimeoutError: Error, LocalizedError { var errorDescription: String? { "Connection timed out" } } /// Service for discovering and communicating with other Yattee instances on the local network. @MainActor @Observable final class LocalNetworkService { // MARK: - Public State /// Discovered devices on the local network. private(set) var discoveredDevices: [DiscoveredDevice] = [] /// Whether the service is actively discovering other devices. private(set) var isDiscovering: Bool = false /// Whether the service is hosting (accepting connections). private(set) var isHosting: Bool = false /// Connected peer device IDs. private(set) var connectedPeers: Set = [] // MARK: - Configuration /// Bonjour service type for Yattee remote control. static let serviceType = "_yattee._tcp" /// This device's unique identifier. let deviceID: String /// This device's display name. var deviceName: String /// Current advertisement info (updated when player state changes). var currentAdvertisement: DeviceAdvertisement { DeviceAdvertisement( deviceID: deviceID, deviceName: deviceName, platform: .current, currentVideoTitle: _currentVideoTitle, currentChannelName: _currentChannelName, currentVideoThumbnailURL: _currentVideoThumbnailURL, isPlaying: _isPlaying ) } // MARK: - Private State private var _currentVideoTitle: String? private var _currentChannelName: String? private var _currentVideoThumbnailURL: URL? private var _isPlaying: Bool = false private var browser: NWBrowser? private var listener: NWListener? private var currentListenerID: UUID? // Track listener instance to ignore stale callbacks private var listenerStartTime: Date? // Track when listener was started for timing diagnostics private var connections: [String: NWConnection] = [:] // Outgoing connections (we initiated) private var incomingConnections: [String: NWConnection] = [:] // Incoming connections (by sender device ID) private var pendingConnections: [NWConnection] = [] // Incoming connections not yet identified private let queue = DispatchQueue(label: "stream.yattee.remotecontrol") // MARK: - Logging Helpers /// Log to LoggingService for comprehensive debugging. private func rcLog(_ operation: String, _ message: String, isWarning: Bool = false, isError: Bool = false, details: String? = nil) { let fullMessage = "[RemoteControl] \(operation) - \(message)" if isError { LoggingService.shared.logRemoteControlError(fullMessage, error: nil) } else if isWarning { LoggingService.shared.logRemoteControlWarning(fullMessage, details: details) } else { LoggingService.shared.logRemoteControl(fullMessage, details: details) } } /// Log debug-level message. Only logs if verbose remote control logging is enabled. private func rcDebug(_ operation: String, _ message: String) { guard UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return } let fullMessage = "[RemoteControl] \(operation) - \(message)" LoggingService.shared.logRemoteControlDebug(fullMessage) } /// Continuation for incoming commands stream. private var commandsContinuation: AsyncStream.Continuation? /// Stream of incoming remote control commands. private(set) var incomingCommands: AsyncStream! /// Task for periodic status summary logging. private var statusSummaryTask: Task? // MARK: - System Device Name /// Returns the system device name (used as placeholder/default when no custom name is set). static var systemDeviceName: String { #if os(macOS) return Host.current().localizedName ?? "Mac" #elseif os(iOS) return UIDevice.current.name #elseif os(tvOS) return "Apple TV" #else return "Yattee Device" #endif } // MARK: - Initialization init() { // Use a persistent device ID stored in UserDefaults if let storedID = UserDefaults.standard.string(forKey: "RemoteControl.DeviceID") { self.deviceID = storedID } else { let newID = UUID().uuidString UserDefaults.standard.set(newID, forKey: "RemoteControl.DeviceID") self.deviceID = newID } // Get device name - check for custom name first, then fall back to system name let customName = UserDefaults.standard.string(forKey: SettingsKey.remoteControlCustomDeviceName.rawValue) ?? "" if !customName.isEmpty { self.deviceName = customName } else { self.deviceName = Self.systemDeviceName } // Set up the incoming commands stream setupCommandsStream() } // Note: cleanup is handled when stopDiscovery() and stopHosting() are called // MARK: - Commands Stream private func setupCommandsStream() { incomingCommands = AsyncStream { [weak self] continuation in self?.commandsContinuation = continuation } } /// Reset the commands stream for a fresh consumer. /// Call this when restarting services to ensure the new command listener Task /// gets a fresh stream that will properly deliver messages. func resetCommandsStream() { // Finish the old continuation if any (signals end of old stream) commandsContinuation?.finish() commandsContinuation = nil // Create a fresh stream setupCommandsStream() rcLog("LIFECYCLE", "Commands stream reset for new consumer") } /// Update the device name from settings. /// Call this when the custom device name setting changes. func updateDeviceName() { let customName = UserDefaults.standard.string(forKey: SettingsKey.remoteControlCustomDeviceName.rawValue) ?? "" if !customName.isEmpty { deviceName = customName } else { deviceName = Self.systemDeviceName } rcLog("LIFECYCLE", "Device name updated to: \(deviceName)") } // MARK: - Discovery /// Devices that have disappeared from Bonjour but still have active connections. /// We'll probe these to check if they're still alive. private var devicesToProbe: Set = [] /// Devices that explicitly disappeared from Bonjour (REMOVED event). /// Used to treat health check timeouts as "dead" for these devices, since Bonjour removal is authoritative. private var bonjourDisappearedDevices: Set = [] /// Start discovering other Yattee devices on the local network. func startDiscovery() { guard !isDiscovering else { rcLog("DISCOVERY", "Already discovering, ignoring duplicate start") return } rcLog("DISCOVERY", "Starting device discovery", details: "serviceType=\(Self.serviceType), ownDeviceID=\(self.deviceID)") rcLog("DISCOVERY", "Current state: \(discoveredDevices.count) cached devices, \(connectedPeers.count) connected peers") let parameters = NWParameters() parameters.includePeerToPeer = true let browser = NWBrowser( for: .bonjour(type: Self.serviceType, domain: nil), using: parameters ) browser.stateUpdateHandler = { [weak self] state in Task { @MainActor [weak self] in self?.handleBrowserStateChange(state) } } browser.browseResultsChangedHandler = { [weak self] results, changes in Task { @MainActor [weak self] in self?.handleBrowseResultsChanged(results: results, changes: changes) } } browser.start(queue: queue) self.browser = browser isDiscovering = true } /// Stop discovering devices. func stopDiscovery() { guard isDiscovering else { rcDebug("DISCOVERY", "Not discovering, ignoring stop") return } rcLog("DISCOVERY", "Stopping device discovery", details: "Had \(discoveredDevices.count) devices, \(probedDevices.count) probed") browser?.cancel() browser = nil isDiscovering = false discoveredDevices.removeAll() probedDevices.removeAll() rcLog("DISCOVERY", "Discovery stopped and cleared") } /// Refresh Bonjour advertisement and discovery after returning from background. /// This restarts the NWListener and NWBrowser without closing existing connections. /// Note: Always unconditionally restarts both services since the caller (coordinator) /// decides to call this method only when both should be enabled. The current /// isHosting/isDiscovering state may be stale (e.g., after DefunctConnection errors). func refreshServices() { rcLog("LIFECYCLE", "Refreshing services after foreground transition") rcLog("LIFECYCLE", "Pre-refresh state: discovering=\(isDiscovering), hosting=\(isHosting), devices=\(discoveredDevices.count), connections=\(connections.count)/\(incomingConnections.count)") // Always restart browser (discovery) - state may be stale after background transition rcLog("LIFECYCLE", "Restarting browser") browser?.cancel() browser = nil isDiscovering = false // Don't clear discoveredDevices - preserve them during refresh // Clear probedDevices to allow re-probing all devices after background transition probedDevices.removeAll() startDiscovery() // Always restart listener (hosting) - state may be stale after DefunctConnection errors rcLog("LIFECYCLE", "Restarting listener") // Just recreate the listener service to re-advertise // Keep existing connections intact listener?.cancel() listener = nil isHosting = false startHosting() rcLog("LIFECYCLE", "Refresh complete") } /// Refresh only Bonjour discovery after returning from background. /// Use when hosting is intentionally disabled (non-discoverable or incognito mode). /// Also ensures hosting is stopped in case it was running before background transition. func refreshDiscoveryOnly() { rcLog("LIFECYCLE", "Refreshing discovery only (hosting disabled)") rcLog("LIFECYCLE", "Pre-refresh state: discovering=\(isDiscovering), hosting=\(isHosting), devices=\(discoveredDevices.count), connections=\(connections.count)") // Ensure hosting is stopped - it may have been running before conditions changed // (e.g., incognito mode enabled while in background) if isHosting { rcLog("LIFECYCLE", "Stopping hosting as part of discovery-only refresh") stopHosting() } // Always restart browser - state may be stale after background transition rcLog("LIFECYCLE", "Restarting browser") browser?.cancel() browser = nil isDiscovering = false // Don't clear discoveredDevices - preserve them during refresh // Clear probedDevices to allow re-probing all devices after background transition probedDevices.removeAll() startDiscovery() rcLog("LIFECYCLE", "Discovery refresh complete") } private func handleBrowserStateChange(_ state: NWBrowser.State) { switch state { case .setup: rcDebug("BROWSER", "State: setup") case .ready: rcLog("BROWSER", "State: ready - actively looking for \(Self.serviceType) services") case .failed(let error): rcLog("BROWSER", "State: FAILED - \(error.localizedDescription)", isError: true) isDiscovering = false case .cancelled: rcLog("BROWSER", "State: cancelled") isDiscovering = false case .waiting(let error): rcLog("BROWSER", "State: waiting - \(error.localizedDescription)", isWarning: true) @unknown default: rcLog("BROWSER", "State: unknown", isWarning: true) } } private func handleBrowseResultsChanged(results: Set, changes: Set) { // Log the changes for debugging var changeDescriptions: [String] = [] for change in changes { switch change { case .added(let result): if case let .service(name, _, _, _) = result.endpoint { changeDescriptions.append("ADDED:\(name)") } case .removed(let result): if case let .service(name, _, _, _) = result.endpoint { changeDescriptions.append("REMOVED:\(name)") } case .changed(old: _, new: let newResult, flags: let flags): if case let .service(name, _, _, _) = newResult.endpoint { changeDescriptions.append("CHANGED:\(name) flags=\(flags)") } case .identical: break @unknown default: changeDescriptions.append("UNKNOWN") } } rcLog("BROWSE", "Results changed: \(results.count) total, changes: [\(changeDescriptions.joined(separator: ", "))]") // Track which devices are currently visible var currentDeviceIDs: Set = [] var newDevices: [DiscoveredDevice] = [] for result in results { // Extract service name from endpoint var serviceName: String? var serviceType: String? var domain: String? if case let .service(name, type, dom, _) = result.endpoint { serviceName = name serviceType = type domain = dom // Skip our own service (including Bonjour conflict suffixes like "(2)") // When rapidly toggling remote control, Bonjour may not fully de-register // the old service before the new one starts, causing conflict naming if name == deviceID || (name.hasPrefix(deviceID) && name.contains("(")) { rcDebug("BROWSE", "Skipping own service: \(name)") continue } currentDeviceIDs.insert(name) // Device is back in Bonjour results - clear the disappeared flag bonjourDisappearedDevices.remove(name) } // Extract TXT record data - try to get it from metadata let metadata = result.metadata var txtDict: [String: String]? var txtDetails: String = "none" switch metadata { case .bonjour(let txtRecord): txtDict = parseTXTRecord(txtRecord) if let dict = txtDict { txtDetails = dict.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") } case .none: txtDetails = "no metadata yet" @unknown default: txtDetails = "unknown metadata type" } if let name = serviceName { rcLog("BROWSE", "Service: \(name)", details: "type=\(serviceType ?? "?"), domain=\(domain ?? "?"), TXT=[\(txtDetails)]") } if let txtDict, let advertisement = DeviceAdvertisement.from(txtRecord: txtDict) { // Skip if it's our own device if advertisement.deviceID != deviceID { let device = advertisement.toDiscoveredDevice() newDevices.append(device) // Cache the device info for when it reappears deviceInfoCache[device.id] = (name: device.name, platform: device.platform) // Track first-seen time for unresponsive device cleanup if deviceFirstSeen[device.id] == nil { deviceFirstSeen[device.id] = Date() } rcLog("BROWSE", "Added device from TXT: \(advertisement.deviceName) (\(advertisement.platform))") // Probe to establish outgoing connection even if we have TXT info // This ensures bidirectional connectivity (both outgoing and incoming connections) probeDeviceForInfo(device) } else { rcDebug("BROWSE", "Skipping own device from TXT: \(advertisement.deviceID)") } } else if let serviceName { // No TXT record yet - check cache, existing list, or create basic entry // Track first-seen time for unresponsive device cleanup if deviceFirstSeen[serviceName] == nil { deviceFirstSeen[serviceName] = Date() } if let cachedInfo = deviceInfoCache[serviceName] { // Use cached info (from previous discovery or message exchange) rcLog("BROWSE", "Using cached info for \(serviceName): \(cachedInfo.name) (\(cachedInfo.platform))") let existingDevice = discoveredDevices.first(where: { $0.id == serviceName }) let device = DiscoveredDevice( id: serviceName, name: cachedInfo.name, platform: cachedInfo.platform, currentVideoTitle: existingDevice?.currentVideoTitle, currentChannelName: existingDevice?.currentChannelName, currentVideoThumbnailURL: existingDevice?.currentVideoThumbnailURL, isPlaying: existingDevice?.isPlaying ?? false ) newDevices.append(device) // Probe to establish outgoing connection for bidirectional communication probeDeviceForInfo(device) } else if let existingDevice = discoveredDevices.first(where: { $0.id == serviceName }) { // Preserve existing device info (name, platform, etc. from previous messages) rcDebug("BROWSE", "Preserving existing info for \(serviceName): \(existingDevice.name)") newDevices.append(existingDevice) // Probe to establish outgoing connection for bidirectional communication probeDeviceForInfo(existingDevice) } else { // No TXT record and no cache - add with placeholder name and probe for real info // This ensures device appears in UI even if probe fails (mDNS issues, etc.) rcLog("BROWSE", "No TXT/cache for \(serviceName), adding placeholder and probing", isWarning: true) let device = DiscoveredDevice( id: serviceName, name: String(localized: "remoteControl.device.placeholder"), platform: .iOS, // Default, will be updated when we get a response currentVideoTitle: nil, currentChannelName: nil, currentVideoThumbnailURL: nil, isPlaying: false ) // Add to discovered devices so user can see and tap on it newDevices.append(device) // Auto-probe this device to get its real info - once we get a response, // the device will be updated with proper name via updateDiscoveredDeviceInfo probeDeviceForInfo(device) } } else { rcLog("BROWSE", "Service without name or TXT record", isWarning: true) } } // Find devices that disappeared from Bonjour (went to background, etc.) let previousDeviceIDs = Set(discoveredDevices.map { $0.id }) let disappearedDeviceIDs = previousDeviceIDs.subtracting(currentDeviceIDs) for deviceID in disappearedDeviceIDs { // Remove from probedDevices so it can be re-probed when it comes back probedDevices.remove(deviceID) // Mark as explicitly disappeared from Bonjour (authoritative signal) bonjourDisappearedDevices.insert(deviceID) let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("BROWSE", "Device disappeared: \(deviceName) (\(deviceID))", isWarning: true) // If this device has an active connection, probe it to check if still alive if connectedPeers.contains(deviceID) { devicesToProbe.insert(deviceID) rcLog("BROWSE", "Disappeared device \(deviceName) has active connection - probing health", isWarning: true) // Trigger async probe Task { await self.probeConnectionHealth(deviceID: deviceID) } } else { rcDebug("BROWSE", "Disappeared device \(deviceName) has no active connection") } } // Preserve devices that have active connections but aren't in browse results // (they may have connected before Bonjour detected them, or Bonjour is slow to update) var newDeviceIDs = Set(newDevices.map { $0.id }) for deviceID in connectedPeers { if !newDeviceIDs.contains(deviceID), let cachedInfo = deviceInfoCache[deviceID] { // Device has active connection but isn't in browse results - preserve it let existingDevice = discoveredDevices.first { $0.id == deviceID } let device = DiscoveredDevice( id: deviceID, name: cachedInfo.name, platform: cachedInfo.platform, currentVideoTitle: existingDevice?.currentVideoTitle, currentChannelName: existingDevice?.currentChannelName, currentVideoThumbnailURL: existingDevice?.currentVideoThumbnailURL, isPlaying: existingDevice?.isPlaying ?? false ) newDevices.append(device) newDeviceIDs.insert(deviceID) rcLog("BROWSE", "Preserving connected device not in results: \(cachedInfo.name)") } } // Also preserve devices we've recently communicated with (within timeout) // This handles the case where a device's connection was cleaned up but we still // know it exists (e.g., iPhone went to background briefly) let now = Date() var expiredDevices: [String] = [] for (deviceID, lastSeen) in recentlySeenDevices { let timeSinceLastSeen = now.timeIntervalSince(lastSeen) if timeSinceLastSeen > recentlySeenTimeout { expiredDevices.append(deviceID) let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("BROWSE", "Device \(deviceName) expired from recently-seen (\(Int(timeSinceLastSeen))s > \(Int(self.recentlySeenTimeout))s)") } else if !newDeviceIDs.contains(deviceID), let cachedInfo = deviceInfoCache[deviceID] { // Device was recently seen but not in browse results or connected - preserve it let existingDevice = discoveredDevices.first { $0.id == deviceID } let device = DiscoveredDevice( id: deviceID, name: cachedInfo.name, platform: cachedInfo.platform, currentVideoTitle: existingDevice?.currentVideoTitle, currentChannelName: existingDevice?.currentChannelName, currentVideoThumbnailURL: existingDevice?.currentVideoThumbnailURL, isPlaying: existingDevice?.isPlaying ?? false ) newDevices.append(device) newDeviceIDs.insert(deviceID) rcLog("BROWSE", "Preserving recently-seen device: \(cachedInfo.name) (last seen \(Int(timeSinceLastSeen))s ago)") } } // Clean up expired entries for deviceID in expiredDevices { recentlySeenDevices.removeValue(forKey: deviceID) // Allow re-probing expired devices so they can reconnect probedDevices.remove(deviceID) } discoveredDevices = newDevices // Log summary of discovered devices let deviceSummary = newDevices.map { "\($0.name) (\($0.platform))" }.joined(separator: ", ") rcLog("BROWSE", "Discovery complete: \(newDevices.count) devices", details: deviceSummary.isEmpty ? "none" : deviceSummary) } /// Devices we've already tried to probe (to avoid repeated connection attempts). private var probedDevices: Set = [] /// Cache of device info (name, platform) that persists across browse result changes. /// This helps restore device info when a device goes to background and comes back. private var deviceInfoCache: [String: (name: String, platform: DevicePlatform)] = [:] /// Devices that have recently communicated with us (within last 30 seconds). /// Used to preserve devices in the list even if Bonjour doesn't see them. private(set) var recentlySeenDevices: [String: Date] = [:] private let recentlySeenTimeout: TimeInterval = 30 /// When we first discovered each device (for removing stale Bonjour entries). private var deviceFirstSeen: [String: Date] = [:] /// Timeout for devices we haven't been able to communicate with. private let unresponsiveTimeout: TimeInterval = 60 /// Status of a discovered device's connection. enum DeviceStatus { case connected // Active connection case recentlySeen(ago: TimeInterval) // No connection but seen recently case discoveredOnly // Only via Bonjour, no communication yet } /// Get the status of a device. func deviceStatus(for deviceID: String) -> DeviceStatus { if connectedPeers.contains(deviceID) { return .connected } else if let lastSeen = recentlySeenDevices[deviceID] { let ago = Date().timeIntervalSince(lastSeen) if ago <= recentlySeenTimeout { return .recentlySeen(ago: ago) } } return .discoveredOnly } /// Clean up devices that have exceeded the recently-seen timeout. /// Call this periodically to remove stale devices from the list. func cleanupStaleDevices() { let now = Date() var devicesToRemove: [String] = [] // Find expired devices in recentlySeenDevices (were communicating but stopped) for (deviceID, lastSeen) in recentlySeenDevices { let timeSinceLastSeen = now.timeIntervalSince(lastSeen) if timeSinceLastSeen > recentlySeenTimeout { devicesToRemove.append(deviceID) recentlySeenDevices.removeValue(forKey: deviceID) let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("CLEANUP", "[\(deviceName)] Expired from recentlySeenDevices (\(Int(timeSinceLastSeen))s > \(Int(recentlySeenTimeout))s)") } } // Find unresponsive devices (discovered but never successfully communicated) for (deviceID, firstSeen) in deviceFirstSeen { let timeSinceFirstSeen = now.timeIntervalSince(firstSeen) if timeSinceFirstSeen > unresponsiveTimeout { if !devicesToRemove.contains(deviceID) { devicesToRemove.append(deviceID) } deviceFirstSeen.removeValue(forKey: deviceID) let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("CLEANUP", "[\(deviceName)] Never responded after \(Int(timeSinceFirstSeen))s", isWarning: true) } } // Remove stale devices from the list for deviceID in devicesToRemove { if !connectedPeers.contains(deviceID) { if let index = discoveredDevices.firstIndex(where: { $0.id == deviceID }) { let device = discoveredDevices[index] discoveredDevices.remove(at: index) // Clear from probed set so we'll try again if it comes back probedDevices.remove(deviceID) rcLog("CLEANUP", "[\(device.name)] Removed stale device from list") } } } } /// Probe a connection to check if it's still alive. /// If the connection is dead, clean it up and transition the device to "recently seen" status. private func probeConnectionHealth(deviceID: String) async { guard devicesToProbe.contains(deviceID) else { return } devicesToProbe.remove(deviceID) let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("HEALTH", "[\(deviceName)] Probing connection health") // Check if we have an active connection (outgoing or incoming) let outgoingConnection = connections[deviceID] let incomingConnection = incomingConnections[deviceID] let outgoingState = outgoingConnection.map { String(describing: $0.state) } ?? "nil" let incomingState = incomingConnection.map { String(describing: $0.state) } ?? "nil" rcLog("HEALTH", "[\(deviceName)] Connection states: outgoing=\(outgoingState), incoming=\(incomingState)") // Check outgoing connection state if let connection = outgoingConnection { await probeConnection(connection, deviceID: deviceID, isOutgoing: true) return } // Check incoming connection state if let connection = incomingConnection { await probeConnection(connection, deviceID: deviceID, isOutgoing: false) return } // No active connection found - device should already be cleaned up rcLog("HEALTH", "[\(deviceName)] No active connection found - removing from connectedPeers", isWarning: true) connectedPeers.remove(deviceID) } /// Probe a specific connection to check if it's still alive. private func probeConnection(_ connection: NWConnection, deviceID: String, isOutgoing: Bool) async { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID let connectionType = isOutgoing ? "outgoing" : "incoming" let state = connection.state rcLog("HEALTH", "[\(deviceName)] Probing \(connectionType) connection (state=\(state))") switch state { case .ready: // Try sending a ping (requestState) and wait for response or connection death rcLog("HEALTH", "[\(deviceName)] Connection ready - sending ping...") let isAlive = await sendHealthCheckAndWaitForResponse(to: deviceID, using: connection) if isAlive { rcLog("HEALTH", "[\(deviceName)] Health check PASSED - connection alive") recentlySeenDevices[deviceID] = Date() } else { rcLog("HEALTH", "[\(deviceName)] Health check FAILED - connection dead", isWarning: true) cleanupDeadConnection(deviceID: deviceID) } case .failed, .cancelled: rcLog("HEALTH", "[\(deviceName)] \(connectionType) connection already dead (state=\(state))", isWarning: true) cleanupDeadConnection(deviceID: deviceID) default: // Connection in transitional state, wait a bit and check again rcLog("HEALTH", "[\(deviceName)] \(connectionType) in transitional state (\(state)) - scheduling recheck") Task { try? await Task.sleep(for: .seconds(2)) devicesToProbe.insert(deviceID) await probeConnectionHealth(deviceID: deviceID) } } } /// Send a health check and wait for a response or connection death. /// Returns true if connection is alive, false if dead. /// /// IMPORTANT: This does NOT do its own receive() call. Doing so would race with the /// regular receiveMessage() loop and corrupt message framing (the 4-byte length header /// could be read by one receive while the body is read by another, causing desync). /// Instead, we send a ping and monitor recentlySeenDevices for updates from the /// regular receive loop. private func sendHealthCheckAndWaitForResponse(to deviceID: String, using connection: NWConnection) async -> Bool { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcDebug("HEALTH", "[\(deviceName)] Sending requestState ping...") let message = RemoteControlMessage( senderDeviceID: self.deviceID, senderDeviceName: self.deviceName, senderPlatform: .current, targetDeviceID: deviceID, command: .requestState ) do { let encoder = JSONEncoder() let data = try encoder.encode(message) var length = UInt32(data.count).bigEndian var framedData = Data(bytes: &length, count: 4) framedData.append(data) // Record the time before sending so we can detect new responses let sendTime = Date() // Send the health check message let sendResult: Bool = await withCheckedContinuation { continuation in connection.send(content: framedData, completion: .contentProcessed { [weak self] error in Task { @MainActor [weak self] in if let error { self?.rcLog("HEALTH", "[\(deviceName)] Ping send failed: \(error.localizedDescription)", isError: true) continuation.resume(returning: false) } else { self?.rcDebug("HEALTH", "[\(deviceName)] Ping sent, waiting for response...") continuation.resume(returning: true) } } }) } guard sendResult else { return false } // Wait for a response by monitoring recentlySeenDevices updates // The regular receiveMessage() loop will update this timestamp when it gets the response let timeout: TimeInterval = 3.0 let checkInterval: TimeInterval = 0.1 var elapsed: TimeInterval = 0 while elapsed < timeout { try? await Task.sleep(for: .milliseconds(Int(checkInterval * 1000))) elapsed += checkInterval // Check if we received data from this device after we sent the ping if let lastSeen = recentlySeenDevices[deviceID], lastSeen > sendTime { rcLog("HEALTH", "[\(deviceName)] Ping response received - ALIVE") return true } // Check if connection died if connection.state != .ready { rcLog("HEALTH", "[\(deviceName)] Connection died (state=\(connection.state)) - DEAD", isWarning: true) return false } } // Timeout without response - check if device explicitly disappeared from Bonjour // Bonjour removal is authoritative - if device stopped advertising, it's intentionally offline if bonjourDisappearedDevices.contains(deviceID) { rcLog("HEALTH", "[\(deviceName)] Ping timed out and device disappeared from Bonjour - DEAD", isWarning: true) return false } // Check current connection state if connection.state == .ready { // Connection still ready but no response - could be slow, assume alive for now rcLog("HEALTH", "[\(deviceName)] Ping timed out but connection ready - assuming alive") return true } else { rcLog("HEALTH", "[\(deviceName)] Ping timed out, connection not ready (state=\(connection.state)) - DEAD", isWarning: true) return false } } catch { rcLog("HEALTH", "[\(deviceName)] Ping encoding failed: \(error.localizedDescription)", isError: true) return false } } /// Clean up a dead connection and transition device to "recently seen" status. private func cleanupDeadConnection(deviceID: String) { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("HEALTH", "[\(deviceName)] Cleaning up dead connection - transitioning to recently-seen") // Cancel and remove connections connections[deviceID]?.cancel() connections.removeValue(forKey: deviceID) incomingConnections[deviceID]?.cancel() incomingConnections.removeValue(forKey: deviceID) // Remove from connected peers connectedPeers.remove(deviceID) // Mark as recently seen so it transitions to orange status instead of disappearing // Only if we had recent communication with it if recentlySeenDevices[deviceID] != nil { // Keep existing timestamp - it will naturally expire rcLog("HEALTH", "[\(deviceName)] Transitioned to recently-seen (existing timestamp)") } else { // Set a timestamp so it shows "recently seen" briefly before expiring recentlySeenDevices[deviceID] = Date() rcLog("HEALTH", "[\(deviceName)] Marked as recently-seen (new timestamp)") } // Allow re-probing when it comes back probedDevices.remove(deviceID) // Clear the Bonjour disappeared flag since we've handled the cleanup bonjourDisappearedDevices.remove(deviceID) } /// Probe a device to get its info by connecting and requesting state. /// Includes retry logic with exponential backoff for connection failures. private func probeDeviceForInfo(_ device: DiscoveredDevice) { // Only probe once per device guard !probedDevices.contains(device.id) else { return } probedDevices.insert(device.id) rcLog("PROBE", "[\(device.name)] Probing device to get info (id=\(device.id))") Task { var retryCount = 0 let maxRetries = 4 // Retry up to 4 times (5 total attempts) // Exponential backoff delays: 0.5s, 1s, 2s, 3s let retryDelays: [TimeInterval] = [0.5, 1.0, 2.0, 3.0] while retryCount <= maxRetries { do { rcLog("PROBE", "[\(device.name)] Attempt \(retryCount + 1)/\(maxRetries + 1) - connecting...") try await connect(to: device) // Send a requestState command to get the device's info try await send(command: .requestState, to: device.id) rcLog("PROBE", "[\(device.name)] Probe SUCCEEDED on attempt \(retryCount + 1)") return } catch { // Retry on any connection error (timeout, TCP RST, cancellation, etc.) // Remote device's listener may not be ready yet retryCount += 1 if retryCount <= maxRetries { let delay = retryDelays[min(retryCount - 1, retryDelays.count - 1)] rcLog("PROBE", "[\(device.name)] Attempt \(retryCount)/\(maxRetries + 1) failed: \(error.localizedDescription), retrying in \(delay)s...", isWarning: true) // Clean up failed connection before retry await MainActor.run { connections.removeValue(forKey: device.id) connectedPeers.remove(device.id) } // Wait with exponential backoff - remote listener may need time to start try? await Task.sleep(for: .seconds(delay)) } else { rcLog("PROBE", "[\(device.name)] Probe FAILED after \(maxRetries + 1) attempts: \(error.localizedDescription)", isError: true) } } } // Remove from probed set so we can try again if re-discovered _ = await MainActor.run { self.probedDevices.remove(device.id) self.rcLog("PROBE", "[\(device.name)] Removed from probed set - can retry on re-discovery") } } } private func parseTXTRecord(_ record: NWTXTRecord) -> [String: String] { // NWTXTRecord.dictionary already returns [String: String] let dict = record.dictionary rcDebug("BROWSE", "Parsed TXT record: \(dict.count) entries [\(dict.keys.joined(separator: ", "))]") return dict } // MARK: - Hosting /// Start hosting to allow other devices to connect. func startHosting() { guard !isHosting else { rcDebug("HOSTING", "Already hosting, ignoring duplicate start") return } rcLog("HOSTING", "Starting hosting", details: "deviceID=\(self.deviceID), name=\(self.deviceName), serviceType=\(Self.serviceType)") do { let parameters = NWParameters.tcp parameters.includePeerToPeer = true let listener = try NWListener(using: parameters) // Generate unique ID for this listener to track stale callbacks let listenerID = UUID() currentListenerID = listenerID listener.stateUpdateHandler = { [weak self] state in Task { @MainActor [weak self] in self?.handleListenerStateChange(state, listenerID: listenerID) } } listener.newConnectionHandler = { [weak self] connection in Task { @MainActor [weak self] in self?.handleNewConnection(connection) } } // Start listener WITHOUT Bonjour advertisement first listener.start(queue: queue) self.listener = listener self.listenerStartTime = Date() // Track startup time for diagnostics isHosting = true startStatusSummaryTimer() rcLog("HOSTING", "Listener started, waiting for ready state before advertising...") // Defer Bonjour advertisement to give listener time to reach .ready state // This prevents race condition where remote devices try to connect before we're ready Task { @MainActor [weak self, weak listener] in guard let self, let listener else { return } // Wait 300ms for listener to stabilize try? await Task.sleep(for: .milliseconds(300)) // Verify listener is still valid (not cancelled during delay) guard self.listener === listener, listenerID == self.currentListenerID else { self.rcLog("HOSTING", "Listener cancelled during startup delay, skipping advertisement", isWarning: true) return } // Now advertise via Bonjour let advert = self.currentAdvertisement let txtDict = advert.toTXTRecord() let txtDetails = txtDict.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") self.rcLog("HOSTING", "Advertising via Bonjour after stabilization", details: "TXT=[\(txtDetails)]") let txtRecord = NWTXTRecord(txtDict) listener.service = NWListener.Service( name: self.deviceID, type: Self.serviceType, txtRecord: txtRecord ) self.rcLog("HOSTING", "Bonjour advertisement active") } } catch { rcLog("HOSTING", "Failed to start listener: \(error.localizedDescription)", isError: true) } } /// Stop hosting. func stopHosting() { guard isHosting else { rcDebug("HOSTING", "Not hosting, ignoring stop") return } rcLog("HOSTING", "Stopping hosting", details: "incoming=\(incomingConnections.count), pending=\(pendingConnections.count)") currentListenerID = nil // Clear before cancel to ignore the cancelled callback listenerStartTime = nil // Clear timing diagnostic listener?.cancel() listener = nil isHosting = false // Close all incoming connections for (deviceID, connection) in incomingConnections { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("HOSTING", "Closing incoming connection: \(deviceName)") connection.cancel() connectedPeers.remove(deviceID) } incomingConnections.removeAll() // Close all pending connections for connection in pendingConnections { rcDebug("HOSTING", "Closing pending connection: \(connection.endpoint)") connection.cancel() } pendingConnections.removeAll() stopStatusSummaryTimer() rcLog("HOSTING", "Hosting stopped") } // MARK: - Status Summary Timer /// Start periodic status summary logging (every 30 seconds). private func startStatusSummaryTimer() { statusSummaryTask?.cancel() statusSummaryTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(30)) guard let self, !Task.isCancelled else { break } self.logStatusSummary() } } } /// Stop the status summary timer. private func stopStatusSummaryTimer() { statusSummaryTask?.cancel() statusSummaryTask = nil } /// Log a comprehensive status summary for debugging. private func logStatusSummary() { // Collect status info let hostingStatus = isHosting ? "YES" : "NO" let listenerPort = listener?.port?.rawValue.description ?? "N/A" let browsingStatus = isDiscovering ? "YES" : "NO" // Discovered devices let deviceNames = discoveredDevices.map { $0.name } let devicesSummary = deviceNames.isEmpty ? "none" : deviceNames.joined(separator: ", ") // Connected peers let peerNames = connectedPeers.compactMap { deviceInfoCache[$0]?.name ?? $0 } let peersSummary = peerNames.isEmpty ? "none" : peerNames.joined(separator: ", ") // Connection counts by state let outgoingReady = connections.values.filter { $0.state == .ready }.count let outgoingOther = connections.count - outgoingReady let incomingReady = incomingConnections.values.filter { $0.state == .ready }.count let incomingOther = incomingConnections.count - incomingReady // Recently seen devices with time ago let now = Date() let recentlySeenSummary = recentlySeenDevices.map { (deviceID, lastSeen) -> String in let name = deviceInfoCache[deviceID]?.name ?? deviceID.prefix(8).description let ago = Int(now.timeIntervalSince(lastSeen)) return "\(name) (\(ago)s ago)" }.joined(separator: ", ") // Build summary var summary = """ === STATUS SUMMARY === Hosting: \(hostingStatus) on port \(listenerPort) Browsing: \(browsingStatus) Discovered: \(discoveredDevices.count) [\(devicesSummary)] Connected: \(connectedPeers.count) [\(peersSummary)] Outgoing: \(outgoingReady) ready, \(outgoingOther) other Incoming: \(incomingReady) ready, \(incomingOther) other """ if !recentlySeenDevices.isEmpty { summary += "\n Recently seen: \(recentlySeenSummary)" } rcLog("STATUS", summary) } /// Update the advertised state (call when player state changes). func updateAdvertisement(videoTitle: String?, channelName: String?, thumbnailURL: URL?, isPlaying: Bool) { // Skip if nothing changed (avoid redundant Bonjour TXT record updates) guard videoTitle != _currentVideoTitle || channelName != _currentChannelName || thumbnailURL != _currentVideoThumbnailURL || isPlaying != _isPlaying else { return } _currentVideoTitle = videoTitle _currentChannelName = channelName _currentVideoThumbnailURL = thumbnailURL _isPlaying = isPlaying // Update the TXT record if we're hosting if isHosting, let listener = listener { let txtRecord = NWTXTRecord(currentAdvertisement.toTXTRecord()) listener.service = NWListener.Service( name: deviceID, type: Self.serviceType, txtRecord: txtRecord ) } } private func handleListenerStateChange(_ state: NWListener.State, listenerID: UUID) { // Ignore callbacks from old listeners (e.g., during refreshServices()) guard listenerID == currentListenerID else { rcDebug("LISTENER", "Ignoring state \(state) from old listener") return } switch state { case .setup: rcDebug("LISTENER", "State: setup") case .ready: // Calculate time to ready for diagnostics let timeToReady = listenerStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? 0 if let port = listener?.port { rcLog("LISTENER", "State: ready on port \(port.rawValue)", details: "advertising as \(self.deviceID), took \(timeToReady)ms to become ready") } case .failed(let error): rcLog("LISTENER", "State: FAILED - \(error.localizedDescription)", isError: true) isHosting = false case .cancelled: rcLog("LISTENER", "State: cancelled") isHosting = false case .waiting(let error): rcLog("LISTENER", "State: waiting - \(error.localizedDescription)", isWarning: true) @unknown default: rcLog("LISTENER", "State: unknown", isWarning: true) } } private func handleNewConnection(_ connection: NWConnection) { rcLog("CONNECT", "New incoming connection from \(connection.endpoint)") pendingConnections.append(connection) setupConnectionHandlers(connection, isOutgoing: false) connection.start(queue: queue) } // MARK: - Connection Management /// Connect to a discovered device with a timeout. func connect(to device: DiscoveredDevice) async throws { // Check if we already have a ready incoming connection from this device // Incoming connections are more reliable since the remote device initiated them if let incomingConnection = incomingConnections[device.id], incomingConnection.state == .ready { rcLog("CONNECT", "Using existing incoming connection from \(device.name)") // Mark this device as one we're controlling (add to controllingDevices in caller) return } // Check if we already have an outgoing connection (ready or establishing) if let existingOutgoing = connections[device.id] { if existingOutgoing.state == .ready { rcDebug("CONNECT", "Already connected to \(device.name) (\(device.id))") return } else { // Connection exists but not ready - let it continue establishing rcLog("CONNECT", "Outgoing connection to \(device.name) already in progress (state: \(existingOutgoing.state))") return } } rcLog("CONNECT", "No existing connection found, initiating outgoing connection to \(device.name)", details: "id=\(device.id), platform=\(device.platform)") // Create endpoint from device ID (service name) let endpoint = NWEndpoint.service( name: device.id, type: Self.serviceType, domain: "local.", interface: nil ) let parameters = NWParameters.tcp parameters.includePeerToPeer = true let connection = NWConnection(to: endpoint, using: parameters) connections[device.id] = connection setupConnectionHandlers(connection, isOutgoing: true, deviceID: device.id) // Wait for connection with 10-second timeout do { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { @MainActor in try await self.waitForConnectionReady(connection, device: device) } group.addTask { try await Task.sleep(for: .seconds(10)) throw ConnectionTimeoutError() } // Wait for first to complete (either connected or timeout) _ = try await group.next() group.cancelAll() } } catch is ConnectionTimeoutError { // Clean up the connection on timeout rcLog("CONNECT", "Connection to \(device.id) timed out after 10 seconds", isWarning: true) connection.cancel() connections.removeValue(forKey: device.id) throw ConnectionTimeoutError() } catch { // Clean up on other errors connections.removeValue(forKey: device.id) throw error } } /// Wait for a connection to become ready. private func waitForConnectionReady(_ connection: NWConnection, device: DiscoveredDevice) async throws { let startTime = Date() try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in // Use a class to track if we've resumed, avoiding concurrent access issues final class ResumedState: @unchecked Sendable { var resumed = false } let state = ResumedState() connection.stateUpdateHandler = { [weak self] connectionState in guard !state.resumed else { return } Task { @MainActor [weak self] in let elapsed = Int(Date().timeIntervalSince(startTime) * 1000) switch connectionState { case .setup: self?.rcDebug("CONNECT", "[\(device.name)] State: setup (\(elapsed)ms)") case .preparing: self?.rcDebug("CONNECT", "[\(device.name)] State: preparing (\(elapsed)ms)") case .waiting(let error): self?.rcLog("CONNECT", "[\(device.name)] State: waiting - \(error.localizedDescription) (\(elapsed)ms)", isWarning: true) case .ready: state.resumed = true self?.connectedPeers.insert(device.id) self?.recentlySeenDevices[device.id] = Date() self?.rcLog("CONNECT", "[\(device.name)] State: READY (\(elapsed)ms)", details: "connectedPeers=\(self?.connectedPeers.count ?? 0)") continuation.resume() case .failed(let error): state.resumed = true self?.connections.removeValue(forKey: device.id) self?.rcLog("CONNECT", "[\(device.name)] State: FAILED - \(error.localizedDescription) (\(elapsed)ms)", isError: true) continuation.resume(throwing: error) case .cancelled: if !state.resumed { state.resumed = true self?.connections.removeValue(forKey: device.id) self?.rcLog("CONNECT", "[\(device.name)] State: cancelled (\(elapsed)ms)", isWarning: true) continuation.resume(throwing: CancellationError()) } @unknown default: self?.rcDebug("CONNECT", "[\(device.name)] State: unknown (\(elapsed)ms)") } } } connection.start(queue: queue) } } /// Disconnect from a device. func disconnect(from deviceID: String) { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID guard let connection = connections[deviceID] else { rcDebug("CONNECT", "Cannot disconnect from \(deviceName) - no connection") return } rcLog("CONNECT", "Disconnecting from \(deviceName)") connection.cancel() connections.removeValue(forKey: deviceID) connectedPeers.remove(deviceID) } /// Disconnect from all devices. func disconnectAll() { for deviceID in connections.keys { disconnect(from: deviceID) } } private func setupConnectionHandlers(_ connection: NWConnection, isOutgoing: Bool, deviceID: String? = nil) { let connectionType = isOutgoing ? "outgoing" : "incoming" let deviceDesc = deviceID ?? "unknown" rcDebug("HANDLER", "Setting up \(connectionType) connection handlers for \(deviceDesc)") receiveMessage(on: connection) connection.stateUpdateHandler = { [weak self] state in Task { @MainActor [weak self] in guard let self else { return } let deviceName = deviceID.flatMap { self.deviceInfoCache[$0]?.name } ?? deviceDesc switch state { case .ready: self.rcLog("HANDLER", "[\(deviceName)] \(connectionType) connection ready") case .failed(let error): self.rcLog("HANDLER", "[\(deviceName)] \(connectionType) connection FAILED: \(error.localizedDescription)", isError: true) self.handleConnectionFailure(connection: connection, deviceID: deviceID, isOutgoing: isOutgoing) case .cancelled: self.rcLog("HANDLER", "[\(deviceName)] \(connectionType) connection cancelled", isWarning: true) self.handleConnectionFailure(connection: connection, deviceID: deviceID, isOutgoing: isOutgoing) case .waiting(let error): self.rcLog("HANDLER", "[\(deviceName)] \(connectionType) connection waiting: \(error.localizedDescription)", isWarning: true) default: break } } } } /// Handle connection failure/cancellation - factored out for clarity private func handleConnectionFailure(connection: NWConnection, deviceID: String?, isOutgoing: Bool) { if isOutgoing, let deviceID { connections.removeValue(forKey: deviceID) // Only remove from connectedPeers if there's no working incoming connection if incomingConnections[deviceID]?.state != .ready { connectedPeers.remove(deviceID) rcLog("HANDLER", "Removed \(deviceID) from connectedPeers (no working incoming connection)") } else { rcLog("HANDLER", "Outgoing to \(deviceID) failed, but incoming still ready") } } if !isOutgoing { pendingConnections.removeAll { $0 === connection } // Clean up from incomingConnections if tracked there for (id, conn) in incomingConnections where conn === connection { incomingConnections.removeValue(forKey: id) // Only remove from connectedPeers if there's no working outgoing connection if connections[id]?.state != .ready { connectedPeers.remove(id) rcLog("HANDLER", "Removed \(id) from connectedPeers (no working outgoing connection)") } else { rcLog("HANDLER", "Incoming from \(id) failed, but outgoing still ready") } break } } } // MARK: - Message Sending /// Ensure we have a verified working connection to a device. /// If we haven't received from the device recently, send a ping and wait for response. /// Returns true if connection is verified, false if we couldn't establish one. func ensureConnection(to deviceID: String) async -> Bool { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("VERIFY", "[\(deviceName)] Ensuring connection is alive") // Check if we have a recent verified connection if let lastSeen = recentlySeenDevices[deviceID], Date().timeIntervalSince(lastSeen) < 10 { // Connection was recently verified (received data within 10 seconds) let ago = Int(Date().timeIntervalSince(lastSeen)) rcLog("VERIFY", "[\(deviceName)] Connection recently verified (\(ago)s ago) - skipping probe") return true } // Log current connection state let outgoingState = connections[deviceID].map { String(describing: $0.state) } ?? "nil" let incomingState = incomingConnections[deviceID].map { String(describing: $0.state) } ?? "nil" rcLog("VERIFY", "[\(deviceName)] Connection states: outgoing=\(outgoingState), incoming=\(incomingState)") // Check if we have an incoming connection (most reliable) if let incoming = incomingConnections[deviceID], incoming.state == .ready { // Incoming connection exists, send a quick ping to verify rcLog("VERIFY", "[\(deviceName)] Probing incoming connection...") let isAlive = await sendHealthCheckAndWaitForResponse(to: deviceID, using: incoming) if isAlive { rcLog("VERIFY", "[\(deviceName)] Incoming connection VERIFIED") return true } // Connection dead, clean it up rcLog("VERIFY", "[\(deviceName)] Incoming connection DEAD - cleaning up", isWarning: true) cleanupConnection(deviceID: deviceID, isOutgoing: false) } // Check if we have an outgoing connection if let outgoing = connections[deviceID], outgoing.state == .ready { rcLog("VERIFY", "[\(deviceName)] Probing outgoing connection...") let isAlive = await sendHealthCheckAndWaitForResponse(to: deviceID, using: outgoing) if isAlive { rcLog("VERIFY", "[\(deviceName)] Outgoing connection VERIFIED") return true } // Connection dead, clean it up rcLog("VERIFY", "[\(deviceName)] Outgoing connection DEAD - cleaning up", isWarning: true) cleanupConnection(deviceID: deviceID, isOutgoing: true) } // No verified connection - try to establish one if let device = discoveredDevices.first(where: { $0.id == deviceID }) { rcLog("VERIFY", "[\(deviceName)] No verified connection - attempting to establish new one") do { try await connect(to: device) // Wait a moment for the connection to stabilize try? await Task.sleep(for: .milliseconds(500)) // Verify the new connection if let outgoing = connections[deviceID], outgoing.state == .ready { rcLog("VERIFY", "[\(deviceName)] New connection ready - probing to verify...") let isAlive = await sendHealthCheckAndWaitForResponse(to: deviceID, using: outgoing) if isAlive { rcLog("VERIFY", "[\(deviceName)] New connection VERIFIED") return true } rcLog("VERIFY", "[\(deviceName)] New connection probe failed", isWarning: true) } } catch { rcLog("VERIFY", "[\(deviceName)] Failed to establish connection: \(error.localizedDescription)", isError: true) } } else { rcLog("VERIFY", "[\(deviceName)] Device not in discovered list - cannot establish connection", isWarning: true) } rcLog("VERIFY", "[\(deviceName)] Could not establish verified connection", isError: true) return false } /// Send a command to a specific device. Automatically retries once on connection failure. func send(command: RemoteControlCommand, to deviceID: String) async throws { try await sendWithRetry(command: command, to: deviceID, isRetry: false) } /// Internal send with retry logic. private func sendWithRetry(command: RemoteControlCommand, to deviceID: String, isRetry: Bool) async throws { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID let commandDesc = String(describing: command).prefix(50) // Find a ready connection - prefer ready connections over non-ready ones let outgoingConnection = connections[deviceID] let incomingConnection = incomingConnections[deviceID] // Log available connections for debugging let outgoingState = outgoingConnection.map { String(describing: $0.state) } ?? "nil" let incomingState = incomingConnection.map { String(describing: $0.state) } ?? "nil" rcDebug("SEND", "[\(deviceName)] Preparing send: outgoing=\(outgoingState), incoming=\(incomingState)") // Check if we've received data from this device recently (connection is verified alive) let lastSeen = recentlySeenDevices[deviceID] let connectionIsRecent = lastSeen.map { Date().timeIntervalSince($0) < 30 } ?? false // Choose the best available connection: // 1. Prefer ready incoming connection (we know it works - remote device initiated it) // 2. Fall back to ready outgoing connection (if recently verified alive) // 3. Fall back to any ready connection // 4. Fall back to any connection (will fail the state check below) let connection: NWConnection? let isOutgoing: Bool let connectionType: String if let incoming = incomingConnection, incoming.state == .ready { // Incoming connections are most reliable - we received data on them connection = incoming isOutgoing = false connectionType = "incoming" rcDebug("SEND", "[\(deviceName)] Selected ready incoming connection") } else if let outgoing = outgoingConnection, outgoing.state == .ready, connectionIsRecent { // Outgoing is OK if we've received from device recently (proving it's alive) connection = outgoing isOutgoing = true connectionType = "outgoing" rcDebug("SEND", "[\(deviceName)] Selected ready outgoing connection (recently verified)") } else if let outgoing = outgoingConnection, outgoing.state == .ready { // Use outgoing even if not recently verified (will reconnect on failure) connection = outgoing isOutgoing = true connectionType = "outgoing" rcDebug("SEND", "[\(deviceName)] Selected ready outgoing connection (not recently verified)") } else { // Fall back to any available connection connection = incomingConnection ?? outgoingConnection isOutgoing = outgoingConnection != nil && incomingConnection == nil connectionType = isOutgoing ? "outgoing" : "incoming" rcDebug("SEND", "[\(deviceName)] No ready connection, falling back to \(connectionType)") } guard let connection else { // If no connection and this isn't a retry, try to establish one if !isRetry, let device = discoveredDevices.first(where: { $0.id == deviceID }) { rcLog("SEND", "[\(deviceName)] No connection - attempting to connect...", isWarning: true) do { try await connect(to: device) try await sendWithRetry(command: command, to: deviceID, isRetry: true) return } catch { rcLog("SEND", "[\(deviceName)] Failed to connect: \(error.localizedDescription)", isError: true) throw RemoteControlError.notConnected } } rcLog("SEND", "[\(deviceName)] Cannot send - no connection available", isError: true, details: "outgoing=[\(connections.keys.joined(separator: ", "))], incoming=[\(incomingConnections.keys.joined(separator: ", "))]") throw RemoteControlError.notConnected } // Check connection state before attempting to send guard connection.state == .ready else { rcLog("SEND", "[\(deviceName)] Connection not ready (state=\(connection.state)) - cleaning up", isWarning: true) cleanupConnection(deviceID: deviceID, isOutgoing: isOutgoing) // If this isn't a retry, try to reconnect if !isRetry, let device = discoveredDevices.first(where: { $0.id == deviceID }) { rcLog("SEND", "[\(deviceName)] Attempting reconnect...") do { try await connect(to: device) try await sendWithRetry(command: command, to: deviceID, isRetry: true) return } catch { rcLog("SEND", "[\(deviceName)] Reconnect failed: \(error.localizedDescription)", isError: true) } } throw RemoteControlError.notConnected } rcLog("SEND", "[\(deviceName)] Sending \(commandDesc) via \(connectionType)\(isRetry ? " (retry)" : "")") let message = RemoteControlMessage( senderDeviceID: self.deviceID, senderDeviceName: self.deviceName, senderPlatform: .current, targetDeviceID: deviceID, command: command ) do { try await sendMessage(message, on: connection, deviceName: deviceName) rcLog("SEND", "[\(deviceName)] Send completed via \(connectionType)") // Update recently seen on successful send recentlySeenDevices[deviceID] = Date() } catch { rcLog("SEND", "[\(deviceName)] Send failed: \(error.localizedDescription)", isError: true) cleanupConnection(deviceID: deviceID, isOutgoing: isOutgoing) // If this isn't a retry, try to reconnect and resend if !isRetry, let device = discoveredDevices.first(where: { $0.id == deviceID }) { rcLog("SEND", "[\(deviceName)] Attempting reconnect and resend...") do { try await connect(to: device) try await sendWithRetry(command: command, to: deviceID, isRetry: true) return } catch { rcLog("SEND", "[\(deviceName)] Reconnect and resend failed: \(error.localizedDescription)", isError: true) } } throw error } } /// Clean up a dead connection. private func cleanupConnection(deviceID: String, isOutgoing: Bool) { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID let connectionType = isOutgoing ? "outgoing" : "incoming" if isOutgoing { connections[deviceID]?.cancel() connections.removeValue(forKey: deviceID) // Only remove from connectedPeers if there's no working incoming connection if incomingConnections[deviceID]?.state != .ready { connectedPeers.remove(deviceID) rcLog("CLEANUP", "[\(deviceName)] Cleaned up \(connectionType), removed from connectedPeers") } else { rcLog("CLEANUP", "[\(deviceName)] Cleaned up \(connectionType), but incoming still ready") } } else { incomingConnections[deviceID]?.cancel() incomingConnections.removeValue(forKey: deviceID) // Only remove from connectedPeers if there's no working outgoing connection if connections[deviceID]?.state != .ready { connectedPeers.remove(deviceID) rcLog("CLEANUP", "[\(deviceName)] Cleaned up \(connectionType), removed from connectedPeers") } else { rcLog("CLEANUP", "[\(deviceName)] Cleaned up \(connectionType), but outgoing still ready") } } // Allow re-probing this device next time it's discovered probedDevices.remove(deviceID) } /// Broadcast a command to all connected devices. func broadcast(command: RemoteControlCommand) async { let message = RemoteControlMessage( senderDeviceID: deviceID, senderDeviceName: deviceName, senderPlatform: .current, targetDeviceID: nil, command: command ) // Collect device IDs that need cleanup var deadOutgoingConnections: [String] = [] var deadIncomingConnections: [String] = [] // Send to all outgoing connections for (deviceID, connection) in connections { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID guard connection.state == .ready else { rcDebug("BROADCAST", "[\(deviceName)] Skipping non-ready outgoing (state=\(connection.state))") deadOutgoingConnections.append(deviceID) continue } do { try await sendMessage(message, on: connection, deviceName: deviceName) } catch { rcLog("BROADCAST", "[\(deviceName)] Failed via outgoing: \(error.localizedDescription)", isWarning: true) deadOutgoingConnections.append(deviceID) } } // Send to all tracked incoming connections for (deviceID, connection) in incomingConnections { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID guard connection.state == .ready else { rcDebug("BROADCAST", "[\(deviceName)] Skipping non-ready incoming (state=\(connection.state))") deadIncomingConnections.append(deviceID) continue } do { try await sendMessage(message, on: connection, deviceName: deviceName) } catch { rcLog("BROADCAST", "[\(deviceName)] Failed via incoming: \(error.localizedDescription)", isWarning: true) deadIncomingConnections.append(deviceID) } } // Send to any unidentified pending connections for connection in pendingConnections { if connection.state == .ready { try? await sendMessage(message, on: connection, deviceName: "pending") } } // Clean up dead connections for deviceID in deadOutgoingConnections { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("BROADCAST", "[\(deviceName)] Cleaning up dead outgoing connection") connections[deviceID]?.cancel() connections.removeValue(forKey: deviceID) connectedPeers.remove(deviceID) } for deviceID in deadIncomingConnections { let deviceName = deviceInfoCache[deviceID]?.name ?? deviceID rcLog("BROADCAST", "[\(deviceName)] Cleaning up dead incoming connection") incomingConnections[deviceID]?.cancel() incomingConnections.removeValue(forKey: deviceID) connectedPeers.remove(deviceID) } // Clean up non-ready pending connections let deadPending = pendingConnections.filter { $0.state != .ready && $0.state != .setup && $0.state != .preparing } if !deadPending.isEmpty { rcDebug("BROADCAST", "Cleaning up \(deadPending.count) dead pending connections") } for connection in deadPending { connection.cancel() } pendingConnections.removeAll { $0.state != .ready && $0.state != .setup && $0.state != .preparing } rcDebug("BROADCAST", "Broadcast complete") } private func sendMessage(_ message: RemoteControlMessage, on connection: NWConnection, deviceName: String? = nil) async throws { let encoder = JSONEncoder() let data = try encoder.encode(message) let name = deviceName ?? message.targetDeviceID ?? "unknown" // Length-prefix framing: 4 bytes for length + data var length = UInt32(data.count).bigEndian var framedData = Data(bytes: &length, count: 4) framedData.append(data) rcDebug("SEND", "[\(name)] Sending \(framedData.count) bytes (4 header + \(data.count) payload)") return try await withCheckedThrowingContinuation { continuation in connection.send(content: framedData, completion: .contentProcessed { [weak self] error in Task { @MainActor [weak self] in if let error { self?.rcLog("SEND", "[\(name)] TCP send failed: \(error.localizedDescription)", isError: true) continuation.resume(throwing: error) } else { self?.rcDebug("SEND", "[\(name)] TCP send completed") continuation.resume() } } }) } } // MARK: - Message Receiving private func receiveMessage(on connection: NWConnection) { // First, read the 4-byte length prefix connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { [weak self] content, _, isComplete, error in guard let self else { return } if let error { Task { @MainActor in self.rcLog("RECV", "Header receive error: \(error.localizedDescription)", isError: true) } return } if isComplete { Task { @MainActor in self.rcDebug("RECV", "Connection completed (EOF)") } return } guard let lengthData = content, lengthData.count == 4 else { // Continue receiving Task { @MainActor in self.receiveMessage(on: connection) } return } // Parse length let length = lengthData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } Task { @MainActor in self.rcDebug("RECV", "Reading message body: \(length) bytes") } // Read the message body connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { [weak self] content, _, _, error in guard let self else { return } if let error { Task { @MainActor in self.rcLog("RECV", "Body receive error: \(error.localizedDescription)", isError: true) } return } if let data = content { Task { @MainActor in self.handleReceivedData(data, from: connection) } } // Continue receiving next message Task { @MainActor in self.receiveMessage(on: connection) } } } } private func handleReceivedData(_ data: Data, from connection: NWConnection) { do { let decoder = JSONDecoder() let message = try decoder.decode(RemoteControlMessage.self, from: data) let senderName = message.senderDeviceName ?? message.senderDeviceID let commandDesc = String(describing: message.command).prefix(50) // Ignore our own messages guard message.senderDeviceID != deviceID else { rcDebug("RECV", "Ignoring own message from \(senderName)") return } // Check if message is for us (or broadcast) if let targetID = message.targetDeviceID, targetID != deviceID { rcDebug("RECV", "Ignoring message from \(senderName) targeted at \(targetID)") return } // Determine connection type for logging let isOutgoing = connections.values.contains(where: { $0 === connection }) let connectionType = isOutgoing ? "outgoing" : "incoming" rcLog("RECV", "[\(senderName)] Received \(commandDesc) via \(connectionType)", details: "\(data.count) bytes") // Track this incoming connection by sender device ID for bidirectional communication Task { @MainActor in // Always track incoming connections - they're useful even if we have outgoing ones // (the outgoing might not be ready yet, but the incoming is proven to work since we just received on it) if self.incomingConnections[message.senderDeviceID] !== connection { self.incomingConnections[message.senderDeviceID] = connection self.connectedPeers.insert(message.senderDeviceID) self.rcLog("RECV", "[\(senderName)] Tracked incoming connection", details: "connectedPeers=\(self.connectedPeers.count)") } // Track when we last saw this device (for preservation after connection cleanup) self.recentlySeenDevices[message.senderDeviceID] = Date() // Clear first-seen since device is responsive self.deviceFirstSeen.removeValue(forKey: message.senderDeviceID) self.rcDebug("RECV", "[\(senderName)] Updated recently-seen timestamp") // Update discovered device info if we got name/platform from the message if let senderName = message.senderDeviceName, let senderPlatform = message.senderPlatform { self.updateDiscoveredDeviceInfo( deviceID: message.senderDeviceID, name: senderName, platform: senderPlatform ) } // Emit to the commands stream self.commandsContinuation?.yield(message) } } catch { rcLog("RECV", "Failed to decode message: \(error.localizedDescription)", isError: true, details: "\(data.count) bytes") } } /// Update a discovered device with info from a message (when TXT record wasn't available). /// If the device isn't in the list, add it (handles case where device connects before Bonjour discovers it). private func updateDiscoveredDeviceInfo(deviceID: String, name: String, platform: DevicePlatform) { // Always update the cache so info is preserved across browse result changes deviceInfoCache[deviceID] = (name: name, platform: platform) if let index = discoveredDevices.firstIndex(where: { $0.id == deviceID }) { let existing = discoveredDevices[index] // Only update if the name was just the UUID (placeholder) if existing.name == deviceID || existing.name != name { discoveredDevices[index] = DiscoveredDevice( id: deviceID, name: name, platform: platform, currentVideoTitle: existing.currentVideoTitle, currentChannelName: existing.currentChannelName, currentVideoThumbnailURL: existing.currentVideoThumbnailURL, isPlaying: existing.isPlaying ) rcLog("DEVICE", "Updated device info for \(deviceID): \(name) (\(platform.rawValue))") } } else { // Device not in list yet (connected before Bonjour discovered it) - add it let device = DiscoveredDevice( id: deviceID, name: name, platform: platform, currentVideoTitle: nil, currentChannelName: nil, currentVideoThumbnailURL: nil, isPlaying: false ) discoveredDevices.append(device) rcLog("DEVICE", "Added device from incoming connection: \(name) (\(platform.rawValue))") } } /// Update a discovered device with playback state from a state update message. func updateDiscoveredDevicePlaybackState(deviceID: String, videoTitle: String?, channelName: String?, thumbnailURL: URL?, isPlaying: Bool) { if let index = discoveredDevices.firstIndex(where: { $0.id == deviceID }) { let existing = discoveredDevices[index] discoveredDevices[index] = DiscoveredDevice( id: deviceID, name: existing.name, platform: existing.platform, currentVideoTitle: videoTitle, currentChannelName: channelName, currentVideoThumbnailURL: thumbnailURL, isPlaying: isPlaying ) } else if let cachedInfo = deviceInfoCache[deviceID] { // Device not in list but we have cached info - add it let device = DiscoveredDevice( id: deviceID, name: cachedInfo.name, platform: cachedInfo.platform, currentVideoTitle: videoTitle, currentChannelName: channelName, currentVideoThumbnailURL: thumbnailURL, isPlaying: isPlaying ) discoveredDevices.append(device) rcLog("DEVICE", "Added device from state update: \(cachedInfo.name)") } } } // MARK: - Errors enum RemoteControlError: LocalizedError { case notConnected case connectionFailed case encodingFailed var errorDescription: String? { switch self { case .notConnected: return "Not connected to device" case .connectionFailed: return "Failed to connect to device" case .encodingFailed: return "Failed to encode message" } } }