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]>
71 lines
2.8 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|
|
}
|