feat: in-place save (File System Access), image paste, print wiring, web capture

The last high-severity web gap plus a small integration bundle.

In-place save: with the File System Access API (Chromium/Edge), Save
now works like desktop. Plain Save on a tab with a retained handle
writes straight through — no picker, no download, no "(1)" copies.
First save or format change opens showSaveFilePicker AT the confirm
gesture (the worker serialization that follows can outlast the
transient-activation window; retained handles need no activation to
write). Picker cancel cancels the save like desktop; picker or write
failure falls back to the legacy download with a note; Firefox/Safari
keep the download flow untouched (feature-detected). Handles are
keyed by tab id in a thread-local registry (Message must be Send —
JsValue cannot ride in messages or App state); closing a tab drops
its handle; Save As re-picks. Dirty clears only on successful
persistence, same race guard as before.

Image paste: Ctrl+V of a screenshot inserts it as an image reference
— the clipboard reader falls back to image/* items when neither the
entity clipboard nor text has anything (precedence preserved), the
bytes enter the image source registry as pasted-<timestamp>.<ext>,
and attach reuses the IMAGE command's model-creation path verbatim.

Browser print: beforeprint snapshots the WebGL canvas into a print-
only <img> (the capture_canvas technique), so the browser's own print
menu produces the drawing instead of a blank page.

Automation capture on web: the capture op no longer calls the
synchronous GPU-map screenshot (dead on wasm); main's frame-parked
capture waits for a presented frame and reads via capture_canvas — the
same workaround the save-thumbnail path uses — delivering the PNG as a
download.

(cherry picked from commit 6e89b36d)

Ported onto main (#1717 review): the web capture rides main's existing
frame-parked capture (one ControlCaptureFrame path, ignoring the timer
tick on wasm) instead of a second parked-capture state; closing a tab
drops its file handle inside main's on_tab_close.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent 0459f20129
commit 835e8d469f
33 files changed
+1084 -29

No files matched your search

+92
View File
@@ -311,6 +311,98 @@ pub async fn read_clipboard_text() -> Option<String> {
value.as_string()
}
/// Extension the pasted-image registry name carries for a clipboard MIME
/// type. Unknown image types fall back to `png` — the flavor every OS
/// screenshot paste uses — so the reference still names a decodable format.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // tested everywhere, used on wasm
pub(crate) fn clipboard_image_extension(mime_type: &str) -> &'static str {
match mime_type.trim().to_ascii_lowercase().as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" | "image/x-ms-bmp" | "image/x-bmp" => "bmp",
_ => "png",
}
}
#[test]
fn clipboard_image_extensions_cover_the_paste_flavors() {
assert_eq!(clipboard_image_extension("image/png"), "png");
assert_eq!(clipboard_image_extension("IMAGE/JPEG"), "jpg");
assert_eq!(clipboard_image_extension(" image/webp "), "webp");
assert_eq!(clipboard_image_extension("image/gif"), "gif");
assert_eq!(clipboard_image_extension("image/x-ms-bmp"), "bmp");
// Anything else (a vendor flavor, a bare "image/*") still gets a name
// the image registry can key and the `image` crate can decode.
assert_eq!(clipboard_image_extension("image/avif"), "png");
assert_eq!(clipboard_image_extension(""), "png");
}
/// Web: read an image from the system clipboard via the async Clipboard API
/// (`navigator.clipboard.read()`), used when a paste found no text. Returns
/// the registry name (`pasted-YYYYMMDD-HHMMSS.<ext>`) plus the image bytes,
/// or `None` when the clipboard holds no image item or the read is denied.
/// Like `read_clipboard_text`, the Ctrl+V keypress that reaches it is a user
/// gesture, which is what makes the permission-gated read allowed at all.
#[cfg(target_arch = "wasm32")]
pub async fn read_clipboard_image() -> Option<(String, Vec<u8>)> {
use wasm_bindgen::JsCast;
let clipboard = web_sys::window()?.navigator().clipboard();
let items = wasm_bindgen_futures::JsFuture::from(clipboard.read())
.await
.ok()?;
let items = js_sys::Array::from(&items);
for index in 0..items.length() {
let Ok(item) = items.get(index).dyn_into::<web_sys::ClipboardItem>() else {
continue;
};
// A clipboard item can offer several flavors; take the first image
// one (browsers list at most one per item in practice). An item with
// no image flavor just moves the search to the next one.
let Some(mime_type) = item
.types()
.iter()
.filter_map(|kind| kind.as_string())
.find(|mime| mime.starts_with("image/"))
else {
continue;
};
let blob = wasm_bindgen_futures::JsFuture::from(item.get_type(&mime_type))
.await
.ok()?
.dyn_into::<web_sys::Blob>()
.ok()?;
let buffer = wasm_bindgen_futures::JsFuture::from(blob.array_buffer())
.await
.ok()?;
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
if bytes.is_empty() {
return None;
}
return Some((pasted_image_name(&mime_type), bytes));
}
None
}
/// Registry name for a pasted clipboard image: `pasted-YYYYMMDD-HHMMSS.ext`
/// stamped from the wall clock (local time, the same clock a screenshot file
/// name uses) with the extension derived from the MIME type.
#[cfg(target_arch = "wasm32")]
fn pasted_image_name(mime_type: &str) -> String {
let now = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0));
format!(
"pasted-{:04}{:02}{:02}-{:02}{:02}{:02}.{}",
now.get_full_year(),
now.get_month() + 1,
now.get_date(),
now.get_hours(),
now.get_minutes(),
now.get_seconds(),
clipboard_image_extension(mime_type)
)
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")]
extern "C" {