forked from 1b-agent/fieldcraft-upstream
Web: usage through fetch, preferences in local storage (#238)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 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 = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
js-sys = "0.3"
|
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.
|
# Autosaves kept in the browser's local storage, which holds text.
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
//! - files live in the page ([`fieldcraft_io::fs`] keeps them in memory): File › Open and drops
|
//! - 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
|
//! read the computer's files into it; Save, Save As and Write Deck hand the written file out as
|
||||||
//! a download;
|
//! 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);
|
//! - 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
|
//! - background jobs (meshing, solving) run on the page's one thread once the window has shown
|
||||||
//! that they run.
|
//! that they run.
|
||||||
@@ -20,6 +21,8 @@
|
|||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod storage;
|
mod storage;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod usage;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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);
|
fieldcraft_io::recovery::set_clock(|| js_sys::Date::now().max(0.0) as u64);
|
||||||
let Some(window) = web_sys::window() else { return };
|
let Some(window) = web_sys::window() else { return };
|
||||||
ask_before_unload(&window);
|
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 {
|
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||||
return;
|
return;
|
||||||
@@ -86,6 +87,10 @@ pub fn start() {
|
|||||||
Box::new(|cc| {
|
Box::new(|cc| {
|
||||||
let inbox = Inbox::default();
|
let inbox = Inbox::default();
|
||||||
let mut app = ModelApp::new(Session::default()).with_services(services(inbox.clone(), cc.egui_ctx.clone()));
|
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 {
|
if let Some(rs) = &cc.wgpu_render_state {
|
||||||
let info = rs.adapter.get_info();
|
let info = rs.adapter.get_info();
|
||||||
log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples);
|
log::info!("fieldcraft-web: graphics {:?} {}, MSAA {}x", info.backend, info.name, rs.msaa_samples);
|
||||||
@@ -102,7 +107,7 @@ pub fn start() {
|
|||||||
if recoverable > 0 {
|
if recoverable > 0 {
|
||||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
|
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;
|
.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)))),
|
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.
|
// The recent list holds the page's files, which go with the page.
|
||||||
recent_path: None,
|
recent_path: None,
|
||||||
// Preferences last for the page's run.
|
// Preferences are kept in the browser's local storage.
|
||||||
prefs_path: None,
|
prefs: Some(crate::usage::prefs_store()),
|
||||||
// egui opens links itself in the browser.
|
// egui opens links itself in the browser.
|
||||||
open_url: None,
|
open_url: None,
|
||||||
}
|
}
|
||||||
@@ -206,6 +211,8 @@ struct Shell {
|
|||||||
autosaved_at: f64,
|
autosaved_at: f64,
|
||||||
/// The last autosave did not fit in the browser's storage (said once until one fits).
|
/// The last autosave did not fit in the browser's storage (said once until one fits).
|
||||||
storage_error: bool,
|
storage_error: bool,
|
||||||
|
/// App time usage events were last sent.
|
||||||
|
sent_at: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Shell {
|
impl Shell {
|
||||||
@@ -271,6 +278,10 @@ impl eframe::App for Shell {
|
|||||||
self.open_arrived();
|
self.open_arrived();
|
||||||
self.app.logic(ctx);
|
self.app.logic(ctx);
|
||||||
self.autosave(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()));
|
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user