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"