From 00209d55e666cdae1e9d67b52bfc1b5904efcbe3 Mon Sep 17 00:00:00 2001 From: TommyFang Date: Sun, 16 Aug 2026 12:39:08 +0800 Subject: [PATCH] fix: read Linux clipboard images natively and ingest them as paste GTK/wl-paste/xclip fallbacks plus a paste-event path so ModLens can take over image paste instead of only synthesizing drop. Co-authored-by: Cursor --- Cargo.lock | 161 ++++++++++++++++++++++++++- crates/dsh-core/src/clipboard.rs | 34 +++++- src-tauri/Cargo.toml | 6 +- src-tauri/src/lib.rs | 112 ++++++++++++++----- tests/test_plugins.py | 16 +++ ui/inject/chrome.js | 77 +++++++++++-- ui/inject/ingest.js | 182 +++++++++++++++++++++++++++---- 7 files changed, 521 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76f63df..a962542 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -904,13 +905,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -943,6 +950,8 @@ dependencies = [ "arboard", "dsh-core", "env_logger", + "gdk", + "gtk", "image", "log", "open", @@ -1157,6 +1166,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1173,6 +1188,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1617,6 +1638,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -2329,6 +2359,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2601,6 +2640,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "pango" version = "0.18.3" @@ -2661,6 +2710,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "phf" version = "0.13.1" @@ -4254,6 +4314,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4519,6 +4590,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -5101,6 +5242,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.20", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/crates/dsh-core/src/clipboard.rs b/crates/dsh-core/src/clipboard.rs index 4922334..10c0231 100644 --- a/crates/dsh-core/src/clipboard.rs +++ b/crates/dsh-core/src/clipboard.rs @@ -170,6 +170,25 @@ pub fn file_from_bytes(name: String, mime: String, data: Vec) -> Option Option<&'static str> { + if data.len() >= 8 && data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + return Some("image/png"); + } + if data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return Some("image/jpeg"); + } + if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" { + return Some("image/webp"); + } + if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + return Some("image/gif"); + } + if data.len() >= 2 && data.starts_with(b"BM") { + return Some("image/bmp"); + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -187,9 +206,22 @@ mod tests { #[test] fn ingest_js_defines_helper() { assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles")); + assert!(INGEST_JS.contains("window.__dshDesktopIngestFiles")); + assert!(INGEST_JS.contains("/modlens/paste")); + assert!(INGEST_JS.contains("ClipboardEvent")); assert!(INGEST_JS.contains("DragEvent")); assert!(INGEST_JS.contains("new File")); - assert!(INGEST_JS.contains("text/uri-list")); + assert!(INGEST_JS.contains("__dshDesktopLooksLikeImagePath")); + } + + #[test] + fn sniffs_image_magic() { + assert_eq!( + detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 1]), + Some("image/png") + ); + assert_eq!(detect_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg")); + assert_eq!(detect_image_mime(b"not-an-image"), None); } #[test] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 868247a..6d7b395 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,9 +24,13 @@ tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -arboard = "3" +arboard = { version = "3", features = ["wayland-data-control"] } image = { version = "0.25", default-features = false, features = ["png"] } open = "5" url = "2" log = "0.4" env_logger = "0.11" + +[target.'cfg(target_os = "linux")'.dependencies] +gdk = "0.18" +gtk = "0.18" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d4ce39a..1e9dc6a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,13 @@ use std::io::Cursor; use std::path::PathBuf; +use std::process::Command; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use dsh_core::clipboard::{ - file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list, ClipboardFile, + detect_image_mime, file_from_bytes, filename_for_mime, files_from_paths, parse_uri_list, + ClipboardFile, }; use dsh_core::launcher::{DshLauncher, DshProcess, URL_TIMEOUT_SECONDS}; use dsh_core::modlens::ensure_modlens; @@ -134,41 +136,95 @@ fn open_in_browser(state: tauri::State) -> Result<(), String> { } #[tauri::command] -fn read_clipboard_images() -> Result, String> { - read_images().map_err(|e| e.to_string()) +fn read_clipboard_images(app: AppHandle) -> Result, String> { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + app.run_on_main_thread(move || { + let _ = tx.send(read_images()); + }) + .map_err(|e| e.to_string())?; + rx.recv().map_err(|e| e.to_string())? } fn read_images() -> Result, String> { - let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; - if let Ok(img) = clipboard.get_image() { - let width = img.width as u32; - let height = img.height as u32; - let bytes = img.bytes.into_owned(); - if let Some(buffer) = image::RgbaImage::from_raw(width, height, bytes) { - let mut png = Vec::new(); - if buffer - .write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png) - .is_ok() - { - if let Some(file) = file_from_bytes( - filename_for_mime("image/png", 0), - "image/png".into(), - png, - ) { - return Ok(vec![file]); - } - } - } + if let Some(file) = read_native_image() { + return Ok(vec![file]); } - if let Ok(text) = clipboard.get_text() { - let loaded = files_from_paths(parse_uri_list(&text)); - if !loaded.is_empty() { - return Ok(loaded); - } + if let Some(file) = read_arboard_image() { + return Ok(vec![file]); + } + if let Some(file) = read_cli_image() { + return Ok(vec![file]); + } + if let Some(files) = read_arboard_image_paths() { + return Ok(files); } Ok(Vec::new()) } +#[cfg(target_os = "linux")] +fn read_native_image() -> Option { + let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD); + let pixbuf = clipboard.wait_for_image()?; + let png = pixbuf.save_to_bufferv("png", &[]).ok()?; + file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png) +} + +#[cfg(not(target_os = "linux"))] +fn read_native_image() -> Option { + None +} + +fn read_arboard_image() -> Option { + let mut clipboard = arboard::Clipboard::new().ok()?; + let img = clipboard.get_image().ok()?; + let width = img.width as u32; + let height = img.height as u32; + let bytes = img.bytes.into_owned(); + let buffer = image::RgbaImage::from_raw(width, height, bytes)?; + let mut png = Vec::new(); + buffer + .write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png) + .ok()?; + file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png) +} + +fn read_arboard_image_paths() -> Option> { + let mut clipboard = arboard::Clipboard::new().ok()?; + let text = clipboard.get_text().ok()?; + let loaded = files_from_paths(parse_uri_list(&text)); + if loaded.is_empty() { + None + } else { + Some(loaded) + } +} + +fn read_cli_image() -> Option { + const ATTEMPTS: &[(&str, &[&str])] = &[ + ("wl-paste", &["--type", "image/png"]), + ("wl-paste", &["--type", "image/jpeg"]), + ("wl-paste", &["--type", "image/webp"]), + ("xclip", &["-selection", "clipboard", "-t", "image/png", "-o"]), + ("xclip", &["-selection", "clipboard", "-t", "image/jpeg", "-o"]), + ("xclip", &["-selection", "clipboard", "-t", "image/webp", "-o"]), + ]; + for (bin, args) in ATTEMPTS { + let output = match Command::new(bin).args(*args).output() { + Ok(output) => output, + Err(_) => continue, + }; + if !output.status.success() || output.stdout.is_empty() { + continue; + } + let mime = detect_image_mime(&output.stdout).unwrap_or("image/png"); + if let Some(file) = file_from_bytes(filename_for_mime(mime, 0), mime.into(), output.stdout) + { + return Some(file); + } + } + None +} + fn is_internal(url: &Url) -> bool { matches!(url.scheme(), "tauri" | "asset" | "about" | "data" | "blob") || matches!( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 6e8f2d9..f677eeb 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -36,6 +36,22 @@ class VisionPluginFilesTests(unittest.TestCase): self.assertIn("example: 'haiku'", host) +class ClipboardIngestTests(unittest.TestCase): + def test_inject_delivers_images_as_paste_not_only_drop(self): + ingest = (ROOT / "ui" / "inject" / "ingest.js").read_text(encoding="utf-8") + chrome = (ROOT / "ui" / "inject" / "chrome.js").read_text(encoding="utf-8") + self.assertIn("window.__dshDesktopIngestFiles", ingest) + self.assertIn("/modlens/paste", ingest) + self.assertIn("ClipboardEvent", ingest) + self.assertIn("navigator.clipboard.read", chrome) + self.assertIn("read_clipboard_images", chrome) + self.assertIn("__dshDesktopIngesting", chrome) + self.assertNotIn( + 'item.kind === "file" && item.getAsFile()', + chrome, + ) + + class BundledAttributionTests(unittest.TestCase): def test_readme_cites_upstream_plugins(self): readme = (ROOT / "README.md").read_text(encoding="utf-8") diff --git a/ui/inject/chrome.js b/ui/inject/chrome.js index d6c1ca0..80e6c3c 100644 --- a/ui/inject/chrome.js +++ b/ui/inject/chrome.js @@ -146,23 +146,76 @@ inject(); } + function clipboardText(e, type) { + try { + return e.clipboardData ? (e.clipboardData.getData(type) || "") : ""; + } catch (err) { + return ""; + } + } + + async function readAsyncClipboardImages() { + if (!navigator.clipboard || !navigator.clipboard.read) return []; + try { + const items = await navigator.clipboard.read(); + const files = []; + for (const item of items) { + for (const type of item.types) { + if (type.indexOf("image/") !== 0) continue; + const blob = await item.getType(type); + if (!blob || !blob.size) continue; + const ext = (type.split("/")[1] || "png").replace("jpeg", "jpg"); + files.push(new File([blob], "clipboard." + ext, { type: blob.type || type })); + } + } + return files; + } catch (err) { + return []; + } + } + const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname); if (harness && location.protocol.indexOf("http") === 0) { document.addEventListener("paste", async function (e) { - const t = api(); - if (!t || !t.core) return; + if (window.__dshDesktopIngesting) return; + const ingest = window.__dshDesktopIngestFiles; + const fromEvent = window.__dshDesktopImageFilesFromEvent; + const pathLike = window.__dshDesktopLooksLikeImagePath; + if (!ingest) return; try { - const items = e.clipboardData ? Array.from(e.clipboardData.items) : []; - if (items.some(function (item) { return item.kind === "file" && item.getAsFile(); })) { - return; + const local = fromEvent ? fromEvent(e) : []; + const text = clipboardText(e, "text/plain"); + const uris = clipboardText(e, "text/uri-list"); + const steal = local.length > 0 + || (pathLike && (pathLike(text) || pathLike(uris))) + || !String(text).trim(); + if (!steal) return; + e.preventDefault(); + e.stopImmediatePropagation(); + window.__dshDesktopIngesting = true; + try { + if (local.length) { + await ingest(local); + return; + } + let files = await readAsyncClipboardImages(); + if (!files.length) { + const t = api(); + if (t && t.core) { + const payload = await t.core.invoke("read_clipboard_images"); + if (payload && payload.length && window.__dshDesktopPasteFiles) { + await window.__dshDesktopPasteFiles(payload); + return; + } + } + } + if (files.length) await ingest(files); + } finally { + window.__dshDesktopIngesting = false; } - const files = await t.core.invoke("read_clipboard_images"); - if (files && files.length) { - e.preventDefault(); - e.stopImmediatePropagation(); - if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files); - } - } catch (err) {} + } catch (err) { + window.__dshDesktopIngesting = false; + } }, true); } })(); diff --git a/ui/inject/ingest.js b/ui/inject/ingest.js index 02ae5e0..22049fc 100644 --- a/ui/inject/ingest.js +++ b/ui/inject/ingest.js @@ -1,45 +1,179 @@ (function () { const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i; + const IMAGE_MIME = /^image\/(png|jpe?g|gif|webp|bmp|tiff?)$/i; + function looksLikeImagePath(text) { const first = (text || "").trim().split(/\s+/, 1)[0] || ""; return IMAGE_PATH.test(first); } - document.addEventListener("paste", function (e) { - try { - const items = Array.from(e.clipboardData ? e.clipboardData.items : []); - const files = items.filter(function (item) { return item.kind === "file"; }) - .map(function (item) { return item.getAsFile(); }) - .filter(Boolean); - if (files.length > 0) return; - const text = e.clipboardData ? (e.clipboardData.getData("text/plain") || "") : ""; - const uris = e.clipboardData ? (e.clipboardData.getData("text/uri-list") || "") : ""; - if (looksLikeImagePath(text) || looksLikeImagePath(uris)) { - e.preventDefault(); - e.stopImmediatePropagation(); + + function addImageFile(out, seen, file) { + if (!file || !file.size) return; + const type = file.type || ""; + if (type && !IMAGE_MIME.test(type)) return; + if (!type && !IMAGE_PATH.test(file.name || "")) return; + const key = (file.name || "") + ":" + file.size + ":" + type; + if (seen.has(key)) return; + seen.add(key); + out.push(file); + } + + function imageFilesFromEvent(e) { + const out = []; + const seen = new Set(); + const data = e.clipboardData || e.dataTransfer; + if (!data) return out; + const items = data.items ? Array.from(data.items) : []; + for (const item of items) { + if (item.kind === "file" || (item.type && item.type.indexOf("image/") === 0)) { + try { + addImageFile(out, seen, item.getAsFile()); + } catch (err) {} } + } + if (data.files) { + for (const file of data.files) addImageFile(out, seen, file); + } + return out; + } + + function composer() { + const el = document.activeElement; + if (el && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable)) { + return el; + } + return document.querySelector("form textarea, textarea, [contenteditable='true']"); + } + + function insertText(target, text) { + const el = target && (target.tagName === "TEXTAREA" || target.tagName === "INPUT") + ? target + : composer(); + if (!el || (el.tagName !== "TEXTAREA" && el.tagName !== "INPUT")) return; + el.focus(); + let inserted = false; + try { + inserted = document.execCommand("insertText", false, text); + } catch (err) { + inserted = false; + } + if (!inserted) { + const proto = el.tagName === "TEXTAREA" + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, "value").set; + const start = el.selectionStart || el.value.length; + const end = el.selectionEnd || start; + setter.call(el, el.value.slice(0, start) + text + el.value.slice(end)); + el.dispatchEvent(new Event("input", { bubbles: true })); + } + } + + function dataTransferOf(files) { + const dt = new DataTransfer(); + for (const file of files) dt.items.add(file); + return dt; + } + + function dispatchWithData(type, target, dt, key) { + let ev; + try { + if (type === "paste") { + ev = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: dt }); + } else { + ev = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }); + } + } catch (err) { + ev = new Event(type, { bubbles: true, cancelable: true }); + } + try { + Object.defineProperty(ev, key, { value: dt, configurable: true }); } catch (err) {} - }, true); + target.dispatchEvent(ev); + return ev; + } + + function currentModelLabel() { + const buttons = document.querySelectorAll("button[aria-label]"); + for (const button of buttons) { + const label = button.getAttribute("aria-label") || ""; + if (/选择模型|select model|current model/i.test(label)) return label; + } + return ""; + } + + function shouldTakeoverPaste() { + const label = currentModelLabel(); + return fetch("/modlens/paste?model=" + encodeURIComponent(label)) + .then(function (res) { + if (!res.ok) return false; + return res.json().then(function (body) { + return body && body.takeover === true; + }); + }) + .catch(function () { + return false; + }); + } + + function uploadModlens(files) { + return Promise.all( + files.map(function (file) { + return file.arrayBuffer().then(function (buffer) { + return fetch("/modlens/paste", { method: "POST", body: buffer }).then(function (res) { + if (!res.ok) throw new Error("modlens paste failed " + res.status); + return res.json(); + }); + }); + }) + ).then(function (results) { + const text = results.map(function (r) { return r.path; }).filter(Boolean).join(" "); + if (text) insertText(composer(), text + " "); + }); + } + + function deliverNative(files) { + const prev = window.__dshDesktopIngesting; + window.__dshDesktopIngesting = true; + try { + const dt = dataTransferOf(files); + const target = composer() || document; + const paste = dispatchWithData("paste", target, dt, "clipboardData"); + if (paste.defaultPrevented) return; + dispatchWithData("drop", document, dt, "dataTransfer"); + } finally { + window.__dshDesktopIngesting = prev; + } + } + + window.__dshDesktopLooksLikeImagePath = looksLikeImagePath; + window.__dshDesktopImageFilesFromEvent = imageFilesFromEvent; + + window.__dshDesktopIngestFiles = function (files) { + if (!files || !files.length) return Promise.resolve(); + return shouldTakeoverPaste() + .then(function (takeover) { + if (takeover) return uploadModlens(files); + deliverNative(files); + }) + .catch(function (err) { + console.error("dsh-desktop paste image failed", err); + }); + }; window.__dshDesktopPasteFiles = function (items) { try { - const dt = new DataTransfer(); + const files = []; for (const item of items) { const bin = atob(item.b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - dt.items.add(new File([bytes], item.name, { type: item.type })); + files.push(new File([bytes], item.name, { type: item.type })); } - const drop = new DragEvent("drop", { - bubbles: true, - cancelable: true, - dataTransfer: dt - }); - try { - Object.defineProperty(drop, "dataTransfer", { value: dt, configurable: true }); - } catch (e) {} - document.dispatchEvent(drop); + return window.__dshDesktopIngestFiles(files); } catch (err) { console.error("dsh-desktop paste image failed", err); + return Promise.resolve(); } }; })();