From 31bc886c7a7cfbad8eac78707fd14536da154771 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:04:52 -0400 Subject: [PATCH] Web: usage through fetch, preferences in local storage (#238) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- apps/fieldcraft-web/Cargo.toml | 2 +- apps/fieldcraft-web/src/main.rs | 5 +- apps/fieldcraft-web/src/usage.rs | 86 ++++++++++++++++++++++++++++++++ apps/fieldcraft-web/src/web.rs | 17 +++++-- 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 apps/fieldcraft-web/src/usage.rs diff --git a/apps/fieldcraft-web/Cargo.toml b/apps/fieldcraft-web/Cargo.toml index e68b258..3261f05 100644 --- a/apps/fieldcraft-web/Cargo.toml +++ b/apps/fieldcraft-web/Cargo.toml @@ -29,6 +29,6 @@ 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"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage", "Crypto", "Headers", "Request", "RequestInit", "Response"] } # Autosaves kept in the browser's local storage, which holds text. base64 = "0.22" diff --git a/apps/fieldcraft-web/src/main.rs b/apps/fieldcraft-web/src/main.rs index 0e31a68..06637a9 100644 --- a/apps/fieldcraft-web/src/main.rs +++ b/apps/fieldcraft-web/src/main.rs @@ -8,7 +8,8 @@ //! - 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`]); +//! - 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. @@ -20,6 +21,8 @@ #[cfg(target_arch = "wasm32")] mod storage; #[cfg(target_arch = "wasm32")] +mod usage; +#[cfg(target_arch = "wasm32")] mod web; #[cfg(target_arch = "wasm32")] diff --git a/apps/fieldcraft-web/src/usage.rs b/apps/fieldcraft-web/src/usage.rs new file mode 100644 index 0000000..1e3ccd2 --- /dev/null +++ b/apps/fieldcraft-web/src/usage.rs @@ -0,0 +1,86 @@ +//! Anonymous usage in the browser (docs/privacy.md): the same events and the same consent as the +//! desktop app, the choice kept in the browser's local storage with the other preferences. Queued +//! events are posted with `fetch` every half minute and when the page is closed or left (`keepalive`, so +//! a closing page still sends them); a failed post is dropped silently. +//! +//! The project key is a build setting, `FIELDCRAFT_POSTHOG_KEY`: a build without one asks nothing +//! and sends nothing. + +use fieldcraft_engine::usage; +use fieldcraft_ui_egui::files::PrefsStore; +use fieldcraft_ui_egui::privacy::UsageHost; +use wasm_bindgen::JsCast as _; + +const KEY: Option<&str> = option_env!("FIELDCRAFT_POSTHOG_KEY"); +/// Where the preferences are kept in local storage. +const PREFS_KEY: &str = "fieldcraft-prefs"; +/// How often queued events are sent (seconds). +pub const INTERVAL: f64 = 30.0; + +fn key() -> Option<&'static str> { + KEY.map(str::trim).filter(|k| !k.is_empty()) +} + +fn local_storage() -> Option { + web_sys::window()?.local_storage().ok().flatten() +} + +/// The preferences in local storage (navigation, privacy). +pub fn prefs_store() -> PrefsStore { + PrefsStore { + load: Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten().map(String::into_bytes)), + save: Box::new(|bytes| { + let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?; + local_storage().ok_or("browser storage is turned off")?.set_item(PREFS_KEY, text).map_err(crate::web::js_err) + }), + } +} + +/// What the window needs to ask and record, when the build has a key. +pub fn host() -> Option { + // The browser's clock for command durations (the plain wasm target has none). + usage::set_clock(js_sys::Date::now); + key().is_some().then(|| UsageHost { + platform: "web", + os: "web", + arch: "wasm32", + new_id: Box::new(|| web_sys::window()?.crypto().ok().map(|c| c.random_uuid())), + }) +} + +/// Post what is queued. +pub fn send() { + let (Some(key), Some(window)) = (key(), web_sys::window()) else { return }; + let url = format!("{}/batch/", usage::DEFAULT_HOST); + while let Some(batch) = usage::take_batch() { + let init = web_sys::RequestInit::new(); + init.set_method("POST"); + init.set_body(&wasm_bindgen::JsValue::from_str(&batch.body(key))); + // The Fetch standard's `keepalive` (a closing page still sends it); web-sys has no setter. + let _ = js_sys::Reflect::set(&init, &"keepalive".into(), &true.into()); + let headers = web_sys::Headers::new().ok(); + if let Some(h) = &headers { + let _ = h.set("Content-Type", "application/json"); + init.set_headers(h); + } + let promise = window.fetch_with_str_and_init(&url, &init); + wasm_bindgen_futures::spawn_local(async move { + // Offline or refused: the batch is dropped. + if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await { + log::debug!("usage: not sent: {}", crate::web::js_err(e)); + } + }); + } +} + +/// When the page is closed or left: the session's length, then send. +pub fn send_when_hidden(window: &web_sys::Window) { + let on_hide = wasm_bindgen::closure::Closure::::new(|| { + usage::closing(); + send(); + }); + if let Err(e) = window.add_event_listener_with_callback("pagehide", on_hide.as_ref().unchecked_ref()) { + log::warn!("usage: {}", crate::web::js_err(e)); + } + on_hide.forget(); +} diff --git a/apps/fieldcraft-web/src/web.rs b/apps/fieldcraft-web/src/web.rs index 5dda3f9..cbb65e4 100644 --- a/apps/fieldcraft-web/src/web.rs +++ b/apps/fieldcraft-web/src/web.rs @@ -74,6 +74,7 @@ pub fn start() { 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); + crate::usage::send_when_hidden(&window); let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { log::error!("missing "); return; @@ -86,6 +87,10 @@ pub fn start() { Box::new(|cc| { let inbox = Inbox::default(); let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone())); + // Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md). + if let Some(host) = crate::usage::host() { + app = app.with_usage(host); + } 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); @@ -102,7 +107,7 @@ pub fn start() { 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 })) + Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false, sent_at: 0.0 })) }), ) .await; @@ -188,8 +193,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { 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, + // Preferences are kept in the browser's local storage. + prefs: Some(crate::usage::prefs_store()), // egui opens links itself in the browser. open_url: None, } @@ -206,6 +211,8 @@ struct Shell { autosaved_at: f64, /// The last autosave did not fit in the browser's storage (said once until one fits). storage_error: bool, + /// App time usage events were last sent. + sent_at: f64, } impl Shell { @@ -271,6 +278,10 @@ impl eframe::App for Shell { self.open_arrived(); self.app.logic(ctx); self.autosave(ctx); + if self.app.now - self.sent_at >= crate::usage::INTERVAL { + self.sent_at = self.app.now; + crate::usage::send(); + } UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty())); }