diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj index ca50297..c6c10e9 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj @@ -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 */; diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index 84c8c8c..4277b5f 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 329099f..24e9b58 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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? + /// In-flight async connection setup (tailnet node start + dial); cancelled on teardown. + private var connectTask: Task? + /// 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? + /// 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 = [] 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() } } diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 25c2ddd..fa3711e 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -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)