Tailnet: interactive browser login + LAN↔tailnet fallback
Browser login — the auth key is now optional on both platforms. When the embedded node starts with no key, TailnetNode asks the backend (LocalAPI backendStatus — IPN state, deliberately not filesystem heuristics: tsnet writes logs and a machine key on every start, registered or not) whether a login is needed, triggers login-interactive, surfaces the auth URL through a new statusStream()/.needsLogin, and waits for Running (4-minute deadline, generation-fenced against stop/restart). The Mac auto-opens the login page from Settings ▸ Remote and shows a re-open button; the iPhone auto-opens only during user-initiated pairing (a background reconnect that suddenly needs a login must not eject the user to Safari — Settings ▸ Tailscale carries the link). The remote-access toggle now reflects the in-flight start instead of snapping off for the whole login window, and toggling off mid-start is honored at both commit points (before and after host.start). LAN↔tailnet fallback — picking Tailnet now keeps LAN on too: the host runs both listeners under a new CompositeSyncListener (merged accept stream; one child ending doesn't end the rest) and the pairing QR carries both hints. The phone builds an ordered candidate chain — LAN first (QR hint or Bonjour), tailnet second — and walks it on pair and reconnect, so a phone that leaves the Mac's Wi‑Fi rolls over to the tailnet and rolls back when it returns. A per-channel 4s connect guard (readiness-checking, bound to exactly its channel) keeps a stale LAN hint from hanging the chain; a stale-client guard in consume() keeps a replaced client's tail events from advancing it; chain exhaustion during pairing lands in a terminal failure instead of spinning on "Connecting…"; routine pre-fallback handshake errors no longer flash the red error bubble. "Connected · LAN / Tailnet" shows whichever transport won. Multi-agent review: 11 confirmed findings (incl. the login gate being dead code via tsnet's eager state-dir writes, and two connect-timeout races), all fixed and re-verified. Suite green (24 sync-related tests incl. 3 new CompositeSyncListener tests); macOS + iOS builds clean. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -155,6 +155,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
private var activeTransport: SyncTransportHint = .lan
|
private var activeTransport: SyncTransportHint = .lan
|
||||||
/// The phone's embedded Tailscale node state, for Settings (nil = not running).
|
/// The phone's embedded Tailscale node state, for Settings (nil = not running).
|
||||||
@Published private(set) var tailnetStatus: String?
|
@Published private(set) var tailnetStatus: String?
|
||||||
|
/// The Tailscale interactive-login URL while the node waits for a browser login (a
|
||||||
|
/// first tailnet start with no auth key). Auto-opened; Settings shows a re-open button.
|
||||||
|
@Published private(set) var tailnetLoginURL: URL?
|
||||||
private var seenSeq: Set<UInt64> = []
|
private var seenSeq: Set<UInt64> = []
|
||||||
private var reconnectAttempts = 0
|
private var reconnectAttempts = 0
|
||||||
|
|
||||||
@@ -290,99 +293,168 @@ final class RemoteStore: ObservableObject {
|
|||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pair from a scanned QR (SYNC §4.2): connect over the transport the QR names — LAN
|
/// One dialable way to reach the Mac. A connect builds an ordered candidate list — LAN
|
||||||
/// (explicit hint first, else Bonjour) or the Mac's tailnet IP via the phone's embedded
|
/// first (cheapest when reachable), then the tailnet (SYNC §3.2's LAN-then-fallback
|
||||||
/// Tailscale node — run XXpsk0, and on success pin the host key for future IK reconnects.
|
/// ordering) — and `attempt` walks it until one carries a session.
|
||||||
|
private enum TransportAttempt {
|
||||||
|
case lan(NWEndpoint)
|
||||||
|
case tailnet(host: String, port: UInt16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-progress connect: remaining candidates plus everything needed to start a
|
||||||
|
/// client on whichever one succeeds. Cleared on `.ready` (chain done) and by teardown.
|
||||||
|
private struct ConnectPlan {
|
||||||
|
var remaining: [TransportAttempt]
|
||||||
|
let hostStaticKey: Data
|
||||||
|
let mode: SyncClient.Mode
|
||||||
|
let deviceID: String
|
||||||
|
let pairingPayload: PairingPayload?
|
||||||
|
}
|
||||||
|
private var connectPlan: ConnectPlan?
|
||||||
|
/// Kills a LAN attempt whose TCP connect just hangs (stale IP hint) so the chain can
|
||||||
|
/// move on — NWConnection's own timeout is far too slow for a fallback decision.
|
||||||
|
private var lanConnectTimeout: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Pair from a scanned QR (SYNC §4.2): try the QR's transports in order (LAN hint or
|
||||||
|
/// Bonjour first, then the Mac's tailnet IP via the phone's embedded 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()
|
guard let hint = payload.transportHint else {
|
||||||
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.")
|
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
guard hint != .relay else {
|
||||||
|
connectivity = .failed("Relay connections aren't supported yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let candidates = buildCandidates(
|
||||||
|
fingerprint: payload.hostStaticKey.fingerprintHex,
|
||||||
|
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
||||||
|
tailnet: hint == .tailnet ? (payload.tailnetHost, payload.tailnetPort) : nil)
|
||||||
|
guard !candidates.isEmpty else {
|
||||||
|
connectivity = .failed(hint == .tailnet && !TailnetSupport.isBuiltIn
|
||||||
|
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
|
||||||
|
: "No Mac found on this network")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectPlan = ConnectPlan(
|
||||||
|
remaining: candidates, hostStaticKey: payload.hostStaticKey,
|
||||||
|
mode: .pair(secret: payload.pairingSecret), deviceID: IdentityStore.deviceID(),
|
||||||
|
pairingPayload: payload)
|
||||||
|
_ = tryNextCandidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconnect to the already-paired host using IK against the pinned static key, over
|
/// Reconnect to the already-paired host using IK against the pinned static key: LAN
|
||||||
/// whichever transport the pairing recorded.
|
/// when reachable, else the pairing's tailnet hint — so a phone that leaves the Mac's
|
||||||
|
/// Wi‑Fi rolls over to the tailnet and rolls back when it returns.
|
||||||
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 {
|
guard host.transportHint != .relay else {
|
||||||
case .lan:
|
connectivity = .failed("Relay connections aren't supported yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let candidates = buildCandidates(
|
||||||
|
fingerprint: host.fingerprint,
|
||||||
|
lanHost: host.lanHost, lanPort: host.lanPort,
|
||||||
|
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil)
|
||||||
|
guard !candidates.isEmpty else {
|
||||||
|
connectivity = .hostOffline
|
||||||
|
scheduleRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectPlan = ConnectPlan(
|
||||||
|
remaining: candidates, hostStaticKey: host.hostStaticKey,
|
||||||
|
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
|
||||||
|
_ = tryNextCandidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LAN first (explicit hint, else a Bonjour match), tailnet second when the pairing
|
||||||
|
/// carries one and this build can dial it.
|
||||||
|
private func buildCandidates(
|
||||||
|
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
||||||
|
tailnet: (host: String?, port: UInt16?)?
|
||||||
|
) -> [TransportAttempt] {
|
||||||
|
var candidates: [TransportAttempt] = []
|
||||||
|
if let endpoint = resolveEndpoint(fingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
|
||||||
|
candidates.append(.lan(endpoint))
|
||||||
|
}
|
||||||
|
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
|
||||||
|
candidates.append(.tailnet(host: host, port: port))
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop and dial the next candidate. False when the plan is exhausted (or gone) — the
|
||||||
|
/// caller then applies its terminal failure handling.
|
||||||
|
private func tryNextCandidate() -> Bool {
|
||||||
|
guard var plan = connectPlan, !plan.remaining.isEmpty else { return false }
|
||||||
|
let next = plan.remaining.removeFirst()
|
||||||
|
connectPlan = plan
|
||||||
|
attempt(next, plan: plan)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attempt(_ candidate: TransportAttempt, plan: ConnectPlan) {
|
||||||
|
teardownClient()
|
||||||
|
switch candidate {
|
||||||
|
case .lan(let endpoint):
|
||||||
activeTransport = .lan
|
activeTransport = .lan
|
||||||
guard let endpoint = resolveEndpoint(
|
let channel = makeChannel(endpoint)
|
||||||
fingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
// Close the channel if TCP isn't up within the window (a stale IP hint would
|
||||||
else { connectivity = .hostOffline; scheduleRetry(); return }
|
// otherwise hang the chain on NWConnection's slow timeout); the finished stream
|
||||||
startClient(
|
// then advances to the next candidate. The timer checks readiness itself — it's
|
||||||
channel: makeChannel(endpoint), hostStaticKey: host.hostStaticKey,
|
// bound to exactly this channel, so a stale timer can never hit a later attempt.
|
||||||
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
|
lanConnectTimeout?.cancel()
|
||||||
case .tailnet:
|
lanConnectTimeout = Task { [weak channel] in
|
||||||
activeTransport = .tailnet
|
try? await Task.sleep(for: .seconds(4))
|
||||||
guard let tailnetHost = host.tailnetHost, let tailnetPort = host.tailnetPort else {
|
guard !Task.isCancelled, let channel, !channel.isReady else { return }
|
||||||
connectivity = .failed("Missing tailnet address — pair with your Mac again.")
|
channel.close()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
startClient(
|
||||||
|
channel: channel, hostStaticKey: plan.hostStaticKey,
|
||||||
|
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
|
||||||
|
case .tailnet(let host, let port):
|
||||||
|
activeTransport = .tailnet
|
||||||
connectTask = Task { [weak self] in
|
connectTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
let channel = try await self.tailnetChannel(host: tailnetHost, port: tailnetPort)
|
let channel = try await self.tailnetChannel(host: host, port: port)
|
||||||
guard !Task.isCancelled else { channel.close(); return }
|
guard !Task.isCancelled else { channel.close(); return }
|
||||||
self.startClient(
|
self.startClient(
|
||||||
channel: channel, hostStaticKey: host.hostStaticKey,
|
channel: channel, hostStaticKey: plan.hostStaticKey,
|
||||||
mode: .reconnect, deviceID: host.deviceID, pairingPayload: nil)
|
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
switch error {
|
self.tailnetAttemptFailed(error, isPairing: plan.pairingPayload != nil)
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tailnet is always the last candidate, so its failure ends the chain: terminal
|
||||||
|
/// for pairing and for anything retrying can't fix; otherwise offline + backoff.
|
||||||
|
private func tailnetAttemptFailed(_ error: Error, isPairing: Bool) {
|
||||||
|
if tryNextCandidate() { return }
|
||||||
|
if isPairing {
|
||||||
|
connectivity = .failed(error.localizedDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch error {
|
||||||
|
case TailnetError.notBuiltIn, TailnetError.notConfigured:
|
||||||
|
connectivity = .failed(error.localizedDescription)
|
||||||
|
default:
|
||||||
|
connectivity = .hostOffline
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Create the `SyncClient` on an established channel and start consuming its events —
|
/// Create the `SyncClient` on an established channel and start consuming its events —
|
||||||
/// the tail of every connect path, LAN or tailnet, pair or reconnect.
|
/// the tail of every connect path, LAN or tailnet, pair or reconnect.
|
||||||
private func startClient(
|
private func startClient(
|
||||||
@@ -404,14 +476,38 @@ final class RemoteStore: ObservableObject {
|
|||||||
private func tailnetChannel(host: String, port: UInt16) async throws -> FDFrameChannel {
|
private func tailnetChannel(host: String, port: UInt16) async throws -> FDFrameChannel {
|
||||||
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
|
guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
|
||||||
let config = Self.phoneTailnetConfig()
|
let config = Self.phoneTailnetConfig()
|
||||||
if config.authKey == nil, !config.hasExistingState {
|
|
||||||
throw TailnetError.notConfigured("Add your Tailscale auth key in Settings ▸ Tailscale first.")
|
|
||||||
}
|
|
||||||
tailnetStatus = "Starting…"
|
tailnetStatus = "Starting…"
|
||||||
|
// Mirror node status while the start is in flight. A first start with no auth key
|
||||||
|
// goes through the interactive browser login; pairing usually runs from the scanner
|
||||||
|
// sheet — not Settings — so take the user straight to the approval page.
|
||||||
|
let watcher = Task { [weak self] in
|
||||||
|
for await status in await TailnetNode.shared.statusStream() {
|
||||||
|
guard let self, !Task.isCancelled else { break }
|
||||||
|
self.tailnetStatus = status.label
|
||||||
|
if case .needsLogin(let url) = status, let loginURL = URL(string: url) {
|
||||||
|
if self.tailnetLoginURL != loginURL {
|
||||||
|
self.tailnetLoginURL = loginURL
|
||||||
|
// Eject to Safari only for user-initiated pairing — the user is
|
||||||
|
// actively watching. A routine reconnect that suddenly needs a
|
||||||
|
// login (node revoked, state wiped) must not yank them out of the
|
||||||
|
// app; Settings ▸ Tailscale carries the login link instead.
|
||||||
|
if self.connectPlan?.pairingPayload != nil {
|
||||||
|
UIApplication.shared.open(loginURL, options: [:], completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.tailnetLoginURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer {
|
||||||
|
watcher.cancel()
|
||||||
|
tailnetLoginURL = nil
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try await TailnetNode.shared.ensureRunning(config: config)
|
try await TailnetNode.shared.ensureRunning(config: config)
|
||||||
} catch TailnetError.timedOut(let message) {
|
} catch TailnetError.timedOut(let message) {
|
||||||
// A login timeout won't fix itself — retrying would just block 45s per lap.
|
// A login/auth timeout won't fix itself — retrying would just block per lap.
|
||||||
// Rethrow as .notConfigured so reconnect() treats it as terminal, not offline.
|
// Rethrow as .notConfigured so reconnect() treats it as terminal, not offline.
|
||||||
tailnetStatus = await TailnetNode.shared.status.label
|
tailnetStatus = await TailnetNode.shared.status.label
|
||||||
throw TailnetError.notConfigured(message)
|
throw TailnetError.notConfigured(message)
|
||||||
@@ -825,7 +921,11 @@ final class RemoteStore: ObservableObject {
|
|||||||
eventTask = Task { [weak self] in
|
eventTask = Task { [weak self] in
|
||||||
let stream = await client.start()
|
let stream = await client.start()
|
||||||
for await event in stream {
|
for await event in stream {
|
||||||
await self?.handle(event, pairingPayload: pairingPayload)
|
// A replaced client's tail events (`.failed` is always chased by `.closed`)
|
||||||
|
// must not leak into the new attempt — they'd advance the candidate chain
|
||||||
|
// or schedule retries against a connection that no longer exists.
|
||||||
|
guard let self, self.client === client else { break }
|
||||||
|
await self.handle(event, pairingPayload: pairingPayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,6 +936,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
break
|
break
|
||||||
case .ready(let welcome):
|
case .ready(let welcome):
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
|
connectPlan = nil // the chain found its transport
|
||||||
connectivity = .connected(activeTransport)
|
connectivity = .connected(activeTransport)
|
||||||
hostName = welcome.host.hostName
|
hostName = welcome.host.hostName
|
||||||
capabilities = welcome.capabilities
|
capabilities = welcome.capabilities
|
||||||
@@ -917,12 +1018,28 @@ final class RemoteStore: ObservableObject {
|
|||||||
// Losing an approval race isn't an error worth interrupting for; the card
|
// Losing an approval race isn't an error worth interrupting for; the card
|
||||||
// collapses on the matching `approvalResolved`.
|
// collapses on the matching `approvalResolved`.
|
||||||
guard error.code != .alreadyResolved else { break }
|
guard error.code != .alreadyResolved else { break }
|
||||||
|
// While the connect chain is still resolving a transport, a pre-ready error
|
||||||
|
// (e.g. "handshake closed" from a LAN probe about to fall back to tailnet) is
|
||||||
|
// routine, not news — the follow-on `.closed` advances the chain silently.
|
||||||
|
guard connectPlan == nil else { break }
|
||||||
showError(error.message, sessionID: error.sessionID)
|
showError(error.message, sessionID: error.sessionID)
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
|
// Another candidate may still carry the session (e.g. LAN died → tailnet).
|
||||||
|
if tryNextCandidate() { break }
|
||||||
|
connectPlan = nil
|
||||||
connectivity = .failed(message)
|
connectivity = .failed(message)
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
case .closed:
|
case .closed:
|
||||||
if connectivity.isLive { connectivity = .reconnecting }
|
if !connectivity.isLive, tryNextCandidate() { break }
|
||||||
|
if connectivity.isLive {
|
||||||
|
connectivity = .reconnecting
|
||||||
|
} else if connectPlan?.pairingPayload != nil {
|
||||||
|
// A pairing chain died silently (every candidate closed pre-welcome).
|
||||||
|
// There's no retry loop before a pairing succeeds, so without a terminal
|
||||||
|
// state this would sit on "Connecting…" forever.
|
||||||
|
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.")
|
||||||
|
}
|
||||||
|
connectPlan = nil
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -963,6 +1080,15 @@ final class RemoteStore: ObservableObject {
|
|||||||
retryTask = nil
|
retryTask = nil
|
||||||
connectTask?.cancel()
|
connectTask?.cancel()
|
||||||
connectTask = nil
|
connectTask = nil
|
||||||
|
connectPlan = nil
|
||||||
|
teardownClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop just the current client/channel — what moving to the next transport candidate
|
||||||
|
/// needs, without discarding the rest of the plan.
|
||||||
|
private func teardownClient() {
|
||||||
|
lanConnectTimeout?.cancel()
|
||||||
|
lanConnectTimeout = nil
|
||||||
eventTask?.cancel()
|
eventTask?.cancel()
|
||||||
eventTask = nil
|
eventTask = nil
|
||||||
if let client { Task { await client.disconnect() } }
|
if let client { Task { await client.disconnect() } }
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
|||||||
var onReady: (@Sendable () -> Void)?
|
var onReady: (@Sendable () -> Void)?
|
||||||
var onFailed: (@Sendable (String) -> Void)?
|
var onFailed: (@Sendable (String) -> Void)?
|
||||||
|
|
||||||
|
/// Whether TCP reached `.ready` — latched, thread-safe. The connect-timeout guard
|
||||||
|
/// polls this instead of relying on a callback that could race connection start.
|
||||||
|
private let stateLock = NSLock()
|
||||||
|
private var ready = false
|
||||||
|
var isReady: Bool {
|
||||||
|
stateLock.lock()
|
||||||
|
defer { stateLock.unlock() }
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
|
||||||
init(endpoint: NWEndpoint) {
|
init(endpoint: NWEndpoint) {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.includePeerToPeer = true
|
params.includePeerToPeer = true
|
||||||
@@ -28,7 +38,7 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
|||||||
|
|
||||||
connection.stateUpdateHandler = { [weak self] state in
|
connection.stateUpdateHandler = { [weak self] state in
|
||||||
switch state {
|
switch state {
|
||||||
case .ready: self?.onReady?()
|
case .ready: self?.markReady(); self?.onReady?()
|
||||||
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
|
case .failed(let error): self?.onFailed?("\(error)"); self?.continuation.finish()
|
||||||
case .cancelled: self?.continuation.finish()
|
case .cancelled: self?.continuation.finish()
|
||||||
default: break
|
default: break
|
||||||
@@ -38,6 +48,12 @@ final class NWFrameChannel: FrameChannel, @unchecked Sendable {
|
|||||||
receiveLoop()
|
receiveLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func markReady() {
|
||||||
|
stateLock.lock()
|
||||||
|
ready = true
|
||||||
|
stateLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func frames() -> AsyncStream<Data> { stream }
|
func frames() -> AsyncStream<Data> { stream }
|
||||||
|
|
||||||
func send(_ frame: Data) {
|
func send(_ frame: Data) {
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ 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 {
|
// What this pairing can dial — the live transport is in Status above.
|
||||||
LabeledContent("Transport", value: transport.label)
|
if let host = IdentityStore.loadPairedHost() {
|
||||||
|
LabeledContent("Transports", value: host.tailnetHost != nil ? "LAN + Tailnet" : "LAN")
|
||||||
}
|
}
|
||||||
Button("Reconnect") { store.reconnect() }
|
Button("Reconnect") { store.reconnect() }
|
||||||
.disabled(!store.isPaired)
|
.disabled(!store.isPaired)
|
||||||
@@ -78,6 +79,11 @@ struct SettingsView: View {
|
|||||||
if let status = store.tailnetStatus {
|
if let status = store.tailnetStatus {
|
||||||
LabeledContent("Node", value: status)
|
LabeledContent("Node", value: status)
|
||||||
}
|
}
|
||||||
|
if let loginURL = store.tailnetLoginURL {
|
||||||
|
Link(destination: loginURL) {
|
||||||
|
Label("Open Tailscale login", systemImage: "arrow.up.forward.app")
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("This build doesn't include Tailscale support.")
|
Text("This build doesn't include Tailscale support.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -85,7 +91,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Tailscale")
|
Text("Tailscale")
|
||||||
} footer: {
|
} 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.")
|
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).")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("This device") {
|
Section("This device") {
|
||||||
|
|||||||
Reference in New Issue
Block a user