Merge nucleic/plucky-ancient-koala-2qbh into dev
This commit is contained in:
@@ -9,7 +9,7 @@ import UIKit
|
||||
/// One phone→Mac connection and its projected state (mesh P3 multiplexer foundation).
|
||||
///
|
||||
/// This is the per-host connection engine extracted from `RemoteStore`: it owns the `SyncClient`,
|
||||
/// the LAN→tailnet candidate chain, reconnect backoff, and the event stream for a single paired
|
||||
/// the Relay-first candidate chain, reconnect backoff, and the event stream for a single paired
|
||||
/// Mac, and it keeps that Mac's projection (connectivity, sessions, dashboard, capabilities, …).
|
||||
/// `RemoteStore` will own one of these per paired host (`[HostID: HostConnection]`), aggregating
|
||||
/// their state and routing intents to the right one; aggregate concerns (badge, Live Activity,
|
||||
@@ -151,6 +151,10 @@ final class HostConnection {
|
||||
/// In-flight LAN-reachability probe (the tailnet/relay → LAN return leg). Nil unless a probe is
|
||||
/// running; at most one at a time so repeated path-change events don't stack.
|
||||
private var lanUpgradeProbe: Task<Void, Never>?
|
||||
/// A successful reachability probe causes one LAN-first authenticated reconnect. If that full
|
||||
/// dial falls back to Relay, consume this flag there and proceed to STUN instead of probing the
|
||||
/// same accepting-but-unusable endpoint forever.
|
||||
private var skipNextLANOptimization = false
|
||||
private var reconnectAttempts = 0
|
||||
private var seenSeq: Set<UInt64> = []
|
||||
|
||||
@@ -176,8 +180,7 @@ final class HostConnection {
|
||||
private enum TransportAttempt {
|
||||
case lan(NWEndpoint)
|
||||
case tailnet(host: String, port: UInt16)
|
||||
/// Covalence Relay (mesh P2) — always the last candidate: works from anywhere,
|
||||
/// but a direct path beats a brokered one when both exist.
|
||||
/// Covalence Relay (mesh P2) — the immediate baseline whenever admission is available.
|
||||
case relay(base: URL, membershipToken: String)
|
||||
}
|
||||
|
||||
@@ -234,10 +237,12 @@ final class HostConnection {
|
||||
_ = tryNextCandidate()
|
||||
}
|
||||
|
||||
/// Reconnect to the pinned host using IK: LAN when reachable, else the pairing's tailnet hint.
|
||||
/// `preferRelay` reorders the candidates to try the Nucleic Edge first (a backgrounded/locked
|
||||
/// App Intent resolving an approval — see `buildCandidates`); the direct paths stay as fallbacks.
|
||||
func reconnect(to host: PairedHost, preferRelay: Bool = false) {
|
||||
/// Reconnect to the pinned host using IK. Relay admission always establishes the baseline first;
|
||||
/// LAN and STUN optimization happen only after that session is usable. `preferLAN` is reserved
|
||||
/// for a LAN endpoint just proven while Relay remained live.
|
||||
func reconnect(
|
||||
to host: PairedHost, preferRelay: Bool = false, preferLAN: Bool = false
|
||||
) {
|
||||
teardown()
|
||||
pinnedHost = host
|
||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||
@@ -248,7 +253,7 @@ final class HostConnection {
|
||||
lanHost: host.lanHost, lanPort: host.lanPort,
|
||||
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil,
|
||||
relay: (host.relayMembershipToken, host.relayURL),
|
||||
preferRelay: preferRelay)
|
||||
preferRelay: preferRelay, preferLAN: preferLAN)
|
||||
guard !candidates.isEmpty else {
|
||||
connectivity = .hostOffline
|
||||
callbacks.didUpdate()
|
||||
@@ -319,32 +324,33 @@ final class HostConnection {
|
||||
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
||||
tailnet: (host: String?, port: UInt16?)?,
|
||||
relay: (membershipToken: String?, url: String?)? = nil,
|
||||
preferRelay: Bool = false
|
||||
preferRelay: Bool = false, preferLAN: Bool = false
|
||||
) -> [TransportAttempt] {
|
||||
let relayCandidate: TransportAttempt? = {
|
||||
guard let relay, let token = relay.membershipToken, !token.isEmpty else { return nil }
|
||||
return .relay(base: RelayAPI.baseURL(relay.url), membershipToken: token)
|
||||
}()
|
||||
var candidates: [TransportAttempt] = []
|
||||
// Relay-first: a backgrounded/locked App Intent (a Live Activity approve/deny) can't rely on
|
||||
// the local network — iOS restricts LAN/Bonjour for a process launched into the background,
|
||||
// so the LAN attempt just burns its connect timeout. Dial the Nucleic Edge first and keep the
|
||||
// direct paths as fallbacks. Only when the account opted into cloud approvals (SyncedSettings).
|
||||
if preferRelay, let relayCandidate { candidates.append(relayCandidate) }
|
||||
var directCandidates: [TransportAttempt] = []
|
||||
// Only offer LAN when the device actually has a LAN-capable path (Wi-Fi/wired). On cellular
|
||||
// the pinned `lanHost:lanPort` is unreachable, so including it here would just burn the
|
||||
// connect timeout before falling through — skip it and dial tailnet/relay immediately.
|
||||
if pathMonitor.canUseLAN,
|
||||
let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
|
||||
candidates.append(.lan(endpoint))
|
||||
directCandidates.append(.lan(endpoint))
|
||||
}
|
||||
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
|
||||
candidates.append(.tailnet(host: host, port: port))
|
||||
directCandidates.append(.tailnet(host: host, port: port))
|
||||
}
|
||||
// Default order: relay is the last resort — a direct path beats a brokered one when both
|
||||
// exist. `preferRelay` already placed it first, so don't add it twice.
|
||||
if !preferRelay, let relayCandidate { candidates.append(relayCandidate) }
|
||||
return candidates
|
||||
// `preferRelay` is retained for existing App-Intent callers, but Relay is now first for all
|
||||
// callers whenever it is configured. A proven LAN promotion gets one LAN-first attempt with
|
||||
// Relay immediately behind it; all other direct candidates remain failure fallbacks.
|
||||
_ = preferRelay
|
||||
if preferLAN, let first = directCandidates.first,
|
||||
case .lan = first, let relayCandidate {
|
||||
return [first, relayCandidate] + Array(directCandidates.dropFirst())
|
||||
}
|
||||
if let relayCandidate { return [relayCandidate] + directCandidates }
|
||||
return directCandidates
|
||||
}
|
||||
|
||||
private func tryNextCandidate() -> Bool {
|
||||
@@ -580,7 +586,7 @@ final class HostConnection {
|
||||
// that predates the field omits it, and we keep whatever we already had.
|
||||
if let settings = welcome.settings { callbacks.settingsChanged(settings) }
|
||||
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
|
||||
callbacks.didPair(PairedHost(
|
||||
let paired = PairedHost(
|
||||
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
||||
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
||||
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
||||
@@ -588,10 +594,16 @@ final class HostConnection {
|
||||
tailnetPort: payload.tailnetPort,
|
||||
relayRoomID: payload.relayRoomID,
|
||||
relayMembershipToken: payload.relayMembershipToken,
|
||||
relayURL: payload.relayURL))
|
||||
relayURL: payload.relayURL)
|
||||
pinnedHost = paired
|
||||
callbacks.didPair(paired)
|
||||
}
|
||||
callbacks.didUpdate()
|
||||
await startDirectUpgradeIfPossible(welcome: welcome)
|
||||
if activeTransport == .relay, let client {
|
||||
optimizeRelayConnection(welcome: welcome, client: client)
|
||||
} else {
|
||||
skipNextLANOptimization = false
|
||||
}
|
||||
send(.listSessions)
|
||||
send(.listDashboard)
|
||||
// Mesh casting: subscribe with the merged ledger's cursors (every origin we hold) —
|
||||
@@ -859,6 +871,41 @@ final class HostConnection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the authenticated Relay session available while checking the cheapest path. A proven
|
||||
/// LAN endpoint gets one LAN-first reconnect; otherwise STUN begins. Failure at either stage
|
||||
/// leaves or returns the connection on Relay.
|
||||
private func optimizeRelayConnection(welcome: Welcome, client: SyncClient) {
|
||||
guard self.client === client else { return }
|
||||
if skipNextLANOptimization {
|
||||
skipNextLANOptimization = false
|
||||
Task { [weak self] in await self?.startDirectUpgradeIfPossible(welcome: welcome) }
|
||||
return
|
||||
}
|
||||
guard let host = pinnedHost, pathMonitor.canUseLAN,
|
||||
let endpoint = discovery.endpoint(
|
||||
forFingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort)
|
||||
else {
|
||||
Task { [weak self] in await self?.startDirectUpgradeIfPossible(welcome: welcome) }
|
||||
return
|
||||
}
|
||||
lanUpgradeProbe?.cancel()
|
||||
lanUpgradeProbe = Task { [weak self, weak client] in
|
||||
let reachable = await Self.probeReachable(endpoint)
|
||||
guard let self else { return }
|
||||
self.lanUpgradeProbe = nil
|
||||
guard !Task.isCancelled, let client, self.client === client,
|
||||
self.connectivity.isLive, self.activeTransport == .relay
|
||||
else { return }
|
||||
if reachable, let host = self.pinnedHost {
|
||||
self.skipNextLANOptimization = true
|
||||
self.reconnectAttempts = 0
|
||||
self.reconnect(to: host, preferLAN: true)
|
||||
} else {
|
||||
await self.startDirectUpgradeIfPossible(welcome: welcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The live data path migrated (direct upgrade / failback). Reflect it in the status chip
|
||||
/// without disturbing the relay-backed session underneath.
|
||||
private func directPathChanged(_ hint: SyncTransportHint) {
|
||||
@@ -979,7 +1026,8 @@ final class HostConnection {
|
||||
let host = self.pinnedHost
|
||||
else { return }
|
||||
self.reconnectAttempts = 0
|
||||
self.reconnect(to: host) // rebuilds the candidate chain LAN-first
|
||||
self.skipNextLANOptimization = true
|
||||
self.reconnect(to: host, preferLAN: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -957,8 +957,8 @@ final class RemoteStore: ObservableObject {
|
||||
/// drop and is asking us to re-dial over a non-local transport before the connection silently
|
||||
/// goes dark. Called from `PushAppDelegate` on a `content-available` background push, which may
|
||||
/// relaunch the app with no scene, so this does the setup `onAppear` normally would. Re-running
|
||||
/// `reconnect()` walks each host's LAN→tailnet→relay candidate loop — LAN just failed, so it
|
||||
/// lands on tailnet or relay from wherever the phone now is. Holds a background-task assertion
|
||||
/// `reconnect()` establishes Relay first when available, so it avoids retrying the LAN link that
|
||||
/// just failed and becomes usable from wherever the phone now is. Holds a background-task assertion
|
||||
/// until a host is live again or the window closes. Best-effort: iOS may deny background runtime
|
||||
/// (budget, force-quit), in which case the reconnect simply happens on the next foreground.
|
||||
func handleReconnectWake(completion: @escaping () -> Void) {
|
||||
@@ -1643,13 +1643,12 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
|
||||
/// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own
|
||||
/// IK reconnect (LAN→tailnet, with backoff), so all your Macs are live simultaneously and the
|
||||
/// IK reconnect (Relay-first when configured, with backoff), so all your Macs are live simultaneously and the
|
||||
/// switcher flips between them instantly. Idempotent — an already-live connection is left alone;
|
||||
/// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch +
|
||||
/// "Reconnect" path.
|
||||
/// `preferRelay` reorders each dialing host's candidates to try the Nucleic Edge first — set
|
||||
/// only from a backgrounded/locked App Intent (a Live Activity approve/deny) where the local
|
||||
/// network is unreliable. The normal launch/foreground path leaves it false (direct-first).
|
||||
/// `preferRelay` remains as a source-compatible hint for App Intent callers; Relay-enabled
|
||||
/// hosts now use that fast baseline for foreground and background reconnects alike.
|
||||
func reconnect(preferRelay: Bool = false) {
|
||||
let hosts = IdentityStore.pairedHosts()
|
||||
guard !hosts.isEmpty else { connectivity = .unpaired; return }
|
||||
|
||||
Reference in New Issue
Block a user