Files
yattee/Yattee/Services/RemoteControl/LocalNetworkService.swift
Arkadiusz Fal 2ed0dc72be Fix CheckedContinuation double-resume in waitForConnectionReady
The 'resumed' guard flag was set inside a dispatched MainActor Task rather than
synchronously in the connection state handler. Two states arriving in quick
succession (e.g. .ready then .failed) could both pass the guard before either
Task ran, resuming the continuation twice and trapping. Claim the continuation
synchronously on the serial queue before dispatching the side effects.

Fixes a SIGTRAP seen in TestFlight build 261.
2026-05-12 22:32:13 +02:00

1902 lines
86 KiB
Swift

//
// 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<String> = []
// 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<RemoteControlMessage>.Continuation?
/// Stream of incoming remote control commands.
private(set) var incomingCommands: AsyncStream<RemoteControlMessage>!
/// Task for periodic status summary logging.
private var statusSummaryTask: Task<Void, Never>?
// 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<String> = []
/// 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<String> = []
/// 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<NWBrowser.Result>, changes: Set<NWBrowser.Result.Change>) {
// 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<String> = []
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<String> = []
/// 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<Void, Error>) 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
// Claim the continuation synchronously here. `stateUpdateHandler`
// runs on the serial `queue`, so this test-and-set is atomic.
// Previously `state.resumed = true` was set inside the dispatched
// MainActor Task, so two states arriving in quick succession (e.g.
// .ready then .failed) could both pass the guard before either Task
// ran, resuming the continuation twice and trapping.
let isTerminal: Bool
switch connectionState {
case .ready, .failed, .cancelled: isTerminal = true
default: isTerminal = false
}
guard !state.resumed else { return }
if isTerminal { state.resumed = true }
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:
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):
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:
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"
}
}
}