diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index cb6433c..e020876 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -95,6 +95,11 @@ final class HostConnection { /// 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 } + /// This host forwarded a Mac-pair confirm — a Mac is joining via a code we shared and + /// wants the user's allow/deny. The phone can approve it (`respondMacPair`). + var macPairRequested: (WireMacPairRequest) -> Void = { _ in } + /// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt. + var macPairResolved: (String) -> Void = { _ in } } private let callbacks: Callbacks @@ -600,6 +605,13 @@ final class HostConnection { // 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 .macPairRequested(let req): + // A Mac is joining via a code we shared and needs allow/deny — forward it up so the + // phone can approve the join. + callbacks.macPairRequested(req) + case .macPairResolved(let deviceID, _): + // Answered on this Mac or another device (or timed out) — dismiss the prompt. + callbacks.macPairResolved(deviceID) 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 f9d24bc..8a276b7 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -92,6 +92,13 @@ final class RemoteStore: ObservableObject { /// its pairing window. private var pairingMintHostID: String? + /// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac + /// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from + /// the phone. The "add a device" sheet renders an allow/deny dialog for it. + @Published private(set) var pendingMacPairRequest: WireMacPairRequest? + /// The host that forwarded the pending confirm, so the answer routes back to it. + private var pendingMacPairHostID: 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 { @@ -561,6 +568,16 @@ final class RemoteStore: ObservableObject { guard case .requesting = self.addDevice else { return } self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable } + cb.macPairRequested = { [weak self] req in + guard let self else { return } + self.pendingMacPairRequest = req + self.pendingMacPairHostID = hostID + } + cb.macPairResolved = { [weak self] deviceID in + guard let self, self.pendingMacPairRequest?.deviceID == deviceID else { return } + self.pendingMacPairRequest = nil + self.pendingMacPairHostID = nil + } return cb } @@ -800,13 +817,25 @@ final class RemoteStore: ObservableObject { } /// 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. + /// window (retire the one-time secret) and reset to idle. Any Mac-join awaiting approval is + /// abandoned here too; the host fails it closed when its pairing window shuts. func cancelPairingCode() { if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) } pairingMintHostID = nil + pendingMacPairRequest = nil + pendingMacPairHostID = nil addDevice = .idle } + /// Approve or deny a Mac joining via a code this phone shared (the forwarded confirm). Routes + /// the answer to the Mac that forwarded it and clears the prompt optimistically. + func respondMacPair(_ approve: Bool) { + guard let req = pendingMacPairRequest else { return } + if let id = pendingMacPairHostID { connections[id]?.send(.respondMacPair(req.deviceID, approve)) } + pendingMacPairRequest = nil + pendingMacPairHostID = nil + } + /// 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" @@ -1009,7 +1038,7 @@ final class RemoteStore: ObservableObject { case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, .registerLiveActivity, .endLiveActivity, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, - .requestPairingCode, .cancelPairingCode: + .requestPairingCode, .cancelPairingCode, .respondMacPair: break } } @@ -1120,7 +1149,7 @@ final class RemoteStore: ObservableObject { .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: + .requestPairingCode, .cancelPairingCode, .respondMacPair: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index c7ffbda..8be0893 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -412,6 +412,22 @@ struct AddDeviceView: View { .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + // A Mac joining via this code needs the user's OK — the accepting Mac forwarded its + // confirm here so it can be approved from the phone (an iPhone/iPad joins hands-free). + .confirmationDialog( + "Let “\(store.pendingMacPairRequest?.label ?? "another Mac")” join?", + isPresented: Binding( + get: { store.pendingMacPairRequest != nil }, + set: { if !$0 { store.respondMacPair(false) } }), + titleVisibility: .visible + ) { + Button("Allow") { store.respondMacPair(true) } + Button("Don't allow", role: .destructive) { store.respondMacPair(false) } + Button("Cancel", role: .cancel) { store.respondMacPair(false) } + } message: { + Text("It joins the whole group — it gets the same control of your Macs' sessions an " + + "iPhone does, and every Mac here stays connected.") + } } // Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to // close its pairing window. @@ -458,7 +474,8 @@ struct AddDeviceView: View { 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.") + + "iPad. A Mac joining asks you to approve it right here; an iPhone or iPad joins " + + "on its own.") } }