forked from 1b-agent/fieldcraft-upstream
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
77 lines
3.2 KiB
Rust
77 lines
3.2 KiB
Rust
//! 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");
|
||
}
|
||
}
|