diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 19b1a0968..f046c3f3b 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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 { - 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> = 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=#" ``` 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 `" "` 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+...#"`) 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. --- diff --git a/docs/plugin-template-v2/DESIGN.md b/docs/plugin-template-v2/DESIGN.md index 8a30078cf..0231ff6c9 100644 --- a/docs/plugin-template-v2/DESIGN.md +++ b/docs/plugin-template-v2/DESIGN.md @@ -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. --- diff --git a/docs/plugin-template-v2/plugin.toml b/docs/plugin-template-v2/plugin.toml new file mode 100644 index 000000000..b57c58a3b --- /dev/null +++ b/docs/plugin-template-v2/plugin.toml @@ -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"] diff --git a/docs/plugin-template/.github/workflows/release.yml b/docs/plugin-template/.github/workflows/release.yml index 76588a1eb..3b3b1e45e 100644 --- a/docs/plugin-template/.github/workflows/release.yml +++ b/docs/plugin-template/.github/workflows/release.yml @@ -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: | diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml index d07f0b702..60b3e7fab 100644 --- a/docs/plugin-template/plugin.toml +++ b/docs/plugin-template/plugin.toml @@ -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"] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6cd3d10d6..95155c21e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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`. diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 493696def..61de77be9 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -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(), + ) + }) +} + /// `/OpenCADStudio/plugins`, matching the settings/recent-files store. /// Overridable via `OCS_PLUGINS_DIR` for tests. pub fn plugins_dir() -> Option { @@ -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] diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index aabc4322a..a462ae70f 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -221,21 +221,11 @@ pub fn fetch_release_info(repo: &str) -> Result, 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, 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 { )); } - 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,