Merge pull request #1306 from ghost/plugin-doc-findings

This commit is contained in:
Hakan Seven committed 2026-09-17 06:50:21 +03:00
commit 49afac176b
8 files changed
+124 -64

No files matched your search

+41 -11
View File
@@ -101,13 +101,16 @@ affect whether a prebuilt plugin can safely be loaded:
A plugin's `plugin.toml` declares the same values under `[opencad]`. For API v4
and later, `rustc_version` is required and the host refuses to load the plugin
unless it matches exactly. This turns the current silent runner crash into a
one-line diagnosis such as:
unless it matches exactly — omitting it is treated as a mismatch, not skipped.
This turns the current silent runner crash into a one-line diagnosis such as:
```
Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required
```
`acadrust_source` is also required for API v4 and later. Missing, empty, or
mismatched metadata makes the package incompatible.
### `PluginManifest`
```rust
@@ -202,11 +205,14 @@ struct ExampleModule;
impl CadModule for ExampleModule {
fn id(&self) -> &'static str { "example" }
fn title(&self) -> &'static str { "Example" }
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef {
// Returns a slice, not an owned Vec — build once into a static and hand
// back a reference, rather than rebuilding (and dropping) it every call.
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef {
id: "EX_HELLO", label: "Hello", icon: IconKind::Glyph("◆"),
event: ModuleEvent::Command("EX_HELLO".to_string()),
})]}]
})]}]})
}
}
@@ -235,8 +241,9 @@ api_version = 5
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
# Filled with the exact compiler output while staging the release asset.
# Both are filled at release-build time; see the CI step below.
rustc_version = "rustc 1.98.0 (hash date)"
acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=<short>#<full-40-char-commit>"
```
The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template);
@@ -255,6 +262,10 @@ tool fires `ModuleEvent::Command("EX_FOO")`, which round-trips to
tool request a native file picker; on selection the host dispatches
`"<command> <path>"` back with the path's original case preserved.
A leading `>` or the persistent literal-input toggle preserves both spaces
and case. Automation and `ModuleEvent::PluginFileDialog` already preserve
case without this prefix.
### Interactive (click-to-place) commands
For tools that collect points — placing a structure, drawing a pipe — call
@@ -306,7 +317,18 @@ plugin.toml
```
A GitHub Actions matrix workflow (see the example repo / template) cross-builds
and uploads these on a `v*` tag.
and uploads these on a `v*` tag. It also fills `plugin.toml`'s `rustc_version`
and `acadrust_source` placeholders from the CI run's actual toolchain and
resolved dependency, rather than hand-editing them per release — see
`docs/plugin-template/.github/workflows/release.yml` for the exact `sed`
substitutions, including reading `acadrust_source` out of `Cargo.lock`'s
resolved `acadrust` package entry (`source = "git+...#<hash>"`) since that's
the same value the host embeds at its own build time. The template's CI pins
`rust-toolchain@stable`, which is a floating target — a real release pipeline
should pin the exact toolchain version the host was built with (check
`crates/ocs_plugin_api/src/version_info.rs`'s embedded value, or the host's
own CI config) rather than trusting "whatever's current stable today" to
keep matching it.
---
@@ -383,6 +405,9 @@ dynamic libraries:
- **Enable/disable** — toggles a loaded plugin's ribbon tab + dispatch without
uninstalling.
One linked repository is one marketplace entry. Publish separate plugins from
separate repositories so release and update histories stay unambiguous.
---
## Compatibility & ABI
@@ -391,9 +416,9 @@ Plugins are loaded as `cdylib`s by a plugin-runner child process. The host spawn
this child from its own executable (`--ocs-plugin-runner` mode), so the runner
and host always share the same `ocs_plugin_api` build. The runner checks
`ocs_plugin_api_version` before any plugin code runs. Each plugin runs in its
own OS process, so the host is protected from plugin crashes and memory
corruption. Process isolation removes the need for the host and plugin to share
a Rust toolchain ABI beyond the stable `ocs_plugin_api` contract.
own OS process, so a plugin crash or memory-corruption bug cannot take down the
host process itself. The runner loads the plugin library in-process, however,
so API v4+ requires matching `rustc_version` and `acadrust_source` metadata.
A future hardening step is a `#[repr(C)]` vtable (a true C ABI) so binaries built
by any toolchain interoperate — required before trusting prebuilt binaries from
@@ -414,6 +439,12 @@ Done:
reinstall / uninstall, enable/disable.
- [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick).
- [x] Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host.
- [x] External automation (`--mcp` — local stdio MCP/JSON-RPC server for AI
clients; `--serve` — headless JSON automation server; both drive OCS
from a separate process without the GUI, per `src/cli.rs`/`src/mcp.rs`/
`app::serve`). This closes the "drive OCS headless from a process" goal
of issue #29; this line previously and incorrectly listed it as not yet
started.
Next:
@@ -421,7 +452,6 @@ Next:
- [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries.
- [ ] Trust: checksums / signatures before spawning plugin processes.
- [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks.
- [ ] External automation API (drive OCS headless from a process) — issue #29.
---
+4 -2
View File
@@ -64,8 +64,10 @@ From the workspace root:
cargo build -p plugin-template-v2
```
The resulting cdylib plus `plugin.toml` can be installed into the host's
plugins folder (see `docs/plugin-architecture.md`).
The resulting cdylib plus `plugin.toml` (checked in alongside this file) can
be installed into the host's plugins folder (see `docs/plugin-architecture.md`).
Replace `plugin.toml`'s `rustc_version` and `acadrust_source` placeholders in
your packaging step. The release workflow in `docs/plugin-template` shows how.
---
+23
View File
@@ -0,0 +1,23 @@
[plugin]
id = "com.example.plugin-template-v2"
name = "Plugin Template v2"
version = "0.1.0"
description = "Demonstrates read/write object round-trips with the host."
author = "Your Name"
license = "GPL-3.0-only"
repository = "your-account/your-plugin-repo"
[opencad]
api_version = 5
# Replace both placeholders in the packaging step. The v1 template's
# release workflow contains a working example.
rustc_version = "__RUSTC_VERSION__"
acadrust_source = "__ACADRUST_SOURCE__"
ribbon_order = 100
command_prefixes = [
"COUNT_SURVEY_POINTS",
"ADD_SURVEY_POINT",
"MARK_FIRST_SURVEY_POINT",
"COUNT_MARKED_SURVEY_POINTS",
]
xdata_apps = ["SURVEYMARK"]
+13 -1
View File
@@ -29,6 +29,14 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
# NOTE: @stable floats to whatever's current when this job runs, which
# is NOT the same thing as "matches the host's rustc exactly" — the API
# v4+ load check compares plugin.toml's rustc_version to the host's
# build-time compiler byte-for-byte (see plugin-architecture.md's
# "Build-time ABI metadata"). Pin this to the host's actual toolchain
# version (check crates/ocs_plugin_api/src/version_info.rs's embedded
# value, or the host's own CI config) before a real release, or your
# binary may be refused at load with a version-mismatch error.
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --release
- name: Stage release asset
@@ -36,7 +44,11 @@ jobs:
run: |
mkdir -p dist
cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}"
sed "s|__RUSTC_VERSION__|$(rustc --version)|" plugin.toml > dist/plugin.toml
acadrust_source=$(grep -A3 '^name = "acadrust"' Cargo.lock | grep '^source' | head -1 | sed 's/^source = "//;s/"$//')
sed \
-e "s|__RUSTC_VERSION__|$(rustc --version)|" \
-e "s|__ACADRUST_SOURCE__|$acadrust_source|" \
plugin.toml > dist/plugin.toml
- uses: softprops/action-gh-release@v2
with:
files: |
+2
View File
@@ -10,6 +10,8 @@ repository = "your-account/your-plugin-repo"
[opencad]
api_version = 5
rustc_version = "__RUSTC_VERSION__"
# release.yml fills both placeholders from the compiler and Cargo.lock.
acadrust_source = "__ACADRUST_SOURCE__"
ribbon_order = 60
command_prefixes = ["MP_"]
xdata_apps = ["MYPLUGIN_RECORD"]
+10 -1
View File
@@ -1726,7 +1726,8 @@ impl OpenCADStudio {
// MTEXT bodies), where the typed case is the content and
// Space must stay in the buffer.
let text_with_spaces = self.is_free_text_active();
let s = if text_with_spaces {
let literal = self.command_line.literal_spaces || s.starts_with('>');
let s = if text_with_spaces || literal {
s
} else {
s.to_uppercase()
@@ -9843,6 +9844,14 @@ mod free_text_entry_tests {
);
assert_eq!(app.text_entry_mode(), TextEntryMode::Command);
}
#[test]
fn literal_command_input_preserves_case() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = app.update(Message::CommandInput(">Plugin MixedCase".into()));
assert_eq!(app.command_line.input, ">Plugin MixedCase");
}
}
/// Set or clear one bit of `SELECTIONPREVIEW`.
+24 -15
View File
@@ -80,19 +80,11 @@ impl ExternalPlugin {
/// Returns whether the package's dependency fingerprint matches the host.
pub fn acadrust_compatible(&self) -> bool {
if !ocs_plugin_api::version_info::uses_acadrust_gate(self.api_version) {
return true;
}
if !self.acadrust_declared {
return true;
}
match self.acadrust_source.as_deref() {
None | Some("") => false,
Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible(
source,
ocs_plugin_api::version_info::host_acadrust_source(),
),
}
acadrust_source_compatible(
self.api_version,
self.acadrust_declared,
self.acadrust_source.as_deref(),
)
}
/// Returns whether the package's rustc matches the host.
@@ -119,6 +111,22 @@ impl ExternalPlugin {
}
}
pub(crate) fn acadrust_source_compatible(
api_version: u32,
declared: bool,
source: Option<&str>,
) -> bool {
!ocs_plugin_api::version_info::uses_acadrust_gate(api_version)
|| declared
&& source.is_some_and(|source| {
!source.is_empty()
&& ocs_plugin_api::version_info::acadrust_sources_compatible(
source,
ocs_plugin_api::version_info::host_acadrust_source(),
)
})
}
/// `<config>/OpenCADStudio/plugins`, matching the settings/recent-files store.
/// Overridable via `OCS_PLUGINS_DIR` for tests.
pub fn plugins_dir() -> Option<PathBuf> {
@@ -643,7 +651,7 @@ rustc_version = "rustc 0.0.0-fake"
}
#[test]
fn undeclared_acadrust_falls_back_to_api_gate() {
fn undeclared_acadrust_is_incompatible_for_gated_api() {
let toml = r#"
[plugin]
id = "opencad.test"
@@ -654,7 +662,8 @@ api_version = 4
let p = parse_plugin_toml(toml).expect("parsed");
assert!(!p.acadrust_declared);
assert!(p.acadrust_source.is_none());
assert!(p.acadrust_compatible(), "undeclared acadrust is treated as compatible");
assert!(!p.acadrust_compatible());
assert!(!p.loadable());
}
#[test]
+7 -34
View File
@@ -221,21 +221,11 @@ pub fn fetch_release_info(repo: &str) -> Result<Vec<ReleaseInfo>, String> {
.ok_or_else(|| format!("release {} plugin.toml is missing an id", release.tag))?;
let acadrust_source = manifest.acadrust_source.clone();
let acadrust_declared = manifest.acadrust_declared;
let acadrust_compatible = if !ocs_plugin_api::version_info::uses_acadrust_gate(
let acadrust_compatible = external::acadrust_source_compatible(
manifest.api_version,
) {
true
} else if !acadrust_declared {
true
} else {
match acadrust_source.as_deref() {
None | Some("") => false,
Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible(
source,
ocs_plugin_api::version_info::host_acadrust_source(),
),
}
};
acadrust_declared,
acadrust_source.as_deref(),
);
let rustc_version = manifest.rustc_version.clone();
let rustc_declared = manifest.rustc_declared;
let rustc_compatible = if !ocs_plugin_api::version_info::uses_acadrust_gate(
@@ -268,17 +258,6 @@ pub fn fetch_release_info(repo: &str) -> Result<Vec<ReleaseInfo>, String> {
}
}
// Once a repo declares a fingerprint, require it on later API versions.
let any_acadrust_declared = info.iter().any(|r| r.acadrust_declared);
if any_acadrust_declared {
for r in &mut info {
if !r.acadrust_declared
&& ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version)
{
r.acadrust_compatible = false;
}
}
}
if info.is_empty() {
Err(last_error.unwrap_or_else(|| "no installable releases found".to_string()))
} else {
@@ -350,18 +329,12 @@ pub fn install(release: &Release, repository: &str) -> Result<String, String> {
));
}
if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version)
&& manifest.acadrust_declared
{
if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) {
let Some(source) = manifest.acadrust_source.as_deref() else {
return Err(
"Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(),
);
return Err("Release has no acadrust source; cannot verify ABI compatibility".to_string());
};
if source.is_empty() {
return Err(
"Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(),
);
return Err("Release has no acadrust source; cannot verify ABI compatibility".to_string());
}
if !ocs_plugin_api::version_info::acadrust_sources_compatible(
source,