Files
OneCAD-Simulation/crates/ui-egui/src/privacy.rs
T
jyvikoandClaude Opus 5.5 2e28a69c04 UI: update notice, Help › Check for Updates and Reinstall, Settings › Updates (#231)
The window checks at start-up at most once a day when automatic checks are on (Settings and
the first run's question), never while that question is open and never without a host, so the
browser and tests make no network call. A newer release shows a quiet notice in the status bar
("FieldCraft X is available — What's new · Install · Skip"); Install downloads and verifies
through the host, then asks to restart, saving or asking about unsaved changes first, and the
host swaps the release in after the window closes. A .deb/.rpm install offers the system's
installer and the command instead. Skip this version is kept in the preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 01:48:30 -04:00

345 lines
15 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
}
pub(crate) 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, mut updates) = (false, false, false, None);
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();
if app.updates_available() {
ui.add_space(4.0);
updates = update_check(ui, &t, app.prefs.update_check);
}
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 let Some(on) = updates {
app.set_update_prefs(on, None);
}
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, mut updates, mut unskip, mut check) = (false, false, None, false, false);
crate::frame::window(ctx, "Settings", crate::frame::Width::Normal).id(egui::Id::new("fc_settings")).show(ctx, |ui| {
if app.updates_available() {
crate::frame::menu_heading(ui, &t, "UPDATES");
updates = update_check(ui, &t, app.prefs.update_check);
ui.horizontal(|ui| {
check = ui.button("Check Now").on_hover_text("Help › Check for Updates").clicked();
if !app.prefs.update_skip.is_empty() {
ui.label(RichText::new(format!("Skipping FieldCraft {}", app.prefs.update_skip)).color(t.text_dim).size(11.0));
unskip = ui.small_button("Offer It Again").clicked();
}
});
ui.add_space(6.0);
}
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 updates.is_some() || unskip {
app.set_update_prefs(updates.unwrap_or(app.prefs.update_check), unskip.then(String::new));
}
if check {
app.check_for_updates();
}
if link {
app.open_link(ctx, PRIVACY_URL);
}
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.prompt = None;
}
}
_ => {}
}
}
/// The automatic update check's checkbox; the new value when it was clicked.
fn update_check(ui: &mut egui::Ui, t: &crate::theme::Tokens, on: bool) -> Option<bool> {
let mut checked = on;
let changed = ui
.checkbox(&mut checked, "Check for updates automatically")
.on_hover_text(
"At start-up, at most once a day, FieldCraft asks GitHub Releases for the latest version. Nothing installs until you choose Install.",
)
.changed();
ui.label(
RichText::new("The check sends only the request (your IP address and the FieldCraft version reach GitHub); off means no check.")
.color(t.text_dim)
.size(11.0),
);
changed.then_some(checked)
}
#[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());
}
}