forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
162bb421f1
commit
79d583ea0f
8 files changed
+444
-36
No files matched your search
@@ -230,7 +230,8 @@ fn temp_png() -> PathBuf {
|
||||
|
||||
impl Backend for Remote {
|
||||
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -158,6 +158,18 @@ fn select(app: &mut ModelApp, p: &Value) -> Result<Value, String> {
|
||||
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)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
+64
-26
@@ -44,8 +44,9 @@ pub struct Services {
|
||||
pub pick_save: Option<PickSave>,
|
||||
/// The recent-files list's file (JSON); `None` keeps the list for this run only.
|
||||
pub recent_path: Option<PathBuf>,
|
||||
/// The user's preferences file (JSON, [`Prefs`]); `None` keeps them for this run only.
|
||||
pub prefs_path: Option<PathBuf>,
|
||||
/// 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<PrefsStore>,
|
||||
/// 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<OpenUrl>,
|
||||
@@ -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<dyn Fn() -> Option<Vec<u8>>>,
|
||||
pub save: Box<dyn Fn(&[u8]) -> 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<bool>,
|
||||
/// 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<fieldcraft_engine::crash::Summary>),
|
||||
/// 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.
|
||||
|
||||
@@ -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<privacy::UsageHost>,
|
||||
/// 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<Vec<fieldcraft_engine::progress::StageState>>),
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ pub fn matches(query: &str) -> Vec<Hit> {
|
||||
/// 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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user