nvrsion: Add: Block Sleep feature, forward approval to phone in settings, refactor sync message handling, and update related files.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 22:20:28 -07:00
co-authored by Nucleic
parent d67f21b8d6
commit e06f0c78f5
3 changed files with 62 additions and 4 deletions
@@ -95,6 +95,11 @@ final class HostConnection {
/// A join code this host minted at our request ("add a device to this mesh") — the /// 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. /// `nucleic://pair?d=…` string to show as a QR / copyable code, or nil if it couldn't.
var pairingCodeReceived: (String?) -> Void = { _ in } 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 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 // 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. // to RemoteStore for the QR/copy sheet.
callbacks.pairingCodeReceived(qr) 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): case .wireError(let error):
if error.code == .channelMismatch { if error.code == .channelMismatch {
connectivity = .failed(error.message) connectivity = .failed(error.message)
@@ -92,6 +92,13 @@ final class RemoteStore: ObservableObject {
/// its pairing window. /// its pairing window.
private var pairingMintHostID: String? 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): /// 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. /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
struct LastError: Equatable, Identifiable { struct LastError: Equatable, Identifiable {
@@ -561,6 +568,16 @@ final class RemoteStore: ObservableObject {
guard case .requesting = self.addDevice else { return } guard case .requesting = self.addDevice else { return }
self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable 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 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 /// 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() { func cancelPairingCode() {
if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) } if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) }
pairingMintHostID = nil pairingMintHostID = nil
pendingMacPairRequest = nil
pendingMacPairHostID = nil
addDevice = .idle 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. /// 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" private static let demoPairingCode = "nucleic://pair?d=demo"
@@ -1009,7 +1038,7 @@ final class RemoteStore: ObservableObject {
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
.registerLiveActivity, .endLiveActivity, .registerLiveActivity, .endLiveActivity,
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
.requestPairingCode, .cancelPairingCode: .requestPairingCode, .cancelPairingCode, .respondMacPair:
break break
} }
} }
@@ -1120,7 +1149,7 @@ final class RemoteStore: ObservableObject {
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .transferOffer, .transferChunk, .transferCommit, .transferCancel,
// "Add a device" mint is handled directly against a live host, not via demoHandle; // "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. // 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) break // passive / already handled by the seeded fixtures (demo has no mesh peers)
} }
} }
@@ -412,6 +412,22 @@ struct AddDeviceView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } 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 // Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to
// close its pairing window. // close its pairing window.
@@ -458,7 +474,8 @@ struct AddDeviceView: View {
Text("Or send a code") Text("Or send a code")
} footer: { } footer: {
Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” on an iPhone or " 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.")
} }
} }