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]>
This commit is contained in:
@@ -17,6 +17,16 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
||||
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
|
||||
@@ -28,7 +38,7 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
||||
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
case .ready: self?.onReady?()
|
||||
case .ready: self?.markReady(); self?.onReady?()
|
||||
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
|
||||
case .cancelled: self?.continuation.finish()
|
||||
default: break
|
||||
@@ -38,6 +48,12 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
||||
receiveLoop()
|
||||
}
|
||||
|
||||
private func markReady() {
|
||||
stateLock.lock()
|
||||
ready = true
|
||||
stateLock.unlock()
|
||||
}
|
||||
|
||||
func frames() -> AsyncStream<Data> { stream }
|
||||
|
||||
func send(_ frame: Data) {
|
||||
|
||||
Reference in New Issue
Block a user