From 335d1f56ac670e392f150f3f4e318e30bc70d0c0 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 27 Oct 2026 20:30:13 +0000 Subject: [PATCH] #2459: reject raw opens through docking command aliases --- crates/ui-egui/src/dock_ui.rs | 14 ++++++++--- crates/ui-egui/tests/it/shared_docking_ui.rs | 25 ++++++++++++++++++++ docs/control-protocol.md | 4 +++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 30710392a..cf180eec0 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -86,6 +86,13 @@ fn panel_param(value: &Value) -> Result { } } +fn check_raw_action(action: &Action) -> Result<(), String> { + if matches!(action, Action::Open { .. } | Action::OpenAt { .. }) { + return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into()); + } + Ok(()) +} + /// The docking commands: `window.dockAction {action}`, craft-ui's `window.panel.*` commands, /// `ui.dock` and `ui.dock.reset`. `None` for any other id. pub(crate) fn route(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -93,9 +100,7 @@ pub(crate) fn route(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, par "window.dockAction" => Some((|| { let value = params.get("action").cloned().ok_or("missing docking action")?; let action: Action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?; - if matches!(&action, Action::Open { .. } | Action::OpenAt { .. }) { - return Err("use the panel open commands to preserve viewer and ScriptUI lifecycle callbacks".into()); - } + check_raw_action(&action)?; app.apply_dock(action)?; Ok(Value::Null) })()), @@ -153,6 +158,9 @@ impl EffectcraftApp { /// Run a craft-ui docking command on the current workspace, all or nothing. Named /// workspaces and Reset are EffectCraft's own (`window.workspace`, `window.resetWorkspace`). pub(crate) fn run_dock_command(&mut self, ctx: &egui::Context, command: DockCommand) -> Result<(), String> { + if let DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) = &command { + check_raw_action(action)?; + } if command.is_app_owned() { if matches!(command, DockCommand::ResetWorkspace {}) { let name = self.ui.workspace.clone(); diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs index c71fadf3c..48e8b8283 100644 --- a/crates/ui-egui/tests/it/shared_docking_ui.rs +++ b/crates/ui-egui/tests/it/shared_docking_ui.rs @@ -278,6 +278,31 @@ fn ui_dock_and_window_panel_commands_parse_craft_ui_commands() { assert_eq!(app.ui.dock, Workspace::new(dock::workspace("Default"))); } +#[test] +fn raw_open_actions_are_rejected_through_every_docking_route() { + let mut app = EffectcraftApp::new(Session::default()); + let before = app.ui.dock.clone(); + for panel in [PanelKind::ScriptPanel(77), PanelKind::Viewer(42)] { + for action in [ + Action::Open { panel, anchor: Some(PanelKind::Project) }, + Action::OpenAt { panel, anchor: PanelKind::Project, placement: Placement::Tab { before: None } }, + ] { + for id in ["ui.dock", "window.panel.layout", "window.dockAction"] { + let error = invoke(&mut app, id, json!({"action": action})).unwrap_err(); + assert!(error.contains("panel open commands"), "{id}: {error}"); + assert_eq!(app.ui.dock, before, "{id} must leave the layout unchanged"); + assert!(app.session.script_ui.window(77).is_none()); + assert!(!app.ui.viewers.contains_key(&42)); + } + } + } + // The normal operation still opens a panel and the raw form still activates one. + invoke(&mut app, "ui.dock", json!({"operation": "open", "panel": "Align"})).unwrap(); + assert!(app.ui.dock.is_open(PanelKind::Align)); + invoke(&mut app, "window.panel.layout", json!({"action": {"Activate": {"panel": "Project"}}})).unwrap(); + assert!(app.ui.dock.is_visible(PanelKind::Project)); +} + #[test] fn catalog_close_keeps_dynamic_panel_identity_and_releases_viewers() { let mut app = EffectcraftApp::new(Session::default()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6c7548835..c68711ea5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -210,7 +210,9 @@ takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and Scr `move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}`, `resizeSplit {path, size}`; `rect` is `[x, y, width, height]`). It returns `{"docking": workspace}`. `ui.dock.reset` returns the current workspace to its saved or - built-in layout and returns the same. + built-in layout and returns the same. Raw `Open` and `OpenAt` actions are refused in both + `ui.dock {action}` and `window.panel.layout {action}`, as in `window.dockAction`; use the + panel open commands instead. - `window.dockPanel {panel, anchor?, zone?, before?}` docks as `window.panel.dock`: `zone` is `center`, `tab`, `left`, `right`, `top`, `bottom`, `above` or `below`; without an anchor a side zone docks along that edge of the whole workspace. `window.floatPanel {panel, rect?}`