fix(apple): restore macOS approval modal and sandboxed file sharing

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.
This commit is contained in:
2026-07-27 15:01:01 +02:00
parent cc194f6a7b
commit a0ebd7c71b
5 changed files with 56 additions and 6 deletions

View File

@@ -14,6 +14,10 @@ struct RootView: View {
@Environment(\.scenePhase) private var scenePhase @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) { init(dependencies: AppDependencies) {
let graph = AppGraph(dependencies: dependencies) let graph = AppGraph(dependencies: dependencies)
_graph = StateObject(wrappedValue: graph) _graph = StateObject(wrappedValue: graph)
@@ -58,6 +62,7 @@ struct RootView: View {
navigation(windowClass: windowClass) navigation(windowClass: windowClass)
SnackbarHost(controller: messages) SnackbarHost(controller: messages)
ApprovalModalHost( ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state, state: approvals.state,
onAccept: approvals.accept, onAccept: approvals.accept,
onRefuse: approvals.refuse onRefuse: approvals.refuse
@@ -98,11 +103,26 @@ struct RootView: View {
break break
} }
} }
// A pending approval is a blocking modal; close the sender's detail panel // 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 // (e.g. the Share/QR sheet) first, then present the approval sheet but on
// on macOS. // 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 .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) #if os(macOS)
// macOS keeps `scenePhase == .active` even when the app loses focus, so // macOS keeps `scenePhase == .active` even when the app loses focus, so

View File

@@ -12,6 +12,10 @@ struct PickedShareFile: Equatable, Identifiable, Sendable {
var isTemporaryCopy: Bool = false var isTemporaryCopy: Bool = false
/// When true, `value` is a directory (path or security-scoped folder URL). /// When true, `value` is a directory (path or security-scoped folder URL).
var isDirectory: Bool = false 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 } var id: String { value }
} }

View File

@@ -5,13 +5,17 @@ import SFSafeSymbols
/// be swiped away. The endpoint id is the trusted identity; display names are /// be swiped away. The endpoint id is the trusted identity; display names are
/// peer-provided. /// peer-provided.
struct ApprovalModalHost: View { 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 state: ApprovalState
let onAccept: (String) -> Void let onAccept: (String) -> Void
let onRefuse: (String) -> Void let onRefuse: (String) -> Void
var body: some View { var body: some View {
Color.clear Color.clear
.sheet(isPresented: .constant(state.current != nil)) { .sheet(isPresented: $isPresented) {
if let request = state.current { if let request = state.current {
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse) ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
.interactiveDismissDisabled(true) .interactiveDismissDisabled(true)

View File

@@ -44,6 +44,22 @@ struct MacFileSystemService: FileSystemService {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share")) 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 { let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
} }

View File

@@ -107,9 +107,15 @@ enum PickerSupport {
) )
#else #else
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) } 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( return PickedShareFile(
value: url.path, displayName: url.lastPathComponent, sizeBytes: size, value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: false, isDirectory: isDirectory isTemporaryCopy: false, isDirectory: isDirectory, securityScopeBookmark: bookmark
) )
#endif #endif
} }