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:
@@ -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()
|
||||||
|
switch payload.transportHint {
|
||||||
|
case .lan:
|
||||||
|
activeTransport = .lan
|
||||||
guard let endpoint = resolveEndpoint(
|
guard let endpoint = resolveEndpoint(
|
||||||
fingerprint: payload.hostStaticKey.fingerprintHex,
|
fingerprint: payload.hostStaticKey.fingerprintHex,
|
||||||
lanHost: payload.lanHost, lanPort: payload.lanPort)
|
lanHost: payload.lanHost, lanPort: payload.lanPort)
|
||||||
else { connectivity = .failed("No Mac found on this network"); return }
|
else { connectivity = .failed("No Mac found on this network"); return }
|
||||||
|
startClient(
|
||||||
let channel = makeChannel(endpoint)
|
channel: makeChannel(endpoint), hostStaticKey: payload.hostStaticKey,
|
||||||
let client = SyncClient(
|
|
||||||
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
|
|
||||||
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
|
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
|
||||||
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
|
pairingPayload: payload)
|
||||||
releaseChannel: BuildInfo.current.channel.releaseChannel)
|
case .tailnet:
|
||||||
self.client = client
|
activeTransport = .tailnet
|
||||||
consume(client, pairingPayload: payload)
|
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
|
||||||
|
switch host.transportHint {
|
||||||
|
case .lan:
|
||||||
|
activeTransport = .lan
|
||||||
guard let endpoint = resolveEndpoint(
|
guard let endpoint = resolveEndpoint(
|
||||||
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
||||||
else { connectivity = .hostOffline; scheduleRetry(); return }
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user