Skip to content

Commit 4181b95

Browse files
authored
[desktop_drop] 0.7.0: macOS: robust multi-source drag & drop (#434)
- 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
1 parent 17a099a commit 4181b95

File tree

7 files changed

+169
-52
lines changed

7 files changed

+169
-52
lines changed

packages/desktop_drop/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## 0.7.0
4+
5+
[macOS] Robust multi-source drag & drop.
6+
7+
* Prefer `public.file-url` / legacy filename arrays when present; fall back to
8+
`NSFilePromiseReceiver` (file promises) otherwise.
9+
* Handle directories (`isDirectory`) and surface as `DropItemDirectory`.
10+
* Add `fromPromise` to `DropItem` so apps can distinguish promise-based drops.
11+
* Generate security-scoped bookmarks only for paths outside the app container
12+
(skip/empty for promise files in `.../tmp/Drops/...`).
13+
* Per-drop unique destination for promised files to avoid name collisions.
14+
* Thread-safe collection of drop results when receiving promises.
15+
* Dart guards: no-op `start/stopAccessingSecurityScopedResource` on empty
16+
bookmarks.
17+
* Bump macOS minimum to 10.13 (SPM/Podspec).
18+
319
## 0.6.1
420

521
* Fix desktop_drop Linux snap build failure due to missing stdlib.h include (#425)

packages/desktop_drop/lib/src/channel.dart

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,17 @@ class DesktopDrop {
3737
});
3838
}
3939

