Add versioned host settings API for plugins

This commit is contained in:
Felix Riestra committed 2026-09-18 15:13:06 +02:00
1 parent 93afe91afc
commit ec2271fc9c
13 files changed
+218 -17

No files matched your search

+4 -2
View File
@@ -44,12 +44,14 @@ The host and each plugin run in separate OS processes. The host re-executes itse
## Versioning and ABI stability ## Versioning and ABI stability
- `API_VERSION` (currently `5`) is the host's advertised major. - `API_VERSION` (currently `6`) is the host's advertised major.
- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. - `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads.
- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `4` to disable V5). - `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `5` to disable V6).
- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. - A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end.
- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). - V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)).
- V5 introduces `BuiltinPlugin::on_load` and tab-keyed document paths. - V5 introduces `BuiltinPlugin::on_load` and tab-keyed document paths.
- V6 appends typed system-variable access to `HostApi`; the fork currently
supports `CLAYER` and `SNAPANG`.
The runtime enforces three gates: The runtime enforces three gates:
+8 -3
View File
@@ -9,7 +9,7 @@ cheaply. The runtime host surface is enabled by the `host` feature.
## Plugin Architecture ## Plugin Architecture
OCS is scriptable and extensible through a versioned plugin API. The API OCS is scriptable and extensible through a versioned plugin API. The API
supports four major generations: **V2**, **V3**, **V4**, and **V5**. Each supports five major generations: **V2**, **V3**, **V4**, **V5**, and **V6**. Each
generation keeps the previous ABI stable by appending new vtable entries and new generation keeps the previous ABI stable by appending new vtable entries and new
enum variants at the end, so older plugins continue to load on newer hosts. enum variants at the end, so older plugins continue to load on newer hosts.
@@ -19,10 +19,11 @@ enum variants at the end, so older plugins continue to load on newer hosts.
| **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended | | **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended |
| **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait | | **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait |
| **V5** | V4 multiplexed runner | One-shot initialization + tab-keyed document paths | `BuiltinPlugin::on_load` and `HostApi::document_path` appended to the traits | | **V5** | V4 multiplexed runner | One-shot initialization + tab-keyed document paths | `BuiltinPlugin::on_load` and `HostApi::document_path` appended to the traits |
| **V6** | V4 multiplexed runner | Read and write supported host system variables | `HostApi::system_variable` and `HostApi::set_system_variable` appended to the trait; request/response variants appended |
The host controls which generations are accepted at runtime through The host controls which generations are accepted at runtime through
`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `4` to disable V5). The `OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `5` to disable V6). The
current host advertises [`API_VERSION = 5`](src/manifest.rs) and supports plugins current host advertises [`API_VERSION = 6`](src/manifest.rs) and supports plugins
back to `API_VERSION_MIN_SUPPORTED = 2`. back to `API_VERSION_MIN_SUPPORTED = 2`.
V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the
@@ -30,6 +31,10 @@ first user command. Plugins can cache `HostApi::plugin_request_sender()` there
for worker-thread access. A panic in the callback fails plugin loading. for worker-thread access. A panic in the callback fails plugin loading.
`HostApi::document_path(tab_id)` returns the saved path for a document tab. `HostApi::document_path(tab_id)` returns the saved path for a document tab.
V6 adds a typed system-variable interface. This fork currently implements
`CLAYER` (current drawing layer) and `SNAPANG` (snap-angle degrees). Unknown
variables return an error rather than silently accepting a value.
### High-level layout ### High-level layout
```mermaid ```mermaid
+1 -1
View File
@@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) {
"acadrust_version": acadrust.version.to_string(), "acadrust_version": acadrust.version.to_string(),
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version, "rustc_version": rustc_version,
"api_version": 5, "api_version": 6,
"api_version_min_supported": 2, "api_version_min_supported": 2,
"build_timestamp": build_timestamp, "build_timestamp": build_timestamp,
}); });
+23
View File
@@ -60,6 +60,13 @@ pub enum LogLevel {
Error, Error,
} }
/// Value of a host-managed drafting or document setting exposed to plugins.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostSettingValue {
Text(String),
Number(f64),
}
/// A notification the host sends to a plugin. These are best-effort, /// A notification the host sends to a plugin. These are best-effort,
/// full-duplex messages correlated with an optional `command_id`. /// full-duplex messages correlated with an optional `command_id`.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -552,6 +559,22 @@ pub trait HostApi {
let _ = tab_id; let _ = tab_id;
None None
} }
/// Read a named host setting without invoking the command dispatcher.
/// Names are case-insensitive. Hosts may support only a subset.
fn system_variable(&self, _name: &str) -> Option<HostSettingValue> {
None
}
/// Set a named host setting without re-entering command dispatch.
/// Returns the effective value after validation and normalization.
fn set_system_variable(
&mut self,
_name: &str,
_value: HostSettingValue,
) -> Result<HostSettingValue, String> {
Err("system variable is not supported by this host".to_owned())
}
} }
/// Simplified, read-only entity kind exposed by [`DocumentReader`]. /// Simplified, read-only entity kind exposed by [`DocumentReader`].
+29 -1
View File
@@ -11,7 +11,7 @@ use acadrust::{CadDocument, EntityType, Handle};
use interprocess::local_socket::traits::Stream as StreamTrait; use interprocess::local_socket::traits::Stream as StreamTrait;
use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName}; use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName};
use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity}; use crate::host::{DocumentReader, HostApi, HostSettingValue, InteractiveCommand, ReaderEntity};
use crate::ipc::protocol::{ use crate::ipc::protocol::{
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake, HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake,
}; };
@@ -416,6 +416,34 @@ impl HostApi for PluginHostApi {
} }
} }
} }
fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
match self.client.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) {
Ok(PluginResponse::SystemVariable(value)) => value,
_ => None,
}
}
fn set_system_variable(
&mut self,
name: &str,
value: HostSettingValue,
) -> Result<HostSettingValue, String> {
match self.client.request(PluginRequest::SetSystemVariable {
name: name.to_owned(),
value,
}) {
Ok(PluginResponse::SystemVariableResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected system variable response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
} }
/// Sentinel reader used when the shared-memory view could not be initialized. /// Sentinel reader used when the shared-memory view could not be initialized.
+7 -1
View File
@@ -17,7 +17,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::host::{CommandSource, CommandStep}; use crate::host::{CommandSource, CommandStep, HostSettingValue};
use crate::manifest::ApiVersion; use crate::manifest::ApiVersion;
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
@@ -138,6 +138,10 @@ pub enum PluginRequest {
GetTabId, GetTabId,
/// V5: ask the host for the filesystem path of the document in `tab_id`. /// V5: ask the host for the filesystem path of the document in `tab_id`.
DocumentPath { tab_id: u64 }, DocumentPath { tab_id: u64 },
/// Read a host-managed setting without nested command dispatch.
GetSystemVariable { name: String },
/// Change a host-managed setting without nested command dispatch.
SetSystemVariable { name: String, value: HostSettingValue },
} }
/// Responses the host sends back for `PluginRequest`. /// Responses the host sends back for `PluginRequest`.
@@ -164,6 +168,8 @@ pub enum PluginResponse {
TabId(u64), TabId(u64),
/// V5: filesystem path of the document in the requested tab, if any. /// V5: filesystem path of the document in the requested tab, if any.
DocumentPath(Option<std::ffi::OsString>), DocumentPath(Option<std::ffi::OsString>),
SystemVariable(Option<HostSettingValue>),
SystemVariableResult(Result<HostSettingValue, String>),
} }
/// Messages sent from the host to the plugin runner. /// Messages sent from the host to the plugin runner.
+6
View File
@@ -77,5 +77,11 @@ pub fn handle_plugin_request(
DocumentPath { tab_id } => PluginResponse::DocumentPath( DocumentPath { tab_id } => PluginResponse::DocumentPath(
host.document_path(tab_id).map(|path| path.into_os_string()), host.document_path(tab_id).map(|path| path.into_os_string()),
), ),
GetSystemVariable { name } => {
PluginResponse::SystemVariable(host.system_variable(&name))
}
SetSystemVariable { name, value } => {
PluginResponse::SystemVariableResult(host.set_system_variable(&name, value))
}
} }
} }
+29 -1
View File
@@ -17,7 +17,7 @@ use interprocess::TryClone;
use crate::host::{ use crate::host::{
DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand, DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand,
PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, HostSettingValue,
}; };
use crate::ipc::protocol::{ use crate::ipc::protocol::{
HostRequest, HostResponse, PluginRequest, PluginResponse, RunnerHandshake, HostRequest, HostResponse, PluginRequest, PluginResponse, RunnerHandshake,
@@ -730,6 +730,34 @@ impl HostApi for V4PluginHostApi {
} }
} }
} }
fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
match self.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) {
Ok(PluginResponse::SystemVariable(value)) => value,
_ => None,
}
}
fn set_system_variable(
&mut self,
name: &str,
value: HostSettingValue,
) -> Result<HostSettingValue, String> {
match self.request(PluginRequest::SetSystemVariable {
name: name.to_owned(),
value,
}) {
Ok(PluginResponse::SystemVariableResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected system variable response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
} }
#[cfg(test)] #[cfg(test)]
+4 -3
View File
@@ -19,8 +19,9 @@
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on /// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
/// adds the `BuiltinPlugin::on_load` lifecycle callback and /// adds the `BuiltinPlugin::on_load` lifecycle callback and
/// `HostApi::document_path`. /// `HostApi::document_path`. v6 appends generic host system-variable access
pub const API_VERSION: u32 = 5; /// (`HostApi::system_variable` / `set_system_variable`).
pub const API_VERSION: u32 = 6;
/// Oldest plugin API major the current host still loads. This keeps previously /// Oldest plugin API major the current host still loads. This keeps previously
/// compiled cdylibs usable as long as their vtable layout is a prefix of the /// compiled cdylibs usable as long as their vtable layout is a prefix of the
@@ -28,7 +29,7 @@ pub const API_VERSION: u32 = 5;
pub const API_VERSION_MIN_SUPPORTED: u32 = 2; pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
/// Environment variable that caps the API major accepted by the host at /// Environment variable that caps the API major accepted by the host at
/// runtime. Set to `4` to disable V5 plugins, `3` to disable V4, or `2` for /// runtime. Set to `5` to disable V6 plugins, `4` to disable V5, or `2` for
/// V2-only mode. /// V2-only mode.
pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION"; pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION";
+10
View File
@@ -739,6 +739,16 @@ impl OpenCADStudio {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn clayer_command_sets_layer_used_by_new_geometry() {
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LAYER NEW Annotations"}"#);
app.automation_op(r#"{"op":"run","cmd":"CLAYER Annotations"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
let lines = app.automation_op(r#"{"op":"query","type":"Line"}"#);
assert_eq!(lines["entities"][0]["layer"], "Annotations");
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
#[test] #[test]
fn built_in_edits_advance_plugin_document_fingerprint_once() { fn built_in_edits_advance_plugin_document_fingerprint_once() {
+1 -4
View File
@@ -2649,10 +2649,7 @@ impl OpenCADStudio {
self.command_line self.command_line
.push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref()); .push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref());
} else { } else {
if self.tabs[i].scene.document.layers.contains(name_arg) { if self.set_current_layer_name(i, name_arg).is_ok() {
self.tabs[i].scene.document.header.current_layer_name =
name_arg.to_string();
self.tabs[i].dirty = true;
self.command_line self.command_line
.push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref()); .push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref());
} else { } else {
+21
View File
@@ -2,6 +2,27 @@ use super::OpenCADStudio;
use crate::ui; use crate::ui;
impl OpenCADStudio { impl OpenCADStudio {
/// Apply the current layer to the document header and all per-tab/UI
/// creation state. Both built-in commands and plugin settings use this.
pub(super) fn set_current_layer_name(&mut self, tab: usize, layer: &str) -> Result<(), String> {
let handle = self.tabs[tab]
.scene
.document
.layers
.get(layer)
.map(|entry| entry.handle)
.ok_or_else(|| format!("layer {layer:?} does not exist"))?;
self.tabs[tab].scene.document.header.current_layer_name = layer.to_owned();
self.tabs[tab].scene.document.header.current_layer_handle = handle;
self.tabs[tab].active_layer = layer.to_owned();
self.tabs[tab].layers.current_layer = layer.to_owned();
self.tabs[tab].dirty = true;
if tab == self.active_tab {
self.ribbon.active_layer = layer.to_owned();
self.refresh_layer_panel();
}
Ok(())
}
pub(super) fn load_layer_state_editor(&mut self, selected: Option<String>) { pub(super) fn load_layer_state_editor(&mut self, selected: Option<String>) {
let i = self.active_tab; let i = self.active_tab;
if let Some(name) = selected { if let Some(name) = selected {
+75 -1
View File
@@ -4,7 +4,7 @@ use std::any::Any;
use acadrust::tables::AppId; use acadrust::tables::AppId;
use acadrust::xdata::ExtendedDataRecord; use acadrust::xdata::ExtendedDataRecord;
use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi}; use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi, HostSettingValue};
use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData}; use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -250,6 +250,41 @@ impl<'a> HostSession<'a> {
self.app.tabs[self.tab].dirty = true; self.app.tabs[self.tab].dirty = true;
} }
pub fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
match name.to_ascii_uppercase().as_str() {
"CLAYER" => Some(HostSettingValue::Text(
self.document().header.current_layer_name.clone(),
)),
"SNAPANG" => Some(HostSettingValue::Number(self.app.snap_angle_deg as f64)),
_ => None,
}
}
pub fn set_system_variable(
&mut self,
name: &str,
value: HostSettingValue,
) -> Result<HostSettingValue, String> {
match (name.to_ascii_uppercase().as_str(), value) {
("CLAYER", HostSettingValue::Text(layer)) => {
self.app.set_current_layer_name(self.tab, &layer)?;
Ok(HostSettingValue::Text(layer))
}
("SNAPANG", HostSettingValue::Number(angle)) if (angle as f32).is_finite() => {
self.app.snap_angle_deg = (angle as f32).rem_euclid(360.0);
Ok(HostSettingValue::Number(self.app.snap_angle_deg as f64))
}
("SNAPANG", HostSettingValue::Number(_)) => {
Err("SNAPANG: finite number required".to_owned())
}
("CLAYER", _) | ("SNAPANG", _) => Err(format!(
"{}: wrong value type",
name.to_ascii_uppercase()
)),
_ => Err(format!("unsupported system variable {name:?}")),
}
}
pub fn push_info(&mut self, msg: &str) { pub fn push_info(&mut self, msg: &str) {
self.app.command_line.push_info(msg); self.app.command_line.push_info(msg);
} }
@@ -356,6 +391,16 @@ impl HostApi for HostSession<'_> {
fn document_path(&self, tab_id: u64) -> Option<std::path::PathBuf> { fn document_path(&self, tab_id: u64) -> Option<std::path::PathBuf> {
self.document_path(tab_id) self.document_path(tab_id)
} }
fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
self.system_variable(name)
}
fn set_system_variable(
&mut self,
name: &str,
value: HostSettingValue,
) -> Result<HostSettingValue, String> {
self.set_system_variable(name, value)
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> { fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
self.document_view_v4(tab_id) self.document_view_v4(tab_id)
@@ -514,6 +559,35 @@ mod tests {
use acadrust::xdata::XDataValue; use acadrust::xdata::XDataValue;
use ocs_plugin_api::host::DocumentReader; use ocs_plugin_api::host::DocumentReader;
#[test]
fn system_variables_change_without_command_reentry() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
assert_eq!(host.system_variable("clayer"), Some(HostSettingValue::Text("0".into())));
assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(0.0)));
assert!(host.set_system_variable("CLAYER", HostSettingValue::Text("Missing".into())).is_err());
assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("0".into())));
let mut point = Point::new();
point.common.layer = "Annotations".into();
host.add_entity(EntityType::Point(point));
assert_eq!(host.set_system_variable("clayer", HostSettingValue::Text("Annotations".into())),
Ok(HostSettingValue::Text("Annotations".into())));
assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("Annotations".into())));
assert_eq!(host.app.tabs[0].active_layer, "Annotations");
assert_eq!(host.app.tabs[0].layers.current_layer, "Annotations");
assert_eq!(host.app.ribbon.active_layer, "Annotations");
assert!(host.app.tabs[0].dirty);
assert_eq!(host.set_system_variable("snapang", HostSettingValue::Number(450.0)),
Ok(HostSettingValue::Number(90.0)));
assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0)));
assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::INFINITY)).is_err());
assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::MAX)).is_err());
assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0)));
}
#[test] #[test]
fn xdata_record_round_trips_and_registers_appid() { fn xdata_record_round_trips_and_registers_appid() {
let mut app = OpenCADStudio::new_for_test(); let mut app = OpenCADStudio::new_for_test();