Merge branch 'claude/vigorous-nash-457773' into dev
Tailscale (Tailnet) sync transport: transport picker in Settings ▸ Remote, interactive browser login, and LAN↔tailnet fallback. Semantic merge fixes (both sides compiled alone but not together): - handleTransportFailure (dev's friendly transport errors) now checks connectivity.isLive — .connected gained a transport payload on the branch — and stays silent while the branch's connect chain still has candidates to try, so a LAN probe failing over to the tailnet doesn't flash a red error. - Chain-exhaustion paths keep a friendlier transport-level failure message when onFailed already surfaced one instead of clobbering it with the generic handshake error. Verified on the merge: swift build + 37 sync/transport/store tests green (incl. dev's FilePairedDeviceStore + the now-fixed contract tests), iOS simulator build green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -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,18 @@ 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?
|
||||
/// 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 reconnectAttempts = 0
|
||||
|
||||
@@ -170,7 +186,7 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
|
||||
private func seedDemo() {
|
||||
connectivity = .connected
|
||||
connectivity = .connected(.lan)
|
||||
hostName = "Andrew's Mac"
|
||||
grantedScope = .control
|
||||
capabilities = WireCapabilities(
|
||||
@@ -277,46 +293,242 @@ final class RemoteStore: ObservableObject {
|
||||
""")
|
||||
}
|
||||
|
||||
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
|
||||
/// One dialable way to reach the Mac. A connect builds an ordered candidate list — LAN
|
||||
/// first (cheapest when reachable), then the tailnet (SYNC §3.2's LAN-then-fallback
|
||||
/// 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) {
|
||||
teardown()
|
||||
connectivity = .connecting
|
||||
hostName = payload.hostName
|
||||
let deviceID = IdentityStore.deviceID()
|
||||
guard let endpoint = resolveEndpoint(
|
||||
guard let hint = payload.transportHint else {
|
||||
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)
|
||||
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)
|
||||
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.
|
||||
/// Reconnect to the already-paired host using IK against the pinned static key: LAN
|
||||
/// 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() {
|
||||
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 }
|
||||
guard host.transportHint != .relay else {
|
||||
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()
|
||||
}
|
||||
|
||||
let channel = makeChannel(endpoint)
|
||||
/// 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
|
||||
let channel = makeChannel(endpoint)
|
||||
// Close the channel if TCP isn't up within the window (a stale IP hint would
|
||||
// otherwise hang the chain on NWConnection's slow timeout); the finished stream
|
||||
// then advances to the next candidate. The timer checks readiness itself — it's
|
||||
// bound to exactly this channel, so a stale timer can never hit a later attempt.
|
||||
lanConnectTimeout?.cancel()
|
||||
lanConnectTimeout = Task { [weak channel] in
|
||||
try? await Task.sleep(for: .seconds(4))
|
||||
guard !Task.isCancelled, let channel, !channel.isReady else { return }
|
||||
channel.close()
|
||||
}
|
||||
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
|
||||
guard let self else { return }
|
||||
do {
|
||||
let channel = try await self.tailnetChannel(host: host, port: port)
|
||||
guard !Task.isCancelled else { channel.close(); return }
|
||||
self.startClient(
|
||||
channel: channel, hostStaticKey: plan.hostStaticKey,
|
||||
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
self.tailnetAttemptFailed(error, isPairing: plan.pairingPayload != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 —
|
||||
/// 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()
|
||||
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 {
|
||||
try await TailnetNode.shared.ensureRunning(config: config)
|
||||
} catch TailnetError.timedOut(let message) {
|
||||
// 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.
|
||||
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 +536,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)
|
||||
}
|
||||
@@ -706,10 +921,12 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
|
||||
/// Map an `NWConnection` failure to a connectivity state the pairing/onboarding UI can act on.
|
||||
/// Only meaningful while we're still establishing the link; once `.connected`, a drop is the
|
||||
/// normal `.closed` → reconnect path's job.
|
||||
/// Only meaningful while we're still establishing the link; once connected, a drop is the
|
||||
/// normal `.closed` → reconnect path's job. And only when the connect chain has nothing
|
||||
/// left to try — a failed LAN probe about to fall back to the tailnet is routine, not news.
|
||||
private func handleTransportFailure(_ error: String) {
|
||||
guard connectivity != .connected else { return }
|
||||
guard !connectivity.isLive else { return }
|
||||
guard connectPlan?.remaining.isEmpty != false else { return }
|
||||
connectivity = .failed(Self.friendlyTransportError(error))
|
||||
}
|
||||
|
||||
@@ -734,7 +951,11 @@ final class RemoteStore: ObservableObject {
|
||||
eventTask = Task { [weak self] in
|
||||
let stream = await client.start()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -745,7 +966,8 @@ final class RemoteStore: ObservableObject {
|
||||
break
|
||||
case .ready(let welcome):
|
||||
reconnectAttempts = 0
|
||||
connectivity = .connected
|
||||
connectPlan = nil // the chain found its transport
|
||||
connectivity = .connected(activeTransport)
|
||||
hostName = welcome.host.hostName
|
||||
capabilities = welcome.capabilities
|
||||
grantedScope = welcome.grantedScope
|
||||
@@ -754,7 +976,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)
|
||||
@@ -824,12 +1048,33 @@ final class RemoteStore: ObservableObject {
|
||||
// Losing an approval race isn't an error worth interrupting for; the card
|
||||
// collapses on the matching `approvalResolved`.
|
||||
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)
|
||||
case .failed(let message):
|
||||
connectivity = .failed(message)
|
||||
// Another candidate may still carry the session (e.g. LAN died → tailnet).
|
||||
if tryNextCandidate() { break }
|
||||
connectPlan = nil
|
||||
// The channel's onFailed may have just surfaced a friendlier transport-level
|
||||
// cause (denied Local Network, connection refused) — don't clobber it.
|
||||
if case .failed = connectivity {} else { connectivity = .failed(message) }
|
||||
scheduleRetry()
|
||||
case .closed:
|
||||
if connectivity == .connected { 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. Keep a friendlier
|
||||
// transport-level failure if one was already surfaced.
|
||||
if case .failed = connectivity {} else {
|
||||
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.")
|
||||
}
|
||||
}
|
||||
connectPlan = nil
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
@@ -856,14 +1101,29 @@ 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
|
||||
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 = nil
|
||||
if let client { Task { await client.disconnect() } }
|
||||
|
||||
Reference in New Issue
Block a user