40+
/// macOS: Attempt to start security-scoped access for a bookmarked URL.
41+
///
42+
/// Pass the [DropItem.extraAppleBookmark] bytes from a dropped file that
43+
/// originated outside the app container. Returns `true` if access began.
44+
///
45+
/// If [bookmark] is empty, this function returns `false` and does not
46+
/// invoke the platform call. Promise files written under your container do
47+
/// not require security-scoped access.
4048
Future<bool> startAccessingSecurityScopedResource(
4149
{required Uint8List bookmark}) async {
50+
if (bookmark.isEmpty) return false;
4251
Map<String, dynamic> resultMap = {};
4352
resultMap["apple-bookmark"] = bookmark;
4453
final bool? result = await _channel.invokeMethod(
@@ -47,8 +56,13 @@ class DesktopDrop {
4756
return result;
4857
}
4958

59+
/// macOS: Stop security-scoped access previously started.
60+
///
61+
/// If [bookmark] is empty, this function returns `true` and does not
62+
/// invoke the platform call, acting as a no-op.
5063
Future<bool> stopAccessingSecurityScopedResource(
5164
{required Uint8List bookmark}) async {
65+
if (bookmark.isEmpty) return true;
5266
Map<String, dynamic> resultMap = {};
5367
resultMap["apple-bookmark"] = bookmark;
5468
final bool result = await _channel.invokeMethod(
@@ -89,16 +103,30 @@ class DesktopDrop {
89103
_offset = null;
90104
break;
91105
case "performOperation_macos":
92-
// final paths = (call.arguments as List).cast<Map<String?, Object?>>();
93-
final paths = call.arguments as List;
106+
final items = (call.arguments as List).cast<Map>();
94107
_notifyEvent(
95108
DropDoneEvent(
96109
location: _offset ?? Offset.zero,
97-
files: paths
98-
.map((e) => DropItemFile(
99-
e["path"] as String,
100-
extraAppleBookmark: e["apple-bookmark"] as Uint8List?,
101-
))
110+
files: items
111+
.map((raw) {
112+
final path = raw["path"] as String;
113+
final bookmark = raw["apple-bookmark"] as Uint8List?;
114+
final isDir = (raw["isDirectory"] as bool?) ?? false;
115+
final fromPromise = (raw["fromPromise"] as bool?) ?? false;
116+
if (isDir) {
117+
return DropItemDirectory(
118+
path,
119+
const [],
120+
extraAppleBookmark: bookmark,
121+
fromPromise: fromPromise,
122+
);
123+
}
124+
return DropItemFile(
125+
path,
126+
extraAppleBookmark: bookmark,
127+
fromPromise: fromPromise,
128+
);
129+
})
102130
.toList(),
103131
),
104132
);

packages/desktop_drop/lib/src/drop_item.dart

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,32 @@ import 'dart:typed_data';
22

33
import 'package:cross_file/cross_file.dart';
44

5+
/// A dropped item.
6+
///
7+
/// On desktop, this is usually a filesystem path (file or directory).
8+
///
9+
/// macOS specifics:
10+
/// - If the drag source provided a real file URL (e.g. Finder/JetBrains),
11+
/// [extraAppleBookmark] will typically be non-null and allow security-scoped
12+
/// access when running sandboxed.
13+
/// - If the drag source used a file promise (e.g. VS Code/Electron), the
14+
/// system delivers bytes into a per-drop temporary folder inside your app's
15+
/// container. In that case [fromPromise] is true and [extraAppleBookmark]
16+
/// is usually null/empty. There is no original source path in this flow.
517
abstract class DropItem extends XFile {
18+
/// Security-scoped bookmark bytes for the dropped item (macOS only).
19+
///
20+
/// Use with [DesktopDrop.startAccessingSecurityScopedResource] to gain
21+
/// temporary access to files outside your sandbox. When empty or null,
22+
/// you typically don't need to call start/stop (e.g. promise files in
23+
/// your app's container).
624
Uint8List? extraAppleBookmark;
25+
26+
/// True when this item was delivered via a macOS file promise and was
27+
/// written into your app's temporary Drops directory.
28+
///
29+
/// In this case, the original source path is not available by design.
30+
final bool fromPromise;
731
DropItem(
832
super.path, {
933
super.mimeType,
@@ -12,6 +36,7 @@ abstract class DropItem extends XFile {
1236
super.bytes,
1337
super.lastModified,
1438
this.extraAppleBookmark,
39+
this.fromPromise = false,
1540
});
1641

1742
DropItem.fromData(
@@ -21,6 +46,8 @@ abstract class DropItem extends XFile {
2146
super.length,
2247
super.lastModified,
2348
super.path,
49+
this.extraAppleBookmark,
50+
this.fromPromise = false,
2451
}) : super.fromData();
2552
}
2653

@@ -33,6 +60,7 @@ class DropItemFile extends DropItem {
3360
super.bytes,
3461
super.lastModified,
3562
super.extraAppleBookmark,
63+
super.fromPromise,
3664
});
3765

3866
DropItemFile.fromData(
@@ -42,9 +70,11 @@ class DropItemFile extends DropItem {
4270
super.length,
4371
super.lastModified,
4472
super.path,
73+
super.fromPromise,
4574
}) : super.fromData();
4675
}
4776

77+
/// A dropped directory.
4878
class DropItemDirectory extends DropItem {
4979
final List<DropItem> children;
5080

@@ -56,6 +86,8 @@ class DropItemDirectory extends DropItem {
5686
super.length,
5787
super.bytes,
5888
super.lastModified,
89+
super.extraAppleBookmark,
90+
super.fromPromise,
5991
});
6092

6193
DropItemDirectory.fromData(
@@ -66,5 +98,7 @@ class DropItemDirectory extends DropItem {
6698
super.length,
6799
super.lastModified,
68100
super.path,
101+
super.extraAppleBookmark,
102+
super.fromPromise,
69103
}) : super.fromData();
70104
}

packages/desktop_drop/macos/desktop_drop.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ A new flutter plugin project.
1616
s.source_files = 'desktop_drop/Sources/desktop_drop/**/*.{h,m,swift}'
1717
s.dependency 'FlutterMacOS'
1818

19-
s.platform = :osx, '10.11'
19+
s.platform = :osx, '10.13'
2020
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
2121
s.swift_version = '5.0'
2222
end

packages/desktop_drop/macos/desktop_drop/Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import PackageDescription
66
let package = Package(
77
name: "desktop_drop",
88
platforms: [
9-
.macOS("10.11")
9+
.macOS("10.13")
1010
],
1111
products: [
1212
.library(name: "desktop-drop", targets: ["desktop_drop"])
@@ -21,4 +21,4 @@ let package = Package(
2121
]
2222
)
2323
]
24-
)
24+
)

packages/desktop_drop/macos/desktop_drop/Sources/desktop_drop/DesktopDropPlugin.swift

Lines changed: 79 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin {
3232
let d = DropTarget(frame: vc.view.bounds, channel: channel)
3333
d.autoresizingMask = [.width, .height]
3434

35-
d.registerForDraggedTypes(NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) })
36-
d.registerForDraggedTypes([NSPasteboard.PasteboardType.fileURL])
35+
// Register for all relevant types (promises, URLs, and legacy filename arrays)
36+
var types = NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) }
37+
types.append(.fileURL) // public.file-url
38+
types.append(NSPasteboard.PasteboardType("NSFilenamesPboardType")) // legacy multi-file array
39+
d.registerForDraggedTypes(types)
3740

3841
vc.view.addSubview(d)
3942

@@ -49,7 +52,7 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin {
4952
let bookmarkByte = map["apple-bookmark"] as! FlutterStandardTypedData
5053
let bookmark = bookmarkByte.data
5154

52-
let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale)
55+
let url = try? URL(resolvingBookmarkData: bookmark, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale)
5356
let suc = url?.startAccessingSecurityScopedResource()
5457
result(suc)
5558
return
@@ -60,7 +63,7 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin {
6063
var isStale: Bool = false
6164
let bookmarkByte = map["apple-bookmark"] as! FlutterStandardTypedData
6265
let bookmark = bookmarkByte.data
63-
let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale)
66+
let url = try? URL(resolvingBookmarkData: bookmark, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale)
6467
url?.stopAccessingSecurityScopedResource()
6568
result(true)
6669
return
@@ -76,6 +79,7 @@ public class DesktopDropPlugin: NSObject, FlutterPlugin {
7679

7780
class DropTarget: NSView {
7881
private let channel: FlutterMethodChannel
82+
private let itemsLock = NSLock()
7983

8084
init(frame frameRect: NSRect, channel: FlutterMethodChannel) {
8185
self.channel = channel
@@ -100,12 +104,17 @@ class DropTarget: NSView {
100104
channel.invokeMethod("exited", arguments: nil)
101105
}
102106

103-
/// Directory URL used for accepting file promises.
104-
private lazy var destinationURL: URL = {
105-
let destinationURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("Drops")
106-
try? FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil)
107-
return destinationURL
108-
}()
107+
/// Create a per-drop destination for promised files (avoids name collisions).
108+
private func uniqueDropDestination() -> URL {
109+
let base = FileManager.default.temporaryDirectory.appendingPathComponent("Drops", isDirectory: true)
110+
let formatter = DateFormatter()
111+
formatter.locale = Locale(identifier: "en_US_POSIX")
112+
formatter.dateFormat = "yyyyMMdd_HHmmss_SSS'Z'"
113+
let stamp = formatter.string(from: Date())
114+
let dest = base.appendingPathComponent(stamp, isDirectory: true)
115+
try? FileManager.default.createDirectory(at: dest, withIntermediateDirectories: true, attributes: nil)
116+
return dest
117+
}
109118

110119
/// Queue used for reading and writing file promises.
111120
private lazy var workQueue: OperationQueue = {
@@ -114,40 +123,70 @@ class DropTarget: NSView {
114123
return providerQueue
115124
}()
116125

117-
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
118-
var items: [[String: Any?]] = [];
119-
120-
let searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [
121-
.urlReadingFileURLsOnly: true,
122-
]
123-
126+
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
127+
let pb = sender.draggingPasteboard
128+
let dest = uniqueDropDestination()
129+
var items: [[String: Any]] = []
130+
var seen = Set<String>()
124131
let group = DispatchGroup()
125132

126-
// retrieve NSFilePromise.
127-
sender.enumerateDraggingItems(options: [], for: nil, classes: [NSFilePromiseReceiver.self, NSURL.self], searchOptions: searchOptions) { draggingItem, _, _ in
128-
switch draggingItem.item {
129-
case let filePromiseReceiver as NSFilePromiseReceiver:
130-
group.enter()
131-
filePromiseReceiver.receivePromisedFiles(atDestination: self.destinationURL, options: [:], operationQueue: self.workQueue) { fileURL, error in
132-
if let error = error {
133-
debugPrint("error: \(error)")
134-
} else {
135-
let data = try? fileURL.bookmarkData()
136-
items.append([
137-
"path":fileURL.path,
138-
"apple-bookmark": data,
139-
])
133+
func push(url: URL, fromPromise: Bool) {
134+
let path = url.path
135+
itemsLock.lock(); defer { itemsLock.unlock() }
136+
137+
// de-dupe safely under lock
138+
if !seen.insert(path).inserted { return }
139+
140+
let values = try? url.resourceValues(forKeys: [.isDirectoryKey])
141+
let isDirectory: Bool = values?.isDirectory ?? false
142+
143+
// Only create a security-scoped bookmark for items outside our container.
144+
let bundleID = Bundle.main.bundleIdentifier ?? ""
145+
let containerRoot = FileManager.default.homeDirectoryForCurrentUser
146+
.appendingPathComponent("Library/Containers/\(bundleID)", isDirectory: true)
147+
.path
148+
let tmpPath = FileManager.default.temporaryDirectory.path
149+
let isInsideContainer = path.hasPrefix(containerRoot) || path.hasPrefix(tmpPath)
150+
151+
let bmData: Any
152+
if isInsideContainer {
153+
bmData = NSNull()
154+
} else {
155+
let bm = try? url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
156+
bmData = bm ?? NSNull()
157+
}
158+
items.append([
159+
"path": path,
160+
"apple-bookmark": bmData,
161+
"isDirectory": isDirectory,
162+
"fromPromise": fromPromise,
163+
])
164+
}
165+
166+
// Prefer real file URLs if they exist; only fall back to promises
167+
let urls = (pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL]) ?? []
168+
let legacyList = (pb.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String]) ?? []
169+
170+
if !urls.isEmpty || !legacyList.isEmpty {
171+
// 1) Modern file URLs
172+
urls.forEach { push(url: $0, fromPromise: false) }
173+
// 2) Legacy filename array used by some apps
174+
legacyList.forEach { push(url: URL(fileURLWithPath: $0), fromPromise: false) }
175+
} else {
176+
// 3) Handle file promises (e.g., VS Code, browsers, Mail)
177+
if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver],
178+
!receivers.isEmpty {
179+
for r in receivers {
180+
group.enter()
181+
r.receivePromisedFiles(atDestination: dest, options: [:], operationQueue: self.workQueue) { url, error in
182+
defer { group.leave() }
183+
if let error = error {
184+
debugPrint("NSFilePromiseReceiver error: \(error)")
185+
return
186+
}
187+
push(url: url, fromPromise: true)
140188
}
141-
group.leave()
142189
}
143-
case let fileURL as URL:
144-
let data = try? fileURL.bookmarkData()
145-
146-
items.append([
147-
"path":fileURL.path,
148-
"apple-bookmark": data,
149-
])
150-
default: break
151190
}
152191
}
153192

packages/desktop_drop/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: desktop_drop
22
description: A plugin which allows user dragging files to your flutter desktop applications.
3-
version: 0.6.1
3+
version: 0.7.0
44
homepage: https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_drop
55

66
environment:
@@ -19,7 +19,7 @@ dependencies:
1919
dev_dependencies:
2020
flutter_test:
2121
sdk: flutter
22-
flutter_lints: ^5.0.0
22+
flutter_lints: ^6.0.0
2323

2424
flutter:
2525
plugin:

0 commit comments

Comments
 (0)