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:
@@ -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/
|
||||
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
: [])
|
||||
)
|
||||
|
||||
|
||||
@@ -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 Wi‑Fi.")
|
||||
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 Wi‑Fi.")
|
||||
.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 Wi‑Fi 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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Executable
+70
@@ -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)"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user