From 79d583ea0f50043ca45dba6d134bcc82a8e64cbe Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:04:52 -0400 Subject: [PATCH] =?UTF-8?q?UI:=20ask=20once=20about=20anonymous=20usage;?= =?UTF-8?q?=20Help=20=E2=80=BA=20Settings=20=E2=80=BA=20Privacy=20(#238)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/mcp/src/backend.rs | 3 +- crates/ui-egui/src/appbar.rs | 3 + crates/ui-egui/src/control.rs | 38 ++++- crates/ui-egui/src/files.rs | 90 +++++++--- crates/ui-egui/src/lib.rs | 35 +++- crates/ui-egui/src/palette.rs | 6 +- crates/ui-egui/src/privacy.rs | 302 ++++++++++++++++++++++++++++++++++ docs/control-protocol.md | 3 +- 8 files changed, 444 insertions(+), 36 deletions(-) create mode 100644 crates/ui-egui/src/privacy.rs diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 85bad6a..c2b4feb 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -230,7 +230,8 @@ fn temp_png() -> PathBuf { impl Backend for Remote { fn execute(&mut self, command: &str, params: Value) -> Result { - self.call("engine.execute", json!({"command": command, "params": params})) + // `client` lets the app count an agent's commands apart from a script's (docs/privacy.md). + self.call("engine.execute", json!({"command": command, "params": params, "client": "mcp"})) } fn commands(&mut self) -> Result { diff --git a/crates/ui-egui/src/appbar.rs b/crates/ui-egui/src/appbar.rs index ac76023..288845e 100644 --- a/crates/ui-egui/src/appbar.rs +++ b/crates/ui-egui/src/appbar.rs @@ -340,9 +340,12 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) { .button("Report a Bug…") .on_hover_text("Draft a GitHub issue with the version, the OS and the last error; nothing is sent until you submit it") .clicked(); + let settings = ui.button("Settings…").on_hover_text("Privacy: anonymous usage sharing").clicked(); ui.label(egui::RichText::new(format!("FieldCraft {}", crate::VERSION)).color(t.text_dim)); if report { app.report_bug(None); + } else if settings { + app.open_settings(); } if let Some(n) = open { app.ui.menu = None; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8d8f2a5..124caa4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -158,6 +158,18 @@ fn select(app: &mut ModelApp, p: &Value) -> Result { Ok(selection_json(app)) } +/// The privacy choices, for `ui.inspect` and `ui.prompt`. +fn privacy_json(app: &ModelApp) -> Value { + let p = app.prefs(); + json!({ + "available": app.usage_available(), + "usage_sharing": p.usage_sharing, + "usage_automation": p.usage_automation, + "recording": fieldcraft_engine::usage::enabled(), + "queued": fieldcraft_engine::usage::queued(), + }) +} + pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -206,11 +218,18 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> crate::files::Prompt::Delete(d) => json!({"delete": d.question(), "kind": d.kind, "name": d.name, "users": d.users, "allowed": d.allowed()}), crate::files::Prompt::Crashed(r) => json!({"crashed": r}), crate::files::Prompt::ReportBug => json!({"report_bug": app.report.as_ref().and_then(|f| f.drafted_json())}), + crate::files::Prompt::Consent => json!({"consent": crate::privacy::QUESTION}), + crate::files::Prompt::Settings => json!("settings"), }), + "privacy": privacy_json(app), "recent": app.recent.paths, "version": crate::VERSION, })), "ui.toolbar" => ok(crate::toolbar::describe(app)), + "ui.settings" => { + app.open_settings(); + ok(privacy_json(app)) + } "ui.select" if p.get("kind").is_none() && VIEWPORT_SELECT.iter().any(|k| p.get(*k).is_some()) => wrap(select(app, p)), "ui.selection" => ok(selection_json(app)), "ui.select" => { @@ -388,6 +407,23 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> } // The form's fields; sending opens the browser, which only a click does. (crate::files::Prompt::ReportBug, Some("set")) => wrap(app.set_report(p)), + (crate::files::Prompt::Consent, Some(a @ ("share" | "decline"))) => { + app.answer_consent(a == "share"); + ok(privacy_json(app)) + } + (crate::files::Prompt::Settings, Some("set")) => { + if !app.usage_available() { + return err("this run cannot share usage (no analytics key, or FIELDCRAFT_TELEMETRY=0, CI or automation)"); + } + let sharing = p.get("usage_sharing").and_then(Value::as_bool).unwrap_or(app.prefs().usage_sharing == Some(true)); + let automation = p.get("usage_automation").and_then(Value::as_bool).unwrap_or(app.prefs().usage_automation); + app.set_usage(sharing, automation); + ok(privacy_json(app)) + } + (crate::files::Prompt::Settings, Some("close" | "cancel")) => { + app.prompt = None; + ok(Value::Null) + } (crate::files::Prompt::ReportBug, Some("close" | "cancel")) => { app.prompt = None; app.report = None; @@ -401,7 +437,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> None => err("missing `id` (from file.recovery.list)"), }, (_, a) => err(format!( - "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close)" + "`answer` {a:?} does not answer this prompt (unsaved: save, discard, cancel; design: new, keep, cancel; replace_mesh: replace, cancel; delete: delete, cancel; recover: recover with id, close; samples: open with name, close; crashed: report, close; report_bug: set with title/observed/share_session/include_model, close; consent: share, decline; settings: set with usage_sharing/usage_automation, close)" )), } } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 7c7a260..0c437d7 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -44,8 +44,9 @@ pub struct Services { pub pick_save: Option, /// The recent-files list's file (JSON); `None` keeps the list for this run only. pub recent_path: Option, - /// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only. - pub prefs_path: Option, + /// Where the user's preferences ([`Prefs`]) are kept: a file on the desktop, the browser's + /// storage on the web; `None` keeps them for this run only. + pub prefs: Option, /// Opens a link in the browser (Help › Report a Bug); without it, egui's own /// `open_url` (the browser build opens the link itself). pub open_url: Option, @@ -174,6 +175,30 @@ impl Recent { /// Largest preferences file read. const MAX_PREFS_BYTES: u64 = 64 * 1024; +/// Where the preferences are kept: reads their JSON (`None` when there is none) and writes it. +pub struct PrefsStore { + pub load: Box Option>>, + pub save: Box Result<(), String>>, +} + +impl PrefsStore { + /// The preferences in `file` (an unreadable or oversized file reads as none). + pub fn file(file: PathBuf) -> PrefsStore { + let read = file.clone(); + PrefsStore { + load: Box::new(move || { + std::fs::metadata(&read).is_ok_and(|m| m.is_file() && m.len() <= MAX_PREFS_BYTES).then(|| std::fs::read(&read).ok()).flatten() + }), + save: Box::new(move |bytes| { + if let Some(dir) = file.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + std::fs::write(&file, bytes).map_err(|e| format!("{}: {e}", file.display())) + }), + } + } +} + /// What the window remembers for the user between runs (`prefs.json` beside `recent.json`). /// Keys it no longer knows (`show_setup`, from before the setup card went) are ignored. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -183,34 +208,37 @@ pub struct Prefs { pub nav_preset: String, /// The wheel zooms the other way. pub reverse_zoom: bool, + /// Share anonymous usage (docs/privacy.md); `None` until the user is asked. + pub usage_sharing: Option, + /// Count commands run by scripts and AI agents (MCP) too. + pub usage_automation: bool, + /// The random id usage is sent under, made when the user first agrees; empty before. + pub install_id: String, } impl Default for Prefs { fn default() -> Self { - Prefs { nav_preset: crate::navigation::DEFAULT_PRESET.into(), reverse_zoom: false } + Prefs { + nav_preset: crate::navigation::DEFAULT_PRESET.into(), + reverse_zoom: false, + usage_sharing: None, + usage_automation: true, + install_id: String::new(), + } } } impl Prefs { - /// The preferences kept in `file` (an unreadable or malformed file gives the defaults). - pub fn load(file: Option<&Path>) -> Prefs { - file.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_PREFS_BYTES)) - .and_then(|f| std::fs::read(f).ok()) - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default() + /// The preferences kept in `store` (unreadable or malformed ones give the defaults). + pub fn load(store: Option<&PrefsStore>) -> Prefs { + store.and_then(|s| (s.load)()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } - /// Write them to `file`; a failure is logged (the preferences stay for this run). - pub fn save(&self, file: Option<&Path>) { - let Some(file) = file else { return }; - let write = || -> std::io::Result<()> { - if let Some(dir) = file.parent() { - std::fs::create_dir_all(dir)?; - } - std::fs::write(file, serde_json::to_vec_pretty(self).unwrap_or_default()) - }; - if let Err(e) = write() { - log::warn!("preferences {}: {e}", file.display()); + /// Write them to `store`; a failure is logged (the preferences stay for this run). + pub fn save(&self, store: Option<&PrefsStore>) { + let Some(store) = store else { return }; + if let Err(e) = (store.save)(&serde_json::to_vec_pretty(self).unwrap_or_default()) { + log::warn!("preferences: {e}"); } } } @@ -280,6 +308,10 @@ pub enum Prompt { /// Delete a definition (the item menu's Delete…, the Delete key): what uses it is listed, and /// the deletion refused or (a datum's partitions) done with it. Delete(crate::menu::DeleteAsk), + /// The first run asks once whether to share anonymous usage ([`crate::privacy`]). + Consent, + /// Help › Settings: the privacy choices ([`crate::privacy`]). + Settings, /// The last run crashed (its new crash reports, newest first): report it, or not now. Crashed(Vec), /// Help › Report a Bug ([`crate::report`]; the form is [`ModelApp::report`]). @@ -389,6 +421,7 @@ impl ModelApp { let base = if fresh { let mut s = Session::default(); s.crash_journal = self.session.crash_journal; + s.origin = self.session.origin; s } else { self.session.clone() @@ -591,7 +624,11 @@ impl ModelApp { /// A finished job's session replaces the window's. fn take_opened(&mut self, o: Opened) { // The session it replaces may be large: it is freed off the window thread. - let old = std::mem::replace(&mut self.session, *o.session); + let mut session = *o.session; + // Its commands come from where the window's do now (the job may have started from the + // command search or the control channel). + session.origin = self.session.origin; + let old = std::mem::replace(&mut self.session, session); off_thread("fieldcraft drop".into(), move || drop(old)); self.model_changed(); if let Some(p) = &o.recent { @@ -1006,8 +1043,8 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { app.prompt = None; } } - // Drawn by `crate::report::show`. - Some(Prompt::Crashed(_) | Prompt::ReportBug) | None => {} + // Drawn by `crate::report::show` and `crate::privacy::show`. + Some(Prompt::Crashed(_) | Prompt::ReportBug | Prompt::Consent | Prompt::Settings) | None => {} } } @@ -1104,8 +1141,9 @@ mod tests { fn navigation_preferences_are_remembered() { let file = temp("prefs").join("prefs.json"); std::fs::write(&file, br#"{"show_setup": false}"#).unwrap(); - assert_eq!(Prefs::load(Some(&file)), Prefs::default()); - let services = || Services { prefs_path: Some(file.clone()), ..Default::default() }; + let store = PrefsStore::file(file.clone()); + assert_eq!(Prefs::load(Some(&store)), Prefs::default()); + let services = || Services { prefs: Some(PrefsStore::file(file.clone())), ..Default::default() }; let mut app = ModelApp::new(Session::default()).with_services(services()); app.ui.nav_preset = "solidworks".into(); app.ui.reverse_zoom = true; @@ -1113,7 +1151,7 @@ mod tests { let again = ModelApp::new(Session::default()).with_services(services()); assert_eq!((again.ui.nav_preset.as_str(), again.ui.reverse_zoom), ("solidworks", true)); std::fs::write(&file, b"{nope").unwrap(); - assert_eq!(Prefs::load(Some(&file)), Prefs::default()); + assert_eq!(Prefs::load(Some(&store)), Prefs::default()); } /// A picker that answers `path` and records the filters it was offered. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4997696..24484b9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,7 @@ pub mod icons; pub mod menu; pub mod navigation; pub mod palette; +pub mod privacy; pub mod reply; pub mod report; pub mod results; @@ -383,7 +384,10 @@ pub struct ModelApp { pub recent: files::Recent, /// The user's preferences as last saved. prefs: files::Prefs, - /// The user's preferences as last saved. + /// The host's go-ahead to share usage, when this run may ([`privacy`]). + usage: Option, + /// The consent question was asked in this run. + consent_asked: bool, /// The setup progress the browser marks, and what it was worked out for. setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc>), /// The file opening in the background, and files waiting to open after it. @@ -430,7 +434,9 @@ pub struct ModelApp { } impl ModelApp { - pub fn new(session: Session) -> ModelApp { + pub fn new(mut session: Session) -> ModelApp { + // The window's own model: its commands count as clicks unless said otherwise. + session.origin.get_or_insert(fieldcraft_engine::usage::Origin::Ui); ModelApp { session, ui: UiState::default(), @@ -469,6 +475,8 @@ impl ModelApp { services: files::Services::default(), recent: files::Recent::default(), prefs: files::Prefs::default(), + usage: None, + consent_asked: false, setup: (None, std::sync::Arc::default()), loading: None, queued: std::collections::VecDeque::new(), @@ -503,7 +511,7 @@ impl ModelApp { /// Use the host's services (file pickers, the recent-files file) and load the recent list. pub fn with_services(mut self, services: files::Services) -> Self { self.recent = files::Recent::load(services.recent_path.clone()); - self.prefs = files::Prefs::load(services.prefs_path.as_deref()); + self.prefs = files::Prefs::load(services.prefs.as_ref()); self.ui.nav_preset = self.prefs.nav_preset.clone(); self.ui.reverse_zoom = self.prefs.reverse_zoom; self.services = services; @@ -624,6 +632,10 @@ impl ModelApp { let spec = fieldcraft_engine::find_command(command).ok_or_else(|| format!("unknown command `{command}`"))?; // A command missing what it needs opens no form: the status names what is missing. if let Err(u) = self.session.availability(spec) { + self.record_ui(fieldcraft_engine::usage::Event::UnavailableClicked { + command: u.command, + missing: u.missing.iter().map(|m| m.0).collect(), + }); let e = u.to_string(); self.set_status(e.clone(), true); return Err(e); @@ -689,6 +701,9 @@ impl ModelApp { /// Close the open form: back to the form it was opened from, if any. pub fn close_dialog(&mut self) { + if let Some(d) = &self.dialog { + self.record_ui(fieldcraft_engine::usage::Event::FormCancelled { command: d.command }); + } self.dialog = self.dialog.take().and_then(|d| d.back).map(|b| b.dialog); } @@ -1460,6 +1475,8 @@ impl ModelApp { palette::popup(self, ui.ctx()); files::show(self, ui.ctx()); report::show(self, ui.ctx()); + self.ask_consent(); + privacy::show(self, ui.ctx()); if self.custom_titlebar { titlebar::resize_zones(ui); } @@ -1480,8 +1497,9 @@ impl ModelApp { /// Save the preferences the window changed (a View menu toggle, `ui.set`). pub(crate) fn save_prefs(&mut self) { if self.ui.nav_preset != self.prefs.nav_preset || self.ui.reverse_zoom != self.prefs.reverse_zoom { - self.prefs = files::Prefs { nav_preset: self.ui.nav_preset.clone(), reverse_zoom: self.ui.reverse_zoom }; - self.prefs.save(self.services.prefs_path.as_deref()); + self.prefs.nav_preset = self.ui.nav_preset.clone(); + self.prefs.reverse_zoom = self.ui.reverse_zoom; + self.prefs.save(self.services.prefs.as_ref()); } } @@ -1502,7 +1520,12 @@ impl ModelApp { let Some(rx) = self.control_rx.take() else { return }; for _ in 0..PER_FRAME { let Ok(req) = rx.try_recv() else { break }; - match control::handle(self, ctx, &req) { + // The MCP server says so; anything else on the channel is a script. + let origin = match req.params.get("client").and_then(Value::as_str) { + Some("mcp") => fieldcraft_engine::usage::Origin::Mcp, + _ => fieldcraft_engine::usage::Origin::Script, + }; + match self.with_origin(origin, |app| control::handle(app, ctx, &req)) { control::Outcome::Done(v) => { let _ = req.reply.send(v); } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index ab65773..2d6aeb7 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -73,6 +73,10 @@ pub fn matches(query: &str) -> Vec { /// Run a line of command-line input: `id {json}` runs the command with those parameters, `id` /// starts it, anything else starts the best match. pub fn submit(app: &mut ModelApp, line: &str) { + app.with_origin(fieldcraft_engine::usage::Origin::Palette, |app| submit_line(app, line)); +} + +fn submit_line(app: &mut ModelApp, line: &str) { let line = line.trim(); if line.is_empty() { return; @@ -246,7 +250,7 @@ pub fn popup(app: &mut ModelApp, ctx: &egui::Context) { if let Some(id) = picked { app.palette_text.clear(); app.ui.palette = false; - let _ = app.start(id); + let _ = app.with_origin(fieldcraft_engine::usage::Origin::Palette, |app| app.start(id)); } else if !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.palette = false; app.palette_text.clear(); diff --git a/crates/ui-egui/src/privacy.rs b/crates/ui-egui/src/privacy.rs new file mode 100644 index 0000000..381ae28 --- /dev/null +++ b/crates/ui-egui/src/privacy.rs @@ -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 Option>, +} + +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(&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 = 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 = 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()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 200266e..89e5cdd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,8 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `ui.select` | `kind` (`set`, `material`, `section`, `step`, `boundary_condition`, `load`, `reference_point`, `coupling`, `fluid_domain`), `name`; nothing to deselect | selects a definition as a browser click does; its properties (with Edit and Delete) show in the context panel | | `ui.select` (viewport) | any of `filter`, `box_mode`, `mode`, `clear`, `at`, `box`, `geometry`, `nodes`, `elements`, `faces` (and no `kind`) | changes the viewport's selection (see [Selection](#selection)); returns `ui.selection`'s reply | | `ui.selection` | — | the viewport's selection: `filter`, `box_mode` (box selection on), `count`, `capped`, `summary`, `items`, `params` (the selection as a set's members), `hover` (what a click would pick under the pointer) | -| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `delete` (the item menu's Delete… or the Delete key: `{delete: question, kind, name, users, allowed}`) `delete` (with a datum's partitions when it has any; refused when `allowed` is false) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close`; for `crashed` (the start after a crash: `{crashed: [reports]}`) `report` (opens Report a Bug for the newest) or `close`; for `report_bug` (`{report_bug: draft}`) `set` (any of `title`, `observed`, `share_session`, `include_model`; replies with the draft) or `close` — the browser opens only from a click (docs/crash-reports.md) | answers the window's question (`ui.inspect`'s `prompt`) | +| `ui.prompt` | `answer`: for `unsaved` `save`, `discard` or `cancel`; for `design` `new` (a new model with the design), `keep` (the design over the current model) or `cancel`; for `replace_mesh` `replace` (runs the form, replacing the mesh) or `cancel` (the form stays open); for `delete` (the item menu's Delete… or the Delete key: `{delete: question, kind, name, users, allowed}`) `delete` (with a datum's partitions when it has any; refused when `allowed` is false) or `cancel`; for `recover` `recover` (with `id` from `file.recovery.list`) or `close`; for `samples` `open` (with `name`) or `close`; for `crashed` (the start after a crash: `{crashed: [reports]}`) `report` (opens Report a Bug for the newest) or `close`; for `report_bug` (`{report_bug: draft}`) `set` (any of `title`, `observed`, `share_session`, `include_model`; replies with the draft) or `close` — the browser opens only from a click (docs/crash-reports.md); for `consent` (the first run's question about anonymous usage) `share` or `decline`; for `settings` (Help › Settings…) `set` (any of `usage_sharing`, `usage_automation`) or `close` (docs/privacy.md) | answers the window's question (`ui.inspect`'s `prompt`) | +| `ui.settings` | — | opens Help › Settings… (answered with `ui.prompt`); replies with the privacy state as `ui.inspect`'s `privacy` gives it: `available` (this run may share usage), `usage_sharing` (`null` until asked), `usage_automation`, `recording`, `queued` | | `ui.item` | `kind` (`body`, `surface`, or a definition kind as `ui.select` takes it, `datum`, `partition`), `name`, `action`: `edit`, `hide`, `show`, `isolate`, `show_all`, `delete`, or `activate`/`deactivate` with `step` | an item menu's entry, as a right click on the item and a click on the entry: runs the same command (`delete` opens the delete prompt, answered with `ui.prompt`) | | `ui.dialog` | `params?` (JSON object, fills the form), `use_selection?`, `run?`, `submit?`, `cancel?` | drives the open command form, in that order; `use_selection` is the form's Target row (see [Selection](#selection)); `run` returns the command's result and closes the form on success; `submit` is the form's OK button: a mesher that would replace a body's mesh first asks (`prompt` `replace_mesh`, with the question and what else goes: sets listing the old labels, solved results), and meshers run in the background (`busy`); a form opened by another form's **Create …** button (a reference field with nothing to pick) goes back to that form on `run` (with the new item picked) or `cancel` | | `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; in the viewport a click selects what is under it (Shift adds, Ctrl/Cmd adds or removes, a click on nothing clears the selection), and a click on a results plot also probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) |