mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user