mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Engine: an optional check before every command
An app that embeds the engine can install a check on the Session (Session::set_command_check). Session::execute asks it before each command with the command id, its parameters and the session (read only); a refusal comes back as EngineError::Refused with the check's reason and leaves the design unchanged. Without a check nothing changes. The check belongs to the host: it survives opening and starting designs, scratch copies (previews, off-thread evaluation) carry it, and the desktop UI hands it on to every document tab and to designs opened in the background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,9 @@ fn classify(e: &EngineError) -> Outcome {
|
||||
let msg = e.to_string();
|
||||
match e {
|
||||
EngineError::Internal(..) => Outcome::new("panic", msg),
|
||||
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
|
||||
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::Refused { .. } | EngineError::UnknownCommand(_) => {
|
||||
Outcome::new("rejected", msg)
|
||||
}
|
||||
// A failed boolean is a failure even when it says "not supported": those are the cases
|
||||
// to fix. Features the kernel doesn't do yet are limitations.
|
||||
// Picks the generator got wrong (a centroid off its face).
|
||||
|
||||
@@ -66,6 +66,10 @@ pub enum EngineError {
|
||||
BadParams { cmd: String, msg: String },
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// The host's pre-command check ([`Session::set_command_check`]) refused the command; the
|
||||
/// message is the reason it gave.
|
||||
#[error("{reason}")]
|
||||
Refused { cmd: String, reason: String },
|
||||
/// A command panicked; the document was kept as it was (a bug: please report it).
|
||||
#[error("internal error in `{0}` (the design was kept as it was): {1}")]
|
||||
Internal(String, String),
|
||||
@@ -325,6 +329,11 @@ impl ComputedCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/// A check an app that embeds the engine runs before every command ([`Session::set_command_check`]):
|
||||
/// it is shown the command's id, its parameters and the session (read only), and returns `Ok` to
|
||||
/// let the command run or `Err(reason)` to refuse it.
|
||||
pub type CommandCheck = Arc<dyn Fn(&str, &Value, &Session) -> std::result::Result<(), String> + Send + Sync>;
|
||||
|
||||
/// An editing session on one design.
|
||||
/// Starts an upload of a file (its name, its bytes) to EasyPrint and returns at once; the host
|
||||
/// reports how it went and opens the answer itself. `Err`: it couldn't start.
|
||||
@@ -442,6 +451,9 @@ pub struct Session {
|
||||
/// that did is no edit of the design before it, so [`Session::execute`] never takes it for a
|
||||
/// change of render intent or part properties alone.
|
||||
replacements: u64,
|
||||
/// The host's pre-command check (none by default: every command may run). It belongs to the
|
||||
/// host, not the design: opening or starting a design keeps it, and scratch copies carry it.
|
||||
command_check: Option<CommandCheck>,
|
||||
/// Record this session's top-level commands in the crash journal (the app's own design;
|
||||
/// never previews or other scratch sessions). See [`crash`].
|
||||
pub crash_journal: bool,
|
||||
@@ -621,6 +633,7 @@ impl Session {
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
motion_traces: Default::default(),
|
||||
command_check: None,
|
||||
crash_journal: false,
|
||||
}
|
||||
}
|
||||
@@ -644,8 +657,10 @@ impl Session {
|
||||
// next frame, so commands sent right after `doc.open` or `file.new` are kept.
|
||||
let crash_journal = self.crash_journal;
|
||||
let replacements = self.replacements.wrapping_add(1);
|
||||
let command_check = self.command_check.take();
|
||||
*self = fresh;
|
||||
self.crash_journal = crash_journal;
|
||||
self.command_check = command_check;
|
||||
self.auto_project = auto_project;
|
||||
self.metric_tap_drill_major_less_pitch = metric_tap_drill_major_less_pitch;
|
||||
self.param_check = param_check;
|
||||
@@ -657,6 +672,32 @@ impl Session {
|
||||
self.replacements = replacements;
|
||||
}
|
||||
|
||||
/// Install (or with `None` remove) the check [`Session::execute`] runs before every command,
|
||||
/// for an app that embeds the engine and decides which commands may run. A refusal comes back
|
||||
/// as [`EngineError::Refused`] and leaves the session as it was.
|
||||
pub fn set_command_check(&mut self, check: Option<CommandCheck>) {
|
||||
self.command_check = check;
|
||||
}
|
||||
|
||||
/// The check installed with [`Session::set_command_check`], to hand on to another session.
|
||||
pub fn command_check(&self) -> Option<CommandCheck> {
|
||||
self.command_check.clone()
|
||||
}
|
||||
|
||||
/// Ask the host's check, if any, whether `spec` may run with `params`. A check that panics
|
||||
/// refuses the command as an internal error instead of taking the session down.
|
||||
fn check_command(&self, spec: &CommandSpec, params: &Value) -> Result<()> {
|
||||
let Some(check) = &self.command_check else { return Ok(()) };
|
||||
// The check sees the parameters as the command will (no parameters: an empty object).
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(spec.id, params, self))) {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(reason)) => Err(EngineError::Refused { cmd: spec.id.to_string(), reason }),
|
||||
Err(_) => Err(EngineError::Internal(spec.id.to_string(), "the host's command check failed".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The design has changes not in its file: in the document, or in what is hidden.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
(!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility
|
||||
@@ -1131,7 +1172,9 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step
|
||||
/// when they change the document; a failing or panicking command leaves it unchanged.
|
||||
/// when they change the document; a failing or panicking command leaves it unchanged. The
|
||||
/// host's check ([`Session::set_command_check`]) is asked first, on every call (commands that
|
||||
/// a command runs through `execute` included).
|
||||
///
|
||||
/// A command that takes `live` ([`CommandSpec::live`]) run with `live: true` is one frame of
|
||||
/// an interactive gesture: the model shows its sketch's new solution and keeps every later
|
||||
@@ -1154,6 +1197,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
// Before anything changes: a refusal leaves the session exactly as it was.
|
||||
self.check_command(spec, params)?;
|
||||
let before = Snapshot {
|
||||
label: spec.label.to_string(),
|
||||
doc: self.doc.clone(),
|
||||
@@ -1394,6 +1439,7 @@ impl Session {
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
motion_traces: self.motion_traces.clone(),
|
||||
command_check: self.command_check.clone(),
|
||||
crash_journal: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4537,3 +4537,78 @@ fn join_leaves_a_hidden_body_alone() {
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Plate (2)"], "visible": true}));
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
}
|
||||
|
||||
/// A pre-command check that refuses one command id.
|
||||
fn refusing(id: &'static str) -> CommandCheck {
|
||||
std::sync::Arc::new(move |cmd: &str, _: &Value, _: &Session| if cmd == id { Err(format!("{cmd} is not allowed here")) } else { Ok(()) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_command_check_every_command_runs() {
|
||||
let mut s = Session::default();
|
||||
assert!(s.command_check().is_none());
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]}));
|
||||
assert_eq!(s.doc.features.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_check_refuses_a_command_and_leaves_the_design() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
s.set_command_check(Some(refusing("solid.box")));
|
||||
let doc = s.doc.clone();
|
||||
let (undo, revision) = (s.undo.len(), s.revision);
|
||||
let r = s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]}));
|
||||
match r {
|
||||
Err(EngineError::Refused { cmd, reason }) => {
|
||||
assert_eq!(cmd, "solid.box");
|
||||
assert_eq!(reason, "solid.box is not allowed here");
|
||||
}
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "the design is unchanged");
|
||||
assert_eq!((s.undo.len(), s.revision), (undo, revision));
|
||||
// Other commands still run, and the check stays with the session across new designs,
|
||||
// scripts and previews.
|
||||
run(&mut s, "file.new", json!({"name": "Next"}));
|
||||
assert!(s.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1})).is_err(), "kept by file.new");
|
||||
assert!(s.run_script(&json!([{"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}])).is_err(), "scripts");
|
||||
assert!(s.preview(&[("solid.box".into(), json!({"length": 1, "width": 1, "height": 1}))]).is_err(), "previews");
|
||||
s.set_command_check(None);
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_check_can_count_the_features() {
|
||||
const MAX: usize = 3;
|
||||
let mut s = Session::default();
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Value)>::new()));
|
||||
let log = seen.clone();
|
||||
s.set_command_check(Some(std::sync::Arc::new(move |cmd: &str, params: &Value, s: &Session| {
|
||||
if let Ok(mut log) = log.lock() {
|
||||
log.push((cmd.to_string(), params.clone()));
|
||||
}
|
||||
if cmd.starts_with("solid.") && s.doc.features.len() >= MAX { Err(format!("at most {MAX} features")) } else { Ok(()) }
|
||||
})));
|
||||
for i in 0..MAX {
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [i * 5, 0, 0]}));
|
||||
}
|
||||
let r = s.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1, "corner": [50, 0, 0]}));
|
||||
assert!(matches!(&r, Err(EngineError::Refused { .. })), "{r:?}");
|
||||
assert_eq!(r.err().map(|e| e.to_string()).as_deref(), Some("at most 3 features"));
|
||||
assert_eq!(s.doc.features.len(), MAX);
|
||||
// The check saw each command's id and parameters.
|
||||
let seen = seen.lock().map(|l| l.clone()).unwrap_or_default();
|
||||
assert_eq!(seen.len(), MAX + 1);
|
||||
assert!(seen.iter().all(|(id, p)| id == "solid.box" && p["length"] == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_check_that_panics_refuses_the_command() {
|
||||
let mut s = Session::default();
|
||||
s.set_command_check(Some(std::sync::Arc::new(|_: &str, _: &Value, _: &Session| -> std::result::Result<(), String> { panic!("host bug") })));
|
||||
let r = s.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1}));
|
||||
assert!(matches!(r, Err(EngineError::Internal(..))), "{r:?}");
|
||||
assert!(s.doc.features.is_empty());
|
||||
}
|
||||
|
||||
@@ -159,6 +159,8 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
// Preferences the session holds go with the user, not the tab (#680).
|
||||
app.session.auto_project = out.session.auto_project;
|
||||
app.session.metric_tap_drill_major_less_pitch = out.session.metric_tap_drill_major_less_pitch;
|
||||
// So does the host's pre-command check: every tab is held to it.
|
||||
app.session.set_command_check(out.session.command_check());
|
||||
// Views cache by revision: the incoming design must not look unchanged.
|
||||
let before = app.session.revision;
|
||||
app.session.invalidate_view(out_rev);
|
||||
@@ -1495,6 +1497,30 @@ mod tests {
|
||||
assert!(!a.session.auto_project, "a design adopted over a blank one");
|
||||
}
|
||||
|
||||
/// The host's pre-command check holds in every tab: new ones, the one switched to, a design
|
||||
/// adopted over a blank one and after the last tab closes.
|
||||
#[test]
|
||||
fn the_command_check_follows_the_host_across_tabs() {
|
||||
let mut a = app();
|
||||
a.session.set_command_check(Some(std::sync::Arc::new(
|
||||
|id: &str, _: &Value, _: &Session| {
|
||||
if id == "solid.box" { Err("no boxes".to_string()) } else { Ok(()) }
|
||||
},
|
||||
)));
|
||||
assert_eq!(a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})), Err("no boxes".to_string()));
|
||||
a.run("solid.cylinder", json!({"radius": 5, "height": 10})).unwrap();
|
||||
new_design(&mut a);
|
||||
assert_eq!(a.docs.count(), 2);
|
||||
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "a new tab");
|
||||
switch(&mut a, 0);
|
||||
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "the tab switched to");
|
||||
close(&mut a, 1, true);
|
||||
close(&mut a, 0, true);
|
||||
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "after the last tab closes");
|
||||
adopt(&mut a, Session::default());
|
||||
assert!(a.run("solid.box", json!({"length": 5, "width": 5, "height": 5})).is_err(), "a design adopted over a blank one");
|
||||
}
|
||||
|
||||
/// Hide Origin by Default hides the Origin of designs that don't keep its eye; a design that
|
||||
/// does keeps it, along with the rest of the browser's view, in its tab and in its file.
|
||||
#[test]
|
||||
|
||||
@@ -408,6 +408,8 @@ fn start(app: &mut SolveApp) {
|
||||
let before = if insert { crate::open_meshing::session_world(&app.session).bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
|
||||
let update = request.kind == Kind::UpdateLinks;
|
||||
let mut scratch = if insert || update || request.kind == Kind::ExportDxf { app.session.scratch() } else { Session::default() };
|
||||
// A design opened or recovered here is run through the host's pre-command check too.
|
||||
scratch.set_command_check(app.session.command_check());
|
||||
if insert {
|
||||
scratch.selection = app.session.selection.clone();
|
||||
}
|
||||
@@ -534,6 +536,7 @@ fn start_staged(app: &mut SolveApp) {
|
||||
let insert = request.insert();
|
||||
let before = if insert { crate::open_meshing::session_world(&app.session).bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
|
||||
let mut session = if insert { app.session.scratch() } else { Session::default() };
|
||||
session.set_command_check(app.session.command_check());
|
||||
if insert {
|
||||
session.selection = app.session.selection.clone();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user