forked from 1b-agent/fieldcraft-upstream
Web: usage through fetch, preferences in local storage (#238)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
ed9ec7748d
commit
31bc886c7a
4 files changed
+105
-5
No files matched your search
@@ -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"
|
||||
@@ -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")]
|
||||
|
||||
@@ -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::Storage> {
|
||||
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<UsageHost> {
|
||||
// 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::<dyn FnMut()>::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();
|
||||
}
|
||||
@@ -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::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
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()));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user