Skip to content

Accept a file dropped anywhere, and say why one is refused - #612

Merged
thcp merged 2 commits into
mainfrom
fix/app-wide-file-drop
Sep 9, 2026
Merged

thcp merged 2 commits into
mainfrom
fix/app-wide-file-drop

Conversation

@thcp

@thcp thcp commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #584

The problem

Dropping an audio file anywhere except the URL zone was left to the browser, which navigates to it. In a tab that is merely surprising. In the desktop shell there is no address bar and no back button, so the window is replaced by the webview's bare media player and the only way out is to quit, which is exactly what @MetalMan1245 hit with a .ogg.

The fix

A file dropped anywhere in the window is now treated as a drop on the URL zone.

Preventing the default is what stops the navigation. Routing it to applyFiles is what makes the gesture do the obvious thing instead of nothing, which is what you asked for and is the better answer than simply blocking it. The URL zone keeps its hover highlight, so it still shows where the file is going.

Both handlers are guarded on the drag carrying Files, so the library's own drags (tracks between folders, into the lanes, onto the trash) are untouched. Their handlers already bail before preventDefault when the drag is not theirs, so the two never see each other's gestures.

Refusals answer in the box

A file the importer declines now says so in the URL box, in red, rather than in the error panel. That panel is for a job that failed: it takes over a region and carries a retry button, and a file declined before anything started has nothing to retry.

Both hard refusals use it, unsupported format and too large, because two different mechanisms for two refusals in the same gesture is how the next report gets written. The partial-skip case still uses the panel, since files were accepted there and it is a note rather than a refusal.

It clears on the next drop, on typing, and after six seconds, so it can never sit beside an armed file describing something else.

No new i18n keys: upload.unsupportedFormat and upload.fileTooLarge already exist in all ten tables.

A note on the tests

The navigation itself cannot be asserted. A synthetic drop never triggers the browser's real navigation, so a page.url() check passes with the fix ripped out and proves nothing. I wrote that test first and the mutation run caught it. The tests assert the drop's default was cancelled instead, which is the mechanism that actually prevents it.

Verified against the previous behaviour: four of the seven fail.

Found in passing

static/css/widgets.css is not loaded by index.html and is referenced by nothing in the repo. The styling for this went in there first and was completely invisible; the test caught it only because it asserts the computed colour rather than the presence of a class. Left alone here, but it wants a look.

Thales added 2 commits September 9, 2026 15:44
Dropping an audio file anywhere except the URL zone was left to the browser,
which navigates to it. In a tab that is merely surprising. In the desktop
shell there is no address bar and no back button, so the window is replaced by
the webview's bare media player and the only way out is to quit the app, which
is what the reporter hit with a .ogg.

A file dropped anywhere in the window is now treated as a drop on the URL
zone. Preventing the default is what stops the navigation; routing it to
applyFiles is what makes the gesture do the obvious thing rather than nothing.
The URL zone keeps its hover highlight, so it still shows where the file is
going.

Both handlers are guarded on the drag carrying "Files", so the library's own
drags -- tracks between folders, into the lanes, onto the trash -- are
untouched. Their handlers already bail before preventDefault when the drag is
not theirs, so the two never see each other's gestures.

A refused file now answers in the box it was aimed at, in red, rather than in
the error panel. That panel is for a job that failed: it takes over a region
and carries a retry button, and a file declined before anything started has
nothing to retry. Both hard refusals use it, unsupported format and too large,
because two different mechanisms for two refusals in the same gesture is how
the next report gets written. The partial-skip case still uses the panel:
files were accepted there, so it is a note rather than a refusal.

The message clears on the next drop, on typing, and after six seconds, so it
can never sit beside an armed file describing something else.

Found while doing this, and worth knowing: static/css/widgets.css is not
loaded by index.html and is referenced by nothing. The styling for this went
in there first and was invisible, which the test caught only because it
asserts the computed colour rather than the presence of a class. Left alone
here; it wants its own look.

tests/e2e/file-drop.spec.mjs covers a drop on the body arming the import, the
same with a track open, the refusal being visible and red and hiding the
input, a refusal clearing when something valid arrives or when the user types,
and a library drag not being mistaken for a file.

The navigation itself cannot be asserted: a synthetic drop never triggers the
browser's real navigation, so a page.url() check passes with the fix removed
and proves nothing. The tests assert the drop's default was cancelled instead,
which is the mechanism that prevents it. Verified against the previous
behaviour: four of the seven fail.

Closes #584
The rule that hides the input while a refusal is showing used :has(). It reads
better than the alternative, and a sibling combinator cannot do the job at all
because the message follows the input in the markup.

But this ships in three webviews and the oldest is the one that matters:
WebKitGTK only grew :has() in 2.38, and Linux is exactly where #584 was
reported from. On an older WebKitGTK the message and the input would both
render and compete for the same flex space. Degraded rather than broken, and
avoidable for one line.

A class toggled alongside the message works in every engine. The codebase had
no other :has() before this, so this keeps it at none.

The rest of the change was already portable: HTML5 drag-and-drop with
preventDefault is the same in WebView2, WKWebView and WebKitGTK, and
dragDropEnabled is false in the single tauri.conf.json with no per-platform
override, so the webview receives the events on all three.
@thcp
thcp merged commit 37926e4 into main Sep 9, 2026
10 checks passed
@thcp
thcp deleted the fix/app-wide-file-drop branch September 9, 2026 15:19
thcp added a commit that referenced this pull request Sep 23, 2026
…hem (#680)

Dropping a file on the Linux desktop app turned the window into a bare media
player playing it, with only a right click and Back to get out. Dropping it
on the URL box typed its file:// URI into the box, and pressing Process then
failed with "URL must use http or https" (#672).

Both come from one place. WebKitGTK reports a drag from a GTK file manager as
text/uri-list and text/html, never as "Files", so the drop guard added in
#612, which keys on "Files", never recognised it and WebKit's default ran:
navigate to the file, or insert the URI into an editable target. The guard
works on Windows, macOS and in Chromium, which do report "Files", which is
why it was never seen there.

Widening the guard was not a fix. Measured against a real drag into
WebKitGTK 2.52, a cancelled drop reaches the page with no File objects, no
file items, and the path blanked out of text/uri-list. The page cannot get
the file at all, only stop the navigation.

So on Linux the main window's native drop handler is on and the drop is taken
in the shell. Tauri's handler returns true on a drop, which consumes it before
WebKit's default runs, and wry only claims drags carrying a file uri-list, so
the library's own HTML5 drags between folders, lanes and the trash are left
alone. That was the reason the handler is off everywhere (#24): on Windows,
WebView2 stops delivering HTML5 drags while it is on. It stays off on Windows
and macOS, where the page already gets the files.

The window is now built in setup from its tauri.conf.json entry, marked
"create": false, with that one field set per platform. A per-platform config
file would have needed a second full copy of the window, since its merge
replaces arrays rather than patching them.

No command takes a path from JavaScript. The page is a remote origin to
Tauri, so app commands are callable from it without an ACL, and dragout and
download_to_path already keep paths on the Rust side for that reason. A drop
is recorded against opaque ids; the page is told a name and a size for each,
and can read back a file the user dropped, once, by id. Only the latest drop
is readable, a folder or a vanished file is refused, and the read is capped
at the upload limit against the open handle so it cannot be raced past.

The page cannot listen to Tauri events without a remote capability, and
granting one would open every core event command to it. It already talks to
the shell only through commands, so it waits on next_drop_signal instead. The
signals are a log read by cursor rather than a queue that is popped: a reload
leaves the old page's call waiting with nobody to answer, and a popped queue
would hand it the next drop.

On the page, applyFiles is split into screening and staging. A native drop is
screened on its names and sizes first, so a file that will be refused as not
audio or too large is never read, and only accepted files are fetched, one at
a time, as raw bytes. From there it is the same path as the picker: same
refusals, same pill, same staging. A file that has gone by the time it is read
says so by name, in all ten languages.

The upload limit now exists in three languages, so a test holds the server,
the page and the shell to the same number. The first two were already
duplicated with only a comment saying they must match.

Verified in a real Tauri 2.11 shell around this module on Linux, driving
genuine XDND drags from a separate GTK process: a drop on the page and a drop
on a text box both arrive as ids with the file's real bytes, nothing
navigates, nothing is typed into the box, a second read of an id is refused,
and an internal drag with the library's own data types still lands on its
target. The Windows build still opens its window.

Co-authored-by: Thales <>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Dragging a music file into StemDeck will cause it to open a play window with no recourse but to close the application.

1 participant