forked from 1b-agent/fieldcraft-upstream
The desktop app installs the crash reporter and a log file at start (both in a temporary folder in --control and CI runs, #266), journals the window's commands (background jobs included) and every control request's method, and ends the journal on a clean exit. A start after a crash asks: "FieldCraft closed unexpectedly", Report It… or Not Now. Help › Report a Bug (also Tools › Help and ui.start diagnostics.bug_report) shows exactly what the GitHub bug form will be filled with: the version, the OS, the last error, and for a crash where it happened. Share my session adds the replayable commands, Include the model file the model. Open GitHub Issue opens the form in the browser through the system's opener; files to attach are saved to bug-reports/<time>/ and a long session goes on the clipboard. Nothing is sent without that click and the user's submit on GitHub. The control channel answers the new prompts (crashed: report, close; report_bug: set, close) and ui.inspect shows the draft; it never opens the browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
331 lines
14 KiB
Rust
331 lines
14 KiB
Rust
//! 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,
|
||
// Preferences last for the page's run.
|
||
prefs_path: None,
|
||
// egui opens links itself in the browser.
|
||
open_url: 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(())
|
||
}
|