Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/desktop_drop/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
42 changes: 35 additions & 7 deletions packages/desktop_drop/lib/src/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> startAccessingSecurityScopedResource(
{required Uint8List bookmark}) async {
if (bookmark.isEmpty) return false;
Map<String, dynamic> resultMap = {};
resultMap["apple-bookmark"] = bookmark;
final bool? result = await _channel.invokeMethod(
Expand All @@ -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<bool> stopAccessingSecurityScopedResource(
{required Uint8List bookmark}) async {
if (bookmark.isEmpty) return true;
Map<String, dynamic> resultMap = {};
resultMap["apple-bookmark"] = bookmark;
final bool result = await _channel.invokeMethod(
Expand Down Expand Up @@ -89,16 +103,30 @@ class DesktopDrop {
_offset = null;
break;
case "performOperation_macos":
// final paths = (call.arguments as List).cast<Map<String?, Object?>>();
final paths = call.arguments as List;
final items = (call.arguments as List).cast<Map>();
_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(),
),
);
Expand Down
34 changes: 34 additions & 0 deletions packages/desktop_drop/lib/src/drop_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +36,7 @@ abstract class DropItem extends XFile {
super.bytes,
super.lastModified,
this.extraAppleBookmark,
this.fromPromise = false,
});

DropItem.fromData(
Expand All @@ -21,6 +46,8 @@ abstract class DropItem extends XFile {
super.length,
super.lastModified,
super.path,
this.extraAppleBookmark,
this.fromPromise = false,
Comment on lines +49 to +50
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DropItem.fromData constructor is missing the extraAppleBookmark parameter in its super() call. This will cause extraAppleBookmark to always be null for items created via fromData, which may not be the intended behavior.

Copilot uses AI. Check for mistakes.
}) : super.fromData();
}

Expand All @@ -33,6 +60,7 @@ class DropItemFile extends DropItem {
super.bytes,
super.lastModified,
super.extraAppleBookmark,
super.fromPromise,
});

DropItemFile.fromData(
Expand All @@ -42,9 +70,11 @@ class DropItemFile extends DropItem {
super.length,
super.lastModified,
super.path,
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DropItemFile.fromData constructor is missing the extraAppleBookmark parameter. It should pass super.extraAppleBookmark to match the pattern used in other constructors.

Suggested change
super.path,
super.path,
super.extraAppleBookmark,

Copilot uses AI. Check for mistakes.
super.fromPromise,
}) : super.fromData();
}

/// A dropped directory.
class DropItemDirectory extends DropItem {
final List<DropItem> children;

Expand All @@ -56,6 +86,8 @@ class DropItemDirectory extends DropItem {
super.length,
super.bytes,
super.lastModified,
super.extraAppleBookmark,
super.fromPromise,
});

DropItemDirectory.fromData(
Expand All @@ -66,5 +98,7 @@ class DropItemDirectory extends DropItem {
super.length,
super.lastModified,
super.path,
super.extraAppleBookmark,
super.fromPromise,
}) : super.fromData();
}
2 changes: 1 addition & 1 deletion packages/desktop_drop/macos/desktop_drop.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions packages/desktop_drop/macos/desktop_drop/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -21,4 +21,4 @@ let package = Package(
]
)
]
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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'"
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The date format string 'yyyyMMdd_HHmmss_SSS'Z'' includes a literal 'Z' suffix but doesn't actually format the date in UTC timezone. Either remove the 'Z' or set the formatter's timeZone to UTC to match the format.

Suggested change
formatter.dateFormat = "yyyyMMdd_HHmmss_SSS'Z'"
formatter.dateFormat = "yyyyMMdd_HHmmss_SSS'Z'"
formatter.timeZone = TimeZone(secondsFromGMT: 0)

Copilot uses AI. Check for mistakes.
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 = {
Expand All @@ -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<String>()
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
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/desktop_drop/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -19,7 +19,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter_lints: ^6.0.0

flutter:
plugin:
Expand Down