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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-16 12:39:08 +08:00
parent d6f5359873
commit 00209d55e6
7 changed files with 521 additions and 67 deletions

161
Cargo.lock generated
View File

@@ -114,6 +114,7 @@ dependencies = [
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -904,13 +905,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [ dependencies = [
"bit-set", "bit-set",
"cssparser", "cssparser",
"foldhash", "foldhash 0.2.0",
"html5ever", "html5ever",
"precomputed-hash", "precomputed-hash",
"selectors", "selectors",
"tendril", "tendril",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -943,6 +950,8 @@ dependencies = [
"arboard", "arboard",
"dsh-core", "dsh-core",
"env_logger", "env_logger",
"gdk",
"gtk",
"image", "image",
"log", "log",
"open", "open",
@@ -1157,6 +1166,12 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -1173,6 +1188,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@@ -1617,6 +1638,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -2329,6 +2359,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2601,6 +2640,16 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2661,6 +2710,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
@@ -4254,6 +4314,17 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -4519,6 +4590,76 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.104" version = "0.3.104"
@@ -5101,6 +5242,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 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]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"

View File

@@ -170,6 +170,25 @@ pub fn file_from_bytes(name: String, mime: String, data: Vec<u8>) -> Option<Clip
}) })
} }
pub fn detect_image_mime(data: &[u8]) -> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -187,9 +206,22 @@ mod tests {
#[test] #[test]
fn ingest_js_defines_helper() { fn ingest_js_defines_helper() {
assert!(INGEST_JS.contains("window.__dshDesktopPasteFiles")); 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("DragEvent"));
assert!(INGEST_JS.contains("new File")); 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] #[test]

View File

@@ -24,9 +24,13 @@ tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
arboard = "3" arboard = { version = "3", features = ["wayland-data-control"] }
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
open = "5" open = "5"
url = "2" url = "2"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
[target.'cfg(target_os = "linux")'.dependencies]
gdk = "0.18"
gtk = "0.18"

View File

@@ -1,11 +1,13 @@
use std::io::Cursor; use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use dsh_core::clipboard::{ 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::launcher::{DshLauncher, DshProcess, URL_TIMEOUT_SECONDS};
use dsh_core::modlens::ensure_modlens; use dsh_core::modlens::ensure_modlens;
@@ -134,39 +136,93 @@ fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
fn read_clipboard_images() -> Result<Vec<ClipboardFile>, String> { fn read_clipboard_images(app: AppHandle) -> Result<Vec<ClipboardFile>, String> {
read_images().map_err(|e| e.to_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<Vec<ClipboardFile>, String> { fn read_images() -> Result<Vec<ClipboardFile>, String> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; if let Some(file) = read_native_image() {
if let Ok(img) = clipboard.get_image() { return Ok(vec![file]);
}
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<ClipboardFile> {
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<ClipboardFile> {
None
}
fn read_arboard_image() -> Option<ClipboardFile> {
let mut clipboard = arboard::Clipboard::new().ok()?;
let img = clipboard.get_image().ok()?;
let width = img.width as u32; let width = img.width as u32;
let height = img.height as u32; let height = img.height as u32;
let bytes = img.bytes.into_owned(); let bytes = img.bytes.into_owned();
if let Some(buffer) = image::RgbaImage::from_raw(width, height, bytes) { let buffer = image::RgbaImage::from_raw(width, height, bytes)?;
let mut png = Vec::new(); let mut png = Vec::new();
if buffer buffer
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png) .write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.is_ok() .ok()?;
{ file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png)
if let Some(file) = file_from_bytes( }
filename_for_mime("image/png", 0),
"image/png".into(), fn read_arboard_image_paths() -> Option<Vec<ClipboardFile>> {
png, let mut clipboard = arboard::Clipboard::new().ok()?;
) { let text = clipboard.get_text().ok()?;
return Ok(vec![file]);
}
}
}
}
if let Ok(text) = clipboard.get_text() {
let loaded = files_from_paths(parse_uri_list(&text)); let loaded = files_from_paths(parse_uri_list(&text));
if !loaded.is_empty() { if loaded.is_empty() {
return Ok(loaded); None
} else {
Some(loaded)
}
}
fn read_cli_image() -> Option<ClipboardFile> {
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);
} }
} }
Ok(Vec::new()) None
} }
fn is_internal(url: &Url) -> bool { fn is_internal(url: &Url) -> bool {

View File

@@ -36,6 +36,22 @@ class VisionPluginFilesTests(unittest.TestCase):
self.assertIn("example: 'haiku'", host) 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): class BundledAttributionTests(unittest.TestCase):
def test_readme_cites_upstream_plugins(self): def test_readme_cites_upstream_plugins(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8")

View File

@@ -146,23 +146,76 @@
inject(); 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); const harness = /^(127\.0\.0\.1|localhost|\[::1\])$/.test(location.hostname);
if (harness && location.protocol.indexOf("http") === 0) { if (harness && location.protocol.indexOf("http") === 0) {
document.addEventListener("paste", async function (e) { document.addEventListener("paste", async function (e) {
const t = api(); if (window.__dshDesktopIngesting) return;
if (!t || !t.core) return; const ingest = window.__dshDesktopIngestFiles;
const fromEvent = window.__dshDesktopImageFilesFromEvent;
const pathLike = window.__dshDesktopLooksLikeImagePath;
if (!ingest) return;
try { try {
const items = e.clipboardData ? Array.from(e.clipboardData.items) : []; const local = fromEvent ? fromEvent(e) : [];
if (items.some(function (item) { return item.kind === "file" && item.getAsFile(); })) { const text = clipboardText(e, "text/plain");
return; const uris = clipboardText(e, "text/uri-list");
} const steal = local.length > 0
const files = await t.core.invoke("read_clipboard_images"); || (pathLike && (pathLike(text) || pathLike(uris)))
if (files && files.length) { || !String(text).trim();
if (!steal) return;
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
if (window.__dshDesktopPasteFiles) window.__dshDesktopPasteFiles(files); 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;
}
} catch (err) {
window.__dshDesktopIngesting = false;
} }
} catch (err) {}
}, true); }, true);
} }
})(); })();

View File

@@ -1,45 +1,179 @@
(function () { (function () {
const IMAGE_PATH = /^(?:file:\/\/|(?:\/|\.{1,2}\/)).+\.(?:png|jpe?g|gif|webp|bmp|tiff?)$/i; 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) { function looksLikeImagePath(text) {
const first = (text || "").trim().split(/\s+/, 1)[0] || ""; const first = (text || "").trim().split(/\s+/, 1)[0] || "";
return IMAGE_PATH.test(first); return IMAGE_PATH.test(first);
} }
document.addEventListener("paste", function (e) {
try { function addImageFile(out, seen, file) {
const items = Array.from(e.clipboardData ? e.clipboardData.items : []); if (!file || !file.size) return;
const files = items.filter(function (item) { return item.kind === "file"; }) const type = file.type || "";
.map(function (item) { return item.getAsFile(); }) if (type && !IMAGE_MIME.test(type)) return;
.filter(Boolean); if (!type && !IMAGE_PATH.test(file.name || "")) return;
if (files.length > 0) return; const key = (file.name || "") + ":" + file.size + ":" + type;
const text = e.clipboardData ? (e.clipboardData.getData("text/plain") || "") : ""; if (seen.has(key)) return;
const uris = e.clipboardData ? (e.clipboardData.getData("text/uri-list") || "") : ""; seen.add(key);
if (looksLikeImagePath(text) || looksLikeImagePath(uris)) { out.push(file);
e.preventDefault();
e.stopImmediatePropagation();
} }
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) {} } catch (err) {}
}, true); }
}
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) {}
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) { window.__dshDesktopPasteFiles = function (items) {
try { try {
const dt = new DataTransfer(); const files = [];
for (const item of items) { for (const item of items) {
const bin = atob(item.b64); const bin = atob(item.b64);
const bytes = new Uint8Array(bin.length); const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); 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", { return window.__dshDesktopIngestFiles(files);
bubbles: true,
cancelable: true,
dataTransfer: dt
});
try {
Object.defineProperty(drop, "dataTransfer", { value: dt, configurable: true });
} catch (e) {}
document.dispatchEvent(drop);
} catch (err) { } catch (err) {
console.error("dsh-desktop paste image failed", err); console.error("dsh-desktop paste image failed", err);
return Promise.resolve();
} }
}; };
})(); })();