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
6 changes: 6 additions & 0 deletions packages/desktop_drop/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down
1 change: 1 addition & 0 deletions packages/desktop_drop/lib/desktop_drop.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'src/drop_target.dart';
export 'src/drop_item.dart';
export 'src/channel.dart';
export 'src/events.dart';
54 changes: 53 additions & 1 deletion packages/desktop_drop/lib/src/channel.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -154,6 +157,22 @@ class DesktopDrop {
_notifyEvent(DropDoneEvent(
location: Offset(offset[0], offset[1]),
files: paths.map((e) => DropItemFile(e)).toList(),
rawText: text,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One GTK 3 detail changes the diagnosis here: since GTK 3.24.37, gtk_drag_dest_add_uri_targets() already adds both application/vnd.portal.filetransfer and the legacy application/vnd.portal.files when the FileTransfer portal is available. Therefore the portal payload can reach this callback on current GTK without another explicit target entry.

There is still a negotiation problem worth addressing. gtk_drag_dest_find_target() selects the first destination target also offered by the source, while this plugin registers STRING first and gtk_target_list_add_uri_targets() appends text/uri-list before the portal targets. If a source offers both URI and portal representations, the inaccessible URI representation may still win. The native layer should prefer the portal target and pass the selected MIME type alongside its payload so Dart does not infer the type from the text.

The tests should also use the payloads GTK can actually deliver: either a URI list or a key-only portal payload. A combined file://...\nportal-key payload is not produced by MIME negotiation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the detailed review @boyan01,pushed fixes for all of it:

  • target name is NULL-checked now (gdk_atom_name can return null) and freed with g_autofree
  • selection data gets copied with g_strndup using the actual length instead of trusting a null terminator
  • docs fixed: smb:// would've been treated as a portal key the way I had it. keys are detected by Uri.hasScheme now, and GTK never actually mixes uris + key in one payload so the example was wrong too
  • toString() doesn't print rawText anymore, no point leaking a one-time key into logs
  • bumped pubspec to 0.8.1 to match the changelog
  • tests use realistic payloads now (uris only or key only) + one for performOperation_portal

also went one step further: performOperation_portal resolves the key through org.freedesktop.portal.FileTransfer.RetrieveFiles and emits the document portal paths (/run/user/$UID/doc/...) as regular DropItemFiles.

reason: since the portal target wins negotiation now, old apps that don't know about rawText would get an empty file list where they used to at least get paths. resolving in the plugin keeps files meaning "openable paths" so nobody has to change their code, and doc paths are readable both inside sandboxes (per-app grant) and outside.

rawText still has the key if an app wants it, and if RetrieveFiles fails the event just fires with an empty list. adds dbus as a pure dart dep — happy to move this into native GDBus if you'd rather keep dart deps out.

));
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<dynamic>)[0] as String;
final portalOffset =
((call.arguments as List<dynamic>)[1] as List<dynamic>)
.cast<double>();
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":
Expand All @@ -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<List<String>> _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);
Expand Down
30 changes: 30 additions & 0 deletions packages/desktop_drop/lib/src/drop_target.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,40 @@ class DropDoneDetails {
required this.files,
required this.localPosition,
required this.globalPosition,
this.rawText,
});

final List<DropItem> 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 {
Expand Down Expand Up @@ -166,6 +195,7 @@ class _DropTargetState extends State<DropTarget> {
files: event.files,
localPosition: position,
globalPosition: globalPosition,
rawText: event.rawText,
));
}
}
Expand Down
31 changes: 30 additions & 1 deletion packages/desktop_drop/lib/src/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,42 @@ class DropUpdateEvent extends DropEvent {
class DropDoneEvent extends DropEvent {
final List<DropItem> 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'})';
}
}
30 changes: 25 additions & 5 deletions packages/desktop_drop/linux/desktop_drop_plugin.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlMethodChannel *>(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<const gchar *>(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);
}

Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion packages/desktop_drop/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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:
sdk: ^3.5.0
flutter: ">=3.0.0"

dependencies:
dbus: ^0.7.10
flutter:
sdk: flutter
flutter_web_plugins:
Expand Down
115 changes: 114 additions & 1 deletion packages/desktop_drop/test/channel_linux_test.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 = <DropEvent>[];
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 = <DropEvent>[];
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 = <DropEvent>[];
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 = <DropEvent>[];
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 = <DropEvent>[];
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 = <DropEvent>[];
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);
});
}
Loading