Files
OneCAD-Simulation/crates/ui-egui/src/privacy.rs
T
jyvikoandClaude Opus 5.5 79d583ea0f 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
2026-10-15 01:07:28 -04:00

303 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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());
}
}