From e1a8f7ee82c006bc2edf40378da768f95d305bf9 Mon Sep 17 00:00:00 2001 From: loucas monir Date: Sun, 23 Aug 2026 17:23:21 +0300 Subject: [PATCH 01/12] feat: expose raw drag text in DropDoneDetails for portal integration --- packages/desktop_drop/CHANGELOG.md | 4 +++ packages/desktop_drop/lib/desktop_drop.dart | 1 + packages/desktop_drop/lib/src/channel.dart | 1 + .../desktop_drop/lib/src/drop_target.dart | 28 ++++++++++++++++++ packages/desktop_drop/lib/src/events.dart | 29 ++++++++++++++++++- 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/desktop_drop/CHANGELOG.md b/packages/desktop_drop/CHANGELOG.md index dccfacf6..544d1a44 100644 --- a/packages/desktop_drop/CHANGELOG.md +++ b/packages/desktop_drop/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.8.1 + +* [desktop_drop] expose raw drag text in `DropDoneEvent.rawText` and `DropDoneDetails.rawText` for XDG Desktop Portal integration on Wayland/Flatpak + ## 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..b2e5f166 100644 --- a/packages/desktop_drop/lib/src/channel.dart +++ b/packages/desktop_drop/lib/src/channel.dart @@ -154,6 +154,7 @@ class DesktopDrop { _notifyEvent(DropDoneEvent( location: Offset(offset[0], offset[1]), files: paths.map((e) => DropItemFile(e)).toList(), + rawText: text, )); break; case "performOperation_web": diff --git a/packages/desktop_drop/lib/src/drop_target.dart b/packages/desktop_drop/lib/src/drop_target.dart index 1ebf4675..df168c8d 100644 --- a/packages/desktop_drop/lib/src/drop_target.dart +++ b/packages/desktop_drop/lib/src/drop_target.dart @@ -11,11 +11,38 @@ 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 or token per line (as delivered by GTK). + /// + /// On Linux/Wayland with Flatpak, this contains the portal key(s) + /// from the `application/vnd.portal.filetransfer` mimetype — a + /// random string token that can be passed to + /// `org.freedesktop.portal.FileTransfer.RetrieveFiles` to get + /// sandbox-accessible file paths. + /// + /// Example content: + /// ``` + /// file:///home/user/Downloads/normal.txt + /// file:///home/user/Documents/portal.txt + /// abc123portalkey456 + /// ``` + /// + /// The portal key line is NOT a file URI and can be distinguished + /// by not starting with `file://`. It is only present when the + /// drag source used the XDG Desktop Portal (typical for sandboxed + /// apps on Wayland). + /// + /// 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 +193,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..336a16f0 100644 --- a/packages/desktop_drop/lib/src/events.dart +++ b/packages/desktop_drop/lib/src/events.dart @@ -27,13 +27,40 @@ class DropUpdateEvent extends DropEvent { class DropDoneEvent extends DropEvent { final List files; + /// The raw text payload from the drag operation. + /// + /// Format: one URI or token per line (as delivered by GTK). + /// + /// On Linux/Wayland with Flatpak, this contains the portal key(s) + /// from the `application/vnd.portal.filetransfer` mimetype — a + /// random string token that can be passed to + /// `org.freedesktop.portal.FileTransfer.RetrieveFiles` to get + /// sandbox-accessible file paths. + /// + /// Example content: + /// ``` + /// file:///home/user/Downloads/normal.txt + /// file:///home/user/Documents/portal.txt + /// abc123portalkey456 + /// ``` + /// + /// The portal key line is NOT a file URI and can be distinguished + /// by not starting with `file://`. It is only present when the + /// drag source used the XDG Desktop Portal (typical for sandboxed + /// apps on Wayland). + /// + /// 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)'; } } From fadfce4bbd5f6d659b7f60e3d2926a1cfc2eb5ba Mon Sep 17 00:00:00 2001 From: loucas monir Date: Mon, 24 Aug 2026 00:25:39 +0300 Subject: [PATCH 02/12] desktop_drop: skip non-URI lines in linux drops + add tests --- packages/desktop_drop/lib/src/channel.dart | 2 +- .../desktop_drop/test/channel_linux_test.dart | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/desktop_drop/lib/src/channel.dart b/packages/desktop_drop/lib/src/channel.dart index b2e5f166..aa5816a6 100644 --- a/packages/desktop_drop/lib/src/channel.dart +++ b/packages/desktop_drop/lib/src/channel.dart @@ -139,7 +139,7 @@ class DesktopDrop { final paths = const LineSplitter().convert(text).map((e) { try { final uri = Uri.tryParse(e); - if (uri == null) { + if (uri == null || uri.scheme.isEmpty) { return ''; } if (uri.scheme == 'file') { diff --git a/packages/desktop_drop/test/channel_linux_test.dart b/packages/desktop_drop/test/channel_linux_test.dart index b1946bca..c7488e35 100644 --- a/packages/desktop_drop/test/channel_linux_test.dart +++ b/packages/desktop_drop/test/channel_linux_test.dart @@ -55,4 +55,116 @@ void main() { final event = events.single as DropDoneEvent; expect(event.files.single.path, '/tmp/file.txt'); }); + + test('linux drop includes rawText with portal key (Flatpak/Wayland)', () async { + final events = []; + void listener(DropEvent event) => events.add(event); + DesktopDrop.instance.addRawDropEventListener(listener); + addTearDown( + () => DesktopDrop.instance.removeRawDropEventListener(listener)); + + // Simulate Flatpak drag: file:// URI + portal key from XDG Desktop Portal + await _invokePlatformMethod(const MethodCall('performOperation_linux', [ + 'file:///home/user/Documents/secret.pdf\nabc123portalkey456', + [100.0, 200.0] + ])); + + final event = events.single as DropDoneEvent; + expect(event.files.single.path, '/home/user/Documents/secret.pdf'); + expect(event.rawText, 'file:///home/user/Documents/secret.pdf\nabc123portalkey456'); + }); + + 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 + portal key (typical Flatpak drag) + await _invokePlatformMethod(const MethodCall('performOperation_linux', [ + 'file:///home/user/Documents/file1.txt\nfile:///home/user/Pictures/photo.png\nxyz789portalkey', + [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\nxyz789portalkey'); + }); + + 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'); + }); } From 24d39c4f58af437270cdf8f15b8b8b44b94738c2 Mon Sep 17 00:00:00 2001 From: loucas monir Date: Mon, 24 Aug 2026 20:51:59 +0300 Subject: [PATCH 03/12] chore: bump version to 0.8.1 --- packages/desktop_drop/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop_drop/pubspec.yaml b/packages/desktop_drop/pubspec.yaml index 4d67a4ae..43d22491 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: From 60b861bcc1c39ba21a072899545cf900dcbc92e2 Mon Sep 17 00:00:00 2001 From: loucas monir Date: Mon, 24 Aug 2026 20:57:26 +0300 Subject: [PATCH 04/12] fix: correct portal key detection docs and prevent key leak in toString --- packages/desktop_drop/lib/src/drop_target.dart | 7 +++---- packages/desktop_drop/lib/src/events.dart | 9 ++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/desktop_drop/lib/src/drop_target.dart b/packages/desktop_drop/lib/src/drop_target.dart index df168c8d..c1eaa783 100644 --- a/packages/desktop_drop/lib/src/drop_target.dart +++ b/packages/desktop_drop/lib/src/drop_target.dart @@ -35,10 +35,9 @@ class DropDoneDetails { /// abc123portalkey456 /// ``` /// - /// The portal key line is NOT a file URI and can be distinguished - /// by not starting with `file://`. It is only present when the - /// drag source used the XDG Desktop Portal (typical for sandboxed - /// apps on Wayland). + /// The portal key is a token WITHOUT a URI scheme (e.g., `abc123key`). + /// Lines with URI schemes like `file://`, `smb://`, `http://` are NOT portal keys. + /// Consumers should parse each line as a URI and check the scheme. /// /// Is `null` on platforms where raw text isn't exposed (Windows, /// macOS, or when the platform channel doesn't provide it). diff --git a/packages/desktop_drop/lib/src/events.dart b/packages/desktop_drop/lib/src/events.dart index 336a16f0..1dd0a306 100644 --- a/packages/desktop_drop/lib/src/events.dart +++ b/packages/desktop_drop/lib/src/events.dart @@ -44,10 +44,9 @@ class DropDoneEvent extends DropEvent { /// abc123portalkey456 /// ``` /// - /// The portal key line is NOT a file URI and can be distinguished - /// by not starting with `file://`. It is only present when the - /// drag source used the XDG Desktop Portal (typical for sandboxed - /// apps on Wayland). + /// The portal key is a token WITHOUT a URI scheme (e.g., `abc123key`). + /// Lines with URI schemes like `file://`, `smb://`, `http://` are NOT portal keys. + /// Consumers should parse each line as a URI and check the scheme. /// /// Is `null` on platforms where raw text isn't exposed (Windows, /// macOS, or when the platform channel doesn't provide it). @@ -61,6 +60,6 @@ class DropDoneEvent extends DropEvent { @override String toString() { - return '$runtimeType($location, $files, rawText: $rawText)'; + return '$runtimeType($location, $files, rawText: ${rawText != null ? 'present' : 'null'})'; } } From 90dd9a0a81885a4c38d3828f8b63c678fa5b1f27 Mon Sep 17 00:00:00 2001 From: loucas monir Date: Mon, 24 Aug 2026 21:10:02 +0300 Subject: [PATCH 05/12] feat: add portal file transfer target registration and handling for Flatpak/Wayland --- packages/desktop_drop/lib/src/channel.dart | 16 +++++++++++- .../desktop_drop/linux/desktop_drop_plugin.cc | 26 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/desktop_drop/lib/src/channel.dart b/packages/desktop_drop/lib/src/channel.dart index aa5816a6..60487106 100644 --- a/packages/desktop_drop/lib/src/channel.dart +++ b/packages/desktop_drop/lib/src/channel.dart @@ -139,12 +139,14 @@ class DesktopDrop { final paths = const LineSplitter().convert(text).map((e) { try { final uri = Uri.tryParse(e); - if (uri == null || uri.scheme.isEmpty) { + 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'); @@ -157,6 +159,18 @@ class DesktopDrop { rawText: text, )); break; + case "performOperation_portal": + // Portal file transfer key received (application/vnd.portal.filetransfer) + // The key is passed as raw text, no parsing needed + final portalText = (call.arguments as List)[0] as String; + final portalOffset = ((call.arguments as List)[1] as List) + .cast(); + _notifyEvent(DropDoneEvent( + location: Offset(portalOffset[0], portalOffset[1]), + files: const [], + rawText: portalText, + )); + break; case "performOperation_web": final results = (call.arguments as List) .cast() diff --git a/packages/desktop_drop/linux/desktop_drop_plugin.cc b/packages/desktop_drop/linux/desktop_drop_plugin.cc index 217db4d6..a51b74da 100644 --- a/packages/desktop_drop/linux/desktop_drop_plugin.cc +++ b/packages/desktop_drop/linux/desktop_drop_plugin.cc @@ -22,13 +22,26 @@ 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); + GdkAtom target = gtk_selection_data_get_target(sdata); + const gchar *target_name = gdk_atom_name(target); auto *data = gtk_selection_data_get_data(sdata); double point[] = {double(x), double(y)}; auto args = fl_value_new_list(); - fl_value_append(args, fl_value_new_string((gchar *) data)); - fl_value_append(args, fl_value_new_float_list(point, 2)); - fl_method_channel_invoke_method(channel, "performOperation_linux", args, - nullptr, nullptr, nullptr); + + // Check if this is the portal file transfer target + if (strcmp(target_name, "application/vnd.portal.filetransfer") == 0) { + // Portal key received - send via dedicated method + fl_value_append(args, fl_value_new_string((gchar *) data)); + fl_value_append(args, fl_value_new_float_list(point, 2)); + fl_method_channel_invoke_method(channel, "performOperation_portal", args, + nullptr, nullptr, nullptr); + } else { + // Standard STRING or text/uri-list target + fl_value_append(args, fl_value_new_string((gchar *) data)); + fl_value_append(args, fl_value_new_float_list(point, 2)); + fl_method_channel_invoke_method(channel, "performOperation_linux", args, + nullptr, nullptr, nullptr); + } } void on_drag_motion(GtkWidget *widget, GdkDragContext *drag_context, @@ -95,10 +108,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(); From c1f039b0f8c246c617a60592fd92cf60826bea5d Mon Sep 17 00:00:00 2001 From: loucas monir Date: Mon, 24 Aug 2026 21:17:28 +0300 Subject: [PATCH 06/12] test: add test for portal file transfer method and fix unrealistic combined payload tests --- .../desktop_drop/test/channel_linux_test.dart | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/desktop_drop/test/channel_linux_test.dart b/packages/desktop_drop/test/channel_linux_test.dart index c7488e35..7625d666 100644 --- a/packages/desktop_drop/test/channel_linux_test.dart +++ b/packages/desktop_drop/test/channel_linux_test.dart @@ -63,15 +63,15 @@ void main() { addTearDown( () => DesktopDrop.instance.removeRawDropEventListener(listener)); - // Simulate Flatpak drag: file:// URI + portal key from XDG Desktop Portal - await _invokePlatformMethod(const MethodCall('performOperation_linux', [ - 'file:///home/user/Documents/secret.pdf\nabc123portalkey456', + // Simulate Flatpak drag: portal key delivered via separate portal target + await _invokePlatformMethod(const MethodCall('performOperation_portal', [ + 'abc123portalkey456', [100.0, 200.0] ])); final event = events.single as DropDoneEvent; - expect(event.files.single.path, '/home/user/Documents/secret.pdf'); - expect(event.rawText, 'file:///home/user/Documents/secret.pdf\nabc123portalkey456'); + expect(event.files, isEmpty); + expect(event.rawText, 'abc123portalkey456'); }); test('linux drop includes rawText for multiple files with portal key', () async { @@ -81,9 +81,9 @@ void main() { addTearDown( () => DesktopDrop.instance.removeRawDropEventListener(listener)); - // Multiple files + portal key (typical Flatpak drag) + // 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\nxyz789portalkey', + 'file:///home/user/Documents/file1.txt\nfile:///home/user/Pictures/photo.png', [150.0, 250.0] ])); @@ -91,7 +91,7 @@ void main() { 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\nxyz789portalkey'); + 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 { @@ -167,4 +167,24 @@ void main() { 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); + }); } From 27540249d4d871c04f62d705df6d2b33d878c17d Mon Sep 17 00:00:00 2001 From: loucas monir Date: Tue, 25 Aug 2026 00:31:53 +0300 Subject: [PATCH 07/12] test: remove duplicate portal drop test The same performOperation_portal payload was asserted twice. --- .../desktop_drop/test/channel_linux_test.dart | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/desktop_drop/test/channel_linux_test.dart b/packages/desktop_drop/test/channel_linux_test.dart index 7625d666..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'; @@ -56,24 +55,6 @@ void main() { expect(event.files.single.path, '/tmp/file.txt'); }); - test('linux drop includes rawText with portal key (Flatpak/Wayland)', () async { - final events = []; - void listener(DropEvent event) => events.add(event); - DesktopDrop.instance.addRawDropEventListener(listener); - addTearDown( - () => DesktopDrop.instance.removeRawDropEventListener(listener)); - - // Simulate Flatpak drag: portal key delivered via separate portal 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'); - }); - test('linux drop includes rawText for multiple files with portal key', () async { final events = []; void listener(DropEvent event) => events.add(event); From ad3dbb52a44ccc48a76308ae9c4546916dba60ed Mon Sep 17 00:00:00 2001 From: loucas monir Date: Tue, 25 Aug 2026 00:33:54 +0300 Subject: [PATCH 08/12] fix: null-check drag target name before comparing gdk_atom_name() may return NULL; strcmp on it is undefined behavior. --- packages/desktop_drop/linux/desktop_drop_plugin.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/desktop_drop/linux/desktop_drop_plugin.cc b/packages/desktop_drop/linux/desktop_drop_plugin.cc index a51b74da..40c9aa6f 100644 --- a/packages/desktop_drop/linux/desktop_drop_plugin.cc +++ b/packages/desktop_drop/linux/desktop_drop_plugin.cc @@ -29,7 +29,8 @@ void on_drag_data_received(GtkWidget *widget, GdkDragContext *drag_context, auto args = fl_value_new_list(); // Check if this is the portal file transfer target - if (strcmp(target_name, "application/vnd.portal.filetransfer") == 0) { + if (target_name != nullptr && + strcmp(target_name, "application/vnd.portal.filetransfer") == 0) { // Portal key received - send via dedicated method fl_value_append(args, fl_value_new_string((gchar *) data)); fl_value_append(args, fl_value_new_float_list(point, 2)); From 2380a906a1546f43127c8f5f979ea0b1fd3af36b Mon Sep 17 00:00:00 2001 From: loucas monir Date: Tue, 25 Aug 2026 00:35:08 +0300 Subject: [PATCH 09/12] fix: free the drag target name after use gdk_atom_name() returns a heap-allocated string; leaking it on every drop. --- packages/desktop_drop/linux/desktop_drop_plugin.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/desktop_drop/linux/desktop_drop_plugin.cc b/packages/desktop_drop/linux/desktop_drop_plugin.cc index 40c9aa6f..d5aa50e0 100644 --- a/packages/desktop_drop/linux/desktop_drop_plugin.cc +++ b/packages/desktop_drop/linux/desktop_drop_plugin.cc @@ -22,8 +22,8 @@ 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); - GdkAtom target = gtk_selection_data_get_target(sdata); - const gchar *target_name = gdk_atom_name(target); + g_autofree gchar *target_name = + gdk_atom_name(gtk_selection_data_get_target(sdata)); auto *data = gtk_selection_data_get_data(sdata); double point[] = {double(x), double(y)}; auto args = fl_value_new_list(); From e48edb29168fd7afb5c260d4c1b187ee8f4cc9b0 Mon Sep 17 00:00:00 2001 From: loucas monir Date: Tue, 25 Aug 2026 00:36:22 +0300 Subject: [PATCH 10/12] fix: copy selection data with explicit length Selection payload is not guaranteed NUL-terminated; casting the raw buffer to a C string could read past its end. Copy exactly length bytes with g_strndup and bail out on a negative length. Also release the FlValue argument list with g_autoptr instead of leaking it. --- .../desktop_drop/linux/desktop_drop_plugin.cc | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/desktop_drop/linux/desktop_drop_plugin.cc b/packages/desktop_drop/linux/desktop_drop_plugin.cc index d5aa50e0..d706bd6a 100644 --- a/packages/desktop_drop/linux/desktop_drop_plugin.cc +++ b/packages/desktop_drop/linux/desktop_drop_plugin.cc @@ -22,27 +22,30 @@ 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); - g_autofree gchar *target_name = - gdk_atom_name(gtk_selection_data_get_target(sdata)); - auto *data = gtk_selection_data_get_data(sdata); - double point[] = {double(x), double(y)}; - auto args = fl_value_new_list(); + const gchar *method_name = "performOperation_linux"; - // Check if this is the portal file transfer target + // 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) { - // Portal key received - send via dedicated method - fl_value_append(args, fl_value_new_string((gchar *) data)); - fl_value_append(args, fl_value_new_float_list(point, 2)); - fl_method_channel_invoke_method(channel, "performOperation_portal", args, - nullptr, nullptr, nullptr); - } else { - // Standard STRING or text/uri-list target - fl_value_append(args, fl_value_new_string((gchar *) data)); - fl_value_append(args, fl_value_new_float_list(point, 2)); - fl_method_channel_invoke_method(channel, "performOperation_linux", args, - nullptr, nullptr, nullptr); + 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)}; + 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, method_name, args, + nullptr, nullptr, nullptr); } void on_drag_motion(GtkWidget *widget, GdkDragContext *drag_context, From 0aa10d5345a29f976ada016fcba3331eecd85dc8 Mon Sep 17 00:00:00 2001 From: loucas monir Date: Tue, 25 Aug 2026 00:36:45 +0300 Subject: [PATCH 11/12] docs: correct rawText payload format, update changelog GTK delivers either a URI list or a single portal transfer key, never both mixed; the previous example implied otherwise. Changelog now covers the new channel message and target registration. --- packages/desktop_drop/CHANGELOG.md | 3 +- .../desktop_drop/lib/src/drop_target.dart | 29 ++++++++++--------- packages/desktop_drop/lib/src/events.dart | 29 ++++++++++--------- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/packages/desktop_drop/CHANGELOG.md b/packages/desktop_drop/CHANGELOG.md index 544d1a44..4e3ac279 100644 --- a/packages/desktop_drop/CHANGELOG.md +++ b/packages/desktop_drop/CHANGELOG.md @@ -2,7 +2,8 @@ ## 0.8.1 -* [desktop_drop] expose raw drag text in `DropDoneEvent.rawText` and `DropDoneDetails.rawText` for XDG Desktop Portal integration on Wayland/Flatpak +* [desktop_drop] [Linux] register the `application/vnd.portal.filetransfer` drag target and deliver its key via a new `performOperation_portal` channel message, so sandboxed (Flatpak) apps can resolve dropped files through `org.freedesktop.portal.FileTransfer` +* [desktop_drop] expose the raw drag payload in `DropDoneEvent.rawText` / `DropDoneDetails.rawText` ## 0.8.0 diff --git a/packages/desktop_drop/lib/src/drop_target.dart b/packages/desktop_drop/lib/src/drop_target.dart index c1eaa783..acc3eb22 100644 --- a/packages/desktop_drop/lib/src/drop_target.dart +++ b/packages/desktop_drop/lib/src/drop_target.dart @@ -20,24 +20,27 @@ class DropDoneDetails { /// The raw text payload from the drag operation, passed through from [DropDoneEvent.rawText]. /// - /// Format: one URI or token per line (as delivered by GTK). + /// 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. /// - /// On Linux/Wayland with Flatpak, this contains the portal key(s) - /// from the `application/vnd.portal.filetransfer` mimetype — a - /// random string token that can be passed to - /// `org.freedesktop.portal.FileTransfer.RetrieveFiles` to get - /// sandbox-accessible file paths. + /// URI payload example: + /// ``` + /// file:///home/user/Documents/file.txt + /// file:///home/user/Pictures/photo.png + /// ``` /// - /// Example content: + /// Portal payload example (Flatpak source app): /// ``` - /// file:///home/user/Downloads/normal.txt - /// file:///home/user/Documents/portal.txt - /// abc123portalkey456 + /// f2c1ee0e-0547-4ea6-9c15-a9cf7dbfef98 /// ``` /// - /// The portal key is a token WITHOUT a URI scheme (e.g., `abc123key`). - /// Lines with URI schemes like `file://`, `smb://`, `http://` are NOT portal keys. - /// Consumers should parse each line as a URI and check the scheme. + /// 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). diff --git a/packages/desktop_drop/lib/src/events.dart b/packages/desktop_drop/lib/src/events.dart index 1dd0a306..3b709611 100644 --- a/packages/desktop_drop/lib/src/events.dart +++ b/packages/desktop_drop/lib/src/events.dart @@ -29,24 +29,27 @@ class DropDoneEvent extends DropEvent { /// The raw text payload from the drag operation. /// - /// Format: one URI or token per line (as delivered by GTK). + /// 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. /// - /// On Linux/Wayland with Flatpak, this contains the portal key(s) - /// from the `application/vnd.portal.filetransfer` mimetype — a - /// random string token that can be passed to - /// `org.freedesktop.portal.FileTransfer.RetrieveFiles` to get - /// sandbox-accessible file paths. + /// URI payload example: + /// ``` + /// file:///home/user/Documents/file.txt + /// file:///home/user/Pictures/photo.png + /// ``` /// - /// Example content: + /// Portal payload example (Flatpak source app): /// ``` - /// file:///home/user/Downloads/normal.txt - /// file:///home/user/Documents/portal.txt - /// abc123portalkey456 + /// f2c1ee0e-0547-4ea6-9c15-a9cf7dbfef98 /// ``` /// - /// The portal key is a token WITHOUT a URI scheme (e.g., `abc123key`). - /// Lines with URI schemes like `file://`, `smb://`, `http://` are NOT portal keys. - /// Consumers should parse each line as a URI and check the scheme. + /// 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). From 30ad21875e7ae366c32c6158338dcb0c8a410ba6 Mon Sep 17 00:00:00 2001 From: loucas monir Date: Tue, 25 Aug 2026 00:48:54 +0300 Subject: [PATCH 12/12] feat: resolve portal transfer keys to readable file paths under /run/user/doc/* When a drop is negotiated as application/vnd.portal.filetransfer, the payload is a one-time key rather than a path. Ask org.freedesktop.portal.FileTransfer.RetrieveFiles for that key; the document portal exports each dropped file and returns paths that are readable both inside and outside a Flatpak sandbox. Consumers receive ordinary DropItemFile paths as before, so no changes are needed in apps using desktop_drop. On resolution failure the event still fires with an empty file list, and rawText keeps the key. --- packages/desktop_drop/CHANGELOG.md | 3 +- packages/desktop_drop/lib/src/channel.dart | 47 +++++++++++++++++++--- packages/desktop_drop/pubspec.yaml | 1 + 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/desktop_drop/CHANGELOG.md b/packages/desktop_drop/CHANGELOG.md index 4e3ac279..c1f26dfe 100644 --- a/packages/desktop_drop/CHANGELOG.md +++ b/packages/desktop_drop/CHANGELOG.md @@ -2,7 +2,8 @@ ## 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, so sandboxed (Flatpak) apps can resolve dropped files through `org.freedesktop.portal.FileTransfer` +* [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 diff --git a/packages/desktop_drop/lib/src/channel.dart b/packages/desktop_drop/lib/src/channel.dart index 60487106..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'; @@ -160,14 +161,17 @@ class DesktopDrop { )); break; case "performOperation_portal": - // Portal file transfer key received (application/vnd.portal.filetransfer) - // The key is passed as raw text, no parsing needed + // 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 portalOffset = + ((call.arguments as List)[1] as List) + .cast(); + final paths = await _resolvePortalFiles(portalText); _notifyEvent(DropDoneEvent( location: Offset(portalOffset[0], portalOffset[1]), - files: const [], + files: paths.map((e) => DropItemFile(e)).toList(), rawText: portalText, )); break; @@ -187,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/pubspec.yaml b/packages/desktop_drop/pubspec.yaml index 43d22491..026257ae 100644 --- a/packages/desktop_drop/pubspec.yaml +++ b/packages/desktop_drop/pubspec.yaml @@ -9,6 +9,7 @@ environment: flutter: ">=3.0.0" dependencies: + dbus: ^0.7.10 flutter: sdk: flutter flutter_web_plugins: