nvrsion: Add: Global Icon For Remote Projects to display globe icon next to remote projects in sidebar, matching local projects in appearance, Fix: Chat: Remove Face ID Permission Text, Refactor: Chat: Live Activity Content Density to open app directly into waiting sessions when tapped, Refactor: Chat: Live Activity Content Density to refresh live activity info significantly faster, Refactor: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Network Type Transition Optimization to reduce delay during network type switches, Feature Enhancement Planning: All devices in a mesh group should see and be aware of each other; “pair” reframed into “join”; scanning
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -60,6 +60,9 @@ final class HostConnection {
|
||||
var wireError: (WireError) -> Void = { _ in }
|
||||
/// A pairing handshake succeeded — persist the pinned host record.
|
||||
var didPair: (PairedHost) -> Void = { _ in }
|
||||
/// The mesh roster changed (mesh "join"): a Mac was learned or revoked via gossip — the
|
||||
/// registry was updated, so (re)connect to every paired Mac and drop any that left.
|
||||
var meshRosterChanged: () -> Void = {}
|
||||
/// The embedded Tailscale node's status changed (for Settings).
|
||||
var tailnetStatus: (String?, URL?) -> Void = { _, _ in }
|
||||
}
|
||||
@@ -69,6 +72,7 @@ final class HostConnection {
|
||||
|
||||
private let identity: DeviceIdentity
|
||||
private let discovery: LANDiscovery
|
||||
private let pathMonitor: NetworkPathMonitor
|
||||
|
||||
// MARK: Connection machine (moved from RemoteStore, one per host)
|
||||
|
||||
@@ -99,12 +103,14 @@ final class HostConnection {
|
||||
|
||||
init(
|
||||
hostID: String, hostName: String,
|
||||
identity: DeviceIdentity, discovery: LANDiscovery, callbacks: Callbacks
|
||||
identity: DeviceIdentity, discovery: LANDiscovery, pathMonitor: NetworkPathMonitor,
|
||||
callbacks: Callbacks
|
||||
) {
|
||||
self.hostID = hostID
|
||||
self.hostName = hostName
|
||||
self.identity = identity
|
||||
self.discovery = discovery
|
||||
self.pathMonitor = pathMonitor
|
||||
self.callbacks = callbacks
|
||||
}
|
||||
|
||||
@@ -171,13 +177,50 @@ final class HostConnection {
|
||||
callbacks.didUpdate()
|
||||
}
|
||||
|
||||
/// Translate a gossiped mesh member (mesh "join") into a pinnable/dialable `PairedHost`. The
|
||||
/// member carries the host static key + last-known addresses — everything IK reconnect needs.
|
||||
/// No relay token yet (the phone never directly paired this Mac); it's adopted from the host's
|
||||
/// `relayMembership` push after the first LAN/tailnet contact, so first contact needs one of
|
||||
/// those paths.
|
||||
static func pairedHost(from member: MeshMember) -> PairedHost {
|
||||
let (lanHost, lanPort) = splitHostPort(member.addresses?.lanHint)
|
||||
let tailnetHost = member.addresses?.tailnet
|
||||
let hasTailnet = !(tailnetHost?.isEmpty ?? true)
|
||||
return PairedHost(
|
||||
deviceID: IdentityStore.deviceID(),
|
||||
hostName: member.label,
|
||||
hostStaticKey: member.staticPublicKey,
|
||||
fingerprint: member.staticPublicKey.fingerprintHex,
|
||||
lanHost: lanHost, lanPort: lanPort,
|
||||
transport: (hasTailnet ? SyncTransportHint.tailnet : .lan).rawValue,
|
||||
tailnetHost: hasTailnet ? tailnetHost : nil,
|
||||
// The sync port on a tailnet is fixed protocol-wide (SyncTransportSetting.tailnetPort);
|
||||
// a gossiped address carries only the IP.
|
||||
tailnetPort: hasTailnet ? 43_753 : nil,
|
||||
relayRoomID: member.addresses?.relayRoomID,
|
||||
relayMembershipToken: nil, relayURL: nil)
|
||||
}
|
||||
|
||||
/// Split a "host:port" hint (last-colon split so a bracketed IPv6 host survives).
|
||||
private static func splitHostPort(_ hint: String?) -> (String?, UInt16?) {
|
||||
guard let hint, let colon = hint.lastIndex(of: ":"),
|
||||
let port = UInt16(hint[hint.index(after: colon)...]) else { return (nil, nil) }
|
||||
var host = String(hint[..<colon])
|
||||
if host.hasPrefix("["), host.hasSuffix("]") { host = String(host.dropFirst().dropLast()) }
|
||||
return (host.isEmpty ? nil : host, port)
|
||||
}
|
||||
|
||||
private func buildCandidates(
|
||||
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
|
||||
tailnet: (host: String?, port: UInt16?)?,
|
||||
relay: (membershipToken: String?, url: String?)? = nil
|
||||
) -> [TransportAttempt] {
|
||||
var candidates: [TransportAttempt] = []
|
||||
if let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
|
||||
// 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))
|
||||
}
|
||||
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
|
||||
@@ -280,7 +323,10 @@ final class HostConnection {
|
||||
channel: channel, identity: identity, hostStaticKey: hostStaticKey,
|
||||
mode: mode, deviceID: deviceID,
|
||||
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
|
||||
releaseChannel: BuildInfo.current.channel.releaseChannel)
|
||||
releaseChannel: BuildInfo.current.channel.releaseChannel,
|
||||
// Mesh "join": advertise roster gossip so a host pushes its group view — the phone
|
||||
// then auto-learns and connects to every Mac in the mesh, not just the one it scanned.
|
||||
clientCaps: WireClientCapabilities(mesh: 1, canSyncRoster: true))
|
||||
self.client = client
|
||||
consume(client, pairingPayload: pairingPayload)
|
||||
}
|
||||
@@ -421,9 +467,37 @@ final class HostConnection {
|
||||
case .peerList(let peers):
|
||||
meshPeers = peers
|
||||
callbacks.didUpdate()
|
||||
case .meshRoster(let push):
|
||||
// Mesh "join": the host gossiped its group view. Auto-learn every Mac member into the
|
||||
// paired-hosts registry (so the multiplexer connects to all of them), and drop any the
|
||||
// group revoked. Phone members are ignored — a phone doesn't dial other phones.
|
||||
var changed = false
|
||||
for member in push.members where member.kind == .mac || member.capabilities.canHost {
|
||||
// A mac deviceID is `sha256(staticPublicKey)`; reject a forged (id, key) pair.
|
||||
guard DeviceIdentity.hostID(ofStaticKey: member.staticPublicKey) == member.deviceID
|
||||
else { continue }
|
||||
let fingerprint = member.staticPublicKey.fingerprintHex
|
||||
guard fingerprint != self.hostID else { continue } // that's the Mac we're on
|
||||
if IdentityStore.pairedHost(id: fingerprint) == nil { changed = true }
|
||||
IdentityStore.mergePairedHost(Self.pairedHost(from: member))
|
||||
}
|
||||
for tombstone in push.tombstones {
|
||||
// fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key.
|
||||
let fingerprint = String(tombstone.deviceID.prefix(16))
|
||||
if IdentityStore.pairedHost(id: fingerprint) != nil {
|
||||
IdentityStore.removePairedHost(id: fingerprint)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed { callbacks.meshRosterChanged() }
|
||||
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
|
||||
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
|
||||
break
|
||||
case .transcriptChunk, .transcriptFetchComplete, .transcriptUnavailable:
|
||||
// Full-transcript fetch replies (mesh sync) only arrive in response to a
|
||||
// `fetchTranscript` this device sent. A phone pulls full history on demand via a
|
||||
// dedicated fetch path (not this subscribe stream), so nothing to do here yet.
|
||||
break
|
||||
case .relayMembership(let membership):
|
||||
// The host issued/refreshed this device's relay credential (mesh P2). Persist it
|
||||
// in place (no reordering — this can arrive from a non-active Mac) so the relay
|
||||
@@ -476,6 +550,27 @@ final class HostConnection {
|
||||
Task { await client.send(msg) }
|
||||
}
|
||||
|
||||
/// The device's network path changed (Wi-Fi ⇄ cellular, joined/left a network). React now rather
|
||||
/// than waiting for a zombie LAN socket to time out or the reconnect backoff to elapse — this is
|
||||
/// what makes the transport switch feel immediate. Only reconnect-managed hosts (a pinned host)
|
||||
/// re-plan here; a host mid-pairing runs its own one-shot candidate chain.
|
||||
func networkPathChanged() {
|
||||
guard let host = pinnedHost else { return }
|
||||
// Using (or dialing) LAN but LAN is no longer reachable → the socket is dead in all but name.
|
||||
// Drop it and re-plan now; `buildCandidates` skips LAN and dials tailnet/relay.
|
||||
if activeTransport == .lan, !pathMonitor.canUseLAN {
|
||||
reconnectAttempts = 0
|
||||
reconnect(to: host)
|
||||
return
|
||||
}
|
||||
// Offline/reconnecting and the network just came back or changed → retry immediately instead
|
||||
// of sitting out the remaining backoff.
|
||||
if !connectivity.isLive, pathMonitor.isSatisfied {
|
||||
reconnectAttempts = 0
|
||||
reconnect(to: host)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleRetry(host: PairedHost?) {
|
||||
guard let host else { return }
|
||||
reconnectAttempts += 1
|
||||
|
||||
@@ -112,6 +112,26 @@ enum IdentityStore {
|
||||
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID })
|
||||
}
|
||||
|
||||
/// Merge a Mac learned via mesh roster gossip (mesh "join"): append it if new, else refresh its
|
||||
/// hints **in place** — unlike `upsertPairedHost`, this must NOT reorder (which would hijack the
|
||||
/// active host) and must not clobber a relay token or our established device id with the
|
||||
/// gossiped nils.
|
||||
static func mergePairedHost(_ host: PairedHost) {
|
||||
var hosts = pairedHosts()
|
||||
if let index = hosts.firstIndex(where: { $0.fingerprint == host.fingerprint }) {
|
||||
var merged = host
|
||||
let existing = hosts[index]
|
||||
merged.deviceID = existing.deviceID // keep our established id for this host
|
||||
merged.relayMembershipToken = host.relayMembershipToken ?? existing.relayMembershipToken
|
||||
merged.relayRoomID = host.relayRoomID ?? existing.relayRoomID
|
||||
merged.relayURL = host.relayURL ?? existing.relayURL
|
||||
hosts[index] = merged
|
||||
} else {
|
||||
hosts.append(host)
|
||||
}
|
||||
savePairedHosts(hosts)
|
||||
}
|
||||
|
||||
/// Mutate one host's record **in place**, preserving registry order — unlike
|
||||
/// `upsertPairedHost`, this must not make the host active (it backs background
|
||||
/// refreshes like the relay-membership push, which can arrive from a non-active Mac).
|
||||
|
||||
@@ -86,6 +86,11 @@ final class RemoteStore: ObservableObject {
|
||||
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
|
||||
@Published var pendingRoute: SessionID?
|
||||
|
||||
/// A request to surface the sessions list itself, no specific session — the Live Activity's
|
||||
/// "work running, nothing waiting" tap. Compact jumps to the Sessions tab; the iPad split
|
||||
/// reveals its sidebar (which always lists sessions). One-shot: the shell clears it.
|
||||
@Published var pendingSessionsList = false
|
||||
|
||||
/// Whether the app is foreground-active (scene phase), mirrored here so the store can
|
||||
/// decide which transitions deserve a notification.
|
||||
private(set) var isActive = true
|
||||
@@ -95,6 +100,19 @@ final class RemoteStore: ObservableObject {
|
||||
/// Route to a session from a notification tap (deep link).
|
||||
func route(to sessionID: SessionID) { pendingRoute = sessionID }
|
||||
|
||||
/// Ask the shell to show the sessions list (no specific session opened).
|
||||
func showSessionsList() { pendingSessionsList = true }
|
||||
|
||||
/// Follow a `nucleic://` deep link — the Live Activity tap. A session waiting on the user
|
||||
/// opens straight into that session; the plain sessions link just surfaces the list.
|
||||
func handleDeepLink(_ url: URL) {
|
||||
switch NucleicDeepLink.route(for: url) {
|
||||
case .session(let id): route(to: SessionID(rawValue: id))
|
||||
case .sessionsList: showSessionsList()
|
||||
case .none: break
|
||||
}
|
||||
}
|
||||
|
||||
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
|
||||
/// channel; if the socket dropped while backgrounded, reconnect and send once ready —
|
||||
/// but only briefly (a stale queued approval must never fire minutes later; see
|
||||
@@ -159,6 +177,9 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
private let identity = IdentityStore.loadOrCreateIdentity()
|
||||
let discovery = LANDiscovery()
|
||||
/// Proactive network-path awareness (shared across every host connection): flips transports the
|
||||
/// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
|
||||
let pathMonitor = NetworkPathMonitor()
|
||||
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat
|
||||
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so
|
||||
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly).
|
||||
@@ -262,10 +283,29 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
func onAppear() {
|
||||
if demoMode { seedDemo(); return }
|
||||
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
|
||||
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
|
||||
pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
|
||||
pathMonitor.start()
|
||||
discovery.start()
|
||||
if isPaired { reconnect() }
|
||||
}
|
||||
|
||||
/// A network-path change: tell each host connection to re-plan its transport now.
|
||||
private func handlePathChange() {
|
||||
guard !demoMode else { return }
|
||||
for conn in connections.values { conn.networkPathChanged() }
|
||||
}
|
||||
|
||||
/// Returning to the foreground: the network may have changed while the app was suspended (and
|
||||
/// path/keepalive callbacks were frozen). Re-read the path and re-dial anything not live so the
|
||||
/// transport is correct by the time the UI is on screen, rather than after a backoff.
|
||||
func onForeground() {
|
||||
guard !demoMode, isPaired else { return }
|
||||
pathMonitor.refresh()
|
||||
reconnect()
|
||||
}
|
||||
|
||||
private func seedDemo() {
|
||||
connectivity = .connected(.lan)
|
||||
hostName = "Andrew's Mac"
|
||||
@@ -436,7 +476,7 @@ final class RemoteStore: ObservableObject {
|
||||
private func makeConnection(hostID: String, hostName: String) -> HostConnection {
|
||||
HostConnection(
|
||||
hostID: hostID, hostName: hostName, identity: identity, discovery: discovery,
|
||||
callbacks: callbacks(for: hostID))
|
||||
pathMonitor: pathMonitor, callbacks: callbacks(for: hostID))
|
||||
}
|
||||
|
||||
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
|
||||
@@ -490,6 +530,11 @@ final class RemoteStore: ObservableObject {
|
||||
IdentityStore.savePairedHost(host)
|
||||
self.activeHostID = host.fingerprint
|
||||
}
|
||||
cb.meshRosterChanged = { [weak self] in
|
||||
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
|
||||
// (connecting the newcomer) and drop any that left — without switching the active host.
|
||||
self?.reconnect()
|
||||
}
|
||||
cb.tailnetStatus = { [weak self] status, loginURL in
|
||||
self?.tailnetStatus = status
|
||||
self?.tailnetLoginURL = loginURL
|
||||
@@ -842,7 +887,7 @@ final class RemoteStore: ObservableObject {
|
||||
case .setSessionShipBranch(let id, let branch):
|
||||
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
||||
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
|
||||
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate,
|
||||
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate, .meshRoster,
|
||||
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
|
||||
// and demo has no peer Macs, so they're inert here.
|
||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
||||
|
||||
Reference in New Issue
Block a user