From f7e64034b56bf07135b0a472c16c7f9cdf0416f9 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 23 Sep 2026 13:41:24 +0100 Subject: [PATCH] Take file drops on Linux in the shell, where the WebView cannot see them 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. --- desktop/src-tauri/src/dropin.rs | 428 ++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 85 +++++- desktop/src-tauri/tauri.conf.json | 2 + static/js/i18n.js | 11 + static/js/main.js | 112 +++++++- tests/e2e/native-file-drop.spec.mjs | 212 ++++++++++++++ tests/test_upload_limit_parity.py | 54 ++++ 7 files changed, 895 insertions(+), 9 deletions(-) create mode 100644 desktop/src-tauri/src/dropin.rs create mode 100644 tests/e2e/native-file-drop.spec.mjs create mode 100644 tests/test_upload_limit_parity.py diff --git a/desktop/src-tauri/src/dropin.rs b/desktop/src-tauri/src/dropin.rs new file mode 100644 index 00000000..056bae32 --- /dev/null +++ b/desktop/src-tauri/src/dropin.rs @@ -0,0 +1,428 @@ +//! Files dragged into StemDeck from the desktop, where the WebView cannot see +//! them. +//! +//! On Windows and macOS an OS file drop reaches the page as an ordinary HTML5 +//! `drop` with real `File` objects, and `main.js` handles it there. WebKitGTK, +//! the Linux WebView, does not do that. A drag from a GTK file manager reports +//! its types as `text/uri-list` and `text/html`, never `Files`; at drop time +//! `dataTransfer.files` is empty and the `file://` URI is blanked out of +//! `text/uri-list`. There is no way for the page to get the file. What it does +//! get, if it does nothing, is WebKit's default action: the window navigates +//! to the file and becomes a bare media player with no way back, or a drop on +//! the URL box types the file's URI into it (#672). +//! +//! So on Linux the native drop handler is switched on for the main window (see +//! [`NATIVE_FILE_DROP`]), and the drop is taken here instead. Tauri's handler +//! returns `true` on a drop, which consumes it before WebKit's default runs, +//! and wry only claims drags that carry a file `text/uri-list`, so the +//! library's own HTML5 drags between folders, lanes and the trash are not +//! affected. +//! +//! ## Paths stay on this side +//! +//! The page is served over http by the Python backend, which Tauri treats as a +//! remote origin, so app-defined commands are callable from it without an ACL. +//! The rule that follows, and that `dragout` and `download_to_path` already +//! keep, is that no command accepts a filesystem path from JavaScript. Here a +//! drop is recorded against opaque ids and the page is told only a name and a +//! size for each. It can read back a file the user dropped, once, by id, and +//! nothing else. +//! +//! ## Why a waiting command rather than an event +//! +//! A remote origin cannot `listen` to Tauri events without a remote capability, +//! and granting one would open every core event command to the page. The page +//! already talks to this process only through commands, so it asks for the +//! next drop signal and the command resolves when there is one. + +use std::collections::{HashMap, VecDeque}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{Condvar, Mutex}; +use std::time::Duration; + +use serde::Serialize; + +/// Whether the main window takes OS file drops natively. +/// +/// Linux only. On Windows, WebView2 stops delivering HTML5 `dragover` and +/// `drop` to the page while the native handler is on, which broke the +/// library's own drags and is why `tauri.conf.json` turns it off (#24). On +/// macOS the HTML5 path already carries the files. Neither needs this module. +pub const NATIVE_FILE_DROP: bool = cfg!(target_os = "linux"); + +/// The largest file that will be read back into the page. +/// +/// Mirrors the server's `_MAX_UPLOAD_BYTES` and `MAX_UPLOAD_BYTES` in +/// `static/js/main.js`; `tests/test_upload_limit_parity.py` holds all three to +/// the same number. The page refuses larger files before it asks for them, so +/// this is the backstop that keeps a stale or hostile caller from reading an +/// arbitrarily large file into memory. +pub const MAX_DROPPED_FILE_BYTES: u64 = 400 * 1024 * 1024; + +/// How long `next_signal` waits before answering "nothing yet". +/// +/// Long enough that an idle window costs almost nothing, short enough that a +/// waiting command never outlives the app by much when it quits. +pub const WAIT: Duration = Duration::from_secs(25); + +/// Signals kept for the page to catch up on. Nothing older than a handful is +/// still useful, and a page that has stopped asking must not make this grow. +const LOG_LIMIT: usize = 16; + +/// What the page is told about one dropped file. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DroppedFile { + pub id: u64, + pub name: String, + pub size: u64, +} + +/// One thing that happened to a drag over the window. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum DropSignal { + Enter, + Leave, + Drop { files: Vec }, +} + +/// The answer to "anything since `after`?". +/// +/// `seq` is always the cursor to ask from next, including when there is no +/// signal, so a page that has only just started never misses one that lands +/// between two of its calls. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct NextSignal { + pub seq: u64, + pub signal: Option, +} + +#[derive(Default)] +struct Inner { + next_id: u64, + /// Files from the most recent drop that the page has not read yet. + pending: HashMap, + /// Sequence number of the newest signal; 0 before there has been one. + seq: u64, + log: VecDeque<(u64, DropSignal)>, +} + +/// Where native drops wait for the page. +/// +/// A log read by cursor rather than a queue that is popped. A reload leaves +/// the old page's call still waiting in here with nobody left to answer, and a +/// popped queue would hand it the next drop, which the new page would never +/// see. With a cursor, every caller sees every signal after its own position. +#[derive(Default)] +pub struct DropInbox { + inner: Mutex, + ready: Condvar, +} + +impl DropInbox { + /// Every change to `Inner` is a single step, so a panic elsewhere while the + /// lock was held cannot have left it half-updated. Carrying on with the + /// data is correct, and it keeps drops from being refused for good because + /// of an unrelated failure. + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn publish(&self, inner: &mut Inner, signal: DropSignal) { + inner.seq += 1; + let seq = inner.seq; + if inner.log.len() >= LOG_LIMIT { + inner.log.pop_front(); + } + inner.log.push_back((seq, signal)); + self.ready.notify_all(); + } + + pub fn enter(&self) { + let mut inner = self.lock(); + self.publish(&mut inner, DropSignal::Enter); + } + + pub fn leave(&self) { + let mut inner = self.lock(); + self.publish(&mut inner, DropSignal::Leave); + } + + /// Records a drop and publishes it. + /// + /// Replaces whatever an earlier drop left unread: the page only ever acts + /// on the latest drop, so older paths would sit here for the life of the + /// app, still readable, for no reason. + pub fn drop_paths(&self, paths: &[PathBuf]) -> Vec { + // Looked at before taking the lock: a stat can be slow on a network + // mount, and nothing else needs to wait for it. + let described: Vec<(String, u64)> = paths + .iter() + .map(|path| { + ( + display_name(path), + // A folder, or something gone by the time it is looked + // at, reports 0. The page screens by name and size, so a + // folder is refused as "not audio" as the HTML5 path does. + fs::metadata(path) + .ok() + .filter(|m| m.is_file()) + .map_or(0, |m| m.len()), + ) + }) + .collect(); + + let mut inner = self.lock(); + inner.pending.clear(); + let mut files = Vec::with_capacity(paths.len()); + for (path, (name, size)) in paths.iter().zip(described) { + inner.next_id += 1; + let id = inner.next_id; + inner.pending.insert(id, path.clone()); + files.push(DroppedFile { id, name, size }); + } + self.publish( + &mut inner, + DropSignal::Drop { + files: files.clone(), + }, + ); + files + } + + /// The first signal after `after`, waiting up to `wait` for one. + /// + /// `after: None` means "from now", for a page that has no cursor yet. + pub fn next_signal(&self, after: Option, wait: Duration) -> NextSignal { + let inner = self.lock(); + let after = after.unwrap_or(inner.seq); + let (inner, _) = self + .ready + .wait_timeout_while(inner, wait, |i| i.seq <= after) + .unwrap_or_else(std::sync::PoisonError::into_inner); + match inner.log.iter().find(|(seq, _)| *seq > after) { + Some((seq, signal)) => NextSignal { + seq: *seq, + signal: Some(signal.clone()), + }, + None => NextSignal { + seq: inner.seq.max(after), + signal: None, + }, + } + } + + /// Hands over the path behind `id`, once. + pub fn take(&self, id: u64) -> Option { + self.lock().pending.remove(&id) + } +} + +/// The name the page shows for a dropped path. Only the last component: the +/// directory it came from is none of the page's business. +fn display_name(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// Reads a dropped file, refusing anything that is not a regular file within +/// `max` bytes. +/// +/// The size is checked against the open handle rather than a separate stat, +/// and the read is capped as well, so a file that grows between the two cannot +/// get past the limit. +pub fn read_dropped(path: &Path, max: u64) -> Result, String> { + let file = fs::File::open(path).map_err(|e| format!("could not open the file: {e}"))?; + let meta = file + .metadata() + .map_err(|e| format!("could not read the file: {e}"))?; + if !meta.is_file() { + return Err("not a file".to_string()); + } + if meta.len() > max { + return Err(format!("file is larger than {max} bytes")); + } + let mut bytes = Vec::with_capacity(meta.len() as usize); + file.take(max + 1) + .read_to_end(&mut bytes) + .map_err(|e| format!("could not read the file: {e}"))?; + if bytes.len() as u64 > max { + return Err(format!("file is larger than {max} bytes")); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn file_with(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf { + let path = dir.join(name); + fs::File::create(&path).unwrap().write_all(bytes).unwrap(); + path + } + + #[test] + fn a_drop_tells_the_page_names_and_sizes_but_not_paths() { + let dir = tempfile::tempdir().unwrap(); + let a = file_with(dir.path(), "Take 3.flac", b"abcd"); + let inbox = DropInbox::default(); + + let files = inbox.drop_paths(&[a]); + + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "Take 3.flac"); + assert_eq!(files[0].size, 4); + // The serialised form is what crosses to the page. + let json = serde_json::to_string(&inbox.next_signal(Some(0), WAIT)).unwrap(); + assert!(json.contains("\"kind\":\"drop\""), "{json}"); + assert!(!json.contains(dir.path().to_str().unwrap()), "{json}"); + } + + #[test] + fn a_folder_is_reported_with_no_size_and_cannot_be_read() { + let dir = tempfile::tempdir().unwrap(); + let inbox = DropInbox::default(); + + let files = inbox.drop_paths(&[dir.path().to_path_buf()]); + + assert_eq!(files[0].size, 0); + let path = inbox.take(files[0].id).unwrap(); + // Refused either way, by a different step per platform: Windows will + // not open a directory at all, and Linux opens it and then fails the + // regular-file check. + assert!(read_dropped(&path, MAX_DROPPED_FILE_BYTES).is_err()); + } + + #[test] + fn a_dropped_file_can_be_read_once() { + let dir = tempfile::tempdir().unwrap(); + let a = file_with(dir.path(), "a.wav", b"RIFF"); + let inbox = DropInbox::default(); + let id = inbox.drop_paths(&[a])[0].id; + + let path = inbox.take(id).expect("first read is allowed"); + assert_eq!( + read_dropped(&path, MAX_DROPPED_FILE_BYTES).unwrap(), + b"RIFF" + ); + assert!(inbox.take(id).is_none(), "a second read is not"); + } + + #[test] + fn an_id_that_was_never_dropped_reads_nothing() { + let inbox = DropInbox::default(); + assert!(inbox.take(1).is_none()); + assert!(inbox.take(u64::MAX).is_none()); + } + + #[test] + fn a_new_drop_retires_what_the_last_one_left_unread() { + let dir = tempfile::tempdir().unwrap(); + let a = file_with(dir.path(), "a.wav", b"a"); + let b = file_with(dir.path(), "b.wav", b"b"); + let inbox = DropInbox::default(); + + let first = inbox.drop_paths(&[a])[0].id; + let second = inbox.drop_paths(&[b])[0].id; + + assert_ne!(first, second, "ids are never reused"); + assert!(inbox.take(first).is_none()); + assert!(inbox.take(second).is_some()); + } + + /// Reads everything after `from`, the way the page walks the log. + fn drain(inbox: &DropInbox, mut from: u64) -> Vec { + let mut got = vec![]; + loop { + let next = inbox.next_signal(Some(from), Duration::from_millis(1)); + match next.signal { + Some(signal) => got.push(signal), + None => return got, + } + from = next.seq; + } + } + + #[test] + fn signals_arrive_in_order() { + let inbox = DropInbox::default(); + inbox.enter(); + inbox.leave(); + inbox.enter(); + inbox.drop_paths(&[]); + + assert_eq!( + drain(&inbox, 0), + vec![ + DropSignal::Enter, + DropSignal::Leave, + DropSignal::Enter, + DropSignal::Drop { files: vec![] }, + ] + ); + } + + #[test] + fn waiting_with_nothing_to_say_still_returns_a_cursor() { + let inbox = DropInbox::default(); + inbox.enter(); + let next = inbox.next_signal(None, Duration::from_millis(20)); + assert_eq!( + next, + NextSignal { + seq: 1, + signal: None + } + ); + } + + #[test] + fn a_waiting_caller_is_woken_by_a_drop() { + let inbox = std::sync::Arc::new(DropInbox::default()); + let waiter = { + let inbox = inbox.clone(); + std::thread::spawn(move || inbox.next_signal(Some(0), Duration::from_secs(10))) + }; + std::thread::sleep(Duration::from_millis(50)); + inbox.enter(); + assert_eq!(waiter.join().unwrap().signal, Some(DropSignal::Enter)); + } + + #[test] + fn a_stale_caller_does_not_take_the_signal_from_a_new_one() { + // A reload leaves the old page's call waiting. The drop that follows + // has to reach the new page as well, not only the caller that is gone. + let inbox = DropInbox::default(); + let old_page = inbox.next_signal(None, Duration::from_millis(1)).seq; + let new_page = inbox.next_signal(None, Duration::from_millis(1)).seq; + inbox.drop_paths(&[]); + + assert!(inbox.next_signal(Some(old_page), WAIT).signal.is_some()); + assert!(inbox.next_signal(Some(new_page), WAIT).signal.is_some()); + } + + #[test] + fn a_page_that_stops_asking_cannot_grow_the_log() { + let inbox = DropInbox::default(); + for _ in 0..(LOG_LIMIT * 4) { + inbox.enter(); + } + assert_eq!(drain(&inbox, 0).len(), LOG_LIMIT); + } + + #[test] + fn a_file_over_the_limit_is_refused_without_reading_it() { + let dir = tempfile::tempdir().unwrap(); + let a = file_with(dir.path(), "a.wav", b"12345"); + assert!(read_dropped(&a, 4).unwrap_err().contains("larger")); + assert_eq!(read_dropped(&a, 5).unwrap(), b"12345"); + } +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 731860d2..b60c18fc 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -1,5 +1,6 @@ mod certs; mod dragout; +mod dropin; use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; @@ -358,12 +359,30 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::default().build()) .setup(|app| { + // The main window is built here rather than by Tauri from + // tauri.conf.json, because one of its settings has to differ by + // platform and the config file has no way to say so. The config + // entry is still the only description of the window (it is marked + // "create": false); this takes it as it is and changes that one + // field. A per-platform config file would have meant a second copy + // of the whole window to keep in step, since its merge replaces + // arrays rather than patching them. + let mut main_window = app + .config() + .app + .windows + .iter() + .find(|w| w.label == "main") + .cloned() + .ok_or("tauri.conf.json describes no main window")?; + main_window.drag_drop_enabled = dropin::NATIVE_FILE_DROP; + let window = + tauri::WebviewWindowBuilder::from_config(app.handle(), &main_window)?.build()?; + // Before anything else: a minimum the screen cannot satisfy leaves // a window the user cannot resize down to fit, and that is not // recoverable from inside the app. - if let Some(window) = app.get_webview_window("main") { - fit_min_size_to_screen(&window); - } + fit_min_size_to_screen(&window); let data_dir = match local_data_dir() { Ok(d) => d, @@ -442,6 +461,22 @@ fn main() { Ok(()) }) .manage(BackendState::default()) + .manage(dropin::DropInbox::default()) + // Only ever fires where the native drop handler is on, which is Linux + // (see dropin::NATIVE_FILE_DROP). Elsewhere the page handles drops. + .on_window_event(|window, event| { + if let tauri::WindowEvent::DragDrop(drop) = event { + let inbox = window.state::(); + match drop { + tauri::DragDropEvent::Enter { .. } => inbox.enter(), + tauri::DragDropEvent::Drop { paths, .. } => { + inbox.drop_paths(paths); + } + tauri::DragDropEvent::Leave => inbox.leave(), + _ => {} + } + } + }) .invoke_handler(tauri::generate_handler![ probe_runtime, ensure_workspace, @@ -460,6 +495,9 @@ fn main() { local_ip, build_target, open_url, + native_file_drop, + next_drop_signal, + read_dropped_file, save_audio_file, pick_export_destination, download_to_path, @@ -3034,6 +3072,47 @@ fn verify_cuda_torch(python: &Path) -> bool { } } +/// Whether OS file drops arrive here rather than in the page. See `dropin`. +#[tauri::command] +fn native_file_drop() -> bool { + dropin::NATIVE_FILE_DROP +} + +/// The first drag signal after `after`, waiting for one if there is none yet. +/// `after` is the `seq` of the previous answer, or absent for "from now". +#[tauri::command] +async fn next_drop_signal( + app: tauri::AppHandle, + after: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + app.state::() + .next_signal(after, dropin::WAIT) + }) + .await + .map_err(|e| e.to_string()) +} + +/// The bytes of one file from the latest drop, by the id the drop reported. +/// +/// Takes an id, never a path: see "Paths stay on this side" in `dropin`. Each +/// id can be read once, and only the latest drop's ids are readable at all. +/// The bytes go back raw rather than as JSON, so a 400 MB file is not also +/// turned into a 1.4 GB array of numbers on the way. +#[tauri::command] +async fn read_dropped_file(app: tauri::AppHandle, id: u64) -> Result { + let path = app + .state::() + .take(id) + .ok_or("that file is no longer available")?; + let bytes = tauri::async_runtime::spawn_blocking(move || { + dropin::read_dropped(&path, dropin::MAX_DROPPED_FILE_BYTES) + }) + .await + .map_err(|e| e.to_string())??; + Ok(tauri::ipc::Response::new(bytes)) +} + /// Opens an http/https URL in the system browser. Rejects non-http schemes. #[tauri::command] fn open_url(url: String) -> Result<(), String> { diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index fe343108..5604ffd7 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -12,6 +12,8 @@ "withGlobalTauri": true, "windows": [ { + "label": "main", + "create": false, "title": "StemDeck", "width": 1280, "height": 820, diff --git a/static/js/i18n.js b/static/js/i18n.js index 3072efae..795ee246 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -762,6 +762,7 @@ const en = { "job.startFailed": "Failed to start job: {message}", "upload.unsupportedFormat": "Only MP3, WAV, FLAC, MP4, M4A, OGG, and Opus files are supported.", "upload.fileTooLarge": "File is too large ({size}). Maximum is {max}.", + "upload.dropReadFailed": "Could not read {name}. It may have been moved or deleted.", "upload.skippedFiles": "Skipped {count} files ({reason}).", "upload.skippedFiles.one": "Skipped {count} file ({reason}).", "upload.skippedFiles.other": "Skipped {count} files ({reason}).", @@ -1376,6 +1377,7 @@ const pl = { "job.startFailed": "Nie udało się uruchomić zadania: {message}", "upload.unsupportedFormat": "Obsługiwane są tylko pliki MP3, WAV, FLAC, MP4, M4A, OGG i Opus.", "upload.fileTooLarge": "Plik jest za duży ({size}). Maksimum to {max}.", + "upload.dropReadFailed": "Nie udało się odczytać {name}. Mógł zostać przeniesiony lub usunięty.", "upload.skippedFiles.one": "Pominięto {count} plik ({reason}).", "upload.skippedFiles.few": "Pominięto {count} pliki ({reason}).", "upload.skippedFiles.many": "Pominięto {count} plików ({reason}).", @@ -1971,6 +1973,7 @@ const ja = { "job.startFailed": "ジョブの開始に失敗しました: {message}", "upload.unsupportedFormat": "対応しているのはMP3、WAV、FLAC、MP4、M4A、OGG、Opusファイルのみです。", "upload.fileTooLarge": "ファイルが大きすぎます({size})。最大は{max}です。", + "upload.dropReadFailed": "{name} を読み込めませんでした。移動または削除された可能性があります。", "upload.skippedFiles.other": "{count}個のファイルをスキップしました({reason})。", "upload.reasonTooLarge": "大きすぎるか、音声ではありません", "upload.reasonNotAudio": "音声ではありません", @@ -2554,6 +2557,7 @@ const zhHans = { "job.startFailed": "启动任务失败:{message}", "upload.unsupportedFormat": "仅支持 MP3、WAV、FLAC、MP4、M4A、OGG 和 Opus 文件。", "upload.fileTooLarge": "文件过大({size})。最大为 {max}。", + "upload.dropReadFailed": "无法读取 {name}。它可能已被移动或删除。", "upload.skippedFiles.other": "已跳过 {count} 个文件({reason})。", "upload.reasonTooLarge": "过大或不是音频", "upload.reasonNotAudio": "不是音频", @@ -3144,6 +3148,7 @@ const de = { "job.startFailed": "Starten des Auftrags fehlgeschlagen: {message}", "upload.unsupportedFormat": "Es werden nur MP3-, WAV-, FLAC-, MP4-, M4A-, OGG- und Opus-Dateien unterstützt.", "upload.fileTooLarge": "Datei ist zu groß ({size}). Maximum ist {max}.", + "upload.dropReadFailed": "{name} konnte nicht gelesen werden. Die Datei wurde möglicherweise verschoben oder gelöscht.", "upload.skippedFiles.one": "{count} Datei übersprungen ({reason}).", "upload.skippedFiles.other": "{count} Dateien übersprungen ({reason}).", "upload.reasonTooLarge": "zu groß oder kein Audio", @@ -3738,6 +3743,7 @@ const pt = { "job.startFailed": "Falha ao iniciar a tarefa: {message}", "upload.unsupportedFormat": "Apenas arquivos MP3, WAV, FLAC, MP4, M4A, OGG e Opus são compatíveis.", "upload.fileTooLarge": "O arquivo é muito grande ({size}). O máximo é {max}.", + "upload.dropReadFailed": "Não foi possível ler {name}. Ele pode ter sido movido ou excluído.", "upload.skippedFiles.one": "{count} arquivo ignorado ({reason}).", "upload.skippedFiles.other": "{count} arquivos ignorados ({reason}).", "upload.reasonTooLarge": "muito grande ou não é áudio", @@ -4327,6 +4333,7 @@ const id = { "job.startFailed": "Gagal memulai tugas: {message}", "upload.unsupportedFormat": "Hanya file MP3, WAV, FLAC, MP4, M4A, OGG, dan Opus yang didukung.", "upload.fileTooLarge": "File terlalu besar ({size}). Maksimum adalah {max}.", + "upload.dropReadFailed": "Tidak dapat membaca {name}. File mungkin telah dipindahkan atau dihapus.", "upload.skippedFiles.other": "Melewati {count} file ({reason}).", "upload.reasonTooLarge": "terlalu besar atau bukan audio", "upload.reasonNotAudio": "bukan audio", @@ -4917,6 +4924,7 @@ const fr = { "job.startFailed": "Échec du démarrage de la tâche : {message}", "upload.unsupportedFormat": "Seuls les fichiers MP3, WAV, FLAC, MP4, M4A, OGG et Opus sont pris en charge.", "upload.fileTooLarge": "Fichier trop volumineux ({size}). Le maximum est {max}.", + "upload.dropReadFailed": "Impossible de lire {name}. Le fichier a peut-être été déplacé ou supprimé.", "upload.skippedFiles.one": "{count} fichier ignoré ({reason}).", "upload.skippedFiles.other": "{count} fichiers ignorés ({reason}).", "upload.reasonTooLarge": "trop volumineux ou pas de l'audio", @@ -5043,6 +5051,7 @@ const ptPT = { "settings.cookies.invalid": "Ficheiro não encontrado ou ilegível.", "upload.unsupportedFormat": "Apenas ficheiros MP3, WAV, FLAC, MP4, M4A, OGG e Opus são compatíveis.", "upload.fileTooLarge": "O ficheiro é demasiado grande ({size}). O máximo é {max}.", + "upload.dropReadFailed": "Não foi possível ler {name}. Pode ter sido movido ou eliminado.", "upload.skippedFiles.one": "{count} ficheiro ignorado ({reason}).", "upload.skippedFiles.other": "{count} ficheiros ignorados ({reason}).", "settings.logs.noFilesYet": "Ainda não há ficheiros de log{folderNote}. O registo começa na primeira mensagem após o arranque.", @@ -5633,6 +5642,7 @@ const es = { "job.startFailed": "No se pudo iniciar la tarea: {message}", "upload.unsupportedFormat": "Solo se admiten archivos MP3, WAV, FLAC, MP4, M4A, OGG y Opus.", "upload.fileTooLarge": "El archivo es demasiado grande ({size}). El máximo es {max}.", + "upload.dropReadFailed": "No se pudo leer {name}. Puede que se haya movido o eliminado.", "upload.skippedFiles": "Se omitieron {count} archivos ({reason}).", "upload.skippedFiles.one": "Se omitió {count} archivo ({reason}).", "upload.skippedFiles.other": "Se omitieron {count} archivos ({reason}).", @@ -6239,6 +6249,7 @@ const ko = { "job.startFailed": "작업을 시작하지 못했어요: {message}", "upload.unsupportedFormat": "MP3, WAV, FLAC, MP4, M4A, OGG, Opus 파일만 쓸 수 있어요.", "upload.fileTooLarge": "파일이 너무 커요 ({size}). 최대 {max}까지예요.", + "upload.dropReadFailed": "{name} 파일을 읽을 수 없어요. 이동되었거나 삭제되었을 수 있어요.", "upload.skippedFiles.other": "{count}개 파일을 건너뛰었어요 ({reason}).", "upload.reasonTooLarge": "너무 크거나 오디오가 아님", "upload.reasonNotAudio": "오디오가 아님", diff --git a/static/js/main.js b/static/js/main.js index f245bff5..73c44463 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -798,9 +798,18 @@ function wireFileDrop() { const AUDIO_EXTS = [".mp3", ".wav", ".flac", ".mp4", ".m4a", ".ogg", ".opus"]; const isAudioFile = (file) => AUDIO_EXTS.some((ext) => file.name.toLowerCase().endsWith(ext)); - function applyFiles(fileList) { - const all = [...(fileList || [])]; - if (!all.length) return; + /** + * Which of a set of picked or dropped files can be staged, and why the rest + * cannot. + * + * Works on anything with a name and a size rather than on File objects, so a + * native drop can be screened before any of its bytes are read: a file that + * is going to be refused should not be loaded into memory to find that out. + * Returns null, having said why, when nothing can be staged. + */ + function screenFiles(list) { + const all = [...(list || [])]; + if (!all.length) return null; // Filter here rather than letting the server reject each one: dropping a // folder, or a folder of mixed content, would otherwise mean one 422 per @@ -808,16 +817,24 @@ function wireFileDrop() { const audio = all.filter(isAudioFile); if (!audio.length) { showDropError(t("upload.unsupportedFormat")); - return; + return null; } const files = audio.filter((f) => f.size <= MAX_UPLOAD_BYTES); const oversized = audio.length - files.length; if (!files.length) { showDropError(t("upload.fileTooLarge", { size: formatBytes(audio[0].size), max: formatBytes(MAX_UPLOAD_BYTES) })); - return; + return null; } + return { files, skipped: all.length - files.length, oversized }; + } + + function applyFiles(fileList) { + const screened = screenFiles(fileList); + if (screened) stageFiles(screened); + } - const skipped = all.length - files.length; + /** Arms the import with files that have already passed screenFiles. */ + function stageFiles({ files, skipped, oversized }) { if (fileName) { fileName.textContent = files.length === 1 ? files[0].name : `${files.length} files`; @@ -895,6 +912,10 @@ function wireFileDrop() { // // dragover must preventDefault too: without it the browser refuses the drop // and no drop event is ever delivered to cancel. + // + // This is the whole story on Windows, macOS and in a browser, where the drag + // carries "Files". It is not on Linux, where it never does: see + // watchNativeDrops below. document.addEventListener("dragover", (e) => { if (!draggingFiles(e)) return; e.preventDefault(); @@ -910,6 +931,85 @@ function wireFileDrop() { fileInput.addEventListener("change", () => { applyFiles(fileInput.files); }); + + watchNativeDrops(); + + // ── Files dropped where the WebView cannot see them (#672) ── + // + // On Linux the WebView never hands the page a dropped file. WebKitGTK + // reports a drag from the file manager as text/uri-list, never as "Files", + // so the guard above does not recognise it, and at drop time there is no + // File to take even if it did. Left alone, WebKit navigates the window to + // the file, or types its file:// URI into the URL box. + // + // So on that platform the shell takes the drop instead (dropin.rs) and this + // asks it what arrived. The drop reaches applyFiles's own screening and + // staging, so a file dropped here is refused, sized, named and armed exactly + // as one from the picker is. Elsewhere the HTML5 path above already works + // and the shell says so, and this returns at once. + async function watchNativeDrops() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) return; + try { + if (!(await invoke("native_file_drop"))) return; + } catch (e) { + console.warn("[drop] could not ask the shell about native drops:", e); + return; + } + + // The shell answers each call with the next signal after `after`, or with + // nothing once it has waited a while. `after` starts empty, meaning "from + // now", and is then whatever the last answer's seq was. + let after = null; + let failures = 0; + for (;;) { + let next; + try { + next = await invoke("next_drop_signal", { after }); + failures = 0; + } catch (e) { + // A command that keeps failing must not become a tight loop. It never + // should fail, so this is a pause rather than a strategy. + failures += 1; + console.warn("[drop] waiting for a drop failed:", e); + await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * failures))); + continue; + } + after = next.seq; + const signal = next.signal; + if (!signal) continue; + if (signal.kind === "enter") { + urlWrap.classList.add("drag-over"); + } else if (signal.kind === "leave") { + urlWrap.classList.remove("drag-over"); + } else if (signal.kind === "drop") { + urlWrap.classList.remove("drag-over"); + await applyNativeDrop(invoke, signal.files); + } + } + } + + // `dropped` is [{ id, name, size }]: descriptions, not files. They are + // screened as they are, and only what is accepted is read. + async function applyNativeDrop(invoke, dropped) { + const screened = screenFiles(dropped); + if (!screened) return; + const files = []; + // One at a time, so a single transfer of up to MAX_UPLOAD_BYTES is in + // flight rather than one per file. Every accepted file is still held until + // staging, as a picked one is: parallel reads would only raise the peak. + for (const d of screened.files) { + try { + const bytes = await invoke("read_dropped_file", { id: d.id }); + files.push(new File([bytes], d.name)); + } catch (e) { + console.warn("[drop] could not read a dropped file:", e); + showDropError(t("upload.dropReadFailed", { name: d.name })); + return; + } + } + stageFiles({ ...screened, files }); + } } // ─── App shell controls ─── diff --git a/tests/e2e/native-file-drop.spec.mjs b/tests/e2e/native-file-drop.spec.mjs new file mode 100644 index 00000000..0944412d --- /dev/null +++ b/tests/e2e/native-file-drop.spec.mjs @@ -0,0 +1,212 @@ +// A file dropped on the desktop app on Linux arrives through the shell, and is +// treated exactly as one dropped anywhere else. +// +// WebKitGTK never gives the page a dropped file. The drag reports its types as +// text/uri-list and text/html, never "Files", so file-drop.spec.mjs's HTML5 +// path does not see it, and at drop time there is no File to take even if it +// did. Left alone, WebKit navigated the window to the file or typed its URI +// into the URL box (#672). On Linux the shell takes the drop instead +// (desktop/src-tauri/src/dropin.rs) and the page asks it what arrived. +// +// What these cover is the page's half of that contract, against a stand-in for +// the shell that answers the same three commands the same way. The Rust half +// has its own tests in dropin.rs. No test here drives the real shell, so a +// fault that lives only in Rust will not show up in this file. +import { test, expect } from "@playwright/test"; +import { + seedLibrary, + stubTauri, + stubExportEndpoints, + stubUpdateCheck, +} from "./helpers.mjs"; + +const MB = 1024 * 1024; + +/** + * Replaces the shell's three drop commands with a controllable stand-in. + * + * Registered after stubTauri, so it wraps the invoke that stub installed. + * `window.__shell.emit(signal)` is a drag event reaching the shell; the page's + * waiting next_drop_signal call is answered from a log by cursor, the way + * DropInbox answers it. + */ +async function standInShell(page, { native = true } = {}) { + await page.addInitScript((nativeDrop) => { + const inner = window.__TAURI__.core.invoke; + let seq = 0; + const log = []; + let waiting = []; + const shell = { + calls: [], + reads: [], + unreadable: new Set(), + sizes: {}, + emit(signal) { + seq += 1; + log.push([seq, signal]); + waiting = waiting.filter((tryAnswer) => !tryAnswer()); + }, + }; + window.__shell = shell; + window.__TAURI__.core.invoke = (cmd, args) => { + if (cmd === "native_file_drop" || cmd === "next_drop_signal" || cmd === "read_dropped_file") { + shell.calls.push(cmd); + } + if (cmd === "native_file_drop") return Promise.resolve(nativeDrop); + if (cmd === "next_drop_signal") { + const after = args?.after ?? seq; + return new Promise((resolve) => { + const tryAnswer = () => { + const hit = log.find(([s]) => s > after); + if (!hit) return false; + resolve({ seq: hit[0], signal: hit[1] }); + return true; + }; + if (!tryAnswer()) waiting.push(tryAnswer); + }); + } + if (cmd === "read_dropped_file") { + shell.reads.push(args.id); + if (shell.unreadable.has(args.id)) { + return Promise.reject("could not open the file: No such file or directory"); + } + return Promise.resolve(new Uint8Array(shell.sizes[args.id] ?? 44).buffer); + } + return inner(cmd, args); + }; + }, native); +} + +async function openDesktop(page, opts) { + await seedLibrary(page); + await stubTauri(page); + await standInShell(page, opts); + await stubExportEndpoints(page); + await stubUpdateCheck(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + // The watcher starts with the rest of the import wiring; wait for it to be + // listening before any drag reaches the shell. + await expect + .poll(() => page.evaluate(() => window.__shell.calls.includes("next_drop_signal"))) + .toBe(opts?.native === false ? false : true); +} + +/** A drag from the file manager, entering and then dropping. */ +const drop = (page, files) => + page.evaluate((f) => { + for (const d of f) window.__shell.sizes[d.id] = d.size; + window.__shell.emit({ kind: "enter" }); + window.__shell.emit({ kind: "drop", files: f }); + }, files); + +const state = (page) => + page.evaluate(() => { + const armed = document.getElementById("fileInput")?._files || []; + const err = document.getElementById("urlDropError"); + return { + pill: !document.getElementById("filePill").classList.contains("hidden"), + name: document.getElementById("fileName")?.textContent || "", + armed: armed.map((f) => ({ name: f.name, size: f.size })), + error: err && !err.classList.contains("hidden") ? err.textContent : "", + hovering: document.querySelector(".url-wrap").classList.contains("drag-over"), + reads: [...window.__shell.reads], + }; + }); + +test.describe("a file dropped on the Linux desktop app", () => { + test("arms the import like a file from the picker", async ({ page }) => { + await openDesktop(page); + await drop(page, [{ id: 7, name: "Take 3.flac", size: 3 * MB }]); + + await expect.poll(() => state(page).then((s) => s.pill)).toBe(true); + const s = await state(page); + expect(s.name).toBe("Take 3.flac"); + // The staged File carries the bytes the shell read, not just a name. + expect(s.armed).toEqual([{ name: "Take 3.flac", size: 3 * MB }]); + expect(s.reads).toEqual([7]); + expect(s.error).toBe(""); + }); + + test("a file that is not audio is refused without being read", async ({ page }) => { + await openDesktop(page); + await drop(page, [{ id: 1, name: "notes.txt", size: 10 }]); + + await expect.poll(() => state(page).then((s) => s.error)).toContain("MP3"); + const s = await state(page); + expect(s.pill).toBe(false); + expect(s.reads).toEqual([]); + }); + + test("a file over the limit is refused without being read", async ({ page }) => { + // Screening happens on the name and size the shell reports, so a file that + // is going to be turned away is never loaded into memory to find that out. + await openDesktop(page); + await drop(page, [{ id: 1, name: "huge.wav", size: 900 * MB }]); + + await expect.poll(() => state(page).then((s) => s.error)).toContain("too large"); + expect((await state(page)).reads).toEqual([]); + }); + + test("a folder is refused as not audio", async ({ page }) => { + // The shell reports a folder with a size of 0 and its own name. + await openDesktop(page); + await drop(page, [{ id: 1, name: "Stems", size: 0 }]); + + await expect.poll(() => state(page).then((s) => s.error)).toContain("MP3"); + expect((await state(page)).reads).toEqual([]); + }); + + test("of a mixed drop, only the audio is read", async ({ page }) => { + await openDesktop(page); + await drop(page, [ + { id: 1, name: "a.wav", size: 1000 }, + { id: 2, name: "cover.jpg", size: 1000 }, + { id: 3, name: "b.mp3", size: 1000 }, + ]); + + await expect.poll(() => state(page).then((s) => s.armed.length)).toBe(2); + const s = await state(page); + expect(s.armed.map((f) => f.name)).toEqual(["a.wav", "b.mp3"]); + expect(s.reads).toEqual([1, 3]); + }); + + test("a file that has gone by the time it is read says so, by name", async ({ page }) => { + await openDesktop(page); + await page.evaluate(() => window.__shell.unreadable.add(5)); + await drop(page, [{ id: 5, name: "moved.wav", size: 1000 }]); + + await expect.poll(() => state(page).then((s) => s.error)).toContain("moved.wav"); + expect((await state(page)).pill).toBe(false); + }); + + test("the URL zone lights while a drag is over the window", async ({ page }) => { + // The HTML5 hover cue never fires on Linux either, for the same reason the + // drop did not: the drag never says it carries files. + await openDesktop(page); + + await page.evaluate(() => window.__shell.emit({ kind: "enter" })); + await expect.poll(() => state(page).then((s) => s.hovering)).toBe(true); + + await page.evaluate(() => window.__shell.emit({ kind: "leave" })); + await expect.poll(() => state(page).then((s) => s.hovering)).toBe(false); + }); + + test("keeps listening after a drop", async ({ page }) => { + await openDesktop(page); + await drop(page, [{ id: 1, name: "first.wav", size: 1000 }]); + await expect.poll(() => state(page).then((s) => s.name)).toBe("first.wav"); + + await drop(page, [{ id: 2, name: "second.wav", size: 1000 }]); + await expect.poll(() => state(page).then((s) => s.name)).toBe("second.wav"); + }); + + test("where the shell does not take drops, the page does not wait on it", async ({ page }) => { + // Windows and macOS: the HTML5 path carries the files there, and a watcher + // parked on a command that will never answer would be a call for nothing. + await openDesktop(page, { native: false }); + await page.waitForTimeout(500); + + const calls = await page.evaluate(() => window.__shell.calls); + expect(calls).toEqual(["native_file_drop"]); + }); +}); diff --git a/tests/test_upload_limit_parity.py b/tests/test_upload_limit_parity.py new file mode 100644 index 00000000..3b140f98 --- /dev/null +++ b/tests/test_upload_limit_parity.py @@ -0,0 +1,54 @@ +"""The upload limit is one number, written in three languages. + +The server refuses anything larger (app/api/jobs.py). The page refuses it first +so a file is not uploaded only to be turned away (static/js/main.js). And on +Linux, where a dropped file reaches the page through the shell rather than the +WebView, the shell refuses to read anything larger into memory +(desktop/src-tauri/src/dropin.rs). + +Each copy used to be held to the others by a comment saying it must match. +If they drift, the failure is quiet in either direction: a limit lower in the +page than on the server refuses files the server would take, and one higher in +the shell than in the page is a backstop that no longer stops anything. +""" + +import re +from pathlib import Path + +from app.api.jobs import _MAX_UPLOAD_BYTES + +ROOT = Path(__file__).resolve().parents[1] + +# "400 * 1024 * 1024" and similar products of integer literals, the way all +# three are written. +_PRODUCT = r"(\d+(?:\s*\*\s*\d+)*)" + + +def _evaluate(product: str) -> int: + value = 1 + for factor in product.split("*"): + value *= int(factor.strip()) + return value + + +def _constant(path: Path, pattern: str) -> int: + text = path.read_text(encoding="utf-8") + match = re.search(pattern, text) + assert match, f"no upload limit found in {path.relative_to(ROOT)}" + return _evaluate(match.group(1)) + + +def test_the_page_refuses_what_the_server_would_refuse(): + page = _constant( + ROOT / "static" / "js" / "main.js", + rf"const MAX_UPLOAD_BYTES\s*=\s*{_PRODUCT}\s*;", + ) + assert page == _MAX_UPLOAD_BYTES + + +def test_the_shell_reads_no_more_than_the_page_would_accept(): + shell = _constant( + ROOT / "desktop" / "src-tauri" / "src" / "dropin.rs", + rf"pub const MAX_DROPPED_FILE_BYTES:\s*u64\s*=\s*{_PRODUCT}\s*;", + ) + assert shell == _MAX_UPLOAD_BYTES