forked from 1b-agent/opencadstudio-upstream
Add versioned host settings API for plugins
This commit is contained in:
1 parent
93afe91afc
commit
ec2271fc9c
13 files changed
+218
-17
No files matched your search
@@ -44,12 +44,14 @@ The host and each plugin run in separate OS processes. The host re-executes itse
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
- 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.
|
||||
- V6 appends typed system-variable access to `HostApi`; the fork currently
|
||||
supports `CLAYER` and `SNAPANG`.
|
||||
|
||||
The runtime enforces three gates:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ cheaply. The runtime host surface is enabled by the `host` feature.
|
||||
## Plugin Architecture
|
||||
|
||||
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
|
||||
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 |
|
||||
| **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 |
|
||||
| **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
|
||||
`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `4` to disable V5). The
|
||||
current host advertises [`API_VERSION = 5`](src/manifest.rs) and supports plugins
|
||||
`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `5` to disable V6). The
|
||||
current host advertises [`API_VERSION = 6`](src/manifest.rs) and supports plugins
|
||||
back to `API_VERSION_MIN_SUPPORTED = 2`.
|
||||
|
||||
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.
|
||||
`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
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -908,7 +908,7 @@ fn generate_version_info(out_dir: &Path) {
|
||||
"acadrust_version": acadrust.version.to_string(),
|
||||
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
|
||||
"rustc_version": rustc_version,
|
||||
"api_version": 5,
|
||||
"api_version": 6,
|
||||
"api_version_min_supported": 2,
|
||||
"build_timestamp": build_timestamp,
|
||||
});
|
||||
|
||||
@@ -60,6 +60,13 @@ pub enum LogLevel {
|
||||
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,
|
||||
/// full-duplex messages correlated with an optional `command_id`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -552,6 +559,22 @@ pub trait HostApi {
|
||||
let _ = tab_id;
|
||||
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`].
|
||||
|
||||
@@ -11,7 +11,7 @@ use acadrust::{CadDocument, EntityType, Handle};
|
||||
use interprocess::local_socket::traits::Stream as StreamTrait;
|
||||
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::{
|
||||
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.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::host::{CommandSource, CommandStep};
|
||||
use crate::host::{CommandSource, CommandStep, HostSettingValue};
|
||||
use crate::manifest::ApiVersion;
|
||||
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
||||
|
||||
@@ -138,6 +138,10 @@ pub enum PluginRequest {
|
||||
GetTabId,
|
||||
/// V5: ask the host for the filesystem path of the document in `tab_id`.
|
||||
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`.
|
||||
@@ -164,6 +168,8 @@ pub enum PluginResponse {
|
||||
TabId(u64),
|
||||
/// V5: filesystem path of the document in the requested tab, if any.
|
||||
DocumentPath(Option<std::ffi::OsString>),
|
||||
SystemVariable(Option<HostSettingValue>),
|
||||
SystemVariableResult(Result<HostSettingValue, String>),
|
||||
}
|
||||
|
||||
/// Messages sent from the host to the plugin runner.
|
||||
|
||||
@@ -77,5 +77,11 @@ pub fn handle_plugin_request(
|
||||
DocumentPath { tab_id } => PluginResponse::DocumentPath(
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use interprocess::TryClone;
|
||||
|
||||
use crate::host::{
|
||||
DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand,
|
||||
PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity,
|
||||
PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, HostSettingValue,
|
||||
};
|
||||
use crate::ipc::protocol::{
|
||||
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)]
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
/// 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
|
||||
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
|
||||
/// `HostApi::document_path`.
|
||||
pub const API_VERSION: u32 = 5;
|
||||
/// `HostApi::document_path`. v6 appends generic host system-variable access
|
||||
/// (`HostApi::system_variable` / `set_system_variable`).
|
||||
pub const API_VERSION: u32 = 6;
|
||||
|
||||
/// 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
|
||||
@@ -28,7 +29,7 @@ pub const API_VERSION: u32 = 5;
|
||||
pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
|
||||
|
||||
/// 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.
|
||||
pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION";
|
||||
|
||||
|
||||
@@ -739,6 +739,16 @@ impl OpenCADStudio {
|
||||
|
||||
#[cfg(test)]
|
||||
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"))]
|
||||
#[test]
|
||||
fn built_in_edits_advance_plugin_document_fingerprint_once() {
|
||||
|
||||
@@ -2649,10 +2649,7 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref());
|
||||
} else {
|
||||
if self.tabs[i].scene.document.layers.contains(name_arg) {
|
||||
self.tabs[i].scene.document.header.current_layer_name =
|
||||
name_arg.to_string();
|
||||
self.tabs[i].dirty = true;
|
||||
if self.set_current_layer_name(i, name_arg).is_ok() {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref());
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,27 @@ use super::OpenCADStudio;
|
||||
use crate::ui;
|
||||
|
||||
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>) {
|
||||
let i = self.active_tab;
|
||||
if let Some(name) = selected {
|
||||
|
||||
+75
-1
@@ -4,7 +4,7 @@ use std::any::Any;
|
||||
|
||||
use acadrust::tables::AppId;
|
||||
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};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -250,6 +250,41 @@ impl<'a> HostSession<'a> {
|
||||
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) {
|
||||
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> {
|
||||
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"))]
|
||||
fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||
self.document_view_v4(tab_id)
|
||||
@@ -514,6 +559,35 @@ mod tests {
|
||||
use acadrust::xdata::XDataValue;
|
||||
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]
|
||||
fn xdata_record_round_trips_and_registers_appid() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
Reference in new issue
Block a user