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