From a0ebd7c71bc679b9d0c95a7a33b8260271050e7c Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:01:01 +0200 Subject: [PATCH] fix(apple): restore macOS approval modal and sandboxed file sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approval modal: since the Share/QR sheet auto-opens after creating a transfer, it is always up when a receiver request arrives, and macOS silently drops a sheet presented while another is still dismissing — so the approval sheet never appeared. Drive the approval sheet from explicit state (not a constant binding) and, on macOS, defer its presentation one dismiss-beat after closing the Share/QR sheet so the hand-off is serialized. Still a non-dismissable sheet; iOS timing unchanged. Sandboxed file sharing: the macOS picker released its security scope immediately, so the core's later import failed with EPERM under the App Store sandbox (the non-sandboxed .dmg was unaffected). Capture a security-scoped bookmark at pick time and re-acquire access across shareFiles() — during which the core imports the bytes — mirroring the receive-folder scoped-access pattern. --- apple/VniDrop/App/RootView.swift | 28 ++++++++++++++++--- apple/VniDrop/Core/FileSystemService.swift | 4 +++ .../Features/Approvals/ApprovalModal.swift | 6 +++- .../Platform/FileSystemService+macOS.swift | 16 +++++++++++ apple/VniDrop/Platform/PlatformPickers.swift | 8 +++++- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 25f715c..dacdc03 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -14,6 +14,10 @@ struct RootView: View { @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) @@ -58,6 +62,7 @@ struct RootView: View { navigation(windowClass: windowClass) SnackbarHost(controller: messages) ApprovalModalHost( + isPresented: $showApproval, state: approvals.state, onAccept: approvals.accept, onRefuse: approvals.refuse @@ -98,11 +103,26 @@ struct RootView: View { break } } - // A pending approval is a blocking modal; close the sender's detail panel - // (e.g. the Share/QR sheet) so the approval sheet isn't presented under it - // on macOS. + // 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 - if id != nil { sendModel.closeDetailPanel() } + 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 diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index f50916e..96b218d 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable { var isTemporaryCopy: Bool = false /// When true, `value` is a directory (path or security-scoped folder URL). var isDirectory: Bool = false + /// macOS sandbox: a security-scoped bookmark captured at pick time so access to + /// `value` can be re-acquired when the core imports the file (the picker's own + /// scope ends immediately). Nil on iOS (which copies into the container instead). + var securityScopeBookmark: Data? = nil var id: String { value } } diff --git a/apple/VniDrop/Features/Approvals/ApprovalModal.swift b/apple/VniDrop/Features/Approvals/ApprovalModal.swift index 53a6e01..7123dbe 100644 --- a/apple/VniDrop/Features/Approvals/ApprovalModal.swift +++ b/apple/VniDrop/Features/Approvals/ApprovalModal.swift @@ -5,13 +5,17 @@ import SFSafeSymbols /// be swiped away. The endpoint id is the trusted identity; display names are /// peer-provided. struct ApprovalModalHost: View { + /// Driven by the host so presentation can be deferred until any competing sheet + /// (the Share/QR drawer) has finished dismissing — macOS silently drops a sheet + /// presented while another is still animating out. + @Binding var isPresented: Bool let state: ApprovalState let onAccept: (String) -> Void let onRefuse: (String) -> Void var body: some View { Color.clear - .sheet(isPresented: .constant(state.current != nil)) { + .sheet(isPresented: $isPresented) { if let request = state.current { ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse) .interactiveDismissDisabled(true) diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index 55628ec..c2130d2 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -44,6 +44,22 @@ struct MacFileSystemService: FileSystemService { guard !files.isEmpty else { return .failure(InvitationError.message("Select at least one file to share")) } + // Re-acquire security-scoped access to every picked source (from the bookmark + // captured at pick time) and hold it across the whole share call. The core + // imports the bytes during shareFiles(), so access only needs to survive that + // call; without this, the import fails with EPERM under the App Store sandbox. + var scopedURLs: [URL] = [] + for file in files { + guard let bookmark = file.securityScopeBookmark else { continue } + var stale = false + guard let url = try? URL( + resolvingBookmarkData: bookmark, options: .withSecurityScope, + relativeTo: nil, bookmarkDataIsStale: &stale + ), url.startAccessingSecurityScopedResource() else { continue } + scopedURLs.append(url) + } + defer { scopedURLs.forEach { $0.stopAccessingSecurityScopedResource() } } + let sources = files.map { ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) } diff --git a/apple/VniDrop/Platform/PlatformPickers.swift b/apple/VniDrop/Platform/PlatformPickers.swift index 9ecb917..184b45c 100644 --- a/apple/VniDrop/Platform/PlatformPickers.swift +++ b/apple/VniDrop/Platform/PlatformPickers.swift @@ -107,9 +107,15 @@ enum PickerSupport { ) #else let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) } + // Capture a security-scoped bookmark while the picker's scope is still held, + // so the core can re-acquire access to open the file at import time (under + // the App Store sandbox). Non-sandboxed builds don't need it but it's harmless. + let bookmark = try? url.bookmarkData( + options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil + ) return PickedShareFile( value: url.path, displayName: url.lastPathComponent, sizeBytes: size, - isTemporaryCopy: false, isDirectory: isDirectory + isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark ) #endif }