import Foundation import Network import NucleicProtocol /// Client-side `FrameChannel` over an outbound `NWConnection` (SYNC_PROTOCOL §3.1). De-frames /// inbound bytes with `FrameAccumulator`; length-prefixes outbound frames. Mirrors the host's /// `LANChannel` but for the phone's side of the TCP connection. final class NWFrameChannel: FrameChannel, @unchecked Sendable { let peerDescription: String private let connection: NWConnection private let queue = DispatchQueue(label: "nucleic.remote.channel") private let accumulator = FrameAccumulator() private let stream: AsyncStream private let continuation: AsyncStream.Continuation /// State callbacks so the store can reflect connectivity truth (UX_IOS §6). var onReady: (@Sendable () -> Void)? var onFailed: (@Sendable (String) -> Void)? /// Whether TCP reached `.ready` — latched, thread-safe. The connect-timeout guard /// polls this instead of relying on a callback that could race connection start. private let stateLock = NSLock() private var ready = false var isReady: Bool { stateLock.lock() defer { stateLock.unlock() } return ready } init(endpoint: NWEndpoint) { // Short TCP keepalive so a silently-dead LAN link (Wi-Fi dropped, Mac left the subnet) is // detected in ~15s instead of the OS default of minutes — the belt to the path-monitor's // braces for the case where a path still exists but the route to the Mac is gone. let tcp = NWProtocolTCP.Options() tcp.enableKeepalive = true tcp.keepaliveIdle = 5 tcp.keepaliveInterval = 5 tcp.keepaliveCount = 2 let params = NWParameters(tls: nil, tcp: tcp) params.includePeerToPeer = true connection = NWConnection(to: endpoint, using: params) peerDescription = "\(endpoint)" var cont: AsyncStream.Continuation! stream = AsyncStream(bufferingPolicy: .unbounded) { cont = $0 } continuation = cont connection.stateUpdateHandler = { [weak self] state in switch state { case .ready: self?.markReady(); self?.onReady?() case .waiting(let error): // No usable path right now (Wi-Fi dropped, host unreachable). NWConnection would sit // in `.waiting` indefinitely, holding the transport hostage — the core of the "still // on LAN after leaving Wi-Fi" stall. Fail fast so the store falls straight through to // tailnet/relay. (Healthy connections go setup→preparing→ready and never hit this; // the connect-timeout guard covers a wrong-network socket that stays `.preparing`.) self?.onFailed?("waiting: \(error)"); self?.continuation.finish() case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish() case .cancelled: self?.continuation.finish() default: break } } connection.start(queue: queue) receiveLoop() } private func markReady() { stateLock.lock() ready = true stateLock.unlock() } func frames() -> AsyncStream { stream } func send(_ frame: Data) { connection.send(content: WireFraming.frame(frame), completion: .idempotent) } func close() { connection.cancel() continuation.finish() } private func receiveLoop() { connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in guard let self else { return } if let data, !data.isEmpty { if let frames = try? self.accumulator.push(data) { for frame in frames { self.continuation.yield(frame) } } else { self.close(); return } } if isComplete || error != nil { self.continuation.finish(); return } self.receiveLoop() } } }