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]>
This commit is contained in:
2026-07-03 03:50:45 -07:00
co-authored by Claude Fable 5
parent 3d05909fb1
commit 61e11572b8
22 changed files with 1448 additions and 68 deletions
+3
View File
@@ -15,6 +15,9 @@ nucleic-*.md
# Bundled Linux kernel staged by scripts/fetch-kernel.sh — large binary, not tracked.
# (Resources/ otherwise holds tracked items like AppIcon.icns.)
Resources/vmlinux-arm64
# TailscaleKit framework staged by scripts/build-tailscalekit.sh — large binary, not
# tracked; Package.swift enables the Tailnet transport only when it exists.
third_party/TailscaleKit/TailscaleKit.xcframework
# Cloudflare Workers local dev cache/state — regenerated by wrangler, not tracked.
.wrangler/
+24
View File
@@ -66,6 +66,30 @@ These builds use SwiftPM's default (SwiftBuild) backend. This repo used to force
`com.apple.provenance` xattrs iCloud stamped on files; the repo no longer lives in
iCloud, so that workaround (and the now-deprecated `native` flag) is no longer needed.
### Tailscale (Tailnet transport) — optional
The Remote settings' Tailnet transport embeds a Tailscale (tsnet) node via
**TailscaleKit**, which has no SwiftPM distribution — it's built from a pinned
[libtailscale](https://github.com/tailscale/libtailscale) commit into an untracked local
binary artifact:
```sh
scripts/build-tailscalekit.sh # needs Xcode + a Go toolchain (brew install go)
```
Output: `third_party/TailscaleKit/TailscaleKit.xcframework` (macOS + iOS + simulator
slices, ~100 MB, gitignored). Package.swift links it only when it exists; without it
everything still builds and the transport picker reports Tailscale support as not built
in. The macOS packaging step embeds the framework in the `.app` automatically; the iOS
app links it through the `NucleicTailnet` package product.
One sharp edge: SwiftPM caches the *evaluated* manifest by content, so Package.swift's
artifact-exists check is not re-run when the xcframework appears or disappears with no
manifest change. The build script clears the cache itself, but if you add or remove the
artifact any other way (`git clean -fdx`, deleting it to reclaim space), run
`rm -rf ~/Library/Caches/org.swift.swiftpm/manifests` before the next `swift build`
(in Xcode: File ▸ Packages ▸ Reset Package Caches).
## Packaging `.app` bundles
The channels build as bare SwiftPM executables; `scripts/package-app.sh` wraps one in
+38 -4
View File
@@ -1,4 +1,5 @@
// swift-tools-version: 6.2
import Foundation
import PackageDescription
// Build channel, selected at manifest-evaluation time via the NUCLEIC_CHANNEL
@@ -13,6 +14,15 @@ import PackageDescription
// none stable), and by NucleicCore's `ContainerManager` to suffix non-release container names.
// Branch mapping (see BUILD.md): dev builds from `dev`, canary from `canary`, beta from `staging`,
// rc from `rc`, stable from `main`.
// TailscaleKit (the embedded tsnet node behind the Tailnet sync transport) has no SwiftPM
// distribution it's an Xcode framework built from a pinned libtailscale commit by
// scripts/build-tailscalekit.sh into third_party/TailscaleKit/ (untracked, ~100 MB). The
// binary target is declared only when the artifact exists so a fresh clone builds without
// Go/Xcode legwork; NucleicTailnet compiles either way (`#if canImport(TailscaleKit)`) and
// reports "not built in" at runtime when the framework is absent.
let tailscaleKitAvailable = FileManager.default.fileExists(
atPath: Context.packageDirectory + "/third_party/TailscaleKit/TailscaleKit.xcframework")
let nucleicChannel = (Context.environment["NUCLEIC_CHANNEL"] ?? "dev").lowercased()
let (appProductName, channelDefine): (String, String) = {
switch nucleicChannel {
@@ -34,9 +44,17 @@ let package = Package(
// `containerization` framework, whose in-process vmnet networking requires macOS 26 + Apple
// silicon. `containerization` is a dependency of NucleicCore ONLY (never of the iOS-linked
// NucleicProtocol), so the iOS build resolves the package graph but never compiles it.
platforms: [.macOS(.v26), .iOS(.v17)],
//
// iOS floor is 18.1: TailscaleKit ships with an 18.1 minimum (its `state()` methods use
// the Failure-typed `any AsyncSequence` existential, an iOS 18 Swift-runtime feature), and
// SwiftPM has no per-target floors, so the iOS-linked targets rise with it. Every device
// that runs iOS 17 also runs 18.1, so this drops no hardware only stale installs.
platforms: [.macOS(.v26), .iOS("18.1")],
products: [
.library(name: "NucleicProtocol", targets: ["NucleicProtocol"]),
// The Tailnet transport (embedded tsnet node + fd-backed FrameChannel). A separate
// product because the iPhone client links it directly (NucleicCore is host-only).
.library(name: "NucleicTailnet", targets: ["NucleicTailnet"]),
.library(name: "NucleicCore", targets: ["NucleicCore"]),
// Product (binary / process) name varies by channel see `appProductName`.
.executable(name: appProductName, targets: ["NucleicApp"]),
@@ -67,10 +85,18 @@ let package = Package(
// protocol (ClientMsg/HostMsg), CBOR framing, and the Noise SecureChannel.
// No GRDB / Process / Network compiles for macOS and iOS alike.
.target(name: "NucleicProtocol"),
// The Tailnet sync transport (SYNC_PROTOCOL §3): an embedded tsnet node
// (TailscaleKit) plus the fd-backed FrameChannel both sides speak over it. Compiles
// for macOS (host listener) and iOS (phone dialer) alike; when the TailscaleKit
// artifact is absent the module still builds and `TailnetSupport.isBuiltIn` is false.
.target(
name: "NucleicTailnet",
dependencies: ["NucleicProtocol"] + (tailscaleKitAvailable ? ["TailscaleKit"] : [])),
.target(
name: "NucleicCore",
dependencies: [
"NucleicProtocol",
"NucleicTailnet",
.product(name: "GRDB", package: "GRDB.swift"),
// In-process container runtime (host-only). EXT4 unpacking + vmnet networking are
// reached transitively through `Containerization`; `ContainerizationOCI` provides
@@ -120,8 +146,16 @@ let package = Package(
),
.testTarget(
name: "NucleicCoreTests",
dependencies: ["NucleicCore"],
resources: [.copy("Fixtures")]
dependencies: ["NucleicCore", "NucleicTailnet"],
resources: [.copy("Fixtures")],
// The TailscaleKit binary framework is staged at the build-products root, which
// executables reach via their default rpath but .xctest bundles do not (their
// rpaths point at PackageFrameworks + the bundle's own Frameworks dir). Walk the
// three levels from Contents/MacOS/<binary> back up to the products dir.
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@loader_path/../../.."])]
),
]
] + (tailscaleKitAvailable
? [.binaryTarget(name: "TailscaleKit", path: "third_party/TailscaleKit/TailscaleKit.xcframework")]
: [])
)
+82 -1
View File
@@ -1,12 +1,15 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
import NucleicCore
import NucleicProtocol
import NucleicTailnet
/// Settings section to run the iPhone sync server and pair devices (UX_IOS §7, host side).
/// Toggling on starts the LAN listener; "Add iPhone" shows the pairing QR the phone scans.
struct RemoteAccessSection: View {
@Environment(AppStore.self) private var store
@State private var showPairing = false
@AppStorage(SyncTransportSetting.defaultsKey) private var transportRaw = SyncTransportHint.lan.rawValue
var body: some View {
Section("iPhone remote access") {
@@ -40,7 +43,9 @@ struct RemoteAccessSection: View {
}
}
Text("Code and transcripts are end-to-end encrypted (Noise). Pair over the same WiFi.")
Text(transportRaw == SyncTransportHint.tailnet.rawValue
? "Code and transcripts are end-to-end encrypted (Noise). Pair from anywhere on your tailnet."
: "Code and transcripts are end-to-end encrypted (Noise). Pair over the same WiFi.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
@@ -48,10 +53,86 @@ struct RemoteAccessSection: View {
PairingQRSheet()
}
ConnectionTransportSection()
PushRelaySection()
}
}
/// Settings Remote Connection: which transport paired iPhones reach this Mac over.
/// LAN is the zero-config default. Tailnet runs an embedded Tailscale node (TailscaleKit)
/// so the phone can connect from anywhere on the user's tailnet no relay in the path,
/// same Noise E2EE. Relay (SYNC_PROTOCOL §3.2) isn't built yet, so its row is disabled.
private struct ConnectionTransportSection: View {
@Environment(AppStore.self) private var store
@AppStorage(SyncTransportSetting.defaultsKey) private var transportRaw = SyncTransportHint.lan.rawValue
@State private var authKey: String = TailnetAuthStore.loadAuthKey() ?? ""
@FocusState private var authKeyFocused: Bool
private var transport: SyncTransportHint { SyncTransportHint(rawValue: transportRaw) ?? .lan }
var body: some View {
Section("Connection") {
Picker("Connect via", selection: $transportRaw) {
Text("Local network (LAN)").tag(SyncTransportHint.lan.rawValue)
Text("Tailscale (tailnet)").tag(SyncTransportHint.tailnet.rawValue)
Text("Relay — coming soon").tag(SyncTransportHint.relay.rawValue)
.selectionDisabled()
}
.onChange(of: transportRaw) { restartIfRunning() }
if transport == .tailnet {
if TailnetSupport.isBuiltIn {
// Commit on editing end, not per keystroke a per-change save would
// clear the valid Keychain key on the first backspace of an edit.
SecureField("Tailscale auth key", text: $authKey, prompt: Text("tskey-auth-…"))
.autocorrectionDisabled()
.focused($authKeyFocused)
.onSubmit { TailnetAuthStore.saveAuthKey(authKey) }
.onChange(of: authKeyFocused) {
if !authKeyFocused { TailnetAuthStore.saveAuthKey(authKey) }
}
if let status = store.tailnetStatus {
LabeledContent("Tailscale node", value: status)
}
} else {
Text("This build doesn't include Tailscale support — run scripts/build-tailscalekit.sh and rebuild.")
.font(.caption).foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
}
}
Text(caption)
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var caption: String {
switch transport {
case .lan:
"iPhones connect directly to this Mac over the same WiFi or LAN."
case .tailnet:
"This Mac joins your Tailscale tailnet as its own device, and paired iPhones "
+ "connect from anywhere the tailnet reaches. Create an auth key in the Tailscale "
+ "admin console (Settings ▸ Keys); it's kept in the Keychain and only needed "
+ "until the Mac has registered."
case .relay:
"Relay connections through Nucleic's cloud aren't available yet."
}
}
/// Settings are read when the sync server starts; make a transport change take effect
/// immediately by bouncing remote access when it's running (same as the Relay toggle).
private func restartIfRunning() {
guard store.syncRunning else { return }
Task {
await store.stopSyncServer()
await store.startSyncServer()
}
}
}
/// The Relay section (docs/PUSH_SETUP.md §4): one toggle. Turning it on lets the Mac ask
/// the relay to wake paired iPhones for approvals when they aren't connected. There is
/// nothing to configure the host self-enrolls with the relay for its own scoped
+114 -17
View File
@@ -1,4 +1,5 @@
import Foundation
import NucleicTailnet
import Observation
/// Rollup numbers for the home dashboard.
@@ -704,11 +705,21 @@ public final class AppStore: ConflictArbiter {
/// Display name advertised to paired devices (the Mac's name).
public var syncHostName: String = "Mac"
/// The running LAN sync server (nil when remote access is off).
/// The running sync server (nil when remote access is off).
private var syncHost: SyncHost?
private var syncListener: LANListener?
private var syncListener: (any SyncListener)?
/// Which transport the running listener is bound to (drives the pairing QR's hints).
/// Snapshotted from Settings at `startSyncServer`; changing the picker restarts the server.
private var syncTransport: SyncTransportHint = .lan
/// Whether the iPhone remote server is currently listening.
public private(set) var syncRunning = false
/// The embedded Tailscale node's state, for Settings Remote (nil = not running).
public private(set) var tailnetStatus: String?
/// Single-flight guard for `startSyncServer`: the tailnet path can await for many
/// seconds (node login), and the `syncHost == nil` check alone doesn't cover that
/// window. A stop requested mid-start is honored at the commit point.
private var syncStartInFlight = false
private var syncStopRequested = false
/// The active pairing QR string to render (nil unless an "Add iPhone" window is open).
public private(set) var pairingQR: String?
/// Labels of currently paired devices (for the Settings list).
@@ -5125,9 +5136,17 @@ public final class AppStore: ConflictArbiter {
// MARK: - Sync server lifecycle (iPhone remote access)
/// Start advertising + listening for iPhone clients (SYNC §3.1). Idempotent.
/// Start advertising + listening for iPhone clients (SYNC §3.1) on the transport the
/// Settings picker selects LAN (Bonjour + TCP) or the user's tailnet. Idempotent;
/// single-flight (the tailnet path awaits a node login for seconds, and a toggle-off or
/// picker change during that window is honored at the commit point).
public func startSyncServer() async {
guard syncHost == nil else { return }
guard syncHost == nil, !syncStartInFlight else { return }
syncStartInFlight = true
defer {
syncStartInFlight = false
syncStopRequested = false
}
if syncHostName == "Mac" { syncHostName = Host.current().localizedName ?? "Mac" }
let identity = HostIdentityStore.loadOrCreate()
let store = InMemoryPairedDeviceStore()
@@ -5137,33 +5156,111 @@ public final class AppStore: ConflictArbiter {
// Worker secrets, never on a user's machine.
let pushRelay = PushRelayConfig.resolve().map { PushRelayClient(config: $0) }
let host = SyncHost(identity: identity, bridge: self, store: store, pushRelay: pushRelay)
let listener = LANListener(identityFingerprint: identity.fingerprint)
do {
try await host.start(listener: listener)
syncHost = host
syncListener = listener
syncRunning = true
await refreshPairedDevices()
} catch {
lastError = "Couldn't start remote access: \(error)"
while true {
let transport = SyncTransportSetting.resolve()
let listener: any SyncListener
switch transport {
case .lan:
listener = LANListener(identityFingerprint: identity.fingerprint)
case .tailnet:
do {
listener = try await startTailnetListener()
} catch {
tailnetStatus = await TailnetNode.shared.status.label
// A half-started node must not outlive its failed start nothing
// else would own (or ever stop) it.
await TailnetNode.shared.stop()
lastError = "Couldn't start remote access over Tailscale: \(error.localizedDescription)"
return
}
case .relay:
// SYNC_PROTOCOL §3.2 the relay data transport doesn't exist yet; the picker
// disables this choice, so only a stale defaults value lands here.
lastError = "Relay connections aren't available yet — choose LAN or Tailnet."
return
}
// Release whatever this pass brought up without committing it.
func abandon() async {
listener.stop()
if transport == .tailnet {
await TailnetNode.shared.stop()
tailnetStatus = nil
}
}
if syncStopRequested { // toggled off while the start was in flight
await abandon()
return
}
if SyncTransportSetting.resolve() != transport { // picker changed mid-start
await abandon()
continue
}
do {
try await host.start(listener: listener)
syncHost = host
syncListener = listener
syncTransport = transport
syncRunning = true
await refreshPairedDevices()
} catch {
await abandon()
lastError = "Couldn't start remote access: \(error)"
}
return
}
}
/// Bring the embedded Tailscale node up (first run needs the Keychain auth key; after
/// that the node's on-disk state carries the registration) and bind the sync listener
/// to it on the fixed tailnet port.
private func startTailnetListener() async throws -> TailnetListener {
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
let config = SyncTransportSetting.nodeConfig(hostName: syncHostName)
if config.authKey == nil, !config.hasExistingState {
throw TailnetError.notConfigured(
"Add a Tailscale auth key in Settings ▸ Remote first (Tailscale admin console ▸ Keys).")
}
tailnetStatus = "Starting…"
try await TailnetNode.shared.ensureRunning(config: config)
tailnetStatus = await TailnetNode.shared.status.label
return try await TailnetNode.shared.makeListener(port: SyncTransportSetting.tailnetPort)
}
public func stopSyncServer() async {
// A start may be mid-flight (node login); it checks this at its commit point and
// abandons what it brought up.
if syncStartInFlight { syncStopRequested = true }
await syncHost?.stop()
syncHost = nil
syncListener = nil
syncRunning = false
pairingQR = nil
if syncTransport == .tailnet {
await TailnetNode.shared.stop()
tailnetStatus = nil
}
}
/// Begin a pairing window and publish the QR string for the Settings sheet to render.
/// The QR carries this Mac's LAN IP + port so the phone dials directly (Bonjour is only a
/// fallback).
/// The QR carries the active transport's connection hint the Mac's LAN IP + port
/// (Bonjour is only a fallback) or its tailnet IP + fixed port.
public func beginPairing() async {
guard let syncHost else { return }
let payload = await syncHost.beginPairing(
lanHost: LANAddress.primaryIPv4(), lanPort: syncListener?.assignedPort)
let payload: PairingPayload
switch syncTransport {
case .tailnet:
let addrs = await TailnetNode.shared.addresses
payload = await syncHost.beginPairing(
transport: .tailnet,
tailnetHost: addrs?.ip4 ?? addrs?.ip6,
tailnetPort: (syncListener as? TailnetListener)?.port)
case .lan, .relay:
payload = await syncHost.beginPairing(
lanHost: LANAddress.primaryIPv4(),
lanPort: (syncListener as? LANListener)?.assignedPort)
}
pairingQR = try? payload.qrString()
}
+8 -2
View File
@@ -103,13 +103,19 @@ public actor SyncHost {
/// Begin a pairing window: mint a one-time secret and return the QR payload to display.
/// The secret stays active until a device completes pairing (`didPair`) or `cancelPairing`.
public func beginPairing(lanHost: String? = nil, lanPort: UInt16? = nil) async -> PairingPayload {
/// The connection hints describe whichever transport the host's listener is bound to.
public func beginPairing(
lanHost: String? = nil, lanPort: UInt16? = nil,
transport: SyncTransportHint? = nil,
tailnetHost: String? = nil, tailnetPort: UInt16? = nil
) async -> PairingPayload {
let secret = PairingPayload.freshSecret()
pairingSecret = secret
return PairingPayload(
hostName: await bridge.hostInfo.hostName,
hostStaticKey: identity.staticPublicKey,
pairingSecret: secret, lanHost: lanHost, lanPort: lanPort)
pairingSecret: secret, lanHost: lanHost, lanPort: lanPort,
transport: transport?.rawValue, tailnetHost: tailnetHost, tailnetPort: tailnetPort)
}
public func cancelPairing() { pairingSecret = nil }
@@ -0,0 +1,69 @@
import Foundation
import NucleicProtocol
import NucleicTailnet
/// The tailnet listener speaks the host transport seam natively (`start()` yields accepted
/// `FrameChannel`s, `stop()` winds down); the conformance lives here because `SyncListener`
/// is host-only while `NucleicTailnet` also compiles for the phone.
extension TailnetListener: SyncListener {}
/// Host-side transport selection + embedded-node configuration for the sync server
/// (Settings Remote Connection; SYNC_PROTOCOL §3). Mirrors `PushRelayConfig.resolve`:
/// env var first (scripted runs), then the defaults key the Settings picker writes.
public enum SyncTransportSetting {
/// Settings picker storage: `lan` | `tailnet` | `relay` (`SyncTransportHint` raw values).
public static let defaultsKey = "nucleic.sync.transport"
public static let envKey = "NUCLEIC_SYNC_TRANSPORT"
/// The tailnet listener's fixed port. Unlike LAN (OS-assigned, carried in the QR
/// because it changes), a tailnet port has no collision pressure the IP is private to
/// the user's tailnet and staying fixed keeps a phone's stored hint valid forever.
public static let tailnetPort: UInt16 = 43_753
public static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
defaults: UserDefaults = .standard
) -> SyncTransportHint {
let raw = environment[envKey] ?? defaults.string(forKey: defaultsKey)
return raw.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan
}
/// This Mac's embedded-node config. The state directory is the node's identity on the
/// tailnet per release channel, so a beta and a local dev build don't fight over one
/// registration and the auth key comes from the Keychain (`TailnetAuthStore`), needed
/// only until that state exists.
public static func nodeConfig(hostName: String) -> TailnetConfig {
TailnetConfig(
hostName: tailnetHostName(hostName),
stateDirectory: stateDirectory,
authKey: TailnetAuthStore.loadAuthKey())
}
private static var stateDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Nucleic", isDirectory: true)
.appendingPathComponent("tailnet\(channelSuffix)", isDirectory: true)
}
/// The node name in the tailnet admin console: "nucleic-<mac-name>", DNS-label-safe,
/// with the channel suffix separating a beta's node from a dev build's.
static func tailnetHostName(_ hostName: String) -> String {
TailnetConfig.nodeName(for: hostName, suffix: channelSuffix)
}
/// Same channel suffix mapping as `ContainerManager`'s container names (Package.swift
/// defines), so every per-channel artifact reads consistently.
private static var channelSuffix: String {
#if NUCLEIC_STABLE
""
#elseif NUCLEIC_RC
"-rc"
#elseif NUCLEIC_BETA
"-beta"
#elseif NUCLEIC_CANARY
"-canary"
#else
"-local"
#endif
}
}
@@ -87,8 +87,14 @@ public final class SecureSession {
// MARK: - Pairing QR payload (SYNC §4.2)
/// What the Mac encodes into the pairing QR: its static public key, a one-time pairing
/// secret (the Noise PSK), a human label, and a LAN hint. The phone scans this to bootstrap
/// the `xxpsk0` pairing handshake bound to exactly this QR.
/// secret (the Noise PSK), a human label, and a connection hint for whichever transport the
/// host is listening on LAN (`lanHost`/`lanPort`) or tailnet (`tailnetHost`/`tailnetPort`).
/// The phone scans this to bootstrap the `xxpsk0` pairing handshake bound to exactly this QR.
///
/// `transport` is a raw string (see `SyncTransportHint`), not an enum, so an old phone
/// scanning a newer QR or this build scanning a future transport still decodes the
/// payload and can say "unsupported" instead of failing the scan. All hint fields are
/// optional; a payload from an older host simply decodes them as nil (LAN).
public struct PairingPayload: Sendable, Codable, Equatable {
public let protocolVersion: Int
public let hostName: String
@@ -96,11 +102,17 @@ public struct PairingPayload: Sendable, Codable, Equatable {
public let pairingSecret: Data // 32 bytes; the Noise PSK
public let lanHost: String?
public let lanPort: UInt16?
/// Which transport the host is listening on (`SyncTransportHint` raw value); nil = LAN.
public let transport: String?
/// The host's tailnet IP (v4 preferred) when `transport == .tailnet`.
public let tailnetHost: String?
public let tailnetPort: UInt16?
public init(
protocolVersion: Int = SyncProtocol.version,
hostName: String, hostStaticKey: Data, pairingSecret: Data,
lanHost: String? = nil, lanPort: UInt16? = nil
lanHost: String? = nil, lanPort: UInt16? = nil,
transport: String? = nil, tailnetHost: String? = nil, tailnetPort: UInt16? = nil
) {
self.protocolVersion = protocolVersion
self.hostName = hostName
@@ -108,6 +120,17 @@ public struct PairingPayload: Sendable, Codable, Equatable {
self.pairingSecret = pairingSecret
self.lanHost = lanHost
self.lanPort = lanPort
self.transport = transport
self.tailnetHost = tailnetHost
self.tailnetPort = tailnetPort
}
/// The transport hint: `.lan` when absent (pre-transport hosts), nil when the QR names
/// a transport this build doesn't know callers surface "update the app" rather than
/// silently dialing the wrong way.
public var transportHint: SyncTransportHint? {
guard let transport else { return .lan }
return SyncTransportHint(rawValue: transport)
}
public static func freshSecret() -> Data {
@@ -0,0 +1,26 @@
import Foundation
/// How sync bytes move between the Mac and a phone (SYNC_PROTOCOL §3) the transport named
/// in a pairing QR and remembered by the phone for reconnects. Everything above it (Noise,
/// frames, messages) is identical across cases.
///
/// On the wire this travels as its raw string inside optional fields, so unknown future
/// values degrade to "unsupported", not decode failures.
public enum SyncTransportHint: String, Sendable, Codable, CaseIterable {
/// Same-network TCP, discovered over Bonjour or an explicit QR host:port hint.
case lan
/// The user's tailnet: both devices run an embedded tsnet node (NucleicTailnet) and the
/// phone dials the Mac's tailnet IP. Works from anywhere the tailnet reaches.
case tailnet
/// Cloudflare relay (SYNC_PROTOCOL §3.2) not yet implemented.
case relay
/// Short user-facing name ("Connected · LAN", the Settings picker, ).
public var label: String {
switch self {
case .lan: "LAN"
case .tailnet: "Tailnet"
case .relay: "Relay"
}
}
}
@@ -0,0 +1,70 @@
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()
}
}
}
}
@@ -0,0 +1,105 @@
import Foundation
import NucleicProtocol
#if canImport(TailscaleKit)
import TailscaleKit
#endif
/// The host-side tailnet listener: binds `:port` on the embedded node and yields each
/// accepted connection as an `FDFrameChannel`. Mirrors `LANListener`'s shape so NucleicCore
/// can adopt it as a `SyncListener` (the conformance lives there the protocol is
/// host-only). Created via `TailnetNode.makeListener`, never directly.
///
/// libtailscale accept mechanics: the listener fd is one half of a socketpair; Go accepts
/// tailnet connections proactively and passes each conn fd across with SCM_RIGHTS, so
/// `POLLIN` on the listener fd means `tailscale_accept` will return promptly. We poll with
/// a short tick rather than blocking so `stop()` can wind the loop down without racing the
/// fd's reuse (closing an fd out from under poll(2) is undefined on Darwin).
public final class TailnetListener: @unchecked Sendable {
public let port: UInt16
/// The node's tailnet addresses at creation time (advertised in the pairing QR).
public let ip4: String?
public let ip6: String?
private let handle: Int32
private let state = NSLock()
private var stopped = false
private var listenerFD: Int32 = -1
private var continuation: AsyncStream<any FrameChannel>.Continuation?
init(handle: Int32, port: UInt16, ip4: String?, ip6: String?) {
self.handle = handle
self.port = port
self.ip4 = ip4
self.ip6 = ip6
}
public func start() throws -> AsyncStream<any FrameChannel> {
#if canImport(TailscaleKit)
var boundFD: Int32 = -1
let rc = tailscale_listen(handle, "tcp", ":\(port)", &boundFD)
guard rc == 0 else {
throw TailnetError.listenFailed(lastTailscaleError(handle: handle))
}
let lfd = boundFD
state.lock()
listenerFD = lfd
state.unlock()
let stream = AsyncStream<any FrameChannel> { continuation in
self.continuation = continuation
}
Thread.detachNewThread { [self] in acceptLoop(lfd) }
return stream
#else
throw TailnetError.notBuiltIn
#endif
}
public func stop() {
state.lock()
stopped = true
state.unlock()
// Wake consumers immediately; the accept loop notices within one poll tick and
// closes the listener fd itself.
continuation?.finish()
}
private var isStopped: Bool {
state.lock()
defer { state.unlock() }
return stopped
}
#if canImport(TailscaleKit)
private func acceptLoop(_ lfd: Int32) {
while !isStopped {
var probe = pollfd(fd: lfd, events: Int16(POLLIN), revents: 0)
let n = poll(&probe, 1, 1000)
if isStopped { break }
if n < 0 {
if errno == EINTR { continue }
break
}
if n == 0 { continue } // tick re-check the stop flag
guard probe.revents & Int16(POLLIN) != 0 else { break } // HUP/ERR/NVAL
var cfd: Int32 = -1
guard tailscale_accept(lfd, &cfd) == 0 else { continue }
let channel = FDFrameChannel(fd: cfd, peerDescription: peerDescription(lfd: lfd, cfd: cfd))
// stop() may have finished the stream between the poll and here a dropped
// yield must still release the conn fd, or it lingers until the peer times out.
if case .enqueued = continuation?.yield(channel) {} else { channel.close() }
}
Darwin.close(lfd)
continuation?.finish()
}
/// Remote tailnet IP for logs. Best-effort: upstream notes the fd bookkeeping behind
/// `tailscale_getremoteaddr` can miss (EBADF), so fall back to a generic label.
private func peerDescription(lfd: Int32, cfd: Int32) -> String {
var buf = [CChar](repeating: 0, count: 128)
guard tailscale_getremoteaddr(lfd, cfd, &buf, buf.count) == 0 else { return "tailnet peer" }
let ip = nulTerminatedString(buf)
return ip.isEmpty ? "tailnet peer" : "\(ip) (tailnet)"
}
#endif
}
+264
View File
@@ -0,0 +1,264 @@
import Foundation
#if canImport(TailscaleKit)
import TailscaleKit
#endif
/// The one embedded tsnet node this process runs the device's identity on the user's
/// tailnet (each side of a Nucleic pairing runs its own). Wraps TailscaleKit's
/// `TailscaleNode` for lifecycle (start/login/addresses) and drops to the framework's
/// public C API (`tailscale_dial`/`tailscale_listen`) for the data path, because the raw
/// calls hand back full-duplex fds the Swift wrapper's connection actors are one-way.
///
/// Built without TailscaleKit, the type still exists (so callers compile) and every entry
/// point throws `TailnetError.notBuiltIn`.
public actor TailnetNode {
public static let shared = TailnetNode()
public enum Status: Sendable, Equatable {
case stopped
case starting
case running(ip4: String?, ip6: String?)
case failed(String)
/// Short user-facing status line for the Settings UI.
public var label: String {
switch self {
case .stopped: "Off"
case .starting: "Starting…"
case .running(let ip4, let ip6): "Connected · \(ip4 ?? ip6 ?? "no address")"
case .failed(let message): message
}
}
}
public private(set) var status: Status = .stopped
#if canImport(TailscaleKit)
private var node: TailscaleNode?
private var handle: Int32?
private var activeConfig: TailnetConfig?
private var startTask: Task<Void, Error>?
/// Bumped by every shutdown. `performStart` may be parked in the un-cancellable `up()`
/// for up to its deadline while the actor stays reentrant; a start may only commit its
/// node if no stop()/restart superseded it in that window (else it closes its own node).
private var startGeneration = 0
#endif
/// The node's tailnet addresses once running (what pairing advertises to the phone).
public var addresses: (ip4: String?, ip6: String?)? {
guard case .running(let ip4, let ip6) = status else { return nil }
return (ip4, ip6)
}
/// Start the node (or join an in-flight start) and wait until it's usable on the
/// tailnet. Idempotent for an unchanged config; a changed config restarts the node.
/// `up()` normally returns in a couple of seconds; the deadline exists because with a
/// bad/missing auth key tsnet waits forever for an interactive login that never comes.
public func ensureRunning(config: TailnetConfig, upTimeout: TimeInterval = 45) async throws {
#if canImport(TailscaleKit)
if let startTask, activeConfig == config {
return try await startTask.value
}
await shutdownNode()
activeConfig = config
let generation = startGeneration
let task = Task { try await self.performStart(config: config, upTimeout: upTimeout, generation: generation) }
startTask = task
do {
try await task.value
} catch {
// Only clear our own memoization a reentrant restart may have installed a
// newer task while we were parked on this one's value.
if startTask == task { startTask = nil }
throw error
}
#else
throw TailnetError.notBuiltIn
#endif
}
public func stop() async {
#if canImport(TailscaleKit)
await shutdownNode()
#endif
status = .stopped
}
/// Dial `host:port` over the tailnet and wrap the resulting fd as a `FrameChannel`.
public func dial(host: String, port: UInt16, timeout: TimeInterval = 20) async throws -> FDFrameChannel {
#if canImport(TailscaleKit)
guard case .running = status, let handle else {
throw TailnetError.notConfigured("The Tailscale node isn't running.")
}
let address = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
let once = ResumeOnce()
return try await withCheckedThrowingContinuation { cont in
// tailscale_dial blocks with no cancellation hook give it its own thread and
// race a deadline; if the deadline wins, the late fd is closed so nothing leaks.
Thread.detachNewThread {
var fd: Int32 = 0
let rc = tailscale_dial(handle, "tcp", address, &fd)
if once.claim() {
if rc == 0 {
cont.resume(returning: FDFrameChannel(fd: fd, peerDescription: "\(address) (tailnet)"))
} else {
cont.resume(throwing: TailnetError.dialFailed(lastTailscaleError(handle: handle)))
}
} else if rc == 0 {
Darwin.close(fd)
}
}
Task.detached {
try? await Task.sleep(for: .seconds(timeout))
if once.claim() {
cont.resume(throwing: TailnetError.timedOut("Couldn't reach the Mac over Tailscale."))
}
}
}
#else
throw TailnetError.notBuiltIn
#endif
}
/// A listener bound to the tailnet on `port`, for the host side. The returned listener
/// is inert until its `start()` matching the `SyncListener` shape NucleicCore adapts
/// it to.
public func makeListener(port: UInt16) throws -> TailnetListener {
#if canImport(TailscaleKit)
guard case .running(let ip4, let ip6) = status, let handle else {
throw TailnetError.notConfigured("The Tailscale node isn't running.")
}
return TailnetListener(handle: handle, port: port, ip4: ip4, ip6: ip6)
#else
throw TailnetError.notBuiltIn
#endif
}
// MARK: - Internals
#if canImport(TailscaleKit)
private func performStart(config: TailnetConfig, upTimeout: TimeInterval, generation: Int) async throws {
guard generation == startGeneration else { throw TailnetError.nodeFailed("stopped during startup") }
status = .starting
do {
try FileManager.default.createDirectory(
at: config.stateDirectory, withIntermediateDirectories: true)
// Go-side logs are a firehose (netmap dumps); keep them opt-in for debugging.
let verbose = ProcessInfo.processInfo.environment["NUCLEIC_TAILNET_VERBOSE"] == "1"
let tsConfig = Configuration(
hostName: config.hostName,
path: config.stateDirectory.path,
authKey: config.authKey,
controlURL: config.controlURL ?? kDefaultControlURL,
ephemeral: config.ephemeral)
let node = try TailscaleNode(config: tsConfig, logger: verbose ? StderrLogSink() : nil)
do {
try await Self.withDeadline(
seconds: upTimeout,
timeoutMessage: "Tailscale login timed out — check the auth key."
) {
try await node.up()
}
} catch {
// up() may still be blocked inside the node; close in the background.
Task.detached { try? await node.close() }
throw error
}
let addrs = try await node.addrs()
let handle = await node.tailscale
// A stop() or config-change restart superseded this start while up() was
// parked this node must die quietly, not win the commit.
guard generation == startGeneration else {
Task.detached { try? await node.close() }
throw TailnetError.nodeFailed("stopped during startup")
}
self.node = node
self.handle = handle
status = .running(ip4: addrs.ip4, ip6: addrs.ip6)
} catch {
let message = (error as? TailnetError)?.errorDescription ?? "\(error)"
// A superseded start must not clobber the current attempt's status either.
if generation == startGeneration { status = .failed(message) }
if error is TailnetError { throw error }
throw TailnetError.nodeFailed(message)
}
}
private func shutdownNode() async {
// Fence out any in-flight start, and clear all state BEFORE the suspension so a
// reentrant caller can't observe (and double-close) a half-shut node.
startGeneration += 1
startTask = nil
let node = self.node
self.node = nil
handle = nil
activeConfig = nil
if let node {
// close() is the real teardown upstream's down() mistakenly calls tailscale_up.
try? await node.close()
}
}
/// Race an un-cancellable async operation against a deadline. Deliberately unstructured:
/// a task group would await the blocked child even after cancelAll, defeating the point.
private static func withDeadline<T: Sendable>(
seconds: TimeInterval, timeoutMessage: String,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
let once = ResumeOnce()
return try await withCheckedThrowingContinuation { cont in
Task.detached {
do {
let value = try await operation()
if once.claim() { cont.resume(returning: value) }
} catch {
if once.claim() { cont.resume(throwing: error) }
}
}
Task.detached {
try? await Task.sleep(for: .seconds(seconds))
if once.claim() { cont.resume(throwing: TailnetError.timedOut(timeoutMessage)) }
}
}
}
#endif
}
#if canImport(TailscaleKit)
/// Read `tailscale_errmsg` for a handle (best-effort; the C API's only error detail).
func lastTailscaleError(handle: Int32) -> String {
var buf = [CChar](repeating: 0, count: 1024)
guard tailscale_errmsg(handle, &buf, buf.count) == 0 else { return "unknown tailscale error" }
let message = nulTerminatedString(buf)
return message.isEmpty ? "unknown tailscale error" : message
}
/// Verbose-mode sink for the node's logs (`NUCLEIC_TAILNET_VERBOSE=1`): Go's log stream
/// goes straight to stderr via the file handle; Swift-side wrapper messages likewise.
private struct StderrLogSink: LogSink {
var logFileHandle: Int32? { STDERR_FILENO }
func log(_ message: String) {
FileHandle.standardError.write(Data(("tailnet: " + message + "\n").utf8))
}
}
#endif
/// Decode a NUL-terminated C string buffer (what the tailscale C API writes into).
func nulTerminatedString(_ buf: [CChar]) -> String {
String(decoding: buf.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) }, as: UTF8.self)
}
/// First-caller-wins flag for racing an un-cancellable operation against a deadline.
final class ResumeOnce: @unchecked Sendable {
private let lock = NSLock()
private var claimed = false
/// True exactly once, for the first claimant.
func claim() -> Bool {
lock.lock()
defer { lock.unlock() }
if claimed { return false }
claimed = true
return true
}
}
+136
View File
@@ -0,0 +1,136 @@
import Foundation
import Security
// The Tailnet transport (SYNC_PROTOCOL §3): both devices run an embedded tsnet node
// (TailscaleKit) on the user's own tailnet, and sync frames flow over an ordinary TCP
// stream between the two tailnet IPs. Noise E2EE runs unchanged above it like the LAN
// and (future) relay transports, this layer only moves bytes.
//
// TailscaleKit is a locally built binary artifact (scripts/build-tailscalekit.sh), so this
// module must compile without it: every TailscaleKit reference sits behind
// `#if canImport(TailscaleKit)` and the public surface stays identical either way, with
// `TailnetSupport.isBuiltIn` telling the UI whether the transport can actually run.
/// Whether this binary was built with the TailscaleKit framework (see Package.swift's
/// `tailscaleKitAvailable`). When false, every Tailnet entry point throws `.notBuiltIn`.
public enum TailnetSupport {
public static var isBuiltIn: Bool {
#if canImport(TailscaleKit)
true
#else
false
#endif
}
}
public enum TailnetError: Error, Equatable, LocalizedError {
/// Built without the TailscaleKit artifact (scripts/build-tailscalekit.sh not run).
case notBuiltIn
/// Missing prerequisite typically no auth key on a first login.
case notConfigured(String)
case nodeFailed(String)
case listenFailed(String)
case dialFailed(String)
case timedOut(String)
public var errorDescription: String? {
switch self {
case .notBuiltIn:
"Tailscale support isn't built into this copy of Nucleic (run scripts/build-tailscalekit.sh and rebuild)."
case .notConfigured(let m): m
case .nodeFailed(let m): "Tailscale node failed: \(m)"
case .listenFailed(let m): "Tailnet listener failed: \(m)"
case .dialFailed(let m): "Tailnet connection failed: \(m)"
case .timedOut(let m): m
}
}
}
/// How to run this device's embedded tsnet node. The state directory is the node's whole
/// identity: reusing it across launches keeps the machine registered on the tailnet, so the
/// auth key is only needed the first time (or after the node is removed from the tailnet).
public struct TailnetConfig: Sendable, Equatable {
/// Node name shown in the tailnet admin console (e.g. "nucleic-mymac").
public var hostName: String
/// Writable directory holding tsnet state (node keys, netmap cache). Unique per node.
public var stateDirectory: URL
/// A `tskey-auth-` key from the tailnet admin console; nil once the state directory
/// already holds a registered identity.
public var authKey: String?
/// Coordination server override (Headscale etc.); nil = the Tailscale SaaS default.
public var controlURL: String?
/// Ephemeral nodes are auto-removed by the control plane after going offline.
public var ephemeral: Bool
public init(
hostName: String, stateDirectory: URL, authKey: String? = nil,
controlURL: String? = nil, ephemeral: Bool = false
) {
self.hostName = hostName
self.stateDirectory = stateDirectory
self.authKey = authKey
self.controlURL = controlURL
self.ephemeral = ephemeral
}
/// DNS-label-safe tailnet node name: "nucleic-<name>" with non-alphanumerics dashed and
/// runs collapsed, plus an optional suffix (the Mac uses its release channel so a beta's
/// node doesn't collide with a dev build's).
public static func nodeName(for deviceName: String, suffix: String = "") -> String {
let dashed = deviceName.lowercased().map { ch -> Character in
ch.isASCII && (ch.isLetter || ch.isNumber) ? ch : "-"
}
let collapsed = String(dashed).split(separator: "-").joined(separator: "-")
return "nucleic-\(collapsed.isEmpty ? "device" : collapsed)\(suffix)"
}
/// Whether the state directory already holds a registered node identity (tsnet state),
/// i.e. the node can come up without an auth key.
public var hasExistingState: Bool {
let contents = try? FileManager.default.contentsOfDirectory(atPath: stateDirectory.path)
return !(contents ?? []).isEmpty
}
}
/// Keychain persistence for the user's Tailscale auth key a real credential (it can
/// register nodes on their tailnet), so it never touches UserDefaults. Same generic-password
/// pattern as the identity stores. The key is only read at node start; once the state
/// directory holds a registered identity it's no longer strictly needed, but we keep it for
/// re-registration after a revoke.
public enum TailnetAuthStore {
private static let account = "xyz.blakeslee.nucleic.tailnet.authkey"
public static func loadAuthKey() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let key = String(data: data, encoding: .utf8), !key.isEmpty
else { return nil }
return key
}
/// Save (or, for nil/empty, remove) the auth key.
public static func saveAuthKey(_ key: String?) {
let delete: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
]
SecItemDelete(delete as CFDictionary)
guard let key, !key.isEmpty else { return }
var add: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: Data(key.utf8),
]
#if os(iOS)
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
#endif
SecItemAdd(add as CFDictionary, nil)
}
}
@@ -0,0 +1,78 @@
import Foundation
import Testing
import NucleicProtocol
@testable import NucleicTailnet
/// `FDFrameChannel` is the byte layer of the Tailnet transport, but it's fd-generic so an
/// ordinary socketpair stands in for the tsnet conn and exercises the real DispatchIO
/// framing paths on both ends.
@Suite struct FDFrameChannelTests {
private func socketPair() -> (Int32, Int32) {
var fds: [Int32] = [0, 0]
precondition(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0)
return (fds[0], fds[1])
}
/// First frame from a channel's stream, bounded so a broken channel fails instead of
/// hanging the suite.
private func nextFrame(_ channel: FDFrameChannel, within seconds: Double = 5) async -> Data? {
await withTaskGroup(of: Data?.self) { group in
group.addTask {
for await frame in channel.frames() { return frame }
return nil
}
group.addTask {
try? await Task.sleep(for: .seconds(seconds))
return nil
}
let first = await group.next()!
group.cancelAll()
return first
}
}
@Test func framesFlowBothWays() async {
let (a, b) = socketPair()
let left = FDFrameChannel(fd: a, peerDescription: "left")
let right = FDFrameChannel(fd: b, peerDescription: "right")
left.send(Data("hello".utf8))
#expect(await nextFrame(right) == Data("hello".utf8))
right.send(Data("world".utf8))
#expect(await nextFrame(left) == Data("world".utf8))
left.close()
right.close()
}
@Test func framesArriveWholeAndInOrder() async {
let (a, b) = socketPair()
let left = FDFrameChannel(fd: a, peerDescription: "left")
let right = FDFrameChannel(fd: b, peerDescription: "right")
let sent = (0..<20).map { Data("frame-\($0) \(String(repeating: "x", count: $0 * 97))".utf8) }
for frame in sent { left.send(frame) }
var received: [Data] = []
for await frame in right.frames() {
received.append(frame)
if received.count == sent.count { break }
}
#expect(received == sent)
left.close()
right.close()
}
@Test func peerCloseFinishesStream() async {
let (a, b) = socketPair()
let left = FDFrameChannel(fd: a, peerDescription: "left")
let right = FDFrameChannel(fd: b, peerDescription: "right")
left.close()
#expect(await nextFrame(right) == nil)
right.close()
}
}
@@ -0,0 +1,44 @@
import Foundation
import Testing
import NucleicProtocol
import NucleicTailnet
@testable import NucleicCore
@Suite struct SyncTransportSettingTests {
private func freshDefaults() -> UserDefaults {
let suite = "SyncTransportSettingTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
@Test func defaultsToLAN() {
#expect(SyncTransportSetting.resolve(environment: [:], defaults: freshDefaults()) == .lan)
}
@Test func readsTheSettingsPickerValue() {
let defaults = freshDefaults()
defaults.set(SyncTransportHint.tailnet.rawValue, forKey: SyncTransportSetting.defaultsKey)
#expect(SyncTransportSetting.resolve(environment: [:], defaults: defaults) == .tailnet)
}
@Test func envOverridesDefaults() {
let defaults = freshDefaults()
defaults.set(SyncTransportHint.lan.rawValue, forKey: SyncTransportSetting.defaultsKey)
let env = [SyncTransportSetting.envKey: SyncTransportHint.tailnet.rawValue]
#expect(SyncTransportSetting.resolve(environment: env, defaults: defaults) == .tailnet)
}
@Test func unknownValueFallsBackToLAN() {
let env = [SyncTransportSetting.envKey: "quic"]
#expect(SyncTransportSetting.resolve(environment: env, defaults: freshDefaults()) == .lan)
}
@Test func nodeNamesAreDNSLabelSafe() {
#expect(TailnetConfig.nodeName(for: "Andrew's MacBook Pro") == "nucleic-andrew-s-macbook-pro")
#expect(TailnetConfig.nodeName(for: "Mac", suffix: "-beta") == "nucleic-mac-beta")
// Nothing usable in the name stable fallback, never an empty label.
#expect(TailnetConfig.nodeName(for: "😀🎉") == "nucleic-device")
}
}
@@ -0,0 +1,63 @@
import Foundation
import Testing
@testable import NucleicProtocol
/// Wire compatibility for the transport fields added to the pairing QR (SYNC §4.2): new
/// fields must round-trip, old payloads must decode as LAN, and old decoders must ignore
/// the new keys both directions of the version skew.
@Suite struct PairingPayloadTransportTests {
private let key = Data(repeating: 7, count: 32)
private let secret = Data(repeating: 9, count: 32)
@Test func tailnetHintsRoundTripThroughQR() throws {
let payload = PairingPayload(
hostName: "Mac", hostStaticKey: key, pairingSecret: secret,
transport: SyncTransportHint.tailnet.rawValue,
tailnetHost: "100.64.0.7", tailnetPort: 43_753)
let decoded = try PairingPayload(qrString: payload.qrString())
#expect(decoded == payload)
#expect(decoded.transportHint == .tailnet)
#expect(decoded.tailnetHost == "100.64.0.7")
#expect(decoded.tailnetPort == 43_753)
}
/// The exact wire shape hosts encoded before transports existed.
private struct LegacyPayload: Codable {
var protocolVersion = SyncProtocol.version
let hostName: String
let hostStaticKey: Data
let pairingSecret: Data
var lanHost: String?
var lanPort: UInt16?
}
@Test func payloadFromPreTransportHostDecodesAsLAN() throws {
let legacy = LegacyPayload(
hostName: "Mac", hostStaticKey: key, pairingSecret: secret,
lanHost: "192.168.1.5", lanPort: 4_242)
let decoded = try CBORDecoder().decode(PairingPayload.self, from: CBOREncoder().encode(legacy))
#expect(decoded.transport == nil)
#expect(decoded.transportHint == .lan)
#expect(decoded.lanHost == "192.168.1.5")
#expect(decoded.tailnetHost == nil)
}
@Test func preTransportDecoderIgnoresNewFields() throws {
let payload = PairingPayload(
hostName: "Mac", hostStaticKey: key, pairingSecret: secret,
transport: SyncTransportHint.tailnet.rawValue,
tailnetHost: "100.64.0.7", tailnetPort: 43_753)
let decoded = try CBORDecoder().decode(LegacyPayload.self, from: CBOREncoder().encode(payload))
#expect(decoded.hostName == "Mac")
#expect(decoded.pairingSecret == secret)
}
/// A transport this build doesn't know yields nil (callers say "update the app"), not a
/// silent LAN fallback that would dial the wrong way.
@Test func unknownFutureTransportIsNilNotLAN() {
let payload = PairingPayload(
hostName: "Mac", hostStaticKey: key, pairingSecret: secret, transport: "quic")
#expect(payload.transportHint == nil)
}
}
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; };
BF00000000000000000003 /* NucleicTailnet in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000002 /* NucleicTailnet */; };
BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; };
E7D43FF12FF0806800BF2408 /* app-logo-canary.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */; };
@@ -93,6 +94,7 @@
buildActionMask = 2147483647;
files = (
BF00000000000000000001 /* NucleicProtocol in Frameworks */,
BF00000000000000000003 /* NucleicTailnet in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -153,6 +155,7 @@
name = NucleicRemote;
packageProductDependencies = (
PD00000000000000000001 /* NucleicProtocol */,
PD00000000000000000002 /* NucleicTailnet */,
);
productName = NucleicRemote;
productReference = FR00000000000000000001 /* NucleicRemote.app */;
@@ -293,7 +296,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
@@ -310,7 +313,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
@@ -342,7 +345,7 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
NUCLEIC_NAME_SUFFIX = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -389,7 +392,7 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
NUCLEIC_NAME_SUFFIX = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -423,7 +426,7 @@
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -454,7 +457,7 @@
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -517,6 +520,10 @@
isa = XCSwiftPackageProductDependency;
productName = NucleicProtocol;
};
PD00000000000000000002 /* NucleicTailnet */ = {
isa = XCSwiftPackageProductDependency;
productName = NucleicTailnet;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = PJ00000000000000000001 /* Project object */;
@@ -3,8 +3,10 @@ import Security
import NucleicProtocol
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
/// reconnect), a display name, and an optional LAN hint. The pairing secret is *not* stored
/// it's one-time. Non-secret, so UserDefaults is fine; the device private key goes to Keychain.
/// reconnect), a display name, and the transport + connection hint from the QR LAN
/// host:port or the Mac's tailnet IP. The pairing secret is *not* stored it's one-time.
/// Non-secret, so UserDefaults is fine; the device private key goes to Keychain. The new
/// optional fields decode as nil from a pre-transport record (= LAN).
struct PairedHost: Codable, Equatable {
var deviceID: String
var hostName: String
@@ -12,6 +14,12 @@ struct PairedHost: Codable, Equatable {
var fingerprint: String
var lanHost: String?
var lanPort: UInt16?
/// `SyncTransportHint` raw value; nil = LAN (records saved before transports existed).
var transport: String?
var tailnetHost: String?
var tailnetPort: UInt16?
var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
}
/// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
@@ -2,6 +2,7 @@ import Foundation
import Network
import SwiftUI
import NucleicProtocol
import NucleicTailnet
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
@@ -16,7 +17,7 @@ final class RemoteStore: ObservableObject {
case unpaired
case connecting
case reconnecting
case connected // LAN
case connected(SyncTransportHint)
case hostOffline
case failed(String)
@@ -25,12 +26,15 @@ final class RemoteStore: ObservableObject {
case .unpaired: "Not paired"
case .connecting: "Connecting…"
case .reconnecting: "Reconnecting…"
case .connected: "Connected · LAN"
case .connected(let transport): "Connected · \(transport.label)"
case .hostOffline: "Mac offline"
case .failed(let m): m
}
}
var isLive: Bool { self == .connected }
var isLive: Bool {
if case .connected = self { return true }
return false
}
}
@Published private(set) var connectivity: Connectivity = .unpaired
@@ -142,6 +146,15 @@ final class RemoteStore: ObservableObject {
let discovery = LANDiscovery()
private var client: SyncClient?
private var eventTask: Task<Void, Never>?
/// In-flight async connection setup (tailnet node start + dial); cancelled on teardown.
private var connectTask: Task<Void, Never>?
/// The pending reconnect backoff timer; cancelled on teardown so a stale retry can't
/// tear down a newer in-flight attempt.
private var retryTask: Task<Void, Never>?
/// The transport the current connection attempt uses (drives the "Connected · " chip).
private var activeTransport: SyncTransportHint = .lan
/// The phone's embedded Tailscale node state, for Settings (nil = not running).
@Published private(set) var tailnetStatus: String?
private var seenSeq: Set<UInt64> = []
private var reconnectAttempts = 0
@@ -170,7 +183,7 @@ final class RemoteStore: ObservableObject {
}
private func seedDemo() {
connectivity = .connected
connectivity = .connected(.lan)
hostName = "Andrew's Mac"
grantedScope = .control
capabilities = WireCapabilities(
@@ -277,46 +290,149 @@ final class RemoteStore: ObservableObject {
""")
}
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
/// and on success pin the host key for future IK reconnects.
/// Pair from a scanned QR (SYNC §4.2): connect over the transport the QR names LAN
/// (explicit hint first, else Bonjour) or the Mac's tailnet IP via the phone's embedded
/// Tailscale node run XXpsk0, and on success pin the host key for future IK reconnects.
func pair(with payload: PairingPayload) {
teardown()
connectivity = .connecting
hostName = payload.hostName
let deviceID = IdentityStore.deviceID()
guard let endpoint = resolveEndpoint(
fingerprint: payload.hostStaticKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort)
else { connectivity = .failed("No Mac found on this network"); return }
let channel = makeChannel(endpoint)
let client = SyncClient(
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
releaseChannel: BuildInfo.current.channel.releaseChannel)
self.client = client
consume(client, pairingPayload: payload)
switch payload.transportHint {
case .lan:
activeTransport = .lan
guard let endpoint = resolveEndpoint(
fingerprint: payload.hostStaticKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort)
else { connectivity = .failed("No Mac found on this network"); return }
startClient(
channel: makeChannel(endpoint), hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
pairingPayload: payload)
case .tailnet:
activeTransport = .tailnet
guard let tailnetHost = payload.tailnetHost, let tailnetPort = payload.tailnetPort else {
connectivity = .failed("The pairing code is missing the Mac's tailnet address.")
return
}
connectTask = Task { [weak self] in
guard let self else { return }
do {
let channel = try await self.tailnetChannel(host: tailnetHost, port: tailnetPort)
guard !Task.isCancelled else { channel.close(); return }
self.startClient(
channel: channel, hostStaticKey: payload.hostStaticKey,
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
pairingPayload: payload)
} catch {
guard !Task.isCancelled else { return }
self.connectivity = .failed(error.localizedDescription)
}
}
case .relay:
connectivity = .failed("Relay connections aren't supported yet.")
case nil:
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
}
}
/// Reconnect to the already-paired host using IK against the pinned static key.
/// Reconnect to the already-paired host using IK against the pinned static key, over
/// whichever transport the pairing recorded.
func reconnect() {
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
teardown()
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
hostName = host.hostName
guard let endpoint = resolveEndpoint(
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
else { connectivity = .hostOffline; scheduleRetry(); return }
switch host.transportHint {
case .lan:
activeTransport = .lan
guard let endpoint = resolveEndpoint(
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
else { connectivity = .hostOffline; scheduleRetry(); return }
startClient(
channel: makeChannel(endpoint), hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
case .tailnet:
activeTransport = .tailnet
guard let tailnetHost = host.tailnetHost, let tailnetPort = host.tailnetPort else {
connectivity = .failed("Missing tailnet address — pair with your Mac again.")
return
}
connectTask = Task { [weak self] in
guard let self else { return }
do {
let channel = try await self.tailnetChannel(host: tailnetHost, port: tailnetPort)
guard !Task.isCancelled else { channel.close(); return }
self.startClient(
channel: channel, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
} catch {
guard !Task.isCancelled else { return }
switch error {
case TailnetError.notBuiltIn, TailnetError.notConfigured:
// Retrying can't fix a missing auth key or a build without Tailscale.
self.connectivity = .failed(error.localizedDescription)
default:
self.connectivity = .hostOffline
self.scheduleRetry()
}
}
}
case .relay:
connectivity = .failed("Relay connections aren't supported yet.")
}
}
let channel = makeChannel(endpoint)
/// Create the `SyncClient` on an established channel and start consuming its events
/// the tail of every connect path, LAN or tailnet, pair or reconnect.
private func startClient(
channel: any FrameChannel, hostStaticKey: Data, mode: SyncClient.Mode,
deviceID: String, pairingPayload: PairingPayload?
) {
let client = SyncClient(
channel: channel, identity: identity, hostStaticKey: host.hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name,
pushToken: PushRegistrar.shared.tokenHex,
channel: channel, identity: identity, hostStaticKey: hostStaticKey,
mode: mode, deviceID: deviceID,
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
releaseChannel: BuildInfo.current.channel.releaseChannel)
self.client = client
consume(client, pairingPayload: nil)
consume(client, pairingPayload: pairingPayload)
}
/// Bring the phone's embedded Tailscale node up (first run needs the auth key from
/// Settings Tailscale; afterwards the on-disk state carries the registration) and dial
/// the Mac's tailnet address.
private func tailnetChannel(host: String, port: UInt16) async throws -> FDFrameChannel {
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
let config = Self.phoneTailnetConfig()
if config.authKey == nil, !config.hasExistingState {
throw TailnetError.notConfigured("Add your Tailscale auth key in Settings ▸ Tailscale first.")
}
tailnetStatus = "Starting…"
do {
try await TailnetNode.shared.ensureRunning(config: config)
} catch TailnetError.timedOut(let message) {
// A login timeout won't fix itself retrying would just block 45s per lap.
// Rethrow as .notConfigured so reconnect() treats it as terminal, not offline.
tailnetStatus = await TailnetNode.shared.status.label
throw TailnetError.notConfigured(message)
} catch {
tailnetStatus = await TailnetNode.shared.status.label
throw error
}
tailnetStatus = await TailnetNode.shared.status.label
return try await TailnetNode.shared.dial(host: host, port: port)
}
/// The phone's embedded-node config. State lives in this app's sandboxed Application
/// Support (no cross-channel collision each channel is its own app container).
private static func phoneTailnetConfig() -> TailnetConfig {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Nucleic", isDirectory: true)
.appendingPathComponent("tailnet", isDirectory: true)
return TailnetConfig(
hostName: TailnetConfig.nodeName(for: UIDevice.current.name),
stateDirectory: base,
authKey: TailnetAuthStore.loadAuthKey())
}
func unpair() {
@@ -324,6 +440,9 @@ final class RemoteStore: ObservableObject {
IdentityStore.clearPairedHost()
connectivity = .unpaired
sessions = []
// Nothing left to dial spin the embedded Tailscale node down if it was running.
Task { await TailnetNode.shared.stop() }
tailnetStatus = nil
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
}
@@ -717,7 +836,7 @@ final class RemoteStore: ObservableObject {
break
case .ready(let welcome):
reconnectAttempts = 0
connectivity = .connected
connectivity = .connected(activeTransport)
hostName = welcome.host.hostName
capabilities = welcome.capabilities
grantedScope = welcome.grantedScope
@@ -726,7 +845,9 @@ final class RemoteStore: ObservableObject {
IdentityStore.savePairedHost(PairedHost(
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort))
lanHost: payload.lanHost, lanPort: payload.lanPort,
transport: payload.transport, tailnetHost: payload.tailnetHost,
tailnetPort: payload.tailnetPort))
}
send(.listSessions)
send(.listDashboard)
@@ -801,7 +922,7 @@ final class RemoteStore: ObservableObject {
connectivity = .failed(message)
scheduleRetry()
case .closed:
if connectivity == .connected { connectivity = .reconnecting }
if connectivity.isLive { connectivity = .reconnecting }
scheduleRetry()
}
}
@@ -828,14 +949,20 @@ final class RemoteStore: ObservableObject {
guard isPaired else { return }
reconnectAttempts += 1
let delay = min(Double(reconnectAttempts) * 1.5, 10)
Task { [weak self] in
retryTask?.cancel()
retryTask = Task { [weak self] in
// `try?` swallows the sleep's CancellationError, so check explicitly.
try? await Task.sleep(for: .seconds(delay))
guard let self, self.connectivity != .connected else { return }
guard !Task.isCancelled, let self, !self.connectivity.isLive else { return }
self.reconnect()
}
}
private func teardown() {
retryTask?.cancel()
retryTask = nil
connectTask?.cancel()
connectTask = nil
eventTask?.cancel()
eventTask = nil
if let client { Task { await client.disconnect() } }
@@ -1,9 +1,12 @@
import SwiftUI
import NucleicProtocol
import NucleicTailnet
struct SettingsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
@FocusState private var tailscaleKeyFocused: Bool
@AppStorage("nucleic.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
@@ -53,10 +56,38 @@ struct SettingsView: View {
if !store.hostName.isEmpty {
LabeledContent("Mac", value: store.hostName)
}
if let transport = IdentityStore.loadPairedHost()?.transportHint {
LabeledContent("Transport", value: transport.label)
}
Button("Reconnect") { store.reconnect() }
.disabled(!store.isPaired)
}
Section {
if TailnetSupport.isBuiltIn {
// Commit on editing end, not per keystroke a per-change save would
// clear the valid Keychain key on the first backspace of an edit.
SecureField("Auth key (tskey-auth-…)", text: $tailscaleAuthKey)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($tailscaleKeyFocused)
.onSubmit { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
.onChange(of: tailscaleKeyFocused) {
if !tailscaleKeyFocused { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
}
if let status = store.tailnetStatus {
LabeledContent("Node", value: status)
}
} else {
Text("This build doesn't include Tailscale support.")
.foregroundStyle(.secondary)
}
} header: {
Text("Tailscale")
} footer: {
Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connect via). Create an auth key in the Tailscale admin console (Settings ▸ Keys); it's kept in the Keychain and used once to join your tailnet — after that the phone stays registered.")
}
Section("This device") {
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# Build the vendored TailscaleKit.xcframework the Tailnet sync transport links against.
#
# scripts/build-tailscalekit.sh
#
# TailscaleKit (github.com/tailscale/libtailscale, swift/) has no SwiftPM package and no
# tagged releases — it's an Xcode framework project wrapping a Go c-archive (tsnet), so we
# build it from a pinned commit and stage the result as a local binary target:
#
# third_party/TailscaleKit/TailscaleKit.xcframework (macOS + iOS + iOS-simulator slices)
#
# Package.swift picks the xcframework up automatically when it exists (see the
# `tailscaleKitAvailable` conditional there); without it every target still builds — the
# Tailnet transport just reports "not built in" at runtime. Like Resources/vmlinux-arm64,
# the artifact is a large binary and is NOT tracked; re-run this script after a clean clone.
#
# Requirements: Xcode (16.1+) and a Go toolchain (go.mod wants 1.25; any Go >= 1.21 will
# auto-download the right toolchain via GOTOOLCHAIN=auto).
set -euo pipefail
# Pinned libtailscale commit (main @ 2026-02-28, tailscale.com v1.94.1). Bump deliberately:
# the Swift wrapper API is pre-1.0 and the umbrella header mirrors the C API by hand.
LIBTAILSCALE_COMMIT="5e89501def80a6579ca5d0f9a02f336be62b8f2e"
LIBTAILSCALE_URL="https://github.com/tailscale/libtailscale.git"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAGE="$ROOT/third_party/TailscaleKit"
CHECKOUT="$STAGE/.build/libtailscale"
OUT="$STAGE/TailscaleKit.xcframework"
command -v go >/dev/null || { echo "error: Go toolchain required (brew install go)" >&2; exit 1; }
mkdir -p "$STAGE/.build"
if [[ ! -d "$CHECKOUT/.git" ]]; then
git clone "$LIBTAILSCALE_URL" "$CHECKOUT"
fi
git -C "$CHECKOUT" fetch --quiet origin "$LIBTAILSCALE_COMMIT" 2>/dev/null || git -C "$CHECKOUT" fetch --quiet origin
git -C "$CHECKOUT" checkout --quiet "$LIBTAILSCALE_COMMIT"
# Their swift/Makefile drives everything: the Go c-archive per platform (via the root
# Makefile), then xcodebuild for each framework slice. Unsigned by design; the app build
# signs on embed. Their Makefile pipes xcodebuild through xcpretty/cat (masking failures),
# so start from a clean slate and verify every slice actually exists afterwards — never
# package stale products from an earlier run.
rm -rf "$CHECKOUT/swift/build"
make -C "$CHECKOUT/swift" macos ios ios-sim
PRODUCTS="$CHECKOUT/swift/build/Build/Products"
for slice in Release Release-iphoneos Release-iphonesimulator; do
[[ -e "$PRODUCTS/$slice/TailscaleKit.framework/TailscaleKit" ]] || {
echo "error: missing $slice slice — xcodebuild failed inside libtailscale's Makefile (see output above)" >&2
exit 1
}
done
rm -rf "$OUT"
xcodebuild -create-xcframework \
-framework "$PRODUCTS/Release/TailscaleKit.framework" \
-framework "$PRODUCTS/Release-iphoneos/TailscaleKit.framework" \
-framework "$PRODUCTS/Release-iphonesimulator/TailscaleKit.framework" \
-output "$OUT"
# SwiftPM caches evaluated manifests by CONTENT, so an unchanged Package.swift keeps the
# pre-artifact "TailscaleKit unavailable" evaluation forever — the next build would silently
# skip the framework. Drop the manifest cache so the availability check re-runs.
rm -rf ~/Library/Caches/org.swift.swiftpm/manifests
echo "$OUT ($(du -sh "$OUT" | cut -f1), libtailscale @ ${LIBTAILSCALE_COMMIT:0:12})"
echo " (if Xcode has the iOS project open, File ▸ Packages ▸ Reset Package Caches once)"
+14
View File
@@ -131,6 +131,20 @@ else
echo " • Sparkle.framework not found in build products — auto-update will be inert"
fi
# TailscaleKit.framework — the embedded tsnet node behind the Tailnet sync transport. Present
# only when the build linked the local binary artifact (scripts/build-tailscalekit.sh); a build
# without it simply reports "not built in" from the Settings transport picker. Signed by the
# generic embedded-frameworks pass below.
for cand in "$BIN_DIR/TailscaleKit.framework" "$BIN_DIR/../TailscaleKit.framework"; do
if [ -d "$cand" ]; then
mkdir -p "$CONTENTS/Frameworks"
cp -R "$cand" "$CONTENTS/Frameworks/"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$CONTENTS/MacOS/$PRODUCT" 2>/dev/null || true
echo " • embedded TailscaleKit.framework (Tailnet transport)"
break
fi
done
# Container runtime: NOTHING is required to be bundled. The kernel, the vminitd initfs, and the
# sandbox image all download + cache automatically on first use. Optionally, a kernel staged at
# Resources/vmlinux-arm64 (scripts/fetch-kernel.sh) is bundled here as an offline/dev fast-path so