From 21483c7c4c034e8dce253adbd5f7c96da008c77c Mon Sep 17 00:00:00 2001 From: omar-hanafy Date: Thu, 11 Sep 2025 17:36:02 +0300 Subject: [PATCH] [desktop_drop] 0.7.0: macOS: robust multi-source drag & drop - Prefer public.file-url / NSFilenamesPboardType; fallback to NSFilePromiseReceiver - Add fromPromise flag; map directories to DropItemDirectory - Generate security-scoped bookmarks only for paths outside container/tmp - Per-drop unique destination for promises - Guards in Dart for empty bookmarks - Thread-safe collection of drop results --- packages/desktop_drop/CHANGELOG.md | 16 +++ packages/desktop_drop/lib/src/channel.dart | 42 +++++-- packages/desktop_drop/lib/src/drop_item.dart | 34 +++++ .../desktop_drop/macos/desktop_drop.podspec | 2 +- .../macos/desktop_drop/Package.swift | 4 +- .../desktop_drop/DesktopDropPlugin.swift | 119 ++++++++++++------ packages/desktop_drop/pubspec.yaml | 4 +- 7 files changed, 169 insertions(+), 52 deletions(-) diff --git a/packages/desktop_drop/CHANGELOG.md b/packages/desktop_drop/CHANGELOG.md index c12b38cc..d662c294 100644 --- a/packages/desktop_drop/CHANGELOG.md +++ b/packages/desktop_drop/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.7.0 + +[macOS] Robust multi-source drag & drop. + +* Prefer `public.file-url` / legacy filename arrays when present; fall back to + `NSFilePromiseReceiver` (file promises) otherwise. +* Handle directories (`isDirectory`) and surface as `DropItemDirectory`. +* Add `fromPromise` to `DropItem` so apps can distinguish promise-based drops. +* Generate security-scoped bookmarks only for paths outside the app container + (skip/empty for promise files in `.../tmp/Drops/...`). +* Per-drop unique destination for promised files to avoid name collisions. +* Thread-safe collection of drop results when receiving promises. +* Dart guards: no-op `start/stopAccessingSecurityScopedResource` on empty + bookmarks. +* Bump macOS minimum to 10.13 (SPM/Podspec). + ## 0.6.1 * Fix desktop_drop Linux snap build failure due to missing stdlib.h include (#425) diff --git a/packages/desktop_drop/lib/src/channel.dart b/packages/desktop_drop/lib/src/channel.dart index a0913383..3e02231f 100644 --- a/packages/desktop_drop/lib/src/channel.dart +++ b/packages/desktop_drop/lib/src/channel.dart @@ -37,8 +37,17 @@ class DesktopDrop { }); } + /// macOS: Attempt to start security-scoped access for a bookmarked URL. + /// + /// Pass the [DropItem.extraAppleBookmark] bytes from a dropped file that + /// originated outside the app container. Returns `true` if access began. + /// + /// If [bookmark] is empty, this function returns `false` and does not + /// invoke the platform call. Promise files written under your container do + /// not require security-scoped access. Future startAccessingSecurityScopedResource( {required Uint8List bookmark}) async { + if (bookmark.isEmpty) return false; Map resultMap = {}; resultMap["apple-bookmark"] = bookmark; final bool? result = await _channel.invokeMethod( @@ -47,8 +56,13 @@ class DesktopDrop { return result; } + /// macOS: Stop security-scoped access previously started. + /// + /// If [bookmark] is empty, this function returns `true` and does not + /// invoke the platform call, acting as a no-op. Future stopAccessingSecurityScopedResource( {required Uint8List bookmark}) async { + if (bookmark.isEmpty) return true; Map resultMap = {}; resultMap["apple-bookmark"] = bookmark; final bool result = await _channel.invokeMethod( @@ -89,16 +103,30 @@ class DesktopDrop { _offset = null; break; case "performOperation_macos": - // final paths = (call.arguments as List).cast>(); - final paths = call.arguments as List; + final items = (call.arguments as List).cast(); _notifyEvent( DropDoneEvent( location: _offset ?? Offset.zero, - files: paths - .map((e) => DropItemFile( - e["path"] as String, - extraAppleBookmark: e["apple-bookmark"] as Uint8List?, - )) + files: items + .map((raw) { + final path = raw["path"] as String; + final bookmark = raw["apple-bookmark"] as Uint8List?; + final isDir = (raw["isDirectory"] as bool?) ?? false; + final fromPromise = (raw["fromPromise"] as bool?) ?? false; + if (isDir) { + return DropItemDirectory( + path, + const [], + extraAppleBookmark: bookmark, + fromPromise: fromPromise, + ); + } + return DropItemFile( + path, + extraAppleBookmark: bookmark, + fromPromise: fromPromise, + ); + }) .toList(), ), ); diff --git a/packages/desktop_drop/lib/src/drop_item.dart b/packages/desktop_drop/lib/src/drop_item.dart index b7c567f8..306cf7f1 100644 --- a/packages/desktop_drop/lib/src/drop_item.dart +++ b/packages/desktop_drop/lib/src/drop_item.dart @@ -2,8 +2,32 @@ import 'dart:typed_data'; import 'package:cross_file/cross_file.dart'; +/// A dropped item. +/// +/// On desktop, this is usually a filesystem path (file or directory). +/// +/// macOS specifics: +/// - If the drag source provided a real file URL (e.g. Finder/JetBrains), +/// [extraAppleBookmark] will typically be non-null and allow security-scoped +/// access when running sandboxed. +/// - If the drag source used a file promise (e.g. VS Code/Electron), the +/// system delivers bytes into a per-drop temporary folder inside your app's +/// container. In that case [fromPromise] is true and [extraAppleBookmark] +/// is usually null/empty. There is no original source path in this flow. abstract class DropItem extends XFile { + /// Security-scoped bookmark bytes for the dropped item (macOS only). + /// + /// Use with [DesktopDrop.startAccessingSecurityScopedResource] to gain + /// temporary access to files outside your sandbox. When empty or null, + /// you typically don't need to call start/stop (e.g. promise files in + /// your app's container). Uint8List? extraAppleBookmark; + + /// True when this item was delivered via a macOS file promise and was + /// written into your app's temporary Drops directory. + /// + /// In this case, the original source path is not available by design. + final bool fromPromise; DropItem( super.path, { super.mimeType, @@ -12,6 +36,7 @@ abstract class DropItem extends XFile { super.bytes, super.lastModified, this.extraAppleBookmark, + this.fromPromise = false, }); DropItem.fromData( @@ -21,6 +46,8 @@ abstract class DropItem extends XFile { super.length, super.lastModified, super.path, + this.extraAppleBookmark, + this.fromPromise = false, }) : super.fromData(); } @@ -33,6 +60,7 @@ class DropItemFile extends DropItem { super.bytes, super.lastModified, super.extraAppleBookmark, + super.fromPromise, }); DropItemFile.fromData( @@ -42,9 +70,11 @@ class DropItemFile extends DropItem { super.length, super.lastModified, super.path, + super.fromPromise, }) : super.fromData(); } +/// A dropped directory. class DropItemDirectory extends DropItem { final List children; @@ -56,6 +86,8 @@ class DropItemDirectory extends DropItem { super.length, super.bytes, super.lastModified, + super.extraAppleBookmark, + super.fromPromise, }); DropItemDirectory.fromData( @@ -66,5 +98,7 @@ class DropItemDirectory extends DropItem { super.length, super.lastModified, super.path, + super.extraAppleBookmark, + super.fromPromise, }) : super.fromData(); } diff --git a/packages/desktop_drop/macos/desktop_drop.podspec b/packages/desktop_drop/macos/desktop_drop.podspec index 0baa38a5..5711b24b 100644 --- a/packages/desktop_drop/macos/desktop_drop.podspec +++ b/packages/desktop_drop/macos/desktop_drop.podspec @@ -16,7 +16,7 @@ A new flutter plugin project. s.source_files = 'desktop_drop/Sources/desktop_drop/**/*.{h,m,swift}' s.dependency 'FlutterMacOS' - s.platform = :osx, '10.11' + s.platform = :osx, '10.13' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' end diff --git a/packages/desktop_drop/macos/desktop_drop/Package.swift b/packages/desktop_drop/macos/desktop_drop/Package.swift index 68c10e3a..c5da455b 100644 --- a/packages/desktop_drop/macos/desktop_drop/Package.swift +++ b/packages/desktop_drop/macos/desktop_drop/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "desktop_drop", platforms: [ - .macOS("10.11") + .macOS("10.13") ], products: [ .library(name: "desktop-drop", targets: ["desktop_drop"]) @@ -21,4 +21,4 @@ let package = Package( ] ) ] -) \ No newline at end of file +) diff --git a/packages/desktop_drop/macos/desktop_drop/Sources/desktop_drop/DesktopDropPlugin.swift b/packages/desktop_drop/macos/desktop_drop/Sources/desktop_drop/DesktopDropPlugin.swift index 0cb33cd9..5efe9e3f 100644 --- a/packages/desktop_drop/macos/desktop_drop/Sources/desktop_drop/DesktopDropPlugin.swift +++ b/packages/desktop_drop/macos/desktop_drop/Sources/desktop_drop/DesktopDropPlugin.swift @@ -32,8 +32,11 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin { let d = DropTarget(frame: vc.view.bounds, channel: channel) d.autoresizingMask = [.width, .height] - d.registerForDraggedTypes(NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) }) - d.registerForDraggedTypes([NSPasteboard.PasteboardType.fileURL]) + // Register for all relevant types (promises, URLs, and legacy filename arrays) + var types = NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) } + types.append(.fileURL) // public.file-url + types.append(NSPasteboard.PasteboardType("NSFilenamesPboardType")) // legacy multi-file array + d.registerForDraggedTypes(types) vc.view.addSubview(d) @@ -49,7 +52,7 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin { let bookmarkByte = map["apple-bookmark"] as! FlutterStandardTypedData let bookmark = bookmarkByte.data - let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale) + let url = try? URL(resolvingBookmarkData: bookmark, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale) let suc = url?.startAccessingSecurityScopedResource() result(suc) return @@ -60,7 +63,7 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin { var isStale: Bool = false let bookmarkByte = map["apple-bookmark"] as! FlutterStandardTypedData let bookmark = bookmarkByte.data - let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale) + let url = try? URL(resolvingBookmarkData: bookmark, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale) url?.stopAccessingSecurityScopedResource() result(true) return @@ -76,6 +79,7 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin { class DropTarget: NSView { private let channel: FlutterMethodChannel + private let itemsLock = NSLock() init(frame frameRect: NSRect, channel: FlutterMethodChannel) { self.channel = channel @@ -100,12 +104,17 @@ class DropTarget: NSView { channel.invokeMethod("exited", arguments: nil) } - /// Directory URL used for accepting file promises. - private lazy var destinationURL: URL = { - let destinationURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("Drops") - try? FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil) - return destinationURL - }() + /// Create a per-drop destination for promised files (avoids name collisions). + private func uniqueDropDestination() -> URL { + let base = FileManager.default.temporaryDirectory.appendingPathComponent("Drops", isDirectory: true) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd_HHmmss_SSS'Z'" + let stamp = formatter.string(from: Date()) + let dest = base.appendingPathComponent(stamp, isDirectory: true) + try? FileManager.default.createDirectory(at: dest, withIntermediateDirectories: true, attributes: nil) + return dest + } /// Queue used for reading and writing file promises. private lazy var workQueue: OperationQueue = { @@ -114,40 +123,70 @@ class DropTarget: NSView { return providerQueue }() - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - var items: [[String: Any?]] = []; - - let searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [ - .urlReadingFileURLsOnly: true, - ] - + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let pb = sender.draggingPasteboard + let dest = uniqueDropDestination() + var items: [[String: Any]] = [] + var seen = Set() let group = DispatchGroup() - // retrieve NSFilePromise. - sender.enumerateDraggingItems(options: [], for: nil, classes: [NSFilePromiseReceiver.self, NSURL.self], searchOptions: searchOptions) { draggingItem, _, _ in - switch draggingItem.item { - case let filePromiseReceiver as NSFilePromiseReceiver: - group.enter() - filePromiseReceiver.receivePromisedFiles(atDestination: self.destinationURL, options: [:], operationQueue: self.workQueue) { fileURL, error in - if let error = error { - debugPrint("error: \(error)") - } else { - let data = try? fileURL.bookmarkData() - items.append([ - "path":fileURL.path, - "apple-bookmark": data, - ]) + func push(url: URL, fromPromise: Bool) { + let path = url.path + itemsLock.lock(); defer { itemsLock.unlock() } + + // de-dupe safely under lock + if !seen.insert(path).inserted { return } + + let values = try? url.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory: Bool = values?.isDirectory ?? false + + // Only create a security-scoped bookmark for items outside our container. + let bundleID = Bundle.main.bundleIdentifier ?? "" + let containerRoot = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Containers/\(bundleID)", isDirectory: true) + .path + let tmpPath = FileManager.default.temporaryDirectory.path + let isInsideContainer = path.hasPrefix(containerRoot) || path.hasPrefix(tmpPath) + + let bmData: Any + if isInsideContainer { + bmData = NSNull() + } else { + let bm = try? url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) + bmData = bm ?? NSNull() + } + items.append([ + "path": path, + "apple-bookmark": bmData, + "isDirectory": isDirectory, + "fromPromise": fromPromise, + ]) + } + + // Prefer real file URLs if they exist; only fall back to promises + let urls = (pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL]) ?? [] + let legacyList = (pb.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String]) ?? [] + + if !urls.isEmpty || !legacyList.isEmpty { + // 1) Modern file URLs + urls.forEach { push(url: $0, fromPromise: false) } + // 2) Legacy filename array used by some apps + legacyList.forEach { push(url: URL(fileURLWithPath: $0), fromPromise: false) } + } else { + // 3) Handle file promises (e.g., VS Code, browsers, Mail) + if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver], + !receivers.isEmpty { + for r in receivers { + group.enter() + r.receivePromisedFiles(atDestination: dest, options: [:], operationQueue: self.workQueue) { url, error in + defer { group.leave() } + if let error = error { + debugPrint("NSFilePromiseReceiver error: \(error)") + return + } + push(url: url, fromPromise: true) } - group.leave() } - case let fileURL as URL: - let data = try? fileURL.bookmarkData() - - items.append([ - "path":fileURL.path, - "apple-bookmark": data, - ]) - default: break } } diff --git a/packages/desktop_drop/pubspec.yaml b/packages/desktop_drop/pubspec.yaml index a8f0def0..de2751e1 100644 --- a/packages/desktop_drop/pubspec.yaml +++ b/packages/desktop_drop/pubspec.yaml @@ -1,6 +1,6 @@ name: desktop_drop description: A plugin which allows user dragging files to your flutter desktop applications. -version: 0.6.1 +version: 0.7.0 homepage: https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_drop environment: @@ -19,7 +19,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: plugin: