From d67f21b8d662b64fc173abb2197c80b2c5ffeda9 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 22:09:25 -0700 Subject: [PATCH] =?UTF-8?q?nvrsion:=20Add:=20Fix=20tab=20bar=20animation?= =?UTF-8?q?=20delay=20when=20exiting=20session=20chat=20to=20ensure=20imme?= =?UTF-8?q?diate=20slide-up=20(Models,=20AdaptiveRootView,=20SessionsView)?= =?UTF-8?q?,=20update=20iOS=20settings=20to=20replace=20=E2=80=9CJoin=20a?= =?UTF-8?q?=20Mac=E2=80=99s=20group=E2=80=9D=20with=20=E2=80=9CJoin=20an?= =?UTF-8?q?=20Orbital=20mesh=E2=80=9D=20and=20add=20QR=20code=20button=20f?= =?UTF-8?q?or=20device=20grouping.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/Models/HostConnection.swift | 7 + .../NucleicRemote/Models/RemoteStore.swift | 78 ++++++++++- .../Views/AdaptiveRootView.swift | 11 +- .../NucleicRemote/Views/SessionsView.swift | 9 ++ .../NucleicRemote/Views/SettingsView.swift | 132 +++++++++++++++++- 5 files changed, 230 insertions(+), 7 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 8587ad9..cb6433c 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -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) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d5cfadc..f9d24bc 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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) } } diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index eeaab5e..fd005d8 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -46,10 +46,13 @@ struct CompactRootView: View { @State private var barHeight: CGFloat = 72 /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's - /// floating glass chat bar (`openSessionID`, set by `SessionDetailView`'s appear/disappear) and - /// to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the composer - /// doesn't just stack on top of a lingering tab bar. - private var showsTabBar: Bool { store.openSessionID == nil && !showNewChat } + /// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation + /// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the + /// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than + /// `openSessionID` because the path flips the instant a back-swipe begins, whereas + /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop + /// transition — which left the bar sliding back up 1–2s late. + private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat } /// The "+" is inert without control scope or a project to start in, so it's shown only when a /// chat could actually be kicked off (mirrors `NewChatAffordance.canStart`). diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 5fc3328..45c70ab 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -73,9 +73,18 @@ struct SessionsView: View { path.append(SessionID(rawValue: raw)) } consumeRoute() + // Sync the shell's tab-bar visibility to the current push depth on (re)appear — + // `onChange` alone won't fire for a path restored across a remount. + store.compactDetailPresented = !path.isEmpty } // A notification tap while this tab is already up. .onChange(of: store.pendingRoute) { consumeRoute() } + // Drive the floating tab bar straight off the navigation path: it empties the instant a + // back-swipe/tap begins, so the bar slides back in immediately instead of waiting for + // `SessionDetailView.onDisappear` (which fires only at the end of the pop transition). + .onChange(of: path) { _, newPath in + store.compactDetailPresented = !newPath.isEmpty + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index fdd2f05..c7ffbda 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoreImage.CIFilterBuiltins import NucleicProtocol import NucleicTailnet import UIKit @@ -7,6 +8,7 @@ struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false @State private var showManualPair = false + @State private var showAddDevice = false /// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3). @State private var pairedHostsToken = UUID() @AppStorage("nucleic.showRawEvents") private var showRaw = false @@ -138,10 +140,20 @@ struct SettingsView: View { } Section { + // Grow the mesh: ask a connected Mac to mint a join code this phone shows — + // scan it from a new iPhone/iPad, or copy the link to a new Mac. Only when a + // live Mac can mint one (the phone can't originate a join code on its own). + if store.canAddDeviceToMesh { + Button { + showAddDevice = true + } label: { + Label("Add a device to this mesh", systemImage: "plus.circle") + } + } Button { showScanner = true } label: { - Label("Join a Mac's group", systemImage: "qrcode.viewfinder") + Label("Join an Orbital mesh", systemImage: "qrcode.viewfinder") } Button { showManualPair = true @@ -171,6 +183,9 @@ struct SettingsView: View { store.pair(with: payload) } } + .sheet(isPresented: $showAddDevice, onDismiss: { store.cancelPairingCode() }) { + AddDeviceView() + } } } @@ -355,3 +370,118 @@ struct ManualPairingView: View { dismiss() } } + +/// "Add a device to this mesh": the phone can't mint a join code itself (it runs no listener), so +/// it asks a connected Mac to open a pairing window and relays back that Mac's code — shown here as +/// a QR to scan from a new iPhone/iPad, or a copyable link to send to a new Mac. Scanning/pasting it +/// joins the new device to the whole group. Dismissing the sheet closes the Mac's pairing window +/// (`RemoteStore.cancelPairingCode`, wired to the sheet's `onDismiss`). +struct AddDeviceView: View { + @EnvironmentObject var store: RemoteStore + @Environment(\.dismiss) private var dismiss + @State private var copied = false + @State private var copyResetTask: Task? + + var body: some View { + NavigationStack { + Form { + switch store.addDevice { + case .idle, .requesting: + Section { + HStack { + ProgressView() + Text("Asking a Mac in your mesh for a join code…") + .foregroundStyle(.secondary) + } + } + case .unavailable: + Section { + Label("Couldn't get a join code", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("This needs a Mac in your mesh that's connected right now. Make sure " + + "one is reachable with its meshing service on, then try again.") + .font(.callout).foregroundStyle(.secondary) + Button("Try again") { store.requestPairingCode() } + } + case .ready(let code): + codeSection(code) + } + } + .navigationTitle("Add a device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + } + // Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to + // close its pairing window. + .onAppear { if case .ready = store.addDevice {} else { store.requestPairingCode() } } + .onDisappear { copyResetTask?.cancel() } + } + + @ViewBuilder + private func codeSection(_ code: String) -> some View { + Section { + if let image = Self.qrImage(code) { + HStack { + Spacer() + Image(uiImage: image) + .interpolation(.none) + .resizable() + .frame(width: 220, height: 220) + .padding(10) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + Spacer() + } + .listRowBackground(Color.clear) + } + } header: { + Text("Scan to join") + } footer: { + Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Orbital mesh and scan this. " + + "It joins the whole group — every Mac and device here.") + } + + Section { + Text(code) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + Button { + copyCode(code) + } label: { + Label(copied ? "Copied" : "Copy join code", + systemImage: copied ? "checkmark" : "doc.on.doc") + } + } header: { + Text("Or send a code") + } footer: { + Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” on an iPhone or " + + "iPad. A Mac joining is confirmed on the Mac that shared the code.") + } + } + + private func copyCode(_ code: String) { + UIPasteboard.general.string = code + withAnimation(.easeOut(duration: 0.12)) { copied = true } + copyResetTask?.cancel() + copyResetTask = Task { + try? await Task.sleep(for: .seconds(1.4)) + guard !Task.isCancelled else { return } + withAnimation(.easeIn(duration: 0.2)) { copied = false } + } + } + + /// Render the join string as a QR code (CoreImage), mirroring the Mac's pairing sheet. + private static func qrImage(_ string: String) -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) + let context = CIContext() + guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return UIImage(cgImage: cg) + } +}