mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
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:
161
Cargo.lock
generated
161
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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)]
|
||||
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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,39 +136,93 @@ fn open_in_browser(state: tauri::State<AppState>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_clipboard_images() -> Result<Vec<ClipboardFile>, String> {
|
||||
read_images().map_err(|e| e.to_string())
|
||||
fn read_clipboard_images(app: AppHandle) -> Result<Vec<ClipboardFile>, 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> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
if let Ok(img) = clipboard.get_image() {
|
||||
if let Some(file) = read_native_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 height = img.height as u32;
|
||||
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();
|
||||
if buffer
|
||||
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]);
|
||||
.ok()?;
|
||||
file_from_bytes(filename_for_mime("image/png", 0), "image/png".into(), png)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
|
||||
fn read_arboard_image_paths() -> Option<Vec<ClipboardFile>> {
|
||||
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() {
|
||||
return Ok(loaded);
|
||||
if loaded.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(loaded)
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_internal(url: &Url) -> bool {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 files = await t.core.invoke("read_clipboard_images");
|
||||
if (files && files.length) {
|
||||
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();
|
||||
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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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) {}
|
||||
}, 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) {
|
||||
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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user