Files
OneCAD-Draft/src/sys.rs
T

525 lines
19 KiB
Rust

// Small platform shims for things the desktop build does natively but the web
// (wasm) build must handle differently or skip.
/// Drawing hyperlinks may open web pages, never local files or custom handlers.
pub(crate) fn web_hyperlink(value: &str) -> Option<String> {
if value.chars().any(char::is_control) {
return None;
}
let url = url::Url::parse(value.trim()).ok()?;
(matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
.then(|| url.into())
}
#[test]
fn drawing_hyperlinks_only_open_web_pages() {
for value in ["https://example.com/path", " HTTP://example.com "] {
assert!(web_hyperlink(value).is_some(), "{value}");
}
for value in [
"", "https://", "javascript:alert(1)", "data:text/html,example",
"file:///tmp/program.desktop", "/tmp/program.desktop", "custom:run",
"mailto:user@example.com", "https://example.com/\npath",
] {
assert!(web_hyperlink(value).is_none(), "{value}");
}
}
/// 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_and_features(url, "_blank", "noopener,noreferrer");
}
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()
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")]
extern "C" {
#[wasm_bindgen::prelude::wasm_bindgen(js_name = copyHistory)]
pub fn copy_history_text(text: &str, fallback_label: &str, close_label: &str) -> js_sys::Promise;
}
/// 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;",
);
overlay.set_inner_html(
"<div><b id=\"ocs-err-title\"></b> \
<button id=\"ocs-err-copy\" style=\"margin-left:8px\" onclick=\"navigator.clipboard.writeText(\
document.getElementById('ocs-err-text').innerText)\"></button> \
<button id=\"ocs-err-dismiss\" onclick=\"document.getElementById('ocs-err').remove()\"></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);
for (id, label) in [
("ocs-err-title", crate::t!("OpenCADStudio renderer error — copy this into a bug report:")),
("ocs-err-copy", crate::t!("Copy")),
("ocs-err-dismiss", crate::t!("Dismiss")),
] {
if let Some(element) = doc.get_element_by_id(id) {
element.set_text_content(Some(label.as_ref()));
}
}
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);
}
}
}