98 lines
4.0 KiB
Swift
98 lines
4.0 KiB
Swift
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<Data>
|
|
private let continuation: AsyncStream<Data>.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<Data>.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<Data> { 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()
|
|
}
|
|
}
|
|
}
|