nvrsion: Add: Fix tab bar animation delay when exiting session chat to ensure immediate slide-up (Models, AdaptiveRootView, SessionsView), update iOS settings to replace “Join a Mac’s group” with “Join an Orbital mesh” and add QR code button for device grouping.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -92,6 +92,9 @@ final class HostConnection {
|
||||
var meshRosterChanged: () -> Void = {}
|
||||
/// The embedded Tailscale node's status changed (for Settings).
|
||||
var tailnetStatus: (String?, URL?) -> Void = { _, _ in }
|
||||
/// A join code this host minted at our request ("add a device to this mesh") — the
|
||||
/// `nucleic://pair?d=…` string to show as a QR / copyable code, or nil if it couldn't.
|
||||
var pairingCodeReceived: (String?) -> Void = { _ in }
|
||||
}
|
||||
private let callbacks: Callbacks
|
||||
|
||||
@@ -593,6 +596,10 @@ final class HostConnection {
|
||||
pinnedHost?.relayMembershipToken = membership.token
|
||||
pinnedHost?.relayURL = membership.url
|
||||
}
|
||||
case .pairingCode(let qr):
|
||||
// This Mac minted a join code we asked for ("add a device to this mesh") — hand it up
|
||||
// to RemoteStore for the QR/copy sheet.
|
||||
callbacks.pairingCodeReceived(qr)
|
||||
case .wireError(let error):
|
||||
if error.code == .channelMismatch {
|
||||
connectivity = .failed(error.message)
|
||||
|
||||
@@ -66,11 +66,32 @@ final class RemoteStore: ObservableObject {
|
||||
private var openSessionHostID: String?
|
||||
@Published private(set) var openEvents: [AgentEvent] = []
|
||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||
/// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct
|
||||
/// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's
|
||||
/// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the
|
||||
/// floating tab bar's visibility off it left the bar sliding back up 1–2s after a back-swipe.
|
||||
/// `SessionsView` sets this straight from its navigation path, which flips the instant the pop
|
||||
/// begins, so the bar reflows immediately while the transcript state survives the animation.
|
||||
@Published var compactDetailPresented = false
|
||||
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
|
||||
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
|
||||
@Published private(set) var openDiff: WireSessionDiff?
|
||||
@Published private(set) var diffLoading = false
|
||||
|
||||
/// "Add a device to this mesh" (Settings): the phone asks a connected Mac to mint a join code
|
||||
/// and shows it as a QR / copyable link. The phone can't mint one itself (it runs no listener),
|
||||
/// so this is always relayed through a live, capable host.
|
||||
enum AddDeviceState: Equatable {
|
||||
case idle
|
||||
case requesting // waiting on the host's `pairingCode` reply
|
||||
case ready(String) // the `nucleic://pair?d=…` join code to display
|
||||
case unavailable // no live host could mint one right now
|
||||
}
|
||||
@Published private(set) var addDevice: AddDeviceState = .idle
|
||||
/// The host we asked to mint the current code, so a dismiss can tell the *same* Mac to close
|
||||
/// its pairing window.
|
||||
private var pairingMintHostID: String?
|
||||
|
||||
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
|
||||
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
|
||||
struct LastError: Equatable, Identifiable {
|
||||
@@ -533,6 +554,13 @@ final class RemoteStore: ObservableObject {
|
||||
self?.tailnetStatus = status
|
||||
self?.tailnetLoginURL = loginURL
|
||||
}
|
||||
cb.pairingCodeReceived = { [weak self] qr in
|
||||
guard let self else { return }
|
||||
// Only apply while a request is outstanding — ignore a late reply after the user
|
||||
// dismissed the sheet (we already told the host to cancel).
|
||||
guard case .requesting = self.addDevice else { return }
|
||||
self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable
|
||||
}
|
||||
return cb
|
||||
}
|
||||
|
||||
@@ -664,6 +692,7 @@ final class RemoteStore: ObservableObject {
|
||||
/// aggregate and spin the embedded Tailscale node down.
|
||||
private func finishUnpairIfEmpty() {
|
||||
openSessionID = nil
|
||||
compactDetailPresented = false
|
||||
openSessionHostID = nil
|
||||
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
||||
connectivity = .unpaired
|
||||
@@ -694,6 +723,7 @@ final class RemoteStore: ObservableObject {
|
||||
demoTasks.forEach { $0.cancel() }
|
||||
demoTasks.removeAll()
|
||||
openSessionID = nil
|
||||
compactDetailPresented = false
|
||||
openSessionHostID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
@@ -742,6 +772,44 @@ final class RemoteStore: ObservableObject {
|
||||
send(.fetchDiff(sessionID))
|
||||
}
|
||||
|
||||
// MARK: - Add a device to this mesh (relayed pairing-code mint)
|
||||
|
||||
/// Whether the "add a device" button should appear: a live Mac that advertises
|
||||
/// `canMintPairingCode` is reachable to mint a join code (or we're in demo).
|
||||
var canAddDeviceToMesh: Bool {
|
||||
demoMode || pairingMintConnection != nil
|
||||
}
|
||||
|
||||
/// The connection we route a mint request to: prefer the context host if it can mint, else any
|
||||
/// live host that can. A code minted by *any* mesh member joins the whole group (mesh "join").
|
||||
private var pairingMintConnection: HostConnection? {
|
||||
if let ctx = contextConnection, ctx.connectivity.isLive, ctx.capabilities.canMintPairingCode {
|
||||
return ctx
|
||||
}
|
||||
return connections.values.first { $0.connectivity.isLive && $0.capabilities.canMintPairingCode }
|
||||
}
|
||||
|
||||
/// Ask a connected Mac to open a pairing window and hand back its join code. The reply arrives
|
||||
/// asynchronously as `AddDeviceState.ready` (or `.unavailable`) via the host connection.
|
||||
func requestPairingCode() {
|
||||
if demoMode { addDevice = .ready(Self.demoPairingCode); return }
|
||||
guard let conn = pairingMintConnection else { addDevice = .unavailable; return }
|
||||
pairingMintHostID = conn.hostID
|
||||
addDevice = .requesting
|
||||
conn.send(.requestPairingCode)
|
||||
}
|
||||
|
||||
/// The user dismissed the "add a device" sheet — tell the minting Mac to close its pairing
|
||||
/// window (retire the one-time secret) and reset to idle.
|
||||
func cancelPairingCode() {
|
||||
if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) }
|
||||
pairingMintHostID = nil
|
||||
addDevice = .idle
|
||||
}
|
||||
|
||||
/// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
|
||||
private static let demoPairingCode = "nucleic://pair?d=demo"
|
||||
|
||||
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
||||
func markOpened(_ sessionID: SessionID) {
|
||||
lastOpenedAt[sessionID] = Date()
|
||||
@@ -936,9 +1004,12 @@ final class RemoteStore: ObservableObject {
|
||||
broadcastLive(msg)
|
||||
|
||||
// Never originated from here (connection-internal, or handled by dedicated loops).
|
||||
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
||||
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
||||
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
||||
.registerLiveActivity, .endLiveActivity,
|
||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript:
|
||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
||||
.requestPairingCode, .cancelPairingCode:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1046,7 +1117,10 @@ final class RemoteStore: ObservableObject {
|
||||
.registerLiveActivity, .endLiveActivity,
|
||||
// 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:
|
||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel,
|
||||
// "Add a device" mint is handled directly against a live host, not via demoHandle;
|
||||
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
|
||||
.requestPairingCode, .cancelPairingCode:
|
||||
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user