diff --git a/packages/desktop_drop/CHANGELOG.md b/packages/desktop_drop/CHANGELOG.md index dccfacf6..c1f26dfe 100644 --- a/packages/desktop_drop/CHANGELOG.md +++ b/packages/desktop_drop/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.8.1 + +* [desktop_drop] [Linux] register the `application/vnd.portal.filetransfer` drag target and deliver its key via a new `performOperation_portal` channel message +* [desktop_drop] [Linux] resolve portal transfer keys through `org.freedesktop.portal.FileTransfer`, so drops from sandboxed sources yield openable document-portal paths +* [desktop_drop] expose the raw drag payload in `DropDoneEvent.rawText` / `DropDoneDetails.rawText` + ## 0.8.0 **BREAKING CHANGE** diff --git a/packages/desktop_drop/lib/desktop_drop.dart b/packages/desktop_drop/lib/desktop_drop.dart index b0d30e1e..087b611d 100644 --- a/packages/desktop_drop/lib/desktop_drop.dart +++ b/packages/desktop_drop/lib/desktop_drop.dart @@ -1,3 +1,4 @@ export 'src/drop_target.dart'; export 'src/drop_item.dart'; export 'src/channel.dart'; +export 'src/events.dart'; diff --git a/packages/desktop_drop/lib/src/channel.dart b/packages/desktop_drop/lib/src/channel.dart index 88e4fbca..efe331b0 100644 --- a/packages/desktop_drop/lib/src/channel.dart +++ b/packages/desktop_drop/lib/src/channel.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:dbus/dbus.dart'; import 'package:desktop_drop/src/drop_item.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -139,12 +140,14 @@ class DesktopDrop { final paths = const LineSplitter().convert(text).map((e) { try { final uri = Uri.tryParse(e); - if (uri == null) { + if (uri == null || !uri.hasScheme) { + // No scheme = likely a portal key return ''; } if (uri.scheme == 'file') { return uri.toFilePath(); } + // smb://, http://, etc. - keep as-is (not portal keys) return e; } catch (error, stacktrace) { debugPrint('failed to parse linux path: $error $stacktrace'); @@ -154,6 +157,22 @@ class DesktopDrop { _notifyEvent(DropDoneEvent( location: Offset(offset[0], offset[1]), files: paths.map((e) => DropItemFile(e)).toList(), + rawText: text, + )); + break; + case "performOperation_portal": + // The portal target carries a one-time transfer key instead of + // file paths. Resolve it against org.freedesktop.portal.FileTransfer + // so consumers receive paths they can open directly. + final portalText = (call.arguments as List)[0] as String; + final portalOffset = + ((call.arguments as List)[1] as List) + .cast(); + final paths = await _resolvePortalFiles(portalText); + _notifyEvent(DropDoneEvent( + location: Offset(portalOffset[0], portalOffset[1]), + files: paths.map((e) => DropItemFile(e)).toList(), + rawText: portalText, )); break; case "performOperation_web": @@ -172,6 +191,39 @@ class DesktopDrop { } } + /// Resolves an XDG FileTransfer portal key into document-portal paths. + /// + /// RetrieveFiles exports each dropped file for this application and + /// returns paths under /run/user/$UID/doc that are readable both inside + /// and outside a Flatpak sandbox. On any failure an empty list is + /// returned; rawText still carries the key for callers that implement + /// their own resolution. + Future> _resolvePortalFiles(String key) async { + DBusClient? client; + try { + client = DBusClient.session(); + final result = await client.callMethod( + destination: 'org.freedesktop.portal.Documents', + path: DBusObjectPath('/org/freedesktop/portal/documents'), + interface: 'org.freedesktop.portal.FileTransfer', + name: 'RetrieveFiles', + values: [DBusString(key), DBusDict.stringVariant({})], + ); + if (result.values.isEmpty || result.values.first is! DBusArray) { + return const []; + } + return (result.values.first as DBusArray) + .children + .map((value) => value.asString()) + .toList(); + } catch (error) { + debugPrint('desktop_drop: failed to resolve portal transfer: $error'); + return const []; + } finally { + await client?.close(); + } + } + void _notifyEvent(DropEvent event) { for (final listener in _listeners) { listener(event); diff --git a/packages/desktop_drop/lib/src/drop_target.dart b/packages/desktop_drop/lib/src/drop_target.dart index 1ebf4675..acc3eb22 100644 --- a/packages/desktop_drop/lib/src/drop_target.dart +++ b/packages/desktop_drop/lib/src/drop_target.dart @@ -11,11 +11,40 @@ class DropDoneDetails { required this.files, required this.localPosition, required this.globalPosition, + this.rawText, }); final List files; final Offset localPosition; final Offset globalPosition; + + /// The raw text payload from the drag operation, passed through from [DropDoneEvent.rawText]. + /// + /// Format: one URI per line for `text/uri-list` drops, or a single + /// transfer key when the drop was negotiated as + /// `application/vnd.portal.filetransfer`. GTK delivers one or the + /// other, never both mixed. + /// + /// URI payload example: + /// ``` + /// file:///home/user/Documents/file.txt + /// file:///home/user/Pictures/photo.png + /// ``` + /// + /// Portal payload example (Flatpak source app): + /// ``` + /// f2c1ee0e-0547-4ea6-9c15-a9cf7dbfef98 + /// ``` + /// + /// The portal key is a token WITHOUT a URI scheme. It can be passed to + /// `org.freedesktop.portal.FileTransfer.RetrieveFiles` to obtain + /// sandbox-accessible paths. Lines with a scheme like `file://`, + /// `smb://`, or `http://` are URIs, not keys — distinguish by parsing + /// each line and checking `Uri.hasScheme`. + /// + /// Is `null` on platforms where raw text isn't exposed (Windows, + /// macOS, or when the platform channel doesn't provide it). + final String? rawText; } class DropEventDetails { @@ -166,6 +195,7 @@ class _DropTargetState extends State { files: event.files, localPosition: position, globalPosition: globalPosition, + rawText: event.rawText, )); } } diff --git a/packages/desktop_drop/lib/src/events.dart b/packages/desktop_drop/lib/src/events.dart index 193f6e2c..3b709611 100644 --- a/packages/desktop_drop/lib/src/events.dart +++ b/packages/desktop_drop/lib/src/events.dart @@ -27,13 +27,42 @@ class DropUpdateEvent extends DropEvent { class DropDoneEvent extends DropEvent { final List files; + /// The raw text payload from the drag operation. + /// + /// Format: one URI per line for `text/uri-list` drops, or a single + /// transfer key when the drop was negotiated as + /// `application/vnd.portal.filetransfer`. GTK delivers one or the + /// other, never both mixed. + /// + /// URI payload example: + /// ``` + /// file:///home/user/Documents/file.txt + /// file:///home/user/Pictures/photo.png + /// ``` + /// + /// Portal payload example (Flatpak source app): + /// ``` + /// f2c1ee0e-0547-4ea6-9c15-a9cf7dbfef98 + /// ``` + /// + /// The portal key is a token WITHOUT a URI scheme. It can be passed to + /// `org.freedesktop.portal.FileTransfer.RetrieveFiles` to obtain + /// sandbox-accessible paths. Lines with a scheme like `file://`, + /// `smb://`, or `http://` are URIs, not keys — distinguish by parsing + /// each line and checking `Uri.hasScheme`. + /// + /// Is `null` on platforms where raw text isn't exposed (Windows, + /// macOS, or when the platform channel doesn't provide it). + final String? rawText; + DropDoneEvent({ required Offset location, required this.files, + this.rawText, }) : super(location); @override String toString() { - return '$runtimeType($location, $files)'; + return '$runtimeType($location, $files, rawText: ${rawText != null ? 'present' : 'null'})'; } } diff --git a/packages/desktop_drop/linux/desktop_drop_plugin.cc b/packages/desktop_drop/linux/desktop_drop_plugin.cc index 217db4d6..d706bd6a 100644 --- a/packages/desktop_drop/linux/desktop_drop_plugin.cc +++ b/packages/desktop_drop/linux/desktop_drop_plugin.cc @@ -22,12 +22,29 @@ void on_drag_data_received(GtkWidget *widget, GdkDragContext *drag_context, gint x, gint y, GtkSelectionData *sdata, guint info, guint time, gpointer user_data) { auto *channel = static_cast(user_data); - auto *data = gtk_selection_data_get_data(sdata); + const gchar *method_name = "performOperation_linux"; + + // The portal target carries a one-time transfer key instead of URIs. + // Send it through its own method so Dart knows what it received. + g_autofree gchar *target_name = gdk_atom_name(gtk_selection_data_get_target(sdata)); + if (target_name != nullptr && + strcmp(target_name, "application/vnd.portal.filetransfer") == 0) { + method_name = "performOperation_portal"; + } + + // Selection data is not guaranteed to be NUL-terminated. + gint length = gtk_selection_data_get_length(sdata); + if (length < 0) { + return; + } + g_autofree gchar *payload = g_strndup( + reinterpret_cast(gtk_selection_data_get_data(sdata)), length); + double point[] = {double(x), double(y)}; - auto args = fl_value_new_list(); - fl_value_append(args, fl_value_new_string((gchar *) data)); + g_autoptr(FlValue) args = fl_value_new_list(); + fl_value_append(args, fl_value_new_string(payload)); fl_value_append(args, fl_value_new_float_list(point, 2)); - fl_method_channel_invoke_method(channel, "performOperation_linux", args, + fl_method_channel_invoke_method(channel, method_name, args, nullptr, nullptr, nullptr); } @@ -95,10 +112,13 @@ void desktop_drop_plugin_register_with_registrar(FlPluginRegistrar *registrar) { g_object_new(desktop_drop_plugin_get_type(), nullptr)); auto *fl_view = fl_plugin_registrar_get_view(registrar); + // Register portal file transfer target FIRST (highest priority) + // then STRING, then URI targets static GtkTargetEntry entries[] = { + {strdup("application/vnd.portal.filetransfer"), GTK_TARGET_OTHER_APP, 0}, {strdup("STRING"), GTK_TARGET_OTHER_APP, 0} }; - gtk_drag_dest_set(GTK_WIDGET(fl_view), GTK_DEST_DEFAULT_ALL, entries, 1, GDK_ACTION_COPY); + gtk_drag_dest_set(GTK_WIDGET(fl_view), GTK_DEST_DEFAULT_ALL, entries, 2, GDK_ACTION_COPY); gtk_drag_dest_add_uri_targets(GTK_WIDGET(fl_view)); g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); diff --git a/packages/desktop_drop/pubspec.yaml b/packages/desktop_drop/pubspec.yaml index 4d67a4ae..026257ae 100644 --- a/packages/desktop_drop/pubspec.yaml +++ b/packages/desktop_drop/pubspec.yaml @@ -1,7 +1,7 @@ name: desktop_drop resolution: workspace description: A plugin which allows user dragging files to your flutter desktop applications. -version: 0.8.0 +version: 0.8.1 homepage: https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_drop environment: @@ -9,6 +9,7 @@ environment: flutter: ">=3.0.0" dependencies: + dbus: ^0.7.10 flutter: sdk: flutter flutter_web_plugins: diff --git a/packages/desktop_drop/test/channel_linux_test.dart b/packages/desktop_drop/test/channel_linux_test.dart index b1946bca..60d2b357 100644 --- a/packages/desktop_drop/test/channel_linux_test.dart +++ b/packages/desktop_drop/test/channel_linux_test.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:desktop_drop/desktop_drop.dart'; -import 'package:desktop_drop/src/events.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -55,4 +54,118 @@ void main() { final event = events.single as DropDoneEvent; expect(event.files.single.path, '/tmp/file.txt'); }); + + test('linux drop includes rawText for multiple files with portal key', () async { + final events = []; + void listener(DropEvent event) => events.add(event); + DesktopDrop.instance.addRawDropEventListener(listener); + addTearDown( + () => DesktopDrop.instance.removeRawDropEventListener(listener)); + + // Multiple files delivered via text/uri-list (no portal key) + await _invokePlatformMethod(const MethodCall('performOperation_linux', [ + 'file:///home/user/Documents/file1.txt\nfile:///home/user/Pictures/photo.png', + [150.0, 250.0] + ])); + + final event = events.single as DropDoneEvent; + expect(event.files.length, 2); + expect(event.files[0].path, '/home/user/Documents/file1.txt'); + expect(event.files[1].path, '/home/user/Pictures/photo.png'); + expect(event.rawText, 'file:///home/user/Documents/file1.txt\nfile:///home/user/Pictures/photo.png'); + }); + + test('linux drop rawText contains only file URIs (no portal key)', () async { + final events = []; + void listener(DropEvent event) => events.add(event); + DesktopDrop.instance.addRawDropEventListener(listener); + addTearDown( + () => DesktopDrop.instance.removeRawDropEventListener(listener)); + + // Normal drag from non-sandboxed app (no portal) + await _invokePlatformMethod(const MethodCall('performOperation_linux', [ + 'file:///home/user/Downloads/normal.txt\nfile:///home/user/Downloads/another.txt', + [50.0, 60.0] + ])); + + final event = events.single as DropDoneEvent; + expect(event.files.length, 2); + expect(event.files[0].path, '/home/user/Downloads/normal.txt'); + expect(event.files[1].path, '/home/user/Downloads/another.txt'); + // rawText still captured (may be used by consumers) + expect(event.rawText, 'file:///home/user/Downloads/normal.txt\nfile:///home/user/Downloads/another.txt'); + }); + + test('linux drop with non-file URI (SMB) still works and rawText captured', () async { + final events = []; + void listener(DropEvent event) => events.add(event); + DesktopDrop.instance.addRawDropEventListener(listener); + addTearDown( + () => DesktopDrop.instance.removeRawDropEventListener(listener)); + + await _invokePlatformMethod(const MethodCall('performOperation_linux', [ + 'smb://server/share/document.pdf', + [10.0, 20.0] + ])); + + final event = events.single as DropDoneEvent; + expect(event.files.single.path, 'smb://server/share/document.pdf'); + expect(event.rawText, 'smb://server/share/document.pdf'); + }); + + test('linux drop decodes percent-encoded filenames', () async { + final events = []; + void listener(DropEvent event) => events.add(event); + DesktopDrop.instance.addRawDropEventListener(listener); + addTearDown( + () => DesktopDrop.instance.removeRawDropEventListener(listener)); + + await _invokePlatformMethod(const MethodCall('performOperation_linux', [ + 'file:///home/user/my%20file.txt', + [5.0, 5.0] + ])); + + final event = events.single as DropDoneEvent; + expect(event.files.single.path, '/home/user/my file.txt'); + expect(event.rawText, 'file:///home/user/my%20file.txt'); + }); + + test('linux drop tolerates trailing blank line in payload', () async { + final events = []; + void listener(DropEvent event) => events.add(event); + DesktopDrop.instance.addRawDropEventListener(listener); + addTearDown( + () => DesktopDrop.instance.removeRawDropEventListener(listener)); + + await _invokePlatformMethod(const MethodCall('performOperation_linux', [ + 'file:///home/user/a.txt\nfile:///home/user/b.txt\n', + [1.0, 1.0] + ])); + + final event = events.single as DropDoneEvent; + expect(event.files.length, 2); + expect(event.files[0].path, '/home/user/a.txt'); + expect(event.files[1].path, '/home/user/b.txt'); + expect(event.rawText, 'file:///home/user/a.txt\nfile:///home/user/b.txt\n'); + }); + + test('linux portal drop returns portal key in rawText with no files', () async { + final events = []; + void listener(DropEvent event) => events.add(event); + DesktopDrop.instance.addRawDropEventListener(listener); + addTearDown( + () => DesktopDrop.instance.removeRawDropEventListener(listener)); + + // Portal key delivered via application/vnd.portal.filetransfer target + await _invokePlatformMethod(const MethodCall('performOperation_portal', [ + 'abc123portalkey456', + [100.0, 200.0] + ])); + + final event = events.single as DropDoneEvent; + expect(event.files, isEmpty); + expect(event.rawText, 'abc123portalkey456'); + expect(event.location.dx, 100.0); + expect(event.location.dy, 200.0); + }); }