forked from 1b-agent/fieldcraft-upstream
Every half hour the page fetches its own address past the browser's cache; when it loads another content-hashed build of the app than the running one, the status bar offers "Reload to update" (the page's unsaved-changes question still guards the reload). Rust only, no service worker: the browser build has no other update path and none of the desktop's download code. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
113 lines
4.9 KiB
Rust
113 lines
4.9 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 and the preferences are kept in the browser's local storage
|
||
//! ([`storage`], [`usage`]), and anonymous usage (when agreed) goes out with `fetch`;
|
||
//! - 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 deploy;
|
||
#[cfg(target_arch = "wasm32")]
|
||
mod storage;
|
||
#[cfg(target_arch = "wasm32")]
|
||
mod usage;
|
||
#[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 build of the app a page loads: the content-hashed name Trunk gives its WebAssembly
|
||
/// (`fieldcraft-web-<hash>_bg.wasm`). A new deployment loads another one.
|
||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||
fn build_in_page(html: &str) -> Option<&str> {
|
||
const HEAD: &str = "fieldcraft-web-";
|
||
const TAIL: &str = "_bg.wasm";
|
||
let mut rest = html;
|
||
while let Some(at) = rest.find(HEAD) {
|
||
let candidate = rest.get(at..)?;
|
||
let hash = candidate.get(HEAD.len()..)?;
|
||
let len = hash.bytes().take_while(u8::is_ascii_alphanumeric).count();
|
||
if len > 0 && hash.get(len..)?.starts_with(TAIL) {
|
||
return candidate.get(..HEAD.len() + len + TAIL.len());
|
||
}
|
||
rest = candidate.get(HEAD.len()..)?;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 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 the_running_build_is_found_in_the_page() {
|
||
let page = r#"<link rel="modulepreload" href="/fieldcraft/fieldcraft-web-1a2b3c.js"><link rel="preload" href="/fieldcraft/fieldcraft-web-1a2b3c_bg.wasm" as="fetch">"#;
|
||
assert_eq!(build_in_page(page), Some("fieldcraft-web-1a2b3c_bg.wasm"));
|
||
let next = page.replace("1a2b3c", "9f8e7d");
|
||
assert_ne!(build_in_page(page), build_in_page(&next));
|
||
// A development page (no hash) and other text: nothing to compare.
|
||
assert_eq!(build_in_page(r#"<script src="fieldcraft-web.js"></script>"#), None);
|
||
assert_eq!(build_in_page("fieldcraft-web-"), None);
|
||
assert_eq!(build_in_page(""), 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");
|
||
}
|
||
}
|