fix(apple): show receiver-approval modal on macOS release builds

The approval modal never appeared for a macOS sender: the receiver request
reached the core and even fired its notification, but the modal stayed hidden.

Root cause was observation, not presentation. `RootView` derived `approvals`
and `messages` as `@ObservedObject` in `init` from a freshly built `AppGraph`.
`init` runs on every view re-creation and each run makes a throwaway graph, so
those observed objects were repointed to a dead `ApprovalCoordinator` that never
receives core events — while the persisted `@StateObject graph` (and the models
wired to it) kept the live one. Debug happened not to re-init the view, so it
stayed on the live instance; release re-inits it, exposing the bug.

Move the snackbar + approval modal into an `OverlayLayer` child view that takes
the coordinator/messages as `@ObservedObject` and is constructed in `body` from
the persisted `graph`, so the subscription is always against the live instances.

While here:
- Present the approval only after any open share/QR sheet has actually finished
  dismissing (macOS can't stack sheets), driven off the sheet's real
  `onDismiss` completion via a new `AdaptiveDrawer.onDismissed` hook and
  `SendModel.shareSheetsDismissed` — no wall-clock delay.
- Move the list-level share-sheet state (`shareTargetId`) into `SendModel` so the
  approval flow can dismiss every share surface centrally.
- Add a fallback: pending receiver rows in the Receivers panel now offer an
  Approve action (`SendModel.acceptReceiver`) alongside Refuse, for the case the
  modal didn't surface.
This commit is contained in:
2026-07-31 11:19:28 +02:00
parent 56d19014d4
commit 5424da855e
5 changed files with 162 additions and 53 deletions

View File

@@ -9,15 +9,9 @@ struct RootView: View {
@StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel
@ObservedObject private var messages: UiMessageController
@ObservedObject private var approvals: ApprovalCoordinator
@Environment(\.scenePhase) private var scenePhase
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
@State private var showApproval = false
init(dependencies: AppDependencies) {
let graph = AppGraph(dependencies: dependencies)
_graph = StateObject(wrappedValue: graph)
@@ -50,8 +44,6 @@ struct RootView: View {
messages: graph.messages,
bugReports: NoopBugReportService()
))
messages = graph.messages
approvals = graph.approvalCoordinator
}
var body: some View {
@@ -60,12 +52,14 @@ struct RootView: View {
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack {
navigation(windowClass: windowClass)
SnackbarHost(controller: messages)
ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
// Observe the coordinator/messages from the *persisted* `graph`
// StateObject. Deriving them in `init` bound the view to a throwaway
// AppGraph rebuilt on every re-init, whose coordinator never receives
// core events so the approval modal never appeared.
OverlayLayer(
approvals: graph.approvalCoordinator,
messages: graph.messages,
sendModel: sendModel
)
}
.overlay {
@@ -103,27 +97,6 @@ struct RootView: View {
break
}
}
// A pending approval is a blocking modal. Close the sender's detail panel
// (e.g. the Share/QR sheet) first, then present the approval sheet but on
// macOS a sheet presented while another is still dismissing is silently
// dropped, so defer the presentation until that dismissal finishes.
.onChange(of: approvals.state.current?.id) { _, id in
guard id != nil else { showApproval = false; return }
let wasShowingSheet = sendModel.state.detailPanel != nil
sendModel.closeDetailPanel()
#if os(macOS)
if wasShowingSheet {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
if approvals.state.current != nil { showApproval = true }
}
} else {
showApproval = true
}
#else
_ = wasShowingSheet
showApproval = true
#endif
}
#if os(macOS)
// macOS keeps `scenePhase == .active` even when the app loses focus, so
// drive foreground/background off NSApplication's active state instead
@@ -217,6 +190,70 @@ struct RootView: View {
}
}
/// Hosts the snackbar and the approval modal, observing the coordinator and message
/// controller passed in from the persisted `AppGraph`. Kept as a child view so the
/// `@ObservedObject` subscriptions are established here (in `body`) against the live
/// instances, rather than in `RootView.init` against a throwaway graph.
private struct OverlayLayer: View {
@ObservedObject var approvals: ApprovalCoordinator
@ObservedObject var messages: UiMessageController
let sendModel: SendModel
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
@State private var showApproval = false
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
/// that sheet and present the approval once its dismissal completes (see
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
@State private var approvalAwaitingSheetDismiss = false
var body: some View {
ZStack {
SnackbarHost(controller: messages)
ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
)
}
// A pending approval is a blocking modal. Close any open share/QR sheet first
// (the detail-view panel *or* the list-level share sheet), then present the
// approval sheet: the approval is presented from the app root and neither
// platform reliably stacks it over a sheet owned by the Send screen.
.onChange(of: approvals.state.current?.id) { _, id in
guard id != nil else {
showApproval = false
approvalAwaitingSheetDismiss = false
return
}
let wasShowingSheet = sendModel.state.detailPanel != nil
|| sendModel.state.shareTargetId != nil
sendModel.dismissShareSheets()
#if os(macOS)
// macOS silently drops a sheet presented while another is still dismissing,
// so wait for that sheet's real dismissal completion before presenting.
if wasShowingSheet {
approvalAwaitingSheetDismiss = true
} else {
showApproval = true
}
#else
_ = wasShowingSheet
showApproval = true
#endif
}
#if os(macOS)
.onReceive(sendModel.shareSheetsDismissed) { _ in
guard approvalAwaitingSheetDismiss else { return }
approvalAwaitingSheetDismiss = false
if approvals.state.current != nil { showApproval = true }
}
#endif
}
}
/// A full-window cover with a centered spinner shown while the core is starting.
private struct CoreStartingOverlay: View {
var body: some View {