diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 114636d..a7b6d03 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.4.0" +version = "0.4.1" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b86f0e5..f0f0208 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.4.0" +version = "0.4.1" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index 5cb32d5..20a20d1 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -92,4 +92,168 @@ } else { start(); } + + // 4) Drag-and-drop file injection. + // The native webview never delivers an OS file drop into this page on Linux + // (broken on Wayland; on X11 the GTK drop is accepted but never reaches the DOM), + // so Rust captures the drop (window.rs `register_drop_handler`) and calls this with + // the file bytes. We rebuild the File(s) and hand them to WhatsApp's own attach flow. + // + // Primary: a hidden — `input.files` is settable in WebKit and + // React fires onChange for synthetic, bubbling events (it never checks isTrusted). + // Fallback: a synthetic drop. In WebKit `new DragEvent('drop',{dataTransfer})` drops + // the dataTransfer, so we dispatch a plain Event with `dataTransfer` defined via + // Object.defineProperty (the Cypress/Playwright WebKit workaround). + // WhatsApp opens a preview/Send composer (never auto-sends), so attempting both is safe. + // Every boundary is logged through the `dlog` command (see commands.rs). + try { + var drop = {}; + drop.log = function (m) { + try { + invoke("dlog", { msg: String(m).slice(0, 280) }); + } catch (e) {} + }; + drop.b64ToFile = function (b64, name, type) { + var bin = atob(b64), + n = bin.length, + u = new Uint8Array(n); + for (var i = 0; i < n; i++) u[i] = bin.charCodeAt(i); + return new File([u], name, { type: type || "application/octet-stream" }); + }; + drop.dataTransfer = function (files) { + var dt = new DataTransfer(); + for (var i = 0; i < files.length; i++) dt.items.add(files[i]); + return dt; + }; + // WhatsApp opens a media preview / caption composer once a file is attached; use it + // as the success signal. Selectors are best-effort across WhatsApp Web versions. + drop.composerOpen = function () { + return !!( + document.querySelector('[data-testid="media-caption-input-container"]') || + document.querySelector('[data-testid="media-editor"]') || + document.querySelector('[data-animate-modal-body="true"]') || + document.querySelector('span[data-icon="send"]') || + document.querySelector('span[data-icon="media-cancel"]') || + document.querySelector('span[data-icon="x-viewer"]') + ); + }; + drop.waitFor = function (pred, ms) { + return new Promise(function (resolve) { + var t0 = Date.now(); + (function poll() { + if (pred()) return resolve(true); + if (Date.now() - t0 >= ms) return resolve(false); + setTimeout(poll, 80); + })(); + }); + }; + drop.tryFileInput = function (dt) { + var sels = [ + 'input[type="file"][accept*="image"][accept*="video"]', + 'input[type="file"][accept*="image"]', + 'input[type="file"][accept]', + 'input[type="file"]', + ]; + var input = null; + for (var i = 0; i < sels.length && !input; i++) { + input = document.querySelector(sels[i]); + } + if (!input) return false; + try { + input.files = dt.files; // settable in WebKit + Blink (WHATWG html#2861) + } catch (e) { + drop.log("input.files assign threw: " + e); + return false; + } + input.dispatchEvent(new Event("change", { bubbles: true })); + input.dispatchEvent(new Event("input", { bubbles: true })); + drop.log("file-input path fired (" + dt.files.length + " file)"); + return true; + }; + drop.tryDrop = function (dt, cssX, cssY) { + var target = null; + var el = document.elementFromPoint(cssX, cssY); + if (el && el !== document.documentElement && el !== document.body) target = el; + if (!target) { + target = + document.querySelector('[data-testid="conversation-panel-body"]') || + document.querySelector("div#main") || + document.querySelector("main") || + document.body; + } + var fire = function (type) { + var ev = new Event(type, { bubbles: true, cancelable: true }); + try { + Object.defineProperty(ev, "dataTransfer", { value: dt, configurable: true }); + } catch (e) {} + target.dispatchEvent(ev); + }; + fire("dragenter"); + fire("dragover"); + fire("drop"); + drop.log("synthetic-drop fired on " + (target.id || target.tagName)); + }; + // De-dupe: a given file can't be re-injected within 4s (guards eval retries). + drop.seen = Object.create(null); + drop.dedupe = function (list) { + return list.filter(function (f) { + var k = f.name + "|" + (f.b64 || "").slice(0, 24); + if (drop.seen[k]) return false; + drop.seen[k] = 1; + setTimeout(function () { + delete drop.seen[k]; + }, 4000); + return true; + }); + }; + + window.__whatrustHandleDrop = function (fileObjs, physX, physY) { + try { + if (!Array.isArray(fileObjs) || fileObjs.length === 0) return; + var fresh = drop.dedupe(fileObjs); + if (fresh.length === 0) { + drop.log("duplicate drop ignored"); + return; + } + var dpr = window.devicePixelRatio || 1; + var cssX = physX / dpr, + cssY = physY / dpr; + var files = fresh.map(function (f) { + return drop.b64ToFile(f.b64, f.name, f.type); + }); + var dt = drop.dataTransfer(files); + drop.log( + "received " + + files.length + + " file(s) [" + + files + .map(function (f) { + return f.type; + }) + .join(",") + + "]" + ); + + var dropFallback = function (reason) { + drop.log("fallback to synthetic drop (" + reason + ")"); + drop.tryDrop(dt, cssX, cssY); + drop.waitFor(drop.composerOpen, 1800).then(function (ok) { + drop.log(ok ? "composer opened via synthetic drop" : "composer NOT detected after synthetic drop"); + }); + }; + + if (drop.tryFileInput(dt)) { + drop.waitFor(drop.composerOpen, 1500).then(function (ok) { + if (ok) drop.log("composer opened via file-input"); + else dropFallback("file-input fired but composer not detected"); + }); + } else { + dropFallback("no file input present"); + } + } catch (e) { + drop.log("handler error: " + e); + } + }; + drop.log("drop injector ready"); + } catch (e) {} })(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4564626..ae91e8c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -58,6 +58,16 @@ pub fn notify(window: tauri::Window, app: tauri::AppHandle, title: String, body: crate::notify::show(&app, &title, &body); } +/// Diagnostic breadcrumb from the injected page script (bridge.js) into the same +/// file log as the Rust side, so a drag-drop failure can be traced end-to-end on a +/// build with no console. Allowed from the WhatsApp page; it only appends a short, +/// length-capped string we author in bridge.js — no page-controlled PII. +#[tauri::command] +pub fn dlog(msg: String) { + let msg: String = msg.chars().take(300).collect(); + crate::dlog::log(&format!("js: {msg}")); +} + #[tauri::command] pub fn set_unread(window: tauri::Window, app: tauri::AppHandle, title: String) { let count = crate::unread::parse_unread(&title); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95f8f98..95e0b0c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::notify, commands::set_unread, + commands::dlog, commands::get_settings, commands::set_settings, commands::open_settings, diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 55d1111..fce4cbd 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -35,18 +35,15 @@ pub fn open_account_window( .icon(icon)? .user_agent(CHROME_UA) .initialization_script(BRIDGE_JS) - // Let WhatsApp Web receive dropped files/images. By default wry installs an - // OS-level drag-and-drop handler that swallows the native drop (firing - // `tauri://drag-drop` instead), so the page's HTML5 dragover/drop never fires - // and dropping a file does nothing. We don't use Tauri's drag-drop events, so - // disabling that handler lets the webview hand drops straight to web.whatsapp.com. - // This is also what makes HTML5 DnD work at all in WebView2 on Windows. - .disable_drag_drop_handler() - // Linux/webkit2gtk safety net: even with the OS handler off, a dropped file can - // make the webview *navigate* to its file:// URL (tauri#9725, #12052) — which - // would blow away the live WhatsApp session. WhatsApp Web never legitimately - // loads a file:// URL, so cancel any such navigation; the in-page HTML5 drop - // (which is what actually attaches the file) still fires. + // Drag-and-drop is done by capturing the OS drop in Rust and injecting the file + // into the page (see `register_drop_handler` + bridge.js `__whatrustHandleDrop`). + // We deliberately KEEP Tauri's drag-drop handler enabled: on Linux/webkit2gtk the + // native webview never delivers a file drop into the page DOM (broken on Wayland; + // on X11 the GTK drop is accepted — the "+" cursor shows — but it still never + // reaches WhatsApp Web), so relying on in-page HTML5 drop simply does not work + // there. The handler is what hands us the dropped paths via `WindowEvent::DragDrop`. + // Belt-and-braces: cancel any `file://` navigation so a stray drop can never + // navigate the window away and tear down the live WhatsApp session. .on_navigation(|url| url.scheme() != "file") .visible(!start_hidden); @@ -71,10 +68,164 @@ pub fn open_account_window( }); register_focus_listener(app, &win); + register_drop_handler(&win); enable_webview_media(&win); Ok(win) } +/// Largest single dropped file we will inline-inject into the page. base64 over `eval` +/// is ~1.33x the byte size and held in memory, so keep it bounded; larger files are +/// skipped (with a log line) rather than risking an OOM or a long UI stall. +const MAX_DROP_FILE_BYTES: u64 = 100 * 1024 * 1024; +/// Cap how many files one drop can inject (WhatsApp itself limits a batch anyway). +const MAX_DROP_FILES: usize = 30; + +/// Capture OS file drops and inject them into WhatsApp Web. +/// +/// On Linux the webview never delivers the drop into the page DOM, so Tauri's +/// drag-drop handler (kept enabled in the builder) is our only source of the dropped +/// paths. We read the files off the UI thread, base64-encode them, and `eval` a call +/// to the page-side `__whatrustHandleDrop` (defined in bridge.js), which rebuilds the +/// `File`s and hands them to WhatsApp's own attach flow. Every boundary is logged to +/// the diagnostic log (see dlog.rs) so a failure can be pinpointed without a console. +fn register_drop_handler(win: &WebviewWindow) { + let win = win.clone(); + win.clone().on_window_event(move |event| { + let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, position }) = event + else { + return; + }; + crate::dlog::log(&format!( + "dragdrop: Drop {} path(s) at ({:.0},{:.0})", + paths.len(), + position.x, + position.y + )); + let paths = paths.clone(); + let (x, y) = (position.x, position.y); + let w = win.clone(); + // Read + encode off the UI thread: a large video would otherwise stall the window. + std::thread::spawn(move || match build_drop_payload(&paths) { + Some(json) if json != "[]" => { + let js = format!( + "window.__whatrustHandleDrop&&window.__whatrustHandleDrop({json},{x},{y});" + ); + match w.eval(&js) { + Ok(()) => crate::dlog::log("dragdrop: injection dispatched to page"), + Err(e) => crate::dlog::log(&format!("dragdrop: eval failed: {e}")), + } + } + _ => crate::dlog::log("dragdrop: nothing injectable (empty/too large/unreadable)"), + }); + }); +} + +/// Read the dropped files into a JSON array `[{name,type,b64}]` for the page-side +/// injector. Skips anything too large, non-regular, or unreadable (logging each skip). +fn build_drop_payload(paths: &[std::path::PathBuf]) -> Option { + let mut items: Vec = Vec::new(); + for p in paths.iter().take(MAX_DROP_FILES) { + let name = p + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("file") + .to_string(); + let meta = match std::fs::metadata(p) { + Ok(m) => m, + Err(e) => { + crate::dlog::log(&format!("dragdrop: stat '{name}' failed: {e}")); + continue; + } + }; + if !meta.is_file() { + crate::dlog::log(&format!("dragdrop: skip '{name}': not a regular file")); + continue; + } + if meta.len() > MAX_DROP_FILE_BYTES { + crate::dlog::log(&format!( + "dragdrop: skip '{name}': {} bytes over the {MAX_DROP_FILE_BYTES} cap", + meta.len() + )); + continue; + } + match std::fs::read(p) { + Ok(bytes) => { + crate::dlog::log(&format!("dragdrop: read '{name}' ({} bytes)", bytes.len())); + items.push(serde_json::json!({ + "name": name, + "type": mime_for(&name), + "b64": base64_encode(&bytes), + })); + } + Err(e) => crate::dlog::log(&format!("dragdrop: read '{name}' failed: {e}")), + } + } + serde_json::to_string(&items).ok() +} + +/// Best-effort MIME from the file extension, so WhatsApp routes images/videos/docs to +/// the right composer. Unknown types fall back to a generic binary type (still sends). +fn mime_for(name: &str) -> &'static str { + let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" | "jfif" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "bmp" => "image/bmp", + "svg" => "image/svg+xml", + "heic" => "image/heic", + "mp4" | "m4v" => "video/mp4", + "mov" => "video/quicktime", + "webm" => "video/webm", + "mkv" => "video/x-matroska", + "3gp" => "video/3gpp", + "avi" => "video/x-msvideo", + "mp3" => "audio/mpeg", + "ogg" | "oga" => "audio/ogg", + "opus" => "audio/opus", + "wav" => "audio/wav", + "m4a" => "audio/mp4", + "pdf" => "application/pdf", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "txt" | "log" => "text/plain", + "csv" => "text/csv", + "zip" => "application/zip", + _ => "application/octet-stream", + } +} + +/// Standard base64 (RFC 4648, with `=` padding). Hand-rolled to avoid pulling a crate +/// into this otherwise lean dependency tree; only used to ferry dropped bytes to the page. +fn base64_encode(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + out.push(if chunk.len() > 1 { + T[((n >> 6) & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + T[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + /// Track the last-focused account window in `ActiveAccount`. Registered once per /// window inside `open_account_window`, so startup *and* dynamically-added windows /// get it exactly once. @@ -341,7 +492,34 @@ pub fn open_settings_window(app: &AppHandle) { #[cfg(test)] mod tests { - use super::{toggle_decision, ToggleAct}; + use super::{base64_encode, mime_for, toggle_decision, ToggleAct}; + + #[test] + fn base64_matches_rfc4648_vectors() { + // The canonical RFC 4648 test vectors, including every padding case. + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); + assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn base64_handles_high_bytes() { + assert_eq!(base64_encode(&[0xff, 0xff, 0xff]), "////"); + assert_eq!(base64_encode(&[0x00]), "AA=="); + } + + #[test] + fn mime_is_extension_and_case_insensitive() { + assert_eq!(mime_for("Photo.JPG"), "image/jpeg"); + assert_eq!(mime_for("clip.mp4"), "video/mp4"); + assert_eq!(mime_for("doc.pdf"), "application/pdf"); + assert_eq!(mime_for("noext"), "application/octet-stream"); + assert_eq!(mime_for("archive.unknownext"), "application/octet-stream"); + } #[test] fn visible_active_window_is_hidden() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2b4f37c..e43d62a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "whatRust", - "version": "0.4.0", + "version": "0.4.1", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui"