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 cb6976baa4
commit 94a962bd20
4 changed files with 214 additions and 41 deletions
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; }; 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, ); }; }; 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 */; }; 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 */; }; E7D43FF12FF0806800BF2408 /* app-logo-canary.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2408 /* app-logo-canary.icon */; };
@@ -93,6 +94,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
BF00000000000000000001 /* NucleicProtocol in Frameworks */, BF00000000000000000001 /* NucleicProtocol in Frameworks */,
BF00000000000000000003 /* NucleicTailnet in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -153,6 +155,7 @@
name = NucleicRemote; name = NucleicRemote;
packageProductDependencies = ( packageProductDependencies = (
PD00000000000000000001 /* NucleicProtocol */, PD00000000000000000001 /* NucleicProtocol */,
PD00000000000000000002 /* NucleicTailnet */,
); );
productName = NucleicRemote; productName = NucleicRemote;
productReference = FR00000000000000000001 /* NucleicRemote.app */; productReference = FR00000000000000000001 /* NucleicRemote.app */;
@@ -293,7 +296,7 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
@@ -310,7 +313,7 @@
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
@@ -342,7 +345,7 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)"; INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
NUCLEIC_NAME_SUFFIX = ""; NUCLEIC_NAME_SUFFIX = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -389,7 +392,7 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)"; INFOPLIST_KEY_CFBundleDisplayName = "Nucleic$(NUCLEIC_NAME_SUFFIX)";
NUCLEIC_NAME_SUFFIX = ""; NUCLEIC_NAME_SUFFIX = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -423,7 +426,7 @@
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist; INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets; INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -454,7 +457,7 @@
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist; INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets; INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -517,6 +520,10 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = NucleicProtocol; productName = NucleicProtocol;
}; };
PD00000000000000000002 /* NucleicTailnet */ = {
isa = XCSwiftPackageProductDependency;
productName = NucleicTailnet;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = PJ00000000000000000001 /* Project object */; rootObject = PJ00000000000000000001 /* Project object */;
@@ -3,8 +3,10 @@ import Security
import NucleicProtocol import NucleicProtocol
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK /// 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 — /// reconnect), a display name, and the transport + connection hint from the QR — LAN
/// it's one-time. Non-secret, so UserDefaults is fine; the device private key goes to Keychain. /// 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 { struct PairedHost: Codable, Equatable {
var deviceID: String var deviceID: String
var hostName: String var hostName: String
@@ -12,6 +14,12 @@ struct PairedHost: Codable, Equatable {
var fingerprint: String var fingerprint: String
var lanHost: String? var lanHost: String?
var lanPort: UInt16? 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 /// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
@@ -2,6 +2,7 @@ import Foundation
import Network import Network
import SwiftUI import SwiftUI
import NucleicProtocol import NucleicProtocol
import NucleicTailnet
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire /// 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. /// 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 unpaired
case connecting case connecting
case reconnecting case reconnecting
case connected // LAN case connected(SyncTransportHint)
case hostOffline case hostOffline
case failed(String) case failed(String)
@@ -25,12 +26,15 @@ final class RemoteStore: ObservableObject {
case .unpaired: "Not paired" case .unpaired: "Not paired"
case .connecting: "Connecting…" case .connecting: "Connecting…"
case .reconnecting: "Reconnecting…" case .reconnecting: "Reconnecting…"
case .connected: "Connected · LAN" case .connected(let transport): "Connected · \(transport.label)"
case .hostOffline: "Mac offline" case .hostOffline: "Mac offline"
case .failed(let m): m 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 @Published private(set) var connectivity: Connectivity = .unpaired
@@ -142,6 +146,15 @@ final class RemoteStore: ObservableObject {
let discovery = LANDiscovery() let discovery = LANDiscovery()
private var client: SyncClient? private var client: SyncClient?
private var eventTask: Task<Void, Never>? 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 seenSeq: Set<UInt64> = []
private var reconnectAttempts = 0 private var reconnectAttempts = 0
@@ -170,7 +183,7 @@ final class RemoteStore: ObservableObject {
} }
private func seedDemo() { private func seedDemo() {
connectivity = .connected connectivity = .connected(.lan)
hostName = "Andrew's Mac" hostName = "Andrew's Mac"
grantedScope = .control grantedScope = .control
capabilities = WireCapabilities( 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, /// Pair from a scanned QR (SYNC §4.2): connect over the transport the QR names — LAN
/// and on success pin the host key for future IK reconnects. /// (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) { func pair(with payload: PairingPayload) {
teardown() teardown()
connectivity = .connecting connectivity = .connecting
hostName = payload.hostName hostName = payload.hostName
let deviceID = IdentityStore.deviceID() let deviceID = IdentityStore.deviceID()
guard let endpoint = resolveEndpoint( switch payload.transportHint {
fingerprint: payload.hostStaticKey.fingerprintHex, case .lan:
lanHost: payload.lanHost, lanPort: payload.lanPort) activeTransport = .lan
else { connectivity = .failed("No Mac found on this network"); return } guard let endpoint = resolveEndpoint(
fingerprint: payload.hostStaticKey.fingerprintHex,
let channel = makeChannel(endpoint) lanHost: payload.lanHost, lanPort: payload.lanPort)
let client = SyncClient( else { connectivity = .failed("No Mac found on this network"); return }
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey, startClient(
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID, channel: makeChannel(endpoint), hostStaticKey: payload.hostStaticKey,
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex, mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
releaseChannel: BuildInfo.current.channel.releaseChannel) pairingPayload: payload)
self.client = client case .tailnet:
consume(client, pairingPayload: payload) 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() { func reconnect() {
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return } guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
teardown() teardown()
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
hostName = host.hostName hostName = host.hostName
guard let endpoint = resolveEndpoint( switch host.transportHint {
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort) case .lan:
else { connectivity = .hostOffline; scheduleRetry(); return } 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( let client = SyncClient(
channel: channel, identity: identity, hostStaticKey: host.hostStaticKey, channel: channel, identity: identity, hostStaticKey: hostStaticKey,
mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name, mode: mode, deviceID: deviceID,
pushToken: PushRegistrar.shared.tokenHex, deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
releaseChannel: BuildInfo.current.channel.releaseChannel) releaseChannel: BuildInfo.current.channel.releaseChannel)
self.client = client 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() { func unpair() {
@@ -324,6 +440,9 @@ final class RemoteStore: ObservableObject {
IdentityStore.clearPairedHost() IdentityStore.clearPairedHost()
connectivity = .unpaired connectivity = .unpaired
sessions = [] 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() LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0) NotificationRouter.shared.updateBadge(0)
} }
@@ -717,7 +836,7 @@ final class RemoteStore: ObservableObject {
break break
case .ready(let welcome): case .ready(let welcome):
reconnectAttempts = 0 reconnectAttempts = 0
connectivity = .connected connectivity = .connected(activeTransport)
hostName = welcome.host.hostName hostName = welcome.host.hostName
capabilities = welcome.capabilities capabilities = welcome.capabilities
grantedScope = welcome.grantedScope grantedScope = welcome.grantedScope
@@ -726,7 +845,9 @@ final class RemoteStore: ObservableObject {
IdentityStore.savePairedHost(PairedHost( IdentityStore.savePairedHost(PairedHost(
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName, deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex, 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(.listSessions)
send(.listDashboard) send(.listDashboard)
@@ -801,7 +922,7 @@ final class RemoteStore: ObservableObject {
connectivity = .failed(message) connectivity = .failed(message)
scheduleRetry() scheduleRetry()
case .closed: case .closed:
if connectivity == .connected { connectivity = .reconnecting } if connectivity.isLive { connectivity = .reconnecting }
scheduleRetry() scheduleRetry()
} }
} }
@@ -828,14 +949,20 @@ final class RemoteStore: ObservableObject {
guard isPaired else { return } guard isPaired else { return }
reconnectAttempts += 1 reconnectAttempts += 1
let delay = min(Double(reconnectAttempts) * 1.5, 10) 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)) 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() self.reconnect()
} }
} }
private func teardown() { private func teardown() {
retryTask?.cancel()
retryTask = nil
connectTask?.cancel()
connectTask = nil
eventTask?.cancel() eventTask?.cancel()
eventTask = nil eventTask = nil
if let client { Task { await client.disconnect() } } if let client { Task { await client.disconnect() } }
@@ -1,9 +1,12 @@
import SwiftUI import SwiftUI
import NucleicProtocol import NucleicProtocol
import NucleicTailnet
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
@State private var showScanner = false @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.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true @AppStorage("nucleic.showLockEvents") private var showLockEvents = true
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true @AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
@@ -53,10 +56,38 @@ struct SettingsView: View {
if !store.hostName.isEmpty { if !store.hostName.isEmpty {
LabeledContent("Mac", value: store.hostName) LabeledContent("Mac", value: store.hostName)
} }
if let transport = IdentityStore.loadPairedHost()?.transportHint {
LabeledContent("Transport", value: transport.label)
}
Button("Reconnect") { store.reconnect() } Button("Reconnect") { store.reconnect() }
.disabled(!store.isPaired) .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") { Section("This device") {
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized) LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
LabeledContent("Key fingerprint", value: store.deviceFingerprint) LabeledContent("Key fingerprint", value: store.deviceFingerprint)