nvrsion: Add: Adjust Tailscale Node Name format and remove “Tailscale auth key” setting to enable immediate browser login when toggle is enabled.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -397,8 +397,6 @@ private struct PairMacSheet: View {
|
||||
private struct ConnectionTransportSection: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@AppStorage(SyncTransportSetting.setDefaultsKey) private var transportsRaw = ""
|
||||
@State private var authKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
||||
@FocusState private var authKeyFocused: Bool
|
||||
|
||||
/// The enabled method set; an empty stored string defers to `resolveEnabled`, which
|
||||
/// migrates the legacy single-choice picker value.
|
||||
@@ -432,8 +430,8 @@ private struct ConnectionTransportSection: View {
|
||||
Text("End-to-end encrypted on every path.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
// First tailnet start with no auth key: the node mints a browser-login URL — take
|
||||
// the user straight there once; the button below covers re-opening.
|
||||
// Turning Tailnet on registers the node, which mints a browser-login URL — take the
|
||||
// user straight there once (independent of remote access); the button below re-opens.
|
||||
.onChange(of: store.tailnetLoginURL) {
|
||||
if let url = store.tailnetLoginURL { NSWorkspace.shared.open(url) }
|
||||
}
|
||||
@@ -475,15 +473,6 @@ private struct ConnectionTransportSection: View {
|
||||
private var tailnetDetails: some View {
|
||||
Group {
|
||||
if TailnetSupport.isBuiltIn {
|
||||
// Commit on editing end, not per keystroke — a per-change save would
|
||||
// clear the valid Keychain key on the first backspace of an edit.
|
||||
SecureField("Tailscale auth key (optional)", text: $authKey, prompt: Text("tskey-auth-…"))
|
||||
.autocorrectionDisabled()
|
||||
.focused($authKeyFocused)
|
||||
.onSubmit { TailnetAuthStore.saveAuthKey(authKey) }
|
||||
.onChange(of: authKeyFocused) {
|
||||
if !authKeyFocused { TailnetAuthStore.saveAuthKey(authKey) }
|
||||
}
|
||||
if let status = store.tailnetStatus {
|
||||
LabeledContent("Tailscale node", value: status)
|
||||
}
|
||||
@@ -494,9 +483,9 @@ private struct ConnectionTransportSection: View {
|
||||
Label("Open Tailscale login page", systemImage: "person.crop.circle.badge.checkmark")
|
||||
}
|
||||
}
|
||||
Text("Leave the key empty to approve the Mac in your browser when remote "
|
||||
+ "access starts, or paste an auth key from the Tailscale admin console "
|
||||
+ "(kept in the Keychain, used once to register).")
|
||||
Text("Approve this Mac in your browser to join it to your tailnet — the login "
|
||||
+ "page opens as soon as you turn Tailnet on, whether or not remote access "
|
||||
+ "is running.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
@@ -531,7 +520,14 @@ private struct ConnectionTransportSection: View {
|
||||
if on { set.insert(hint) } else { set.remove(hint) }
|
||||
guard !set.isEmpty else { return } // at least one method stays enabled
|
||||
transportsRaw = SyncTransportSetting.serializeEnabled(set)
|
||||
restartIfRunning()
|
||||
// Tailnet registers its node the moment it's enabled — independent of remote
|
||||
// access — so the browser login opens right away. When remote access *is*
|
||||
// running, the restart binds/unbinds the listener (and owns the node) instead.
|
||||
if hint == .tailnet, !store.syncRunning {
|
||||
Task { on ? await store.startTailnetNode() : await store.stopTailnetNode() }
|
||||
} else {
|
||||
restartIfRunning()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5360,11 +5360,22 @@ public final class AppStore: ConflictArbiter {
|
||||
}
|
||||
|
||||
/// Bring the embedded Tailscale node up and bind the sync listener to it on the fixed
|
||||
/// tailnet port. With a Keychain auth key (or existing node state) this is silent; a
|
||||
/// first start with neither goes through the interactive browser login — the node's
|
||||
/// status stream carries the login URL, mirrored here so Settings can open it.
|
||||
/// tailnet port. The node may already be registered — the user can turn the Tailnet
|
||||
/// method on before remote access, which brings it up early — so `bringUpTailnetNode`
|
||||
/// is idempotent for an unchanged config.
|
||||
private func startTailnetListener() async throws -> TailnetListener {
|
||||
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
|
||||
try await bringUpTailnetNode()
|
||||
return try await TailnetNode.shared.makeListener(port: SyncTransportSetting.tailnetPort)
|
||||
}
|
||||
|
||||
/// Register this Mac's embedded Tailscale node and mirror its status here. The node has
|
||||
/// no auth key, so a first start with no existing node state always goes through the
|
||||
/// interactive browser login — the node's status stream carries the login URL, surfaced
|
||||
/// on `tailnetLoginURL` so Settings can open it. Idempotent: an already-running node for
|
||||
/// an unchanged config returns at once.
|
||||
private func bringUpTailnetNode() async throws {
|
||||
if syncHostName == "Mac" { syncHostName = Host.current().localizedName ?? "Mac" }
|
||||
let config = SyncTransportSetting.nodeConfig(hostName: syncHostName)
|
||||
tailnetStatus = "Starting…"
|
||||
let watcher = Task { [weak self] in
|
||||
@@ -5384,7 +5395,36 @@ public final class AppStore: ConflictArbiter {
|
||||
}
|
||||
try await TailnetNode.shared.ensureRunning(config: config)
|
||||
tailnetStatus = await TailnetNode.shared.status.label
|
||||
return try await TailnetNode.shared.makeListener(port: SyncTransportSetting.tailnetPort)
|
||||
}
|
||||
|
||||
/// Bring the tailnet node online on its own, the moment the user enables the Tailnet
|
||||
/// connection method — so the browser login opens immediately instead of waiting for
|
||||
/// remote access to be turned on. When remote access later starts, `startTailnetListener`
|
||||
/// finds the node already registered and just binds its listener. A no-op while a sync
|
||||
/// start is already in flight (that path brings the node up itself).
|
||||
public func startTailnetNode() async {
|
||||
guard TailnetSupport.isBuiltIn, !syncStartInFlight else { return }
|
||||
syncTransportHealth[.tailnet] = nil
|
||||
do {
|
||||
try await bringUpTailnetNode()
|
||||
} catch {
|
||||
tailnetStatus = await TailnetNode.shared.status.label
|
||||
// A half-started node must not linger — nothing else owns it while remote
|
||||
// access is off.
|
||||
await TailnetNode.shared.stop()
|
||||
syncTransportHealth[.tailnet] = "Tailscale: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Spin the standalone tailnet node down when the user disables the Tailnet method while
|
||||
/// remote access is off. A no-op once remote access is running — the sync server owns the
|
||||
/// node's lifecycle then, and `stopSyncServer` tears it down.
|
||||
public func stopTailnetNode() async {
|
||||
guard !syncRunning, syncTailnetListener == nil else { return }
|
||||
await TailnetNode.shared.stop()
|
||||
tailnetStatus = nil
|
||||
tailnetLoginURL = nil
|
||||
syncTransportHealth[.tailnet] = nil
|
||||
}
|
||||
|
||||
public func stopSyncServer() async {
|
||||
|
||||
@@ -69,13 +69,12 @@ public enum SyncTransportSetting {
|
||||
|
||||
/// This Mac's embedded-node config. The state directory is the node's identity on the
|
||||
/// tailnet — per release channel, so a beta and a local dev build don't fight over one
|
||||
/// registration — and the auth key comes from the Keychain (`TailnetAuthStore`), needed
|
||||
/// only until that state exists.
|
||||
/// registration. The node registers via interactive browser login on first start; once
|
||||
/// the state directory holds a registered identity, later starts are silent.
|
||||
public static func nodeConfig(hostName: String) -> TailnetConfig {
|
||||
TailnetConfig(
|
||||
hostName: tailnetHostName(hostName),
|
||||
stateDirectory: stateDirectory,
|
||||
authKey: TailnetAuthStore.loadAuthKey())
|
||||
stateDirectory: stateDirectory)
|
||||
}
|
||||
|
||||
private static var stateDirectory: URL {
|
||||
@@ -84,10 +83,10 @@ public enum SyncTransportSetting {
|
||||
.appendingPathComponent("tailnet\(channelSuffix)", isDirectory: true)
|
||||
}
|
||||
|
||||
/// The node name in the tailnet admin console: "nucleic-<mac-name>", DNS-label-safe,
|
||||
/// with the channel suffix separating a beta's node from a dev build's.
|
||||
/// The node name in the tailnet admin console: "nucleic-<channel>-<mac-name>",
|
||||
/// DNS-label-safe, with the channel separating a beta's node from a dev build's.
|
||||
static func tailnetHostName(_ hostName: String) -> String {
|
||||
TailnetConfig.nodeName(for: hostName, suffix: channelSuffix)
|
||||
TailnetConfig.nodeName(for: hostName, channel: String(channelSuffix.dropFirst()))
|
||||
}
|
||||
|
||||
/// Same channel → suffix mapping as `ContainerManager`'s container names (Package.swift
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// The Tailnet transport (SYNC_PROTOCOL §3): both devices run an embedded tsnet node
|
||||
// (TailscaleKit) on the user's own tailnet, and sync frames flow over an ordinary TCP
|
||||
@@ -73,58 +72,17 @@ public struct TailnetConfig: Sendable, Equatable {
|
||||
self.ephemeral = ephemeral
|
||||
}
|
||||
|
||||
/// DNS-label-safe tailnet node name: "nucleic-<name>" with non-alphanumerics dashed and
|
||||
/// runs collapsed, plus an optional suffix (the Mac uses its release channel so a beta's
|
||||
/// node doesn't collide with a dev build's).
|
||||
public static func nodeName(for deviceName: String, suffix: String = "") -> String {
|
||||
/// DNS-label-safe tailnet node name: "nucleic-<channel>-<name>" with non-alphanumerics
|
||||
/// dashed and runs collapsed. The optional channel (the Mac uses its release channel) sits
|
||||
/// ahead of the device name so a beta's node doesn't collide with a dev build's.
|
||||
public static func nodeName(for deviceName: String, channel: String = "") -> String {
|
||||
let dashed = deviceName.lowercased().map { ch -> Character in
|
||||
ch.isASCII && (ch.isLetter || ch.isNumber) ? ch : "-"
|
||||
}
|
||||
let collapsed = String(dashed).split(separator: "-").joined(separator: "-")
|
||||
return "nucleic-\(collapsed.isEmpty ? "device" : collapsed)\(suffix)"
|
||||
let device = collapsed.isEmpty ? "device" : collapsed
|
||||
let prefix = channel.isEmpty ? "" : "\(channel)-"
|
||||
return "nucleic-\(prefix)\(device)"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Keychain persistence for the user's Tailscale auth key — a real credential (it can
|
||||
/// register nodes on their tailnet), so it never touches UserDefaults. Same generic-password
|
||||
/// pattern as the identity stores. The key is only read at node start; once the state
|
||||
/// directory holds a registered identity it's no longer strictly needed, but we keep it for
|
||||
/// re-registration after a revoke.
|
||||
public enum TailnetAuthStore {
|
||||
private static let account = "xyz.blakeslee.nucleic.tailnet.authkey"
|
||||
|
||||
public static func loadAuthKey() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data,
|
||||
let key = String(data: data, encoding: .utf8), !key.isEmpty
|
||||
else { return nil }
|
||||
return key
|
||||
}
|
||||
|
||||
/// Save (or, for nil/empty, remove) the auth key.
|
||||
public static func saveAuthKey(_ key: String?) {
|
||||
let delete: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(delete as CFDictionary)
|
||||
guard let key, !key.isEmpty else { return }
|
||||
var add: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecValueData as String: Data(key.utf8),
|
||||
]
|
||||
#if os(iOS)
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
#endif
|
||||
SecItemAdd(add as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ import NucleicTailnet
|
||||
|
||||
@Test func nodeNamesAreDNSLabelSafe() {
|
||||
#expect(TailnetConfig.nodeName(for: "Andrew's MacBook Pro") == "nucleic-andrew-s-macbook-pro")
|
||||
#expect(TailnetConfig.nodeName(for: "Mac", suffix: "-beta") == "nucleic-mac-beta")
|
||||
#expect(TailnetConfig.nodeName(for: "Mac", channel: "beta") == "nucleic-beta-mac")
|
||||
// Nothing usable in the name → stable fallback, never an empty label.
|
||||
#expect(TailnetConfig.nodeName(for: "😀🎉") == "nucleic-device")
|
||||
}
|
||||
|
||||
@@ -641,8 +641,7 @@ final class RemoteStore: ObservableObject {
|
||||
.appendingPathComponent("tailnet", isDirectory: true)
|
||||
return TailnetConfig(
|
||||
hostName: TailnetConfig.nodeName(for: UIDevice.current.name),
|
||||
stateDirectory: base,
|
||||
authKey: TailnetAuthStore.loadAuthKey())
|
||||
stateDirectory: base)
|
||||
}
|
||||
|
||||
func unpair() {
|
||||
|
||||
@@ -9,8 +9,6 @@ struct SettingsView: View {
|
||||
@State private var showManualPair = false
|
||||
/// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
|
||||
@State private var pairedHostsToken = UUID()
|
||||
@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
|
||||
@@ -106,16 +104,6 @@ struct SettingsView: View {
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -123,6 +111,9 @@ struct SettingsView: View {
|
||||
Link(destination: loginURL) {
|
||||
Label("Open Tailscale login", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
} else {
|
||||
Text("No setup needed — when your Mac shares over Tailscale, this iPhone joins your tailnet on first connect and opens a browser login to approve itself.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("This build doesn't include Tailscale support.")
|
||||
@@ -131,7 +122,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Tailscale")
|
||||
} footer: {
|
||||
Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connect via). Leave the key empty to approve this iPhone in your browser on first connect, or create an auth key in the Tailscale admin console (kept in the Keychain, used once to join your tailnet).")
|
||||
Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connection methods). This iPhone joins your tailnet in the browser on first connect.")
|
||||
}
|
||||
|
||||
Section("This device") {
|
||||
|
||||
Reference in New Issue
Block a user