Files
nucleic/Sources/NucleicTailnet/FDFrameChannel.swift
T
abkslmandClaude Fable 5 61e11572b8 Add Tailscale (Tailnet) as a sync transport between Mac and iPhone
Settings ▸ Remote gains a "Connect via" picker — LAN (default), Tailscale
(tailnet), or Relay (disabled, coming soon). On Tailnet, both devices run an
embedded tsnet node via TailscaleKit (tailscale/libtailscale) and sync frames
flow over the user's tailnet, so the phone can connect from anywhere the
tailnet reaches; Noise E2EE runs above the transport unchanged.

- NucleicTailnet (new target, macOS + iOS): TailnetNode wraps TailscaleKit's
  node lifecycle (auth-key login, generation-fenced start/stop since up() is
  un-cancellable) and drops to the framework's public C API for the data path
  — tailscale_dial/listen/accept hand back full-duplex socketpair fds, wrapped
  by FDFrameChannel (DispatchIO) into the shared FrameChannel seam. The Swift
  wrapper's one-way connection actors can't carry a bidirectional stream.
- Host: TailnetListener adopts SyncListener; startSyncServer is single-flight
  and honors toggle-off/picker changes at the commit point; pairing QRs carry
  transport + tailnet IP/port hints (PairingPayload additive optional fields,
  forward/backward compatible over CBOR).
- iPhone: pair/reconnect dial over whichever transport the pairing recorded;
  Settings gains a Tailscale auth-key field (Keychain, committed on editing
  end); connectivity chip shows "Connected · Tailnet".
- TailscaleKit has no SwiftPM distribution: scripts/build-tailscalekit.sh
  builds a pinned libtailscale commit into an untracked local xcframework;
  Package.swift links it only when present (everything builds without it, the
  picker then reports Tailscale support as not built in), and the script
  clears SwiftPM's content-keyed manifest cache so the toggle is picked up.
- iOS floor 17.0 → 18.1 (TailscaleKit requires the iOS 18 Swift runtime);
  package-app.sh embeds the framework in the .app like Sparkle.

703-test suite: no new failures (the 7 fake-claude/fake-grok staging issues
reproduce identically on an untouched checkout — pre-existing, tracked
separately). New coverage: FDFrameChannel over socketpairs, pairing-payload
version-skew both directions, transport-setting resolution.

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

71 lines
2.8 KiB
Swift

import Foundation
import NucleicProtocol
/// A `FrameChannel` over a raw, already-connected file descriptor. The Tailnet transport's
/// dial/accept calls hand back one end of a socketpair that the embedded tsnet node pumps
/// to/from the tailnet TCP stream (libtailscale: "a pipe(2) on which you can use read(2),
/// write(2), and close(2)"), so plain DispatchIO gives us a full-duplex byte stream. Framing
/// matches `LANChannel`/`NWFrameChannel`: `FrameAccumulator` de-frames inbound bytes,
/// `WireFraming` length-prefixes outbound frames.
///
/// TailscaleKit-independent on purpose — it works over any connected fd, which is also what
/// makes it unit-testable with an ordinary socketpair.
public final class FDFrameChannel: FrameChannel, @unchecked Sendable {
public let peerDescription: String
private let io: DispatchIO
private let queue = DispatchQueue(label: "nucleic.tailnet.channel")
private let accumulator = FrameAccumulator()
private let stream: AsyncStream<Data>
private let continuation: AsyncStream<Data>.Continuation
/// Wrap a connected, bidirectional fd. The channel takes ownership: the fd is closed
/// exactly once, when the channel closes (or fails).
public init(fd: Int32, peerDescription: String) {
self.peerDescription = peerDescription
var cont: AsyncStream<Data>.Continuation!
stream = AsyncStream(bufferingPolicy: .unbounded) { cont = $0 }
continuation = cont
io = DispatchIO(type: .stream, fileDescriptor: fd, queue: queue) { _ in
Darwin.close(fd)
}
io.setLimit(lowWater: 1)
receiveLoop()
}
public func frames() -> AsyncStream<Data> { stream }
public func send(_ frame: Data) {
let framed = WireFraming.frame(frame)
let dispatchData = framed.withUnsafeBytes { DispatchData(bytes: $0) }
io.write(offset: 0, data: dispatchData, queue: queue) { [weak self] done, _, error in
if done, error != 0 { self?.teardown() }
}
}
public func close() { teardown() }
private func teardown() {
io.close(flags: .stop)
continuation.finish()
}
private func receiveLoop() {
io.read(offset: 0, length: Int.max, queue: queue) { [weak self] done, data, error in
guard let self else { return }
if let data, !data.isEmpty {
if let frames = try? self.accumulator.push(Data(data)) {
for frame in frames { self.continuation.yield(frame) }
} else {
// Oversize/garbage frame → drop the connection (same as LANChannel).
self.teardown()
return
}
}
if done {
// EOF or error — either way the peer is gone.
self.teardown()
}
}
}
}