Files
OneCAD-Draft/src/sys.rs
T
Hakan Seven 2ebad14586 fix(web): capture save previews from the browser canvas
Capture the rendered canvas during the clean frame instead of requesting synchronous GPU buffer readback. Preserve embedded previews and allow saving when canvas readback is denied. Add a browser save and reopen regression check.

Fixes #1021
2026-09-06 13:14:56 +03:00

490 lines
17 KiB
Rust

// Small platform shims for things the desktop build does natively but the web
// (wasm) build must handle differently or skip.
/// Open a URL in the user's browser.
///
/// Wayland requires an xdg-activation token from the source window before it
/// will let an existing browser window take focus. Linux obtains that token
/// from the OCS surface and passes it to xdg-open.
#[cfg(target_os = "linux")]
pub fn open_url<Message: Send + 'static>(
url: &str,
parent: Option<iced::window::Id>,
) -> iced::Task<Message> {
let url = url.to_string();
match parent {
Some(parent) => iced::window::run(parent, move |window| {
open_url_linux(&url, linux_activation_token(window));
})
.discard(),
None => {
let _ = open::that_detached(url);
iced::Task::none()
}
}
}
#[cfg(target_os = "linux")]
fn linux_activation_token(window: &dyn iced::window::Window) -> Option<String> {
use iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
let RawWindowHandle::Wayland(window_handle) =
window.window_handle().ok()?.as_raw()
else {
return None;
};
let RawDisplayHandle::Wayland(display_handle) =
window.display_handle().ok()?.as_raw()
else {
return None;
};
// Keep the iced window borrowed while ashpd uses its raw Wayland surface.
// The returned token is an owned string and is immediately handed to the
// child process.
unsafe {
iced::futures::executor::block_on(
ashpd::ActivationToken::from_wayland_raw(
None,
window_handle.surface.as_ptr(),
display_handle.display.as_ptr(),
),
)
}
.map(String::from)
}
#[cfg(target_os = "linux")]
fn open_url_linux(url: &str, activation_token: Option<String>) {
let Some(token) = activation_token else {
let _ = open::that_detached(url);
return;
};
let mut command = std::process::Command::new("xdg-open");
command
.arg(url)
.env("XDG_ACTIVATION_TOKEN", &token)
.env("DESKTOP_STARTUP_ID", token);
if let Ok(mut child) = command.spawn() {
std::thread::spawn(move || {
let _ = child.wait();
});
} else {
let _ = open::that_detached(url);
}
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "linux")))]
pub fn open_url<Message>(
url: &str,
_parent: Option<iced::window::Id>,
) -> iced::Task<Message> {
let _ = open::that_detached(url);
iced::Task::none()
}
/// Web opens the tab synchronously so the browser still sees the click as a
/// user gesture and does not block it as a pop-up.
#[cfg(target_arch = "wasm32")]
pub fn open_url<Message>(
url: &str,
_parent: Option<iced::window::Id>,
) -> iced::Task<Message> {
if let Some(window) = web_sys::window() {
let _ = window.open_with_url_and_target(url, "_blank");
}
iced::Task::none()
}
#[cfg(target_arch = "wasm32")]
thread_local! {
static BEFORE_UNLOAD_WARNING: std::cell::RefCell<
Option<wasm_bindgen::closure::Closure<dyn FnMut(web_sys::BeforeUnloadEvent)>>
> = const { std::cell::RefCell::new(None) };
}
/// Enable the browser's standard leave-page confirmation while drawings have
/// unsaved changes. Browsers control the dialog text; preventing the event and
/// setting `returnValue` are the portable signal that a warning is required.
///
/// The callback is installed only while needed so clean sessions remain
/// eligible for the browser back/forward cache.
#[cfg(target_arch = "wasm32")]
pub fn set_unsaved_changes_warning(active: bool) {
use wasm_bindgen::JsCast;
let Some(window) = web_sys::window() else {
return;
};
BEFORE_UNLOAD_WARNING.with(|warning| {
let mut warning = warning.borrow_mut();
if active == warning.is_some() {
return;
}
if active {
let callback = wasm_bindgen::closure::Closure::new(
move |event: web_sys::BeforeUnloadEvent| {
event.prevent_default();
event.set_return_value("");
},
);
window.set_onbeforeunload(Some(callback.as_ref().unchecked_ref()));
*warning = Some(callback);
} else {
window.set_onbeforeunload(None);
warning.take();
}
});
}
/// Reveal a saved drawing in the native platform's file manager.
#[cfg(not(target_arch = "wasm32"))]
pub fn reveal_in_file_manager(path: &std::path::Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
let status = std::process::Command::new("explorer.exe")
.arg("/select,")
.arg(path)
.status();
#[cfg(target_os = "macos")]
let status = std::process::Command::new("open")
.arg("-R")
.arg(path)
.status();
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let folder = path
.parent()
.ok_or_else(|| format!("Path has no parent folder: {}", path.display()))?;
return open::that(folder).map_err(|e| e.to_string());
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(format!("File manager exited with {status}")),
Err(error) => Err(error.to_string()),
}
}
/// Copy the rendered web canvas during the frame callback, before the browser
/// clears its drawing buffer. Canvas readback avoids Iced's synchronous GPU map.
#[cfg(target_arch = "wasm32")]
pub fn capture_canvas() -> Option<iced::window::Screenshot> {
use wasm_bindgen::JsCast;
let window = web_sys::window()?;
let document = window.document()?;
let source = document
.query_selector("canvas")
.ok()??
.dyn_into::<web_sys::HtmlCanvasElement>()
.ok()?;
let (width, height) = (source.width(), source.height());
if width == 0 || height == 0 {
return None;
}
let copy = document
.create_element("canvas")
.ok()?
.dyn_into::<web_sys::HtmlCanvasElement>()
.ok()?;
copy.set_width(width);
copy.set_height(height);
let context = copy
.get_context("2d")
.ok()??
.dyn_into::<web_sys::CanvasRenderingContext2d>()
.ok()?;
context
.draw_image_with_html_canvas_element(&source, 0.0, 0.0)
.ok()?;
let pixels = context
.get_image_data(0.0, 0.0, width as f64, height as f64)
.ok()?;
Some(iced::window::Screenshot::new(
pixels.data().0,
iced::Size::new(width, height),
window.device_pixel_ratio() as f32,
))
}
/// Web: read text from the system clipboard via the async Clipboard API.
/// iced's own `clipboard::read` is a no-op on the web (the browser clipboard is
/// async + permission-gated), so the editor paste paths use this instead. The
/// Ctrl+V keypress that drives it is a user gesture, so the read is permitted.
/// Returns `None` when denied, empty, or unsupported.
#[cfg(target_arch = "wasm32")]
pub async fn read_clipboard_text() -> Option<String> {
let clipboard = web_sys::window()?.navigator().clipboard();
let value = wasm_bindgen_futures::JsFuture::from(clipboard.read_text())
.await
.ok()?;
value.as_string()
}
/// Web: write text to the system clipboard (fire-and-forget). Backs Ctrl+C in
/// plain text_input fields, whose iced-internal copy is a no-op on the web
/// (#346). The triggering keypress is a user gesture, so the write is allowed.
#[cfg(target_arch = "wasm32")]
pub fn write_clipboard_text(text: &str) {
if let Some(window) = web_sys::window() {
let _ = window.navigator().clipboard().write_text(text);
}
}
/// Web: replay `text` into the focused iced widget as synthetic KeyboardEvents
/// dispatched on the winit canvas. This is the only route INTO a focused
/// text_input on the web — iced's clipboard read is a no-op there and it
/// exposes no insert-text operation (#346). Control characters are dropped
/// and the length capped so a runaway clipboard can't wedge the event loop.
#[cfg(target_arch = "wasm32")]
pub fn synthesize_typing(text: &str) {
let Some(canvas) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector("canvas").ok().flatten())
else {
return;
};
for ch in text.chars().filter(|c| !c.is_control()).take(1024) {
let init = web_sys::KeyboardEventInit::new();
init.set_key(&ch.to_string());
init.set_bubbles(true);
init.set_cancelable(true);
for kind in ["keydown", "keyup"] {
if let Ok(ev) =
web_sys::KeyboardEvent::new_with_keyboard_event_init_dict(kind, &init)
{
let _ = canvas.dispatch_event(&ev);
}
}
}
}
/// Turn an `rfd` file handle into a `PathBuf` the rest of the app keys on.
///
/// Desktop returns the real filesystem path. The browser has no path, so we
/// synthesize one from the file name — enough for the app to compile and track
/// the document name; actual byte I/O on the web reads the handle directly
/// (a follow-up).
#[cfg(not(target_arch = "wasm32"))]
pub fn handle_path(h: &rfd::FileHandle) -> std::path::PathBuf {
let p = h.path().to_path_buf();
// Every dialog result funnels through here — remember its folder so the
// next dialog opens where the user left off.
crate::config::remember_dialog_dir(&p);
p
}
#[cfg(target_arch = "wasm32")]
pub fn handle_path(h: &rfd::FileHandle) -> std::path::PathBuf {
std::path::PathBuf::from(h.file_name())
}
/// New async file dialog seeded with the last directory a dialog was used in.
/// All pickers should start from this instead of `rfd::AsyncFileDialog::new()`.
#[cfg(not(target_arch = "wasm32"))]
pub fn file_dialog() -> rfd::AsyncFileDialog {
let dlg = rfd::AsyncFileDialog::new();
match crate::config::last_dialog_dir() {
Some(dir) => dlg.set_directory(dir),
None => dlg,
}
}
/// Blocking native file dialog seeded with the last-used directory.
///
/// Use this through `iced::window::run` when the picker needs the main
/// window's raw handle. Portal-based desktops can otherwise reject or lose a
/// parentless save request without ever showing a dialog (#537).
#[cfg(not(target_arch = "wasm32"))]
pub fn blocking_file_dialog() -> rfd::FileDialog {
let dlg = rfd::FileDialog::new();
match crate::config::last_dialog_dir() {
Some(dir) => dlg.set_directory(dir),
None => dlg,
}
}
/// Web: no filesystem paths, nothing to remember.
#[cfg(target_arch = "wasm32")]
pub fn file_dialog() -> rfd::AsyncFileDialog {
rfd::AsyncFileDialog::new()
}
/// Trigger a browser download of `bytes` as `name`. Builds a Blob, points a
/// hidden `<a download>` at it and clicks it programmatically — because this
/// runs inside the Save button's click (a user gesture), the file downloads
/// immediately with no extra "click to download" link. Web only.
#[cfg(target_arch = "wasm32")]
pub fn download_bytes(name: &str, bytes: &[u8]) {
use wasm_bindgen::JsCast;
let Some(window) = web_sys::window() else { return };
let Some(document) = window.document() else { return };
let array = js_sys::Uint8Array::from(bytes);
let parts = js_sys::Array::new();
parts.push(&array.buffer());
let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence(&parts) else {
return;
};
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
return;
};
if let Ok(el) = document.create_element("a") {
let a: web_sys::HtmlAnchorElement = el.unchecked_into();
a.set_href(&url);
a.set_download(name);
a.click();
}
let _ = web_sys::Url::revoke_object_url(&url);
}
/// Short platform string for bug reports: OS + architecture on the desktop,
/// the browser user-agent on the web.
#[cfg(not(target_arch = "wasm32"))]
pub fn platform_info() -> String {
format!("{} {}", std::env::consts::OS, std::env::consts::ARCH)
}
#[cfg(target_arch = "wasm32")]
pub fn platform_info() -> String {
web_sys::window()
.and_then(|w| w.navigator().user_agent().ok())
.map(|ua| format!("Web — {ua}"))
.unwrap_or_else(|| "Web (wasm)".to_string())
}
/// Percent-encode `s` for use in a URL query value (e.g. a GitHub issue
/// `?body=`). Encodes everything outside the unreserved set.
pub fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Web renderer-error surface (#414): wgpu / naga report pipeline and shader
/// failures through the `log` facade and then leave the canvas empty — with no
/// logger installed the message is lost, so a broken GPU path looks like "the
/// app draws nothing" with a clean console. Mirror every Error-level record to
/// the browser console AND into a fixed DOM banner whose text is selectable
/// (the canvas UI is not), with a one-click Copy button, so a failing user can
/// paste the exact error into a bug report. Panics land in the same banner via
/// a chained hook.
#[cfg(target_arch = "wasm32")]
pub mod web_diag {
use std::sync::Mutex;
/// Cap on banner entries so a per-frame error can't grow the DOM forever.
const MAX_LINES: u32 = 12;
/// Last banner message + repeat count, for collapsing a hot error loop
/// into one line with an `(xN)` suffix.
static LAST: Mutex<(String, u32)> = Mutex::new((String::new(), 0));
struct BannerLogger;
impl log::Log for BannerLogger {
fn enabled(&self, meta: &log::Metadata) -> bool {
meta.level() <= log::Level::Error
}
fn log(&self, record: &log::Record) {
if record.level() > log::Level::Error {
return;
}
let msg = format!("[{}] {}", record.target(), record.args());
web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&msg));
show_banner(&msg);
}
fn flush(&self) {}
}
/// Install the logger + panic mirror. Call once at web startup, AFTER
/// `console_error_panic_hook::set_once` so the chained hook keeps the
/// console stack trace.
pub fn init() {
let console_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
show_banner(&info.to_string());
console_hook(info);
}));
if log::set_boxed_logger(Box::new(BannerLogger)).is_ok() {
log::set_max_level(log::LevelFilter::Error);
}
}
/// Append `msg` to the on-page banner, creating the overlay on first use.
fn show_banner(msg: &str) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let pre = match doc.get_element_by_id("ocs-err-text") {
Some(pre) => pre,
None => {
let Some(body) = doc.body() else { return };
let Ok(overlay) = doc.create_element("div") else {
return;
};
overlay.set_id("ocs-err");
let _ = overlay.set_attribute(
"style",
"position:fixed;top:0;left:0;right:0;z-index:2147483647;\
background:#5c1a1a;color:#ffdddd;font:12px monospace;\
padding:8px 12px;max-height:40vh;overflow:auto;\
user-select:text;cursor:text;",
);
// Inline `onclick` keeps this dependency-free (no JS closures):
// Copy puts the full error text on the clipboard; Dismiss
// removes the overlay so the app stays usable underneath.
overlay.set_inner_html(
"<div><b>OpenCADStudio renderer error</b> — please copy \
this into a bug report: \
<button style=\"margin-left:8px\" onclick=\"navigator.clipboard.writeText(\
document.getElementById('ocs-err-text').innerText)\">Copy</button> \
<button onclick=\"document.getElementById('ocs-err').remove()\">\
Dismiss</button></div>\
<pre id=\"ocs-err-text\" style=\"margin:6px 0 0;\
white-space:pre-wrap;user-select:text;\"></pre>",
);
let _ = body.append_child(&overlay);
match doc.get_element_by_id("ocs-err-text") {
Some(pre) => pre,
None => return,
}
}
};
// Collapse repeats: an error thrown every frame becomes one line with
// a running (xN) counter instead of MAX_LINES copies of itself.
let mut last = match LAST.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if last.0 == msg {
last.1 += 1;
if let Some(line) = pre.last_element_child() {
line.set_text_content(Some(&format!("{msg} (x{})", last.1)));
}
return;
}
*last = (msg.to_string(), 1);
if pre.child_element_count() >= MAX_LINES {
if let Some(first) = pre.first_element_child() {
first.remove();
}
}
if let Ok(line) = doc.create_element("div") {
line.set_text_content(Some(msg));
let _ = pre.append_child(&line);
}
}
}