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 */
|
||||
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()
|
||||
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 }
|
||||
|
||||
let channel = makeChannel(endpoint)
|
||||
let client = SyncClient(
|
||||
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
|
||||
startClient(
|
||||
channel: makeChannel(endpoint), 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)
|
||||
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
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user