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).
|
/// 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`,
|
/// 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, …).
|
/// Mac, and it keeps that Mac's projection (connectivity, sessions, dashboard, capabilities, …).
|
||||||
/// `RemoteStore` will own one of these per paired host (`[HostID: HostConnection]`), aggregating
|
/// `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,
|
/// 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
|
/// 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.
|
/// running; at most one at a time so repeated path-change events don't stack.
|
||||||
private var lanUpgradeProbe: Task<Void, Never>?
|
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 reconnectAttempts = 0
|
||||||
private var seenSeq: Set<UInt64> = []
|
private var seenSeq: Set<UInt64> = []
|
||||||
|
|
||||||
@@ -176,8 +180,7 @@ final class HostConnection {
|
|||||||
private enum TransportAttempt {
|
private enum TransportAttempt {
|
||||||
case lan(NWEndpoint)
|
case lan(NWEndpoint)
|
||||||
case tailnet(host: String, port: UInt16)
|
case tailnet(host: String, port: UInt16)
|
||||||
/// Covalence Relay (mesh P2) — always the last candidate: works from anywhere,
|
/// Covalence Relay (mesh P2) — the immediate baseline whenever admission is available.
|
||||||
/// but a direct path beats a brokered one when both exist.
|
|
||||||
case relay(base: URL, membershipToken: String)
|
case relay(base: URL, membershipToken: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,10 +237,12 @@ final class HostConnection {
|
|||||||
_ = tryNextCandidate()
|
_ = tryNextCandidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconnect to the pinned host using IK: LAN when reachable, else the pairing's tailnet hint.
|
/// Reconnect to the pinned host using IK. Relay admission always establishes the baseline first;
|
||||||
/// `preferRelay` reorders the candidates to try the Nucleic Edge first (a backgrounded/locked
|
/// LAN and STUN optimization happen only after that session is usable. `preferLAN` is reserved
|
||||||
/// App Intent resolving an approval — see `buildCandidates`); the direct paths stay as fallbacks.
|
/// for a LAN endpoint just proven while Relay remained live.
|
||||||
func reconnect(to host: PairedHost, preferRelay: Bool = false) {
|
func reconnect(
|
||||||
|
to host: PairedHost, preferRelay: Bool = false, preferLAN: Bool = false
|
||||||
|
) {
|
||||||
teardown()
|
teardown()
|
||||||
pinnedHost = host
|
pinnedHost = host
|
||||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||||
@@ -248,7 +253,7 @@ final class HostConnection {
|
|||||||
lanHost: host.lanHost, lanPort: host.lanPort,
|
lanHost: host.lanHost, lanPort: host.lanPort,
|
||||||
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil,
|
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil,
|
||||||
relay: (host.relayMembershipToken, host.relayURL),
|
relay: (host.relayMembershipToken, host.relayURL),
|
||||||
preferRelay: preferRelay)
|
preferRelay: preferRelay, preferLAN: preferLAN)
|
||||||
guard !candidates.isEmpty else {
|
guard !candidates.isEmpty else {
|
||||||
connectivity = .hostOffline
|
connectivity = .hostOffline
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
@@ -319,32 +324,33 @@ final class HostConnection {
|
|||||||
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
||||||
tailnet: (host: String?, port: UInt16?)?,
|
tailnet: (host: String?, port: UInt16?)?,
|
||||||
relay: (membershipToken: String?, url: String?)? = nil,
|
relay: (membershipToken: String?, url: String?)? = nil,
|
||||||
preferRelay: Bool = false
|
preferRelay: Bool = false, preferLAN: Bool = false
|
||||||
) -> [TransportAttempt] {
|
) -> [TransportAttempt] {
|
||||||
let relayCandidate: TransportAttempt? = {
|
let relayCandidate: TransportAttempt? = {
|
||||||
guard let relay, let token = relay.membershipToken, !token.isEmpty else { return nil }
|
guard let relay, let token = relay.membershipToken, !token.isEmpty else { return nil }
|
||||||
return .relay(base: RelayAPI.baseURL(relay.url), membershipToken: token)
|
return .relay(base: RelayAPI.baseURL(relay.url), membershipToken: token)
|
||||||
}()
|
}()
|
||||||
var candidates: [TransportAttempt] = []
|
var directCandidates: [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) }
|
|
||||||
// Only offer LAN when the device actually has a LAN-capable path (Wi-Fi/wired). On cellular
|
// 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
|
// 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.
|
// connect timeout before falling through — skip it and dial tailnet/relay immediately.
|
||||||
if pathMonitor.canUseLAN,
|
if pathMonitor.canUseLAN,
|
||||||
let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
|
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 {
|
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
|
// `preferRelay` is retained for existing App-Intent callers, but Relay is now first for all
|
||||||
// exist. `preferRelay` already placed it first, so don't add it twice.
|
// callers whenever it is configured. A proven LAN promotion gets one LAN-first attempt with
|
||||||
if !preferRelay, let relayCandidate { candidates.append(relayCandidate) }
|
// Relay immediately behind it; all other direct candidates remain failure fallbacks.
|
||||||
return candidates
|
_ = 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 {
|
private func tryNextCandidate() -> Bool {
|
||||||
@@ -580,7 +586,7 @@ final class HostConnection {
|
|||||||
// that predates the field omits it, and we keep whatever we already had.
|
// that predates the field omits it, and we keep whatever we already had.
|
||||||
if let settings = welcome.settings { callbacks.settingsChanged(settings) }
|
if let settings = welcome.settings { callbacks.settingsChanged(settings) }
|
||||||
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
|
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
|
||||||
callbacks.didPair(PairedHost(
|
let paired = PairedHost(
|
||||||
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,
|
||||||
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
|
||||||
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
lanHost: payload.lanHost, lanPort: payload.lanPort,
|
||||||
@@ -588,10 +594,16 @@ final class HostConnection {
|
|||||||
tailnetPort: payload.tailnetPort,
|
tailnetPort: payload.tailnetPort,
|
||||||
relayRoomID: payload.relayRoomID,
|
relayRoomID: payload.relayRoomID,
|
||||||
relayMembershipToken: payload.relayMembershipToken,
|
relayMembershipToken: payload.relayMembershipToken,
|
||||||
relayURL: payload.relayURL))
|
relayURL: payload.relayURL)
|
||||||
|
pinnedHost = paired
|
||||||
|
callbacks.didPair(paired)
|
||||||
}
|
}
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
await startDirectUpgradeIfPossible(welcome: welcome)
|
if activeTransport == .relay, let client {
|
||||||
|
optimizeRelayConnection(welcome: welcome, client: client)
|
||||||
|
} else {
|
||||||
|
skipNextLANOptimization = false
|
||||||
|
}
|
||||||
send(.listSessions)
|
send(.listSessions)
|
||||||
send(.listDashboard)
|
send(.listDashboard)
|
||||||
// Mesh casting: subscribe with the merged ledger's cursors (every origin we hold) —
|
// 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
|
/// The live data path migrated (direct upgrade / failback). Reflect it in the status chip
|
||||||
/// without disturbing the relay-backed session underneath.
|
/// without disturbing the relay-backed session underneath.
|
||||||
private func directPathChanged(_ hint: SyncTransportHint) {
|
private func directPathChanged(_ hint: SyncTransportHint) {
|
||||||
@@ -979,7 +1026,8 @@ final class HostConnection {
|
|||||||
let host = self.pinnedHost
|
let host = self.pinnedHost
|
||||||
else { return }
|
else { return }
|
||||||
self.reconnectAttempts = 0
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// `reconnect()` establishes Relay first when available, so it avoids retrying the LAN link that
|
||||||
/// lands on tailnet or relay from wherever the phone now is. Holds a background-task assertion
|
/// 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
|
/// 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.
|
/// (budget, force-quit), in which case the reconnect simply happens on the next foreground.
|
||||||
func handleReconnectWake(completion: @escaping () -> Void) {
|
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
|
/// 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;
|
/// 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 +
|
/// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch +
|
||||||
/// "Reconnect" path.
|
/// "Reconnect" path.
|
||||||
/// `preferRelay` reorders each dialing host's candidates to try the Nucleic Edge first — set
|
/// `preferRelay` remains as a source-compatible hint for App Intent callers; Relay-enabled
|
||||||
/// only from a backgrounded/locked App Intent (a Live Activity approve/deny) where the local
|
/// hosts now use that fast baseline for foreground and background reconnects alike.
|
||||||
/// network is unreliable. The normal launch/foreground path leaves it false (direct-first).
|
|
||||||
func reconnect(preferRelay: Bool = false) {
|
func reconnect(preferRelay: Bool = false) {
|
||||||
let hosts = IdentityStore.pairedHosts()
|
let hosts = IdentityStore.pairedHosts()
|
||||||
guard !hosts.isEmpty else { connectivity = .unpaired; return }
|
guard !hosts.isEmpty else { connectivity = .unpaired; return }
|
||||||
|
|||||||
Reference in New Issue
Block a user