UI: ask once about anonymous usage; Help › Settings › Privacy (#238)

The first run asks whether to share anonymous usage (only when the host
says this run may), the answer and a random install id are kept in the
preferences, and Help › Settings… changes it. Commands are counted by
where they came from (ui, palette, script; mcp when the MCP server says
so), and cancelled forms and clicks on commands that cannot run yet are
recorded. The preferences go through a PrefsStore (a file, or the
browser's storage). Control channel: ui.settings, ui.prompt answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 01:07:28 -04:00
1 parent 162bb421f1
commit 79d583ea0f
8 files changed
+444 -36

No files matched your search

+302
View File
@@ -0,0 +1,302 @@
//! Anonymous usage sharing (docs/privacy.md): the question the first run asks once, and Help ›
//! Settings, where it is changed. The choice is kept in the preferences ([`crate::files::Prefs`]);
//! the events are [`fieldcraft_engine::usage`]'s, and the host sends them.
//!
//! The window records only when the host says this run may ([`ModelApp::with_usage`]: the build
//! has an analytics key, and neither `FIELDCRAFT_TELEMETRY=0`, CI nor automation turned it off)
//! and the user said yes.
use egui::RichText;
use fieldcraft_engine::usage::{self, Event, Identity, Origin};
use crate::ModelApp;
use crate::files::Prompt;
/// What is sent, event by event.
pub const PRIVACY_URL: &str = "https://github.com/jyviko/fieldcraft/blob/main/docs/privacy.md";
/// The question, as the first run asks it.
pub const QUESTION: &str = "Help improve FieldCraft by sharing anonymous usage: which tools you use, nothing from your models.";
const DETAIL: &str = "Counted: the commands you run (their names, how long they took, whether they failed), the steps from a design to results, \
the samples you open, and the version, OS and a random id for this install. Never sent: file names or paths, geometry, model \
contents, parameter values or messages.";
/// What the host provides when this run may share usage.
pub struct UsageHost {
/// `desktop` or `web`.
pub platform: &'static str,
pub os: &'static str,
pub arch: &'static str,
/// A new random install id; `None` when there is no randomness to make one.
pub new_id: Box<dyn Fn() -> Option<String>>,
}
impl ModelApp {
/// This run may share usage (the host checked the build's key and the environment): ask the
/// user once, or record if they already agreed.
pub fn with_usage(mut self, host: UsageHost) -> Self {
self.usage = Some(host);
self.apply_usage();
self
}
/// The user's preferences as last saved.
pub fn prefs(&self) -> &crate::files::Prefs {
&self.prefs
}
/// Whether this run can share usage at all.
pub fn usage_available(&self) -> bool {
self.usage.is_some()
}
/// Start or stop recording as the preferences say.
fn apply_usage(&mut self) {
let Some(host) = &self.usage else { return };
if self.prefs.usage_sharing != Some(true) {
usage::disable();
return;
}
if self.prefs.install_id.is_empty() {
let Some(id) = (host.new_id)() else {
log::warn!("usage sharing: no random install id could be made");
return;
};
self.prefs.install_id = id;
}
let identity = Identity {
install_id: self.prefs.install_id.clone(),
platform: host.platform,
os: host.os,
arch: host.arch,
app_version: crate::VERSION,
};
usage::enable(identity, self.prefs.usage_automation);
}
/// Change the choices (the consent question, Help › Settings, `ui.prompt`) and keep them.
pub fn set_usage(&mut self, sharing: bool, automation: bool) {
self.prefs.usage_sharing = Some(sharing);
self.prefs.usage_automation = automation;
// Agreeing makes the install id, which is kept with the choice.
self.apply_usage();
self.prefs.save(self.services.prefs.as_ref());
}
/// Answer the first run's question.
pub fn answer_consent(&mut self, share: bool) {
if self.prompt == Some(Prompt::Consent) {
self.prompt = None;
}
self.set_usage(share, self.prefs.usage_automation);
}
/// Open Help › Settings.
pub fn open_settings(&mut self) {
self.prompt = Some(Prompt::Settings);
self.ui.menu = None;
}
/// The window is closing: the session's length.
pub fn usage_closing(&self) {
if self.usage.is_some() {
usage::closing();
}
}
/// Ask the question once, when nothing else is being asked.
pub(crate) fn ask_consent(&mut self) {
if self.usage.is_some() && self.prefs.usage_sharing.is_none() && self.prompt.is_none() && !self.consent_asked {
self.consent_asked = true;
self.prompt = Some(Prompt::Consent);
}
}
/// Record something the user did in the window (not a script or an agent driving it).
pub(crate) fn record_ui(&self, event: Event) {
if !self.session.origin.is_some_and(Origin::automated) {
usage::record(event);
}
}
/// Run `f` with the session's commands counted as coming from `origin`.
pub fn with_origin<R>(&mut self, origin: Origin, f: impl FnOnce(&mut Self) -> R) -> R {
let before = self.session.origin.replace(origin);
let r = f(self);
self.session.origin = before;
r
}
fn open_link(&mut self, ctx: &egui::Context, url: &str) {
match &self.services.open_url {
Some(open) => {
if let Err(e) = open(url) {
self.set_status(format!("Could not open the browser: {e}"), true);
}
}
None => ctx.open_url(egui::OpenUrl::new_tab(url)),
}
}
}
/// The consent question and Help › Settings, when open.
pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
let t = crate::theme::Tokens::get(app.ui.dark);
match app.prompt {
Some(Prompt::Consent) => {
let (mut share, mut decline, mut link) = (false, false, false);
crate::frame::window(ctx, "Help Improve FieldCraft", crate::frame::Width::Normal).id(egui::Id::new("fc_consent")).show(ctx, |ui| {
ui.label(QUESTION);
ui.add_space(4.0);
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.add_space(2.0);
ui.label(RichText::new("You can change this any time in Help › Settings.").color(t.text_dim).size(11.0));
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
ui.add_space(6.0);
ui.horizontal(|ui| {
share = ui.button("Share Anonymous Usage").clicked();
decline = ui.button("Don't Share").clicked();
});
});
if link {
app.open_link(ctx, PRIVACY_URL);
}
if share {
app.answer_consent(true);
} else if decline || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.answer_consent(false);
}
}
Some(Prompt::Settings) => {
let available = app.usage_available();
let (mut sharing, mut automation) = (app.prefs.usage_sharing == Some(true), app.prefs.usage_automation);
let (mut close, mut link) = (false, false);
crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| {
crate::frame::menu_heading(ui, &t, "PRIVACY");
ui.add_enabled(available, egui::Checkbox::new(&mut sharing, "Share anonymous usage")).on_hover_text(QUESTION);
ui.indent("fc_usage_automation", |ui| {
ui.add_enabled(available && sharing, egui::Checkbox::new(&mut automation, "Include scripts and AI agents (MCP)"))
.on_hover_text("Count the commands scripts, the control channel and AI agents run, apart from yours");
});
if !available {
ui.label(
RichText::new(
"This build or run sends nothing: it has no analytics key, or FIELDCRAFT_TELEMETRY=0, CI or automation turned it off.",
)
.color(t.text_dim)
.size(11.0),
);
}
ui.label(RichText::new(DETAIL).color(t.text_dim).size(11.0));
ui.label(
RichText::new("Crash reports and the log stay on this computer; Help › Report a Bug sends only what you submit.")
.color(t.text_dim)
.size(11.0),
);
link = ui.link("What is sent (docs/privacy.md)").on_hover_text(PRIVACY_URL).clicked();
ui.add_space(6.0);
close = ui.button("Close").clicked();
});
if available && (sharing != (app.prefs.usage_sharing == Some(true)) || automation != app.prefs.usage_automation) {
app.set_usage(sharing, automation);
}
if link {
app.open_link(ctx, PRIVACY_URL);
}
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use fieldcraft_engine::Session;
use fieldcraft_engine::usage::TEST_LOCK;
use serde_json::json;
use super::*;
use crate::files::{PrefsStore, Services};
fn host() -> UsageHost {
UsageHost { platform: "desktop", os: "test", arch: "test", new_id: Box::new(|| Some("11111111-2222-4333-8444-555555555555".into())) }
}
fn app(file: &std::path::Path) -> ModelApp {
ModelApp::new(Session::default()).with_services(Services { prefs: Some(PrefsStore::file(file.to_path_buf())), ..Default::default() })
}
fn temp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fieldcraft-privacy-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join("prefs.json")
}
/// The first run asks once; nothing is recorded before the answer or after a no, and the
/// answer is kept: the next run neither asks again nor forgets it.
#[test]
fn consent_is_asked_once_and_kept() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
usage::disable();
let file = temp("consent");
// Without the host's go-ahead (no key, automation, FIELDCRAFT_TELEMETRY=0) nothing asks.
let mut a = app(&file);
a.ask_consent();
assert_eq!(a.prompt, None);
let mut a = app(&file).with_usage(host());
a.ask_consent();
assert_eq!(a.prompt, Some(Prompt::Consent));
let _ = a.run("model.info", json!({}));
assert_eq!(usage::queued(), 0, "nothing before the answer");
a.answer_consent(false);
assert_eq!(a.prompt, None);
let _ = a.run("model.info", json!({}));
assert!(!usage::enabled() && usage::queued() == 0);
a.ask_consent();
assert_eq!(a.prompt, None, "asked once");
let mut again = app(&file).with_usage(host());
again.ask_consent();
assert_eq!((again.prompt.clone(), again.prefs.usage_sharing), (None, Some(false)));
// Settings › Privacy turns it on: the install id is made and kept, and commands count.
again.set_usage(true, true);
assert!(usage::enabled());
let _ = again.run("model.info", json!({}));
let events: Vec<Event> = std::iter::from_fn(usage::take_batch).flat_map(|b| b.events).collect();
assert!(events.contains(&Event::AppStarted), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::CommandRun { command: "model.info", from: Origin::Ui, .. })), "{events:?}");
let kept = crate::files::Prefs::load(Some(&PrefsStore::file(file.clone())));
assert_eq!((kept.usage_sharing, kept.install_id.as_str()), (Some(true), "11111111-2222-4333-8444-555555555555"));
usage::disable();
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
/// A cancelled form and a command clicked before the model is ready are counted, by command
/// id and the requirements' names; the control channel's are not the user's.
#[test]
fn cancelled_forms_and_unavailable_clicks_are_counted() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
usage::disable();
let file = temp("events");
let mut a = app(&file).with_usage(host());
a.set_usage(true, true);
let _ = std::iter::from_fn(usage::take_batch).count();
let _ = a.start("solve.run");
a.start("model.material.create").unwrap();
a.close_dialog();
a.with_origin(Origin::Script, |a| {
let _ = a.start("solve.run");
});
let events: Vec<Event> = std::iter::from_fn(usage::take_batch).flat_map(|b| b.events).collect();
let unavailable: Vec<_> = events.iter().filter(|e| matches!(e, Event::UnavailableClicked { command: "solve.run", .. })).collect();
assert_eq!(unavailable.len(), 1, "{events:?}");
assert!(events.contains(&Event::FormCancelled { command: "model.material.create" }), "{events:?}");
assert_eq!(a.session.origin, Some(Origin::Ui));
usage::disable();
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
}