From 5424da855edd76f0d658403d94c6a6a14f028473 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:19:28 +0200 Subject: [PATCH 1/5] fix(apple): show receiver-approval modal on macOS release builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apple/VniDrop/App/RootView.swift | 107 ++++++++++++------ apple/VniDrop/Features/Send/SendModel.swift | 41 ++++++- apple/VniDrop/Features/Send/SendScreen.swift | 18 ++- .../Features/Send/TransferDetailsView.swift | 39 +++++-- .../UI/Components/AdaptiveDrawer.swift | 10 +- 5 files changed, 162 insertions(+), 53 deletions(-) diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index dacdc03..7210c35 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -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 { diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 951bb77..88410cc 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -25,6 +25,11 @@ struct SendState: Equatable { var selectedTransferId: UInt64? var transferThumbnails: [UInt64: Data] = [:] var detailPanel: TransferDetailPanel? + /// Transfer whose share panel is presented inline from the list context menu + /// (distinct from `detailPanel == .share`, which shows it from the detail view). + /// Held in the model — not `SendScreen` @State — so the approval flow can dismiss + /// it centrally before presenting its modal. + var shareTargetId: UInt64? var receiverHistory: [ReceiverRequestModel] = [] var isLoadingReceivers = false var isDeleteConfirmationOpen = false @@ -56,6 +61,18 @@ final class SendModel: ObservableObject { private let messages: UiMessageController private var cancellables = Set() + /// Fires *after* a share/QR sheet (the detail-view panel or the list-level share + /// sheet) has finished animating out. The approval flow waits on this to present + /// its modal on macOS, where a sheet shown while another is still dismissing is + /// dropped — using the real completion instead of a guessed delay. + private let shareSheetsDismissedSubject = PassthroughSubject() + var shareSheetsDismissed: AnyPublisher { + shareSheetsDismissedSubject.eraseToAnyPublisher() + } + + /// Invoked by a share sheet's `onDismiss` completion. + func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) } + init( repository: CoreGateway, fileSystemService: FileSystemService, @@ -201,6 +218,17 @@ final class SendModel: ObservableObject { } func closeDetailPanel() { state.detailPanel = nil } + func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId } + func closeShareTarget() { state.shareTargetId = nil } + + /// Dismisses every share/QR surface at once — the detail-view share panel and the + /// list-level share sheet. Used before presenting the receiver-approval modal, so + /// no competing sheet is left open (macOS drops a sheet shown over another). + func dismissShareSheets() { + state.detailPanel = nil + state.shareTargetId = nil + } + func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true } func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } } @@ -257,8 +285,19 @@ final class SendModel: ObservableObject { /// Uses the core's `respondReceiverRequest` (no backend change); applies to /// receivers that are still pending or accepted. func cancelReceiver(requestId: String) { + respondToReceiver(requestId: requestId, accepted: false) + } + + /// Approves a single pending receiver by responding to its request positively. + /// A fallback for when the approval modal didn't surface — the pending receiver + /// can still be accepted from its row in the transfer's receivers panel. + func acceptReceiver(requestId: String) { + respondToReceiver(requestId: requestId, accepted: true) + } + + private func respondToReceiver(requestId: String, accepted: Bool) { Task { - let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil) + let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil) switch result { case .success: if let transferId = state.selectedTransferId { refreshReceivers(transferId) } diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index 4d73bea..f596ed5 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -7,14 +7,18 @@ struct SendScreen: View { @ObservedObject var model: SendModel let windowClass: WindowClass - /// Transfer whose share panel is presented inline from the list context menu. - @State private var shareTarget: Transfer? /// Transfer pending an inline (list-level) delete confirmation. @State private var deleteTarget: Transfer? private var outgoing: [Transfer] { model.coreState.transfers.filter { $0.direction == .send } } + /// The transfer whose list-level share sheet is open, resolved from the model's + /// `shareTargetId` (kept in the model so the approval flow can dismiss it). + private var shareTarget: Transfer? { + guard let id = model.state.shareTargetId else { return nil } + return outgoing.first { $0.transferId == id } + } private var selectedTransfer: Transfer? { guard let id = model.state.selectedTransferId else { return nil } return outgoing.first { $0.transferId == id } @@ -50,9 +54,10 @@ struct SendScreen: View { // composer drawer on the outer body, so the two don't clash). Opens the // share panel over the list without navigating into the transfer detail. .adaptiveDrawer( - isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }), + isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }), windowClass: windowClass, - onDismiss: { shareTarget = nil } + onDismiss: model.closeShareTarget, + onDismissed: model.shareSheetDidDismiss ) { if let shareTarget { TransferSharePanel(model: model, transfer: shareTarget) @@ -92,7 +97,8 @@ struct SendScreen: View { .adaptiveDrawer( isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }), windowClass: windowClass, - onDismiss: model.closeDetailPanel + onDismiss: model.closeDetailPanel, + onDismissed: model.shareSheetDidDismiss ) { if let panel = model.state.detailPanel { DetailPanelContent(model: model, transfer: transfer, panel: panel) @@ -127,7 +133,7 @@ struct SendScreen: View { .contextMenu { if transfer.ticket != nil { Button { - shareTarget = transfer + model.openShareTarget(transfer.transferId) } label: { Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp) } diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index b0358fc..a65dd10 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -142,7 +142,8 @@ struct DetailPanelContent: View { loading: model.state.isLoadingReceivers, events: model.coreState.events, transferTotalSize: transfer.totalSize, - onCancel: model.cancelReceiver + onCancel: model.cancelReceiver, + onAccept: model.acceptReceiver ) case .share: TransferSharePanel(model: model, transfer: transfer) @@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View { let events: [CoreEventModel] let transferTotalSize: UInt64 let onCancel: (String) -> Void + let onAccept: (String) -> Void var body: some View { PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) { @@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View { } else { ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in if index > 0 { Divider().overlay(colors.borderDefault) } - ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel) + ReceiverRow( + receiver: receiver, + sendProgress: sendProgress(for: receiver), + onCancel: onCancel, + onAccept: onAccept + ) } } } @@ -224,6 +231,7 @@ private struct ReceiverRow: View { let receiver: ReceiverRequestModel let sendProgress: TransferProgress? let onCancel: (String) -> Void + let onAccept: (String) -> Void /// Only pending requests can be cancelled per-receiver: the core rejects a /// negative response to an already-accepted request ("...not approved, or it @@ -257,14 +265,27 @@ private struct ReceiverRow: View { } .frame(maxWidth: .infinity, alignment: .leading) if isCancelable { - Button(role: .destructive) { - onCancel(receiver.id) - } label: { - Text(String(localized: L10n.Button.refuse)) - .font(VniType.bodySmall) + VStack(alignment: .trailing, spacing: 8) { + Button(role: .destructive) { + onCancel(receiver.id) + } label: { + Text(String(localized: L10n.Button.refuse)) + .font(VniType.bodySmall) + } + .buttonStyle(.borderless) + .tint(.red) + // Fallback approve action, in case the approval modal didn't surface. + Button { + onAccept(receiver.id) + } label: { + Text(String(localized: L10n.Button.approve)) + .font(VniType.bodySmall).fontWeight(.medium) + .foregroundStyle(.white) + .padding(.horizontal, 16).padding(.vertical, 7) + .background(Color.green, in: Capsule()) + } + .buttonStyle(.plain) } - .buttonStyle(.borderless) - .tint(.red) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apple/VniDrop/UI/Components/AdaptiveDrawer.swift b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift index 7b75752..d991b07 100644 --- a/apple/VniDrop/UI/Components/AdaptiveDrawer.swift +++ b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift @@ -7,11 +7,16 @@ struct AdaptiveDrawer: ViewModifier { @Binding var isPresented: Bool let windowClass: WindowClass let onDismiss: () -> Void + /// Fired after the sheet's dismissal animation completes (as opposed to + /// `onDismiss`, which requests the close). Lets callers serialize a follow-up + /// sheet against this one's actual teardown instead of guessing a delay. + let onDismissed: (() -> Void)? @ViewBuilder let drawerContent: () -> DrawerContent func body(content: Content) -> some View { content.sheet( - isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }) + isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }), + onDismiss: onDismissed ) { SheetChrome(onClose: onDismiss) { drawerContent() } .modifier(PhoneDetents(enabled: windowClass == .phone)) @@ -56,11 +61,12 @@ extension View { isPresented: Binding, windowClass: WindowClass, onDismiss: @escaping () -> Void, + onDismissed: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> DrawerContent ) -> some View { modifier(AdaptiveDrawer( isPresented: isPresented, windowClass: windowClass, - onDismiss: onDismiss, drawerContent: content + onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content )) } } From 9079c814096041003ff4e3bfb18150409b40f63f Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:20:36 +0200 Subject: [PATCH 2/5] build(apple): pin ARCHS to arm64 project-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust core's macOS slice (vnidrop.xcframework) is built aarch64-apple-darwin only, so every target is Apple-Silicon-only — not just the Release-Direct build. Hoist ARCHS: arm64 from the VniDropDirect target into the project-wide base settings so no configuration attempts a universal link that would fail looking for x86_64 symbols. Intel Macs are unsupported (EOL with macOS 28). --- apple/project.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apple/project.yml b/apple/project.yml index e883004..17fdb16 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -26,6 +26,10 @@ configs: # Project-wide build settings (applied to every target/config). settings: base: + # Apple Silicon only. Intel Macs are unsupported (going EOL with macOS 28), and + # the Rust core's macOS slice (vnidrop.xcframework) is built arm64-only, so a + # universal link would fail looking for x86_64 symbols anyway. + ARCHS: arm64 # Strip unreachable code from release binaries. DEAD_CODE_STRIPPING: YES # Flag user-facing strings that aren't localized (the app ships 9 languages). @@ -135,11 +139,6 @@ targets: # provisioning profile, which direct distribution avoids. (App Store target # keeps VniDrop.entitlements with the sandbox.) CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDropDirect.entitlements - # The Rust core's macOS slice (vnidrop.xcframework) is arm64-only - # (build-core.sh builds aarch64-apple-darwin only), so the direct build is - # Apple-Silicon-only. Pin ARCHS so the Release-Direct (universal-by-default) - # link doesn't fail looking for x86_64 symbols. - ARCHS: arm64 dependencies: - package: Sparkle From ff391f55023e439bf7094a59a93dde3dc233e0f5 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:40:34 +0200 Subject: [PATCH 3/5] build(apple): publish prebuilt core bundle in release assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle the compiled Apple core — vnidrop.xcframework plus the generated UniFFI bindings (Vnidrop.swift, a source file that lives outside the xcframework) — into VnidropCore-.zip with a checksum, and attach it to the GitHub Release. This lets a consumer (e.g. Xcode Cloud, later) use the prebuilt core instead of installing Rust and running build-core.sh. No duplicate builds: the release job compiles the core once (build-apple-dmg -> build-core.sh release), links it into the signed DMG, and package-core.sh only zips that same output. Package.swift is unchanged (still binaryTarget(path:)). - apple/scripts/package-core.sh: stage xcframework + Vnidrop.swift and zip them with a sha256sum/shasum-compatible checksum sidecar (macOS-native). - Makefile: package-apple-core target. - apple-release.yml: run package-apple-core after the DMG and upload the zip + checksum in the macOS artifact. - assemble-release.sh: verify the core zip's checksum, copy it into the final assets, and list it in release-manifest.json + SHA256SUMS (+ fixture update). --- .github/workflows/apple-release.yml | 8 +++ Makefile | 5 +- apple/scripts/package-core.sh | 72 ++++++++++++++++++++++ packaging/release/assemble-release.sh | 8 ++- packaging/release/test-assemble-release.sh | 7 +++ 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100755 apple/scripts/package-core.sh diff --git a/.github/workflows/apple-release.yml b/.github/workflows/apple-release.yml index 95e1a20..205ee46 100644 --- a/.github/workflows/apple-release.yml +++ b/.github/workflows/apple-release.yml @@ -134,6 +134,12 @@ jobs: - name: Build, sign & notarize DMG run: make build-apple-dmg + - name: Package prebuilt core + # build-apple-dmg builds the release Rust core + Swift bindings; bundle them + # (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can + # skip building the core. See apple/scripts/package-core.sh. + run: make package-apple-core + - name: Upload notarization diagnostics if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -156,5 +162,7 @@ jobs: apple/dist/VniDrop-*.dmg apple/dist/VniDrop-*.build-info.json apple/dist/appcast.xml + apple/dist/VnidropCore-*.zip + apple/dist/VnidropCore-*.zip.sha256 if-no-files-found: error retention-days: 14 diff --git a/Makefile b/Makefile index d33d5ef..5b185ca 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ include $(ROOT)/make/release.mk .PHONY: format test check check-rust audit-rust test-rust test-rust-all .PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink .PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop -.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple +.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core .PHONY: prepare-release check-version check-release check-localization localization localization-migrate .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics @@ -150,6 +150,9 @@ build-apple-macos-direct: apple-project ## Build the direct-download macOS targe build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env). cd $(ROOT) && apple/scripts/build-dmg.sh +package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first). + cd $(ROOT) && apple/scripts/package-core.sh + open-apple: build-apple-macos ## Build and launch the native macOS app. @test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; } $(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" diff --git a/apple/scripts/package-core.sh b/apple/scripts/package-core.sh new file mode 100755 index 0000000..0074312 --- /dev/null +++ b/apple/scripts/package-core.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Packages the prebuilt Apple core into a single zip + checksum, for attaching to +# the GitHub Release. Lets a consumer (e.g. Xcode Cloud) use the compiled core +# instead of installing Rust and running build-core.sh. Run AFTER the core exists +# (apple/scripts/build-core.sh, or `make apple-core` / `make build-apple-dmg`). +# +# The bundle carries both build outputs of build-core.sh: +# - vnidrop.xcframework (static libs for device/sim/macOS + the FFI module) +# - Vnidrop.swift (generated UniFFI bindings — a plain source file, not +# part of the xcframework, so it must ship alongside) +# +# Produces (under apple/dist): +# VnidropCore-.zip +# VnidropCore-.zip.sha256 (sha256sum(1)/shasum-compatible format) +# +# Zip layout (root): +# vnidrop.xcframework/ +# Vnidrop.swift +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +APPLE_DIR="$REPO_ROOT/apple" +PKG_DIR="$APPLE_DIR/VnidropCore" +XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework" +BINDINGS="$PKG_DIR/Sources/VnidropCore/Vnidrop.swift" +DIST_DIR="$APPLE_DIR/dist" + +VERSION="$("$REPO_ROOT/packaging/version/resolve-version.sh" product)" +NAME="VnidropCore-$VERSION" +ZIP="$DIST_DIR/$NAME.zip" +CHECKSUM="$ZIP.sha256" + +[ -d "$XCFRAMEWORK" ] || { + echo "error: missing xcframework: $XCFRAMEWORK" >&2 + echo " build the core first (apple/scripts/build-core.sh)." >&2 + exit 1 +} +[ -f "$BINDINGS" ] || { + echo "error: missing generated bindings: $BINDINGS" >&2 + echo " build the core first (apple/scripts/build-core.sh)." >&2 + exit 1 +} + +mkdir -p "$DIST_DIR" +rm -f "$ZIP" "$CHECKSUM" + +# Stage a clean tree so the zip root holds exactly the two payloads (no absolute +# paths or stray parent directories leak into the archive). +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +cp -R "$XCFRAMEWORK" "$STAGE/vnidrop.xcframework" +cp "$BINDINGS" "$STAGE/Vnidrop.swift" + +# -X drops extra file attributes for a stabler archive across machines. +( cd "$STAGE" && zip -q -r -X "$ZIP" vnidrop.xcframework Vnidrop.swift ) + +# sha256sum on Linux; shasum -a 256 on macOS. Both emit " ", which +# `sha256sum --check` (used by assemble-release.sh) accepts. +( + cd "$DIST_DIR" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$NAME.zip" > "$NAME.zip.sha256" + else + shasum -a 256 "$NAME.zip" > "$NAME.zip.sha256" + fi +) + +echo "==> Packaged prebuilt core" +echo " zip: $ZIP" +echo " checksum: $CHECKSUM" diff --git a/packaging/release/assemble-release.sh b/packaging/release/assemble-release.sh index 398025e..eff0e16 100755 --- a/packaging/release/assemble-release.sh +++ b/packaging/release/assemble-release.sh @@ -58,6 +58,7 @@ rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')" dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')" appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')" apple_metadata="$(find_single "$input_dir/macos" '*.build-info.json' 'direct macOS build metadata')" +apple_core="$(find_single "$input_dir/macos" 'VnidropCore-*.zip' 'Apple prebuilt core bundle')" play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')" play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')" msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')" @@ -67,6 +68,7 @@ windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Window [[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]] [[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]] [[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]] +[[ $(basename "$apple_core") == "VnidropCore-${version}.zip" ]] [[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]] [[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]] [[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]] @@ -83,10 +85,12 @@ deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')" rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')" windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')" play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK checksums')" +apple_core_checksum="$(find_single "$input_dir/macos" 'VnidropCore-*.zip.sha256' 'Apple prebuilt core checksum')" verify_checksum_file "$deb_checksum" verify_checksum_file "$rpm_checksum" verify_checksum_file "$windows_checksums" verify_checksum_file "$play_checksums" +verify_checksum_file "$apple_core_checksum" [[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]] [[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]] @@ -103,7 +107,7 @@ mkdir -p "$output_dir" printf 'Release output directory must be empty: %s\n' "$output_dir" >&2 exit 1 } -cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$output_dir/" +cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$apple_core" "$output_dir/" payloads=( "$output_dir/$(basename "$deb")" @@ -111,6 +115,7 @@ payloads=( "$output_dir/$(basename "$dmg")" "$output_dir/$(basename "$appcast")" "$output_dir/$(basename "$play_apk")" + "$output_dir/$(basename "$apple_core")" ) files_json="$( for file in "${payloads[@]}"; do @@ -168,6 +173,7 @@ jq -n \ "$(basename "$dmg")" \ "$(basename "$appcast")" \ "$(basename "$play_apk")" \ + "$(basename "$apple_core")" \ release-manifest.json \ > SHA256SUMS ) diff --git a/packaging/release/test-assemble-release.sh b/packaging/release/test-assemble-release.sh index 578191a..f4cd40d 100755 --- a/packaging/release/test-assemble-release.sh +++ b/packaging/release/test-assemble-release.sh @@ -25,6 +25,7 @@ printf 'deb\n' > "$input_dir/deb/vnidrop_${version}-1_amd64.deb" printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm" printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg" printf 'VniDrop-%s.dmg\n' "$version" > "$input_dir/macos/appcast.xml" +printf 'core\n' > "$input_dir/macos/VnidropCore-${version}.zip" printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk" printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix" printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload" @@ -68,6 +69,11 @@ jq -n \ sha256sum "vnidrop-${version}-1.x86_64.rpm" \ > "vnidrop-${version}-1.x86_64.rpm.sha256" ) +( + cd "$input_dir/macos" + sha256sum "VnidropCore-${version}.zip" \ + > "VnidropCore-${version}.zip.sha256" +) ( cd "$input_dir/play" sha256sum \ @@ -94,6 +100,7 @@ expected_public_files=( "SHA256SUMS" "VniDrop-${version}-${android_code}-play-universal.apk" "VniDrop-${version}.dmg" + "VnidropCore-${version}.zip" "appcast.xml" "release-manifest.json" "vnidrop-${version}-1.x86_64.rpm" From c670dda0a913d7476c2004918bc6e55b559b49be Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:46:05 +0200 Subject: [PATCH 4/5] fix(apple): run the notification delegate on the main actor (iOS crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping an approval notification while the app was backgrounded crashed on iOS with "Call must be made on main thread". The UNUserNotificationCenterDelegate methods are `async` and nonisolated, so their continuation resumes off the main thread at the return point — where UIKit synchronously runs state-restoration / snapshot work, tripping the main-thread assertion. (The empty iOS `didReceive` body didn't matter; even an empty async method returns off-main.) Isolate NotificationPresenter to `@MainActor` so the delegate returns on the main thread. `@preconcurrency` on the UNUserNotificationCenterDelegate conformance is required because those requirements are nonisolated with non-Sendable UN* parameters, which strict concurrency won't otherwise let a main-actor type witness. The macOS branch's now-redundant `await MainActor.run { … }` is dropped. --- .../Core/LocalNotificationService.swift | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apple/VniDrop/Core/LocalNotificationService.swift b/apple/VniDrop/Core/LocalNotificationService.swift index 8b246e2..653bd7a 100644 --- a/apple/VniDrop/Core/LocalNotificationService.swift +++ b/apple/VniDrop/Core/LocalNotificationService.swift @@ -19,7 +19,19 @@ struct LocalNotification { /// Presents notifications even while the app is active. Without a delegate the /// system drops the banner when the app is frontmost — very visible on macOS, /// where the app window is usually open when a transfer completes. -private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { +/// +/// `@MainActor` is required, not just convenient: these delegate methods are +/// `async`, so their continuation resumes at the return point on whatever executor +/// they ran on. When the system hands a notification-tap back to UIKit it performs +/// state-restoration/snapshot work synchronously on that thread — which asserts +/// "Call must be made on main thread" and crashes if the method returned off-main. +/// Main-actor isolation guarantees the return happens on the main thread. +// `@preconcurrency` on the conformance: these delegate requirements are nonisolated +// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a +// main actor-isolated type witness. The main-actor isolation is what fixes the +// crash (see the type doc above); the attribute inserts the runtime hop. +@MainActor +private final class NotificationPresenter: NSObject, @preconcurrency UNUserNotificationCenterDelegate { func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification @@ -36,14 +48,12 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel didReceive response: UNNotificationResponse ) async { #if os(macOS) - await MainActor.run { - NSApp.activate(ignoringOtherApps: true) - // Reopen/focus the single main window (activation triggers SwiftUI's - // reopen handling when it was closed). - for window in NSApp.windows where window.canBecomeMain { - window.makeKeyAndOrderFront(nil) - break - } + NSApp.activate(ignoringOtherApps: true) + // Reopen/focus the single main window (activation triggers SwiftUI's + // reopen handling when it was closed). + for window in NSApp.windows where window.canBecomeMain { + window.makeKeyAndOrderFront(nil) + break } #endif } From fe97c21c7a345be6d94bf1f087cd09d5a395caca Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:46:12 +0200 Subject: [PATCH 5/5] fix(apple): keep the snackbar above the approval overlay The earlier approval-modal fix folded SnackbarHost and the approval modal into a single OverlayLayer child; nested that way the approval host's full-bleed clear layer covered the toast, so snackbars stopped appearing. Split them: rename OverlayLayer to ApprovalLayer (approval modal only) and hoist SnackbarHost to a top-most direct child of the root ZStack, observing the live `graph.messages` directly. The toast now renders above the overlay again. --- apple/VniDrop/App/RootView.swift | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 7210c35..5d2527a 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -56,11 +56,13 @@ struct RootView: View { // 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( + ApprovalLayer( approvals: graph.approvalCoordinator, - messages: graph.messages, sendModel: sendModel ) + // Top-most so the toast is never covered by the approval overlay's + // full-bleed clear layer. Observes the live `graph.messages` directly. + SnackbarHost(controller: graph.messages) } .overlay { // A small, unobtrusive indicator while the core finishes its async @@ -190,13 +192,12 @@ 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 { +/// Hosts the approval modal, observing the coordinator passed in from the persisted +/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is +/// established here (in `body`) against the live instance, rather than in +/// `RootView.init` against a throwaway graph. +private struct ApprovalLayer: View { @ObservedObject var approvals: ApprovalCoordinator - @ObservedObject var messages: UiMessageController let sendModel: SendModel /// Drives the approval sheet; toggled from the pending-approval `onChange` so the @@ -209,15 +210,12 @@ private struct OverlayLayer: View { @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 - ) - } + 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