diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a7b6d03..d0541ef 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4695,7 +4695,7 @@ dependencies = [ [[package]] name = "whatrust" -version = "0.4.1" +version = "0.4.2" dependencies = [ "argon2", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f0f0208..d745821 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whatrust" -version = "0.4.1" +version = "0.4.2" edition = "2021" rust-version = "1.82" diff --git a/src-tauri/resources/bridge.js b/src-tauri/resources/bridge.js index 20a20d1..924a019 100644 --- a/src-tauri/resources/bridge.js +++ b/src-tauri/resources/bridge.js @@ -99,19 +99,20 @@ // 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). + // WhatsApp Web keeps a sticker-creator (image-only accept) ALWAYS + // mounted, but mounts the real "Photos & videos" input (accept includes video) and + // the "Document" input (accept "*") only when the attach (+) menu is opened. Targeting + // a file input blindly therefore lands on the sticker input — which turns photos into + // stickers and rejects video/documents. So we ROUTE BY TYPE: open the attach menu to + // mount the right input, then set its .files and fire change (React's onChange fires + // for synthetic bubbling events; it doesn't check isTrusted, and input.files is + // settable in WebKit). Images + native videos -> media input; everything else (zip, + // pdf, docs, webm/mkv/avi) -> document input. We never use the sticker input. try { var drop = {}; drop.log = function (m) { - try { - invoke("dlog", { msg: String(m).slice(0, 280) }); - } catch (e) {} + try { console.log("[whatRust drop] " + m); } catch (e) {} + try { invoke("dlog", { msg: String(m).slice(0, 280) }); } catch (e) {} }; drop.b64ToFile = function (b64, name, type) { var bin = atob(b64), @@ -125,40 +126,76 @@ 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"]') - ); + // Only these video types are accepted by WhatsApp's Photos & Videos input; other + // video containers (webm/mkv/avi) are rejected there and must go as a document. + drop.NATIVE_VIDEO = { "video/mp4": 1, "video/3gpp": 1, "video/quicktime": 1 }; + drop.isMedia = function (type) { + return /^image\//.test(type || "") || drop.NATIVE_VIDEO[type] === 1; }; - drop.waitFor = function (pred, ms) { + drop.qs = function (sels) { + for (var i = 0; i < sels.length; i++) { + var e = document.querySelector(sels[i]); + if (e) return e; + } + return null; + }; + // The media input is the only file input whose accept lists a video type; the sticker + // input is image-only, so it can never match this. + drop.findMediaInput = function () { + var ins = document.querySelectorAll('input[type="file"]'); + for (var i = 0; i < ins.length; i++) { + if (/video/i.test(ins[i].accept || "")) return ins[i]; + } + return null; + }; + // The document input accepts everything (accept "*"/""/no image+video). The sticker + // input (image-only) and media input (has video) are both excluded. + drop.findDocInput = function () { + var ins = document.querySelectorAll('input[type="file"]'); + for (var i = 0; i < ins.length; i++) { + var a = (ins[i].accept || "").trim(); + if (a === "" || a === "*" || a === "*/*") return ins[i]; + if (!/image/i.test(a) && !/video/i.test(a)) return ins[i]; + } + return null; + }; + drop.openMenu = function () { + var b = drop.qs([ + '[data-icon="plus"]', + '[data-icon="attach-menu-plus"]', + '[data-icon="clip"]', + '[data-testid="clip"]', + 'button[title="Attach"]', + '[aria-label="Attach"]', + '[title="Attach"]', + ]); + if (!b) return false; + (b.closest('button,[role="button"],div[role="button"]') || b).click(); + return true; + }; + drop.clickMenuItem = function (kind) { + var sels = + kind === "media" + ? ['[data-testid="attach-image"]', '[data-icon="media-multiple"]', '[aria-label*="Photos"]', '[aria-label*="hoto"]'] + : ['[data-testid="attach-document"]', '[data-icon="document"]', '[aria-label*="Document"]', '[aria-label*="ocument"]']; + var e = drop.qs(sels); + if (!e) return false; + (e.closest('li,button,[role="button"],div[role="button"]') || e).click(); + return true; + }; + drop.poll = function (fn, 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); + (function p() { + var r = fn(); + if (r) return resolve(r); + if (Date.now() - t0 >= ms) return resolve(null); + setTimeout(p, 60); })(); }); }; - 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; + drop.inject = function (input, files) { + var dt = drop.dataTransfer(files); try { input.files = dt.files; // settable in WebKit + Blink (WHATWG html#2861) } catch (e) { @@ -167,31 +204,50 @@ } 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; + // Open the attach menu (which mounts the lazily-rendered inputs) and inject into the + // one matching `kind`. If opening the menu alone doesn't mount it, click the matching + // submenu item to force it. No user gesture exists here, so the OS file picker can't + // open — only the input element is mounted, which is all we need. + drop.mountAndInject = function (kind, files) { + var find = kind === "media" ? drop.findMediaInput : drop.findDocInput; + var existing = find(); + if (existing) { + drop.log(kind + " input already present"); + return Promise.resolve(drop.inject(existing, files)); } - 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)); + drop.openMenu(); + return drop.poll(find, 1000).then(function (input) { + if (input) return drop.inject(input, files); + drop.clickMenuItem(kind); + return drop.poll(find, 1000).then(function (input2) { + if (input2) return drop.inject(input2, files); + drop.log(kind + " input NOT found after opening attach menu"); + return false; + }); + }); + }; + // WhatsApp opens a media/document preview composer once a file is attached; use it as + // the success signal for diagnostics. Best-effort selectors across WA 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"]') + ); + }; + 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); + })(); + }); }; // De-dupe: a given file can't be re-injected within 4s (guards eval retries). drop.seen = Object.create(null); @@ -207,7 +263,7 @@ }); }; - window.__whatrustHandleDrop = function (fileObjs, physX, physY) { + window.__whatrustHandleDrop = function (fileObjs) { try { if (!Array.isArray(fileObjs) || fileObjs.length === 0) return; var fresh = drop.dedupe(fileObjs); @@ -215,45 +271,34 @@ 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"); - }); - }; + var media = files.filter(function (f) { + return drop.isMedia(f.type); + }); + var docs = files.filter(function (f) { + return !drop.isMedia(f.type); + }); + drop.log("drop: " + media.length + " media + " + docs.length + " document file(s)"); - 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"); + // A single drop opens one composer, so handle one group. Media wins if both are + // present (the rarer mixed case logs the leftover for a follow-up drop). + var kind = media.length ? "media" : "document"; + var batch = media.length ? media : docs; + drop.mountAndInject(kind, batch).then(function (ok) { + drop.log(kind + " inject " + (ok ? "dispatched" : "FAILED")); + if (media.length && docs.length) { + drop.log("note: " + docs.length + " document(s) skipped — drop them separately"); + } + drop.waitFor(drop.composerOpen, 1800).then(function (open) { + drop.log("composer " + (open ? "opened" : "NOT detected") + " (" + kind + ")"); }); - } else { - dropFallback("no file input present"); - } + }); } catch (e) { drop.log("handler error: " + e); } }; - drop.log("drop injector ready"); + drop.log("drop injector v2 (type-routed) ready"); } catch (e) {} })(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e43d62a..29fd016 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.1", + "version": "0.4.2", "identifier": "com.karem.whatrust", "build": { "frontendDist": "../settings-ui"