forked from 1b-agent/opencadstudio-upstream
Merge pull request #1306 from ghost/plugin-doc-findings
This commit is contained in:
commit
49afac176b
8 files changed
+124
-64
No files matched your search
+41
-11
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user