forked from 1b-agent/fieldcraft-upstream
web: FieldCraft in the browser (apps/fieldcraft-web)
The desktop's ModelApp in a web page through eframe's web runner (WebGPU, WebGL2 fallback, `?webgl` to force it), built with Trunk. The browser services sit behind the UI's host-service seams: File > Open uses the browser's file picker, dropped files are read asynchronously, and both go into the page's files; Save, Save As and Write Deck hand the written file out as a download. Autosaves go to the recovery folder as on the desktop and are mirrored to local storage, so File > Recover offers what a closed or crashed page left. `?sample=NAME` opens a sample. A loading screen with the app icon, and a message instead of the app when neither WebGPU nor WebGL2 is available. `cargo xtask wasm` (and so `cargo xtask ci`) now checks fieldcraft-web for wasm32 with the library crates. docs/web.md: running it locally, what works in the browser and what does not. Part of #119 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
5011ae5896
commit
ebd2493051
13 files changed
+756
-15
No files matched your search
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "fieldcraft-web"
|
||||
description = "FieldCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "fieldcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
fieldcraft-engine = { workspace = true }
|
||||
fieldcraft-ui-egui = { workspace = true }
|
||||
fieldcraft-io = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
# `file-handle-inner`: a picked file's size, checked before it is read.
|
||||
rfd = { workspace = true, features = ["file-handle-inner"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage"] }
|
||||
# Autosaves kept in the browser's local storage, which holds text.
|
||||
base64 = "0.22"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Build with: trunk build --release (from apps/fieldcraft-web; docs/web.md)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8790
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>FieldCraft</title>
|
||||
<meta name="description" content="Open-source CAE modeling in Rust: mesh a part, set up the analysis and solve it, in your browser.">
|
||||
<link data-trunk rel="rust" data-bin="fieldcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" type="image/svg+xml" href="../../assets/app-icon/icon-small.svg" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/icon.iconset/[email protected]" data-target-path="icons" />
|
||||
<link rel="apple-touch-icon" href="icons/[email protected]">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262c37; }
|
||||
#fieldcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#fieldcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 16px; padding: 0 16px; text-align: center; color: #c3cad6; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#fieldcraft_loading img { width: 96px; height: 96px; }
|
||||
#fieldcraft_loading p { margin: 0; max-width: 32em; }
|
||||
#fieldcraft_loading.failed { pointer-events: auto; }
|
||||
#fieldcraft_loading.failed img, #fieldcraft_loading.failed .spinner { display: none; }
|
||||
.spinner { width: 24px; height: 24px; border-radius: 50%; border: 3px solid #4a5468; border-top-color: #c3cad6; animation: spin 0.9s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fieldcraft_canvas"></canvas>
|
||||
<div id="fieldcraft_loading">
|
||||
<img src="icons/[email protected]" alt="">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading FieldCraft…</p>
|
||||
<noscript><p>FieldCraft needs JavaScript and WebAssembly.</p></noscript>
|
||||
</div>
|
||||
<script>
|
||||
// The page could not load or start the app (no WebAssembly, a failed download): say so
|
||||
// instead of loading forever. Once the app runs it removes the loading screen.
|
||||
(function () {
|
||||
function fail(why) {
|
||||
var el = document.getElementById("fieldcraft_loading");
|
||||
if (!el || el.classList.contains("failed")) return;
|
||||
el.classList.add("failed");
|
||||
el.textContent = "";
|
||||
["FieldCraft could not start: " + why, "It needs a current browser with WebAssembly and WebGPU or WebGL2."].forEach(function (t) {
|
||||
var p = document.createElement("p");
|
||||
p.textContent = t;
|
||||
el.appendChild(p);
|
||||
});
|
||||
}
|
||||
if (typeof WebAssembly !== "object") fail("this browser has no WebAssembly.");
|
||||
addEventListener("unhandledrejection", function (e) { fail(String(e.reason && e.reason.message || e.reason)); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
//! FieldCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`fieldcraft_ui_egui::ModelApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`); docs/web.md says what works in the browser.
|
||||
//!
|
||||
//! Differences from the desktop app, all behind the UI's host services:
|
||||
//! - files live in the page ([`fieldcraft_io::fs`] keeps them in memory): File › Open and drops
|
||||
//! read the computer's files into it; Save, Save As and Write Deck hand the written file out as
|
||||
//! a download;
|
||||
//! - autosaves for recovery are kept in the browser's local storage ([`storage`]);
|
||||
//! - no control channel (a page can't listen on a socket);
|
||||
//! - background jobs (meshing, solving) run on the page's one thread once the window has shown
|
||||
//! that they run.
|
||||
//!
|
||||
//! Address flags: `?sample` or `?sample=cantilever` opens a sample, `?webgl` forces WebGL2.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod storage;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("fieldcraft-web only runs in the browser: build it with `trunk build --release` in apps/fieldcraft-web (docs/web.md)");
|
||||
}
|
||||
|
||||
/// The sample an address asks for (`?sample` opens the first sample, `?sample=NAME` that one).
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn sample_in_query(query: &str) -> Option<String> {
|
||||
let first = fieldcraft_engine::sample::SAMPLES.first().map_or("", |s| s.name);
|
||||
query.trim_start_matches('?').split('&').find_map(|kv| {
|
||||
let (key, value) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
(key == "sample").then(|| if value.is_empty() { first } else { value }.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// The name a file from the computer is kept under in the page: its last path part, without
|
||||
/// characters a path would read as folders.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn safe_name(name: &str) -> String {
|
||||
let last = name.rsplit(['/', '\\']).next().unwrap_or_default();
|
||||
let clean: String = last.chars().filter(|c| !c.is_control()).take(200).collect();
|
||||
if clean.is_empty() || clean == "." || clean == ".." { "file".to_string() } else { clean }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn samples_from_the_address() {
|
||||
assert_eq!(sample_in_query(""), None);
|
||||
assert_eq!(sample_in_query("?sample").as_deref(), Some("parts"));
|
||||
assert_eq!(sample_in_query("?webgl&sample=cantilever").as_deref(), Some("cantilever"));
|
||||
assert_eq!(sample_in_query("?sample=").as_deref(), Some("parts"));
|
||||
assert_eq!(sample_in_query("?samples=x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_stay_in_their_folder() {
|
||||
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
|
||||
assert_eq!(safe_name("../../etc/passwd"), "passwd");
|
||||
assert_eq!(safe_name("C:\\work\\plate.solidcraft"), "plate.solidcraft");
|
||||
assert_eq!(safe_name(".."), "file");
|
||||
assert_eq!(safe_name(""), "file");
|
||||
assert_eq!(safe_name("a\u{0}b.3mf"), "ab.3mf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Autosaves kept in the browser.
|
||||
//!
|
||||
//! The engine's recovery folder is [`fieldcraft_io::recovery::BROWSER_DIR`] among the page's
|
||||
//! files, which last only as long as the page. This mirrors it to the browser's local storage
|
||||
//! (kept across visits, shared by the site's tabs): each file as base64 under
|
||||
//! [`PREFIX`]`<file name>`. At start the stored files go back into the folder, so the desktop's
|
||||
//! recovery flow (File › Recover…, `file.recovery.*`) offers what a closed or crashed page left.
|
||||
//!
|
||||
//! Local storage holds a few megabytes per site: an autosave larger than [`MAX_AUTOSAVE_BYTES`]
|
||||
//! (or one the browser refuses) is not kept, and the status bar says so.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use fieldcraft_io::recovery::BROWSER_DIR;
|
||||
|
||||
const PREFIX: &str = "fieldcraft-recovery/";
|
||||
|
||||
/// Largest stored file taken back (local storage itself holds less).
|
||||
const MAX_STORED_CHARS: usize = 64 << 20;
|
||||
|
||||
/// Largest autosave kept: base64 makes it a third larger, and browsers give a site about 5 MB.
|
||||
pub const MAX_AUTOSAVE_BYTES: usize = 3 << 20;
|
||||
|
||||
fn local_storage() -> Result<web_sys::Storage, String> {
|
||||
web_sys::window().ok_or("no window")?.local_storage().map_err(crate::web::js_err)?.ok_or_else(|| "browser storage is turned off".into())
|
||||
}
|
||||
|
||||
/// The mirror: the stored files this page knows of (took back or wrote), by file name.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
known: BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub fn dir() -> &'static Path {
|
||||
Path::new(BROWSER_DIR)
|
||||
}
|
||||
|
||||
/// A file name of the recovery folder that is safe as a key (the engine's ids and extensions).
|
||||
fn valid(name: &str) -> bool {
|
||||
!name.is_empty() && name.len() <= 100 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') && !name.starts_with('.')
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Put the stored files back into the recovery folder.
|
||||
pub fn load() -> Store {
|
||||
let mut store = Store::default();
|
||||
let s = match local_storage() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("autosaves: {e}");
|
||||
return store;
|
||||
}
|
||||
};
|
||||
let keys: Vec<String> = (0..s.length().unwrap_or(0)).filter_map(|i| s.key(i).ok().flatten()).collect();
|
||||
for key in keys {
|
||||
let Some(name) = key.strip_prefix(PREFIX).filter(|n| valid(n)) else { continue };
|
||||
let Some(text) = s.get_item(&key).ok().flatten().filter(|t| t.len() <= MAX_STORED_CHARS) else { continue };
|
||||
let Ok(bytes) = STANDARD.decode(text.as_bytes()).map_err(|e| log::warn!("autosave {name} is damaged: {e}")) else { continue };
|
||||
match fieldcraft_io::fs::insert(&dir().join(name), bytes) {
|
||||
Ok(()) => {
|
||||
store.known.insert(name.to_string());
|
||||
}
|
||||
Err(e) => log::warn!("autosave {name}: {e}"),
|
||||
}
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
/// Keep a file the engine wrote in the recovery folder.
|
||||
pub fn put(&mut self, path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let name =
|
||||
path.file_name().and_then(|n| n.to_str()).filter(|n| valid(n)).ok_or_else(|| format!("{}: not a recovery file", path.display()))?;
|
||||
if bytes.len() > MAX_AUTOSAVE_BYTES {
|
||||
return Err(format!("the model ({} MB) is too large for the browser's storage", bytes.len() >> 20));
|
||||
}
|
||||
self.known.insert(name.to_string());
|
||||
local_storage()?
|
||||
.set_item(&format!("{PREFIX}{name}"), &STANDARD.encode(bytes))
|
||||
.map_err(|e| format!("browser storage is full or turned off ({})", crate::web::js_err(e)))
|
||||
}
|
||||
|
||||
/// Drop the stored files whose entries left the recovery folder (cleared once saved,
|
||||
/// recovered and discarded). Only files this page knows of: another tab's stay.
|
||||
pub fn sync_removals(&mut self) {
|
||||
let present: BTreeSet<String> =
|
||||
fieldcraft_io::fs::list(dir()).iter().filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string)).collect();
|
||||
let gone: Vec<String> = self.known.difference(&present).cloned().collect();
|
||||
if gone.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(s) = local_storage() else { return };
|
||||
for name in gone {
|
||||
if s.remove_item(&format!("{PREFIX}{name}")).is_ok() {
|
||||
self.known.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The browser shell: the web services of the UI (file pickers, downloads), dropped files, the
|
||||
//! autosave, and eframe's web runner.
|
||||
//!
|
||||
//! Structure follows VectorCraft's `apps/vectorcraft-web/src/web.rs` and SolidCraft's
|
||||
//! `apps/solidcraft-web/src/main.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_engine::autosave::Autosaver;
|
||||
use fieldcraft_ui_egui::files::Kind;
|
||||
use fieldcraft_ui_egui::{Filter, ModelApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::storage::{self, Store};
|
||||
|
||||
const CANVAS_ID: &str = "fieldcraft_canvas";
|
||||
const LOADING_ID: &str = "fieldcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// The page's folder for files from the computer and the files commands write.
|
||||
const FILES_DIR: &str = "/files";
|
||||
|
||||
/// How often unsaved changes are autosaved for recovery (seconds), as on the desktop.
|
||||
const AUTOSAVE_SECONDS: f64 = 30.0;
|
||||
|
||||
/// Files read from the computer (picked together or dropped together), waiting for the next
|
||||
/// frame: (name, bytes or why they could not be read) each.
|
||||
type Batch = Vec<(String, Result<Vec<u8>, String>)>;
|
||||
type Inbox = Rc<RefCell<Vec<Batch>>>;
|
||||
|
||||
/// Largest file read from the computer: what the page can hold.
|
||||
const MAX_READ_BYTES: f64 = fieldcraft_io::memory::MAX_TOTAL_BYTES as f64;
|
||||
|
||||
/// Why a file of `size` bytes is not read, if it is too large.
|
||||
fn too_large(size: f64) -> Option<String> {
|
||||
(size > MAX_READ_BYTES)
|
||||
.then(|| format!("{} MB is more than this page holds ({} MB)", (size / 1048576.0).round(), fieldcraft_io::memory::MAX_TOTAL_BYTES >> 20))
|
||||
}
|
||||
|
||||
/// Hand files read from the computer to the next frame.
|
||||
fn deliver(inbox: &Inbox, ctx: &egui::Context, batch: Batch) {
|
||||
inbox.borrow_mut().push(batch);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Where a file named `name` is kept in the page.
|
||||
fn page_path(name: &str) -> String {
|
||||
format!("{FILES_DIR}/{}", crate::safe_name(name))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The model has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
pub fn js_err(e: wasm_bindgen::JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn element(id: &str) -> Option<web_sys::Element> {
|
||||
web_sys::window()?.document()?.get_element_by_id(id)
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
// The browser's clock for the recovery entries (the plain wasm target has none).
|
||||
fieldcraft_io::recovery::set_clock(|| js_sys::Date::now().max(0.0) as u64);
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
web_options(),
|
||||
Box::new(|cc| {
|
||||
let inbox = Inbox::default();
|
||||
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let info = rs.adapter.get_info();
|
||||
log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx);
|
||||
}
|
||||
// The autosaves a closed or crashed page left, back in the recovery folder.
|
||||
let store = Store::load();
|
||||
let listed = app.session.execute("file.recovery.list", &serde_json::json!({}));
|
||||
let recoverable = listed.ok().and_then(|v| v.as_array().map(Vec::len)).unwrap_or(0);
|
||||
let autosaver = Autosaver::new(storage::dir()).map_err(|e| log::warn!("autosave unavailable: {e}")).ok();
|
||||
if let Some(name) = crate::sample_in_query(&query()) {
|
||||
app.open_sample(&name);
|
||||
}
|
||||
if recoverable > 0 {
|
||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
|
||||
}
|
||||
Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Err(e) => failed(&format!("FieldCraft could not start: {}", js_err(e))),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2).
|
||||
fn web_options() -> eframe::WebOptions {
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().split(['?', '&']).any(|kv| kv == "webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
/// Replace the loading screen with why the app could not start.
|
||||
fn failed(why: &str) {
|
||||
log::error!("{why}");
|
||||
let Some(el) = element(LOADING_ID) else { return };
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
el.set_text_content(None);
|
||||
// Best effort: the console has the message too.
|
||||
let _ = el.class_list().add_1("failed");
|
||||
for line in
|
||||
[why, "FieldCraft needs a browser with WebGPU or WebGL2 (a current Chrome, Edge, Firefox or Safari) and graphics acceleration turned on."]
|
||||
{
|
||||
if let Ok(p) = document.create_element("p") {
|
||||
p.set_text_content(Some(line));
|
||||
let _ = el.append_child(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while the model has
|
||||
/// unsaved changes (a page that is closing can't show its own dialog).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
if let Err(e) = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref()) {
|
||||
log::warn!("couldn't ask before leaving: {}", js_err(e));
|
||||
}
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
/// The UI's host services in the browser.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
Services {
|
||||
// The browser's file picker: the files arrive in a later frame (the picker and the reads
|
||||
// are asynchronous), so the call itself picks nothing.
|
||||
pick_open: Some(Box::new(move |filters: &[Filter]| {
|
||||
let (inbox, ctx) = (inbox.clone(), ctx.clone());
|
||||
let dialog = filters.iter().fold(rfd::AsyncFileDialog::new(), |d, f| d.add_filter(f.name, f.extensions));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(files) = dialog.pick_files().await else { return };
|
||||
let mut batch = Vec::with_capacity(files.len());
|
||||
for f in files {
|
||||
let read = match too_large(f.inner().size()) {
|
||||
Some(why) => Err(why),
|
||||
None => Ok(f.read().await),
|
||||
};
|
||||
batch.push((f.file_name(), read));
|
||||
}
|
||||
deliver(&inbox, &ctx, batch);
|
||||
});
|
||||
None
|
||||
})),
|
||||
// Saving writes into the page; the shell hands each written file out as a download.
|
||||
pick_save: Some(Box::new(|name: &str, _: &[Filter]| Some(page_path(name)))),
|
||||
// The recent list holds the page's files, which go with the page.
|
||||
recent_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The app with the browser's files around it.
|
||||
struct Shell {
|
||||
app: ModelApp,
|
||||
inbox: Inbox,
|
||||
store: Store,
|
||||
/// `None` when autosave could not start.
|
||||
autosaver: Option<Autosaver>,
|
||||
/// App time of the last autosave check.
|
||||
autosaved_at: f64,
|
||||
/// The last autosave did not fit in the browser's storage (said once until one fits).
|
||||
storage_error: bool,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Files read from the computer go into the page's folder, then open: a model with the
|
||||
/// design and meshes it refers to picked or dropped with it, else each file in turn.
|
||||
fn open_arrived(&mut self) {
|
||||
let batches: Vec<_> = self.inbox.borrow_mut().drain(..).collect();
|
||||
for batch in batches {
|
||||
let mut paths = Vec::with_capacity(batch.len());
|
||||
for (name, read) in batch {
|
||||
let path = page_path(&name);
|
||||
match read.and_then(|bytes| fieldcraft_io::fs::insert(Path::new(&path), bytes).map_err(|e| e.to_string())) {
|
||||
Ok(()) => paths.push(path),
|
||||
Err(e) => self.app.set_status(format!("{name}: {e}"), true),
|
||||
}
|
||||
}
|
||||
let model = paths.iter().find(|p| Kind::of(p) == Some(Kind::Model)).cloned();
|
||||
match model {
|
||||
// Its geometry files open with it, from the same folder.
|
||||
Some(model) => self.app.open_path(&model),
|
||||
None => {
|
||||
for p in &paths {
|
||||
self.app.open_path(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What commands wrote: autosaves to the browser's storage, everything else downloaded.
|
||||
fn hand_out_writes(&mut self) {
|
||||
for (path, bytes) in fieldcraft_io::fs::take_writes() {
|
||||
if path.parent() == Some(storage::dir()) {
|
||||
match self.store.put(&path, &bytes) {
|
||||
Ok(()) => self.storage_error = false,
|
||||
Err(e) if !self.storage_error => {
|
||||
self.storage_error = true;
|
||||
self.app.set_status(format!("Autosave: {e}. Save the model to keep it (File › Save downloads it)"), true);
|
||||
}
|
||||
Err(e) => log::warn!("autosave: {e}"),
|
||||
}
|
||||
} else if let Err(e) = download(&path, &bytes) {
|
||||
self.app.set_status(format!("{}: the download did not start ({e})", path.display()), true);
|
||||
}
|
||||
}
|
||||
self.store.sync_removals();
|
||||
}
|
||||
|
||||
fn autosave(&mut self, ctx: &egui::Context) {
|
||||
let Some(a) = &mut self.autosaver else { return };
|
||||
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
|
||||
self.autosaved_at = self.app.now;
|
||||
if let Err(e) = a.save(&self.app.session) {
|
||||
log::warn!("autosave: {e}");
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.open_arrived();
|
||||
self.app.logic(ctx);
|
||||
self.autosave(ctx);
|
||||
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
|
||||
}
|
||||
|
||||
/// Dropped files are read here (browsers read them asynchronously) and arrive through the
|
||||
/// inbox; the app never sees their names as paths.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
let dropped = std::mem::take(&mut raw.dropped_files);
|
||||
if !dropped.is_empty() {
|
||||
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let mut batch = Vec::with_capacity(dropped.len());
|
||||
for f in dropped {
|
||||
let name = f.path().to_string_lossy().to_string();
|
||||
let read = match f.web_file().and_then(|w| too_large(w.size())) {
|
||||
Some(why) => Err(why),
|
||||
None => f.bytes_async().await,
|
||||
};
|
||||
batch.push((name, read));
|
||||
}
|
||||
deliver(&inbox, &ctx, batch);
|
||||
});
|
||||
}
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
// Once a frame, after everything that writes: autosaves, saves and decks.
|
||||
self.hand_out_writes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand `bytes` out as a download named after the last part of `path`.
|
||||
fn download(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("fieldcraft").to_string();
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let options = web_sys::BlobPropertyBag::new();
|
||||
options.set_type("application/octet-stream");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_err)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js_err)?.dyn_into().map_err(|_| "not a link")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
a.style().set_property("display", "none").map_err(js_err)?;
|
||||
document.body().ok_or("no body")?.append_child(&a).map_err(js_err)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoked once the click has been dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
// The download has started: a timer that can't be set leaves the URL allocated, no more.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user