Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Net/NWFrameChannel.swift
T
abkslmandClaude Fable 5 272039cca5 Tailnet: interactive browser login + LAN↔tailnet fallback
Browser login — the auth key is now optional on both platforms. When the
embedded node starts with no key, TailnetNode asks the backend (LocalAPI
backendStatus — IPN state, deliberately not filesystem heuristics: tsnet
writes logs and a machine key on every start, registered or not) whether a
login is needed, triggers login-interactive, surfaces the auth URL through a
new statusStream()/.needsLogin, and waits for Running (4-minute deadline,
generation-fenced against stop/restart). The Mac auto-opens the login page
from Settings ▸ Remote and shows a re-open button; the iPhone auto-opens only
during user-initiated pairing (a background reconnect that suddenly needs a
login must not eject the user to Safari — Settings ▸ Tailscale carries the
link). The remote-access toggle now reflects the in-flight start instead of
snapping off for the whole login window, and toggling off mid-start is
honored at both commit points (before and after host.start).

LAN↔tailnet fallback — picking Tailnet now keeps LAN on too: the host runs
both listeners under a new CompositeSyncListener (merged accept stream; one
child ending doesn't end the rest) and the pairing QR carries both hints.
The phone builds an ordered candidate chain — LAN first (QR hint or Bonjour),
tailnet second — and walks it on pair and reconnect, so a phone that leaves
the Mac's Wi‑Fi rolls over to the tailnet and rolls back when it returns.
A per-channel 4s connect guard (readiness-checking, bound to exactly its
channel) keeps a stale LAN hint from hanging the chain; a stale-client guard
in consume() keeps a replaced client's tail events from advancing it; chain
exhaustion during pairing lands in a terminal failure instead of spinning on
"Connecting…"; routine pre-fallback handshake errors no longer flash the red
error bubble. "Connected · LAN / Tailnet" shows whichever transport won.

Multi-agent review: 11 confirmed findings (incl. the login gate being dead
code via tsnet's eager state-dir writes, and two connect-timeout races), all
fixed and re-verified. Suite green (24 sync-related tests incl. 3 new
CompositeSyncListener tests); macOS + iOS builds clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 16:14:45 -07:00

83 lines
2.9 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) {
let params = NWParameters.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 .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()
}
}
}