From ed3e064a479c61010b4204a232cbc5b0f395875b Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:34:47 +0200 Subject: [PATCH 1/8] feat(plugins): include rustc version in plugin compatibility gate OpenCADStudio/plugins#901 - Record the host's rustc --version output in the embedded version_info.json. - Add rustc_version/rustc_declared/rustc_compatible to plugin manifests and release metadata, mirroring the existing acadrust gate. - Reject API v4+ plugins whose declared rustc differs from the host, with a clear one-line error message. - Propagate the rustc gate through the marketplace, install flow, and UI. - Update plugin architecture docs to describe the new ABI metadata. --- crates/ocs_plugin_api/ARCHITECTURE.md | 6 +- crates/ocs_plugin_api/build.rs | 15 ++ crates/ocs_plugin_api/src/version_info.rs | 62 ++++++++ docs/plugin-architecture.md | 25 ++++ src/plugin/external.rs | 163 +++++++++++++++++++++- src/plugin/marketplace.rs | 58 +++++++- src/ui/window/plugin_manager.rs | 22 ++- 7 files changed, 344 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 20e77acc8..81b7b429e 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -50,10 +50,11 @@ The host and each plugin run in separate OS processes. The host re-executes itse - 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)). -The runtime enforces two gates: +The runtime enforces three gates: 1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`. 2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true. +3. For v4+, when the plugin declares `rustc_version`, `rustc_versions_compatible(host_rustc_version(), plugin_rustc_version)` must be true. --- @@ -215,7 +216,7 @@ Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 p The crate embeds two JSON blobs at build time: - **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). -- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). +- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). Both are accessible without enabling the `host` feature. @@ -242,6 +243,7 @@ This is defense-in-depth, not a sandbox: plugins execute native code. | Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. | | Version mismatch | Host refuses plugin before running plugin code. | | Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. | +| Rustc mismatch (v4+) | Host refuses plugin to avoid cross-compiler ABI crashes. | | Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. | --- diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 480f2435f..5be2df03e 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::SystemTime; use cargo_lock::Lockfile; @@ -383,11 +384,25 @@ fn generate_version_info(out_dir: &Path) { .find(|p| p.name.as_str() == "acadrust") .expect("acadrust package in Cargo.lock"); + let rustc_version = Command::new("rustc") + .arg("--version") + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }) + .unwrap_or_default(); + let info = serde_json::json!({ "ocs_version": ocs.version.to_string(), "ocs_plugin_api_version": env!("CARGO_PKG_VERSION"), "acadrust_version": acadrust.version.to_string(), "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), + "rustc_version": rustc_version, "api_version": 4, "api_version_min_supported": 2, "build_timestamp": build_timestamp, diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 35c870cde..4222205cb 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -11,6 +11,12 @@ //! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two //! sources are considered compatible only when they resolve to the same 40 //! character git commit hash. +//! +//! The rustc version string is also recorded because Rust has no stable ABI: +//! a plugin built with a different compiler than the host can pass simple calls +//! and then crash the runner on compound types. For API v4 and later the host +//! can compare the plugin's declared rustc with its own via +//! [`rustc_versions_compatible`]. use std::sync::OnceLock; @@ -26,6 +32,7 @@ pub fn get_embedded_version_info_json() -> &'static str { #[derive(Debug, Clone)] struct VersionInfo { acadrust_source: String, + rustc_version: String, } pub const ACADRUST_GATE_API_VERSION: u32 = 4; @@ -48,6 +55,8 @@ fn parsed_version_info() -> &'static VersionInfo { INFO.get_or_init(|| VersionInfo { acadrust_source: json_string_value(EMBEDDED_VERSION_INFO_JSON, "acadrust_source") .unwrap_or_default(), + rustc_version: json_string_value(EMBEDDED_VERSION_INFO_JSON, "rustc_version") + .unwrap_or_default(), }) } @@ -56,6 +65,25 @@ pub fn host_acadrust_source() -> &'static str { &parsed_version_info().acadrust_source } +/// Returns the host's `rustc --version` output. +pub fn host_rustc_version() -> &'static str { + &parsed_version_info().rustc_version +} + +/// Returns whether two `rustc --version` outputs are considered compatible. +/// +/// The full version line (e.g. "rustc 1.98.0 (hash date)") is compared +/// verbatim after normalising whitespace. This is intentionally strict: Rust +/// has no stable ABI, so even the same nominal compiler built in different +/// environments can produce incompatible layouts. +pub fn rustc_versions_compatible(a: &str, b: &str) -> bool { + if a.is_empty() || b.is_empty() { + return false; + } + a.split_whitespace().collect::() + .eq_ignore_ascii_case(&b.split_whitespace().collect::()) +} + /// Extracts the full git commit hash from a Cargo source. pub fn acadrust_source_hash(source: &str) -> Option<&str> { let hash = source.rsplit('#').next()?; @@ -85,6 +113,7 @@ mod tests { let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON"); assert!(value.get("ocs_version").is_some()); assert!(value.get("acadrust_source").is_some()); + assert!(value.get("rustc_version").is_some()); } #[test] @@ -96,6 +125,7 @@ mod tests { "ocs_plugin_api_version", "acadrust_version", "acadrust_source", + "rustc_version", "api_version", "api_version_min_supported", "build_timestamp", @@ -125,6 +155,38 @@ mod tests { assert!(!host_acadrust_source().is_empty()); } + #[test] + fn host_rustc_version_is_non_empty() { + assert!(!host_rustc_version().is_empty()); + } + + #[test] + fn rustc_version_comparison_is_strict() { + assert!(rustc_versions_compatible( + "rustc 1.98.0 (abc123 2026-01-01)", + "rustc 1.98.0 (abc123 2026-01-01)" + )); + assert!(!rustc_versions_compatible( + "rustc 1.96.0 (def456 2025-11-01)", + "rustc 1.98.0 (abc123 2026-01-01)" + )); + } + + #[test] + fn rustc_version_comparison_normalises_whitespace() { + assert!(rustc_versions_compatible( + "rustc 1.98.0 (abc123 2026-01-01)", + "rustc 1.98.0 (abc123 2026-01-01)" + )); + } + + #[test] + fn rustc_version_comparison_rejects_empty() { + assert!(!rustc_versions_compatible("", "rustc 1.98.0")); + assert!(!rustc_versions_compatible("rustc 1.98.0", "")); + assert!(!rustc_versions_compatible("", "")); + } + #[test] fn dependency_gate_starts_at_api_v4() { assert!(!uses_acadrust_gate(3)); diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index dc1a8d0aa..d4a97e5a6 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -89,6 +89,25 @@ plugin compiles against. Two tiers: A plugin enables the `host` feature. +### Build-time ABI metadata + +For API v4 and later the host records two pieces of build-time metadata that +affect whether a prebuilt plugin can safely be loaded: + +- `acadrust_source` — the exact git source of the `acadrust` crate the host was + built against. +- `rustc_version` — the output of `rustc --version` for the compiler that built + the host. + +A plugin's `plugin.toml` may declare the same values under `[opencad]`. When +either is declared, the host refuses to load the plugin unless it matches the +host exactly. 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 +``` + ### `PluginManifest` ```rust @@ -161,6 +180,10 @@ ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", featur acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" } ``` +Your release build must also use the same `rustc` as the host. Record it in +`plugin.toml` (see below) and pin the toolchain in your CI matrix so every +published asset matches the host's compiler. + ```rust // src/lib.rs use ocs_plugin_api::host::{BuiltinPlugin, HostApi}; @@ -210,6 +233,8 @@ api_version = 3 ribbon_order = 50 command_prefixes = ["EX_"] xdata_apps = [] +# For API v4+, also record the exact rustc used to build the release asset. +# rustc_version = "rustc 1.98.0 (hash date)" ``` The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template); diff --git a/src/plugin/external.rs b/src/plugin/external.rs index c395b1e59..40f63bd1b 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -36,6 +36,12 @@ pub struct ReleaseInfo { pub acadrust_declared: bool, /// Whether the release matches this host. pub acadrust_compatible: bool, + /// Full `rustc --version` output used by the release. + pub rustc_version: Option, + /// Whether `[opencad]` declares `rustc_version`. + pub rustc_declared: bool, + /// Whether the release's rustc matches this host. + pub rustc_compatible: bool, } /// An add-on package found on disk (not necessarily loaded or compatible). @@ -54,6 +60,10 @@ pub struct ExternalPlugin { pub acadrust_source: Option, /// Whether `[opencad]` declares `acadrust_source`. pub acadrust_declared: bool, + /// Full `rustc --version` output used by the plugin. + pub rustc_version: Option, + /// Whether `[opencad]` declares `rustc_version`. + pub rustc_declared: bool, pub ribbon_order: i32, pub command_prefixes: Vec, /// The package directory under the plugins folder. @@ -85,10 +95,30 @@ impl ExternalPlugin { } } + /// Returns whether the package's rustc matches the host. + pub fn rustc_compatible(&self) -> bool { + if !ocs_plugin_api::version_info::uses_acadrust_gate(self.api_version) { + return true; + } + if !self.rustc_declared { + return true; + } + match self.rustc_version.as_deref() { + None | Some("") => false, + Some(version) => ocs_plugin_api::version_info::rustc_versions_compatible( + version, + ocs_plugin_api::version_info::host_rustc_version(), + ), + } + } + /// Returns whether the package can be loaded. #[allow(dead_code)] // plugin-host surface (issue #100); not yet wired pub fn loadable(&self) -> bool { - self.api_compatible() && self.acadrust_compatible() && self.lib_present + self.api_compatible() + && self.acadrust_compatible() + && self.rustc_compatible() + && self.lib_present } } @@ -204,6 +234,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { let mut api_version: u32 = 0; let mut acadrust_source: Option = None; let mut acadrust_declared = false; + let mut rustc_version: Option = None; + let mut rustc_declared = false; let mut ribbon_order: i32 = 0; let mut command_prefixes: Vec = Vec::new(); let mut section = None; @@ -236,6 +268,11 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { let v = unquote(value); acadrust_source = if v.is_empty() { None } else { Some(v) }; } + "rustc_version" if section == Some("opencad") => { + rustc_declared = true; + let v = unquote(value); + rustc_version = if v.is_empty() { None } else { Some(v) }; + } "ribbon_order" => ribbon_order = value.parse().unwrap_or(0), "command_prefixes" => command_prefixes = parse_string_array(value), _ => {} @@ -251,6 +288,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { api_version, acadrust_source, acadrust_declared, + rustc_version, + rustc_declared, ribbon_order, command_prefixes, dir: PathBuf::new(), @@ -372,6 +411,17 @@ mod loader { )); continue; } + if !d.rustc_compatible() { + let host_rustc = ocs_plugin_api::version_info::host_rustc_version(); + let plugin_rustc = d.rustc_version.as_deref().unwrap_or("unknown"); + out.push(( + d.id.clone(), + Err(format!( + "Plugin built with {plugin_rustc}, host requires {host_rustc} - rebuild required" + )), + )); + continue; + } let Some(path) = lib_file(&d.dir) else { out.push(( d.id.clone(), @@ -483,6 +533,117 @@ xdata_apps = ["MYPLUGIN_RECORD"] assert!(!p.loadable()); } + #[test] + fn undeclared_rustc_falls_back_to_api_gate() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(!p.rustc_declared); + assert!(p.rustc_version.is_none()); + assert!(p.rustc_compatible(), "undeclared rustc is treated as compatible"); + } + + #[test] + fn declared_empty_rustc_version_is_incompatible() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +rustc_version = "" +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(p.rustc_declared); + assert!(p.rustc_version.is_none()); + assert!(!p.rustc_compatible(), "declared but empty rustc is incompatible"); + assert!(!p.loadable()); + } + + #[test] + fn rustc_version_outside_opencad_is_ignored() { + let toml = r#" +[plugin] +id = "opencad.test" +api_version = 4 +rustc_version = "rustc 1.98.0" +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(!p.rustc_declared); + assert!(p.rustc_version.is_none()); + } + + #[test] + fn rustc_mismatch_detected() { + let _host = ocs_plugin_api::version_info::host_rustc_version(); + let other = "rustc 0.0.0-fake (not matching anything)"; + let toml = format!( + r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +rustc_version = "{other}" +"# + ); + let p = parse_plugin_toml(&toml).expect("parsed"); + assert!(p.rustc_declared); + assert!(!p.rustc_compatible(), "mismatched rustc should be incompatible"); + assert!(!p.loadable()); + } + + #[test] + fn rustc_match_detected() { + let host = ocs_plugin_api::version_info::host_rustc_version(); + let toml = format!( + r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +rustc_version = "{host}" +"# + ); + let p = parse_plugin_toml(&toml).expect("parsed"); + assert!(p.rustc_declared); + assert!(p.rustc_compatible(), "matching rustc should be compatible"); + } + + #[test] + fn rustc_gate_only_applies_to_api_v4_and_newer() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 2 + +[opencad] +rustc_version = "rustc 0.0.0-fake" +"#; + let mut p = parse_plugin_toml(toml).expect("parsed"); + p.lib_present = true; + assert!(p.rustc_declared); + assert!( + p.rustc_compatible(), + "API v2 plugin should bypass rustc gate" + ); + assert!(p.loadable()); + } + #[test] fn undeclared_acadrust_falls_back_to_api_gate() { let toml = r#" diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index f3f6c5f3d..a89f4b4e5 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -236,12 +236,32 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { ), } }; + 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( + manifest.api_version, + ) { + true + } else if !rustc_declared { + true + } else { + match rustc_version.as_deref() { + None | Some("") => false, + Some(version) => ocs_plugin_api::version_info::rustc_versions_compatible( + version, + ocs_plugin_api::version_info::host_rustc_version(), + ), + } + }; Ok::<_, String>(ReleaseInfo { tag: release.tag, api_version: manifest.api_version, acadrust_source, acadrust_declared, acadrust_compatible, + rustc_version, + rustc_declared, + rustc_compatible, }) })(); match result { @@ -251,8 +271,8 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { } // Once a repo declares a fingerprint, require it on later API versions. - let any_declared = info.iter().any(|r| r.acadrust_declared); - if any_declared { + 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) @@ -261,6 +281,16 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { } } } + let any_rustc_declared = info.iter().any(|r| r.rustc_declared); + if any_rustc_declared { + for r in &mut info { + if !r.rustc_declared + && ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version) + { + r.rustc_compatible = false; + } + } + } if info.is_empty() { Err(last_error.unwrap_or_else(|| "no installable releases found".to_string())) @@ -361,6 +391,30 @@ pub fn install(release: &Release, repository: &str) -> Result { } } + if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) + && manifest.rustc_declared + { + let Some(version) = manifest.rustc_version.as_deref() else { + return Err( + "Release declares rustc metadata but has no version; cannot verify ABI compatibility".to_string(), + ); + }; + if version.is_empty() { + return Err( + "Release declares rustc metadata but has no version; cannot verify ABI compatibility".to_string(), + ); + } + if !ocs_plugin_api::version_info::rustc_versions_compatible( + version, + ocs_plugin_api::version_info::host_rustc_version(), + ) { + let host_rustc = ocs_plugin_api::version_info::host_rustc_version(); + return Err(format!( + "Plugin built with {version}, host requires {host_rustc} - rebuild required" + )); + } + } + let dir = external::plugins_dir() .ok_or("cannot locate the plugins folder")? .join(&manifest.id); diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 1b11c7354..2db1bdd31 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -203,6 +203,7 @@ fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option { .filter(|release| { ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) && release.acadrust_compatible + && release.rustc_compatible }) .filter_map(|release| { semver::Version::parse(trim_version_prefix(&release.tag)) @@ -225,13 +226,14 @@ fn external_card<'a>( ) -> Element<'a, Message> { let failed_old_api = load_error.is_some() && !p.api_compatible(); let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible(); + let rustc_mismatch = p.rustc_declared && !p.rustc_compatible(); let (status, kind) = if loaded && disabled { (t!("Disabled"), StatusKind::Muted) } else if loaded { (t!("Loaded"), StatusKind::Success) } else if !p.api_compatible() || failed_old_api { (t!("API incompatible"), StatusKind::Danger) - } else if acadrust_mismatch { + } else if acadrust_mismatch || rustc_mismatch { (t!("Incompatible"), StatusKind::Danger) } else if load_error.is_some() { (t!("Load failed"), StatusKind::Danger) @@ -375,7 +377,8 @@ fn install_controls<'a>( let action = match selected_release { Some(release) if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) - && release.acadrust_compatible => + && release.acadrust_compatible + && release.rustc_compatible => { pill_button( t!("Install"), @@ -990,6 +993,9 @@ mod tests { acadrust_source: None, acadrust_declared: false, acadrust_compatible: true, + rustc_version: None, + rustc_declared: false, + rustc_compatible: true, }, ReleaseInfo { tag: "v2.0.0".to_string(), @@ -997,6 +1003,9 @@ mod tests { acadrust_source: None, acadrust_declared: false, acadrust_compatible: true, + rustc_version: None, + rustc_declared: false, + rustc_compatible: true, }, ReleaseInfo { tag: "v1.9.9".to_string(), @@ -1004,6 +1013,9 @@ mod tests { acadrust_source: None, acadrust_declared: false, acadrust_compatible: true, + rustc_version: None, + rustc_declared: false, + rustc_compatible: true, }, ]; assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0")); @@ -1021,6 +1033,9 @@ mod tests { ), acadrust_declared: true, acadrust_compatible: true, + rustc_version: None, + rustc_declared: false, + rustc_compatible: true, }, ReleaseInfo { tag: "v2.0.0".to_string(), @@ -1030,6 +1045,9 @@ mod tests { ), acadrust_declared: true, acadrust_compatible: false, + rustc_version: None, + rustc_declared: false, + rustc_compatible: true, }, ]; assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0")); From 996f6f7299232cc701ae695aa5e32ed2eed4af47 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:12:33 +0200 Subject: [PATCH 2/8] feat(plugins): add BuiltinPlugin::on_load lifecycle callback OpenCADStudio/plugins#901 (comment) - Add BuiltinPlugin::on_load(&mut self, host: &mut dyn HostApi) with a default no-op implementation so existing plugins remain source-compatible. - Invoke on_load once for V4 plugins immediately after the runner connects and before entering the request loop, giving plugins a HostApi surface to cache plugin_request_sender() or set up worker threads before any dispatch. - V2/V3 plugins are unchanged: HostApi is only available during dispatch. --- crates/ocs_plugin_api/src/host.rs | 10 ++++++++++ crates/ocs_plugin_api/src/runner.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index f146c23ab..39ae9a04f 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -298,6 +298,16 @@ pub trait BuiltinPlugin: Send + Sync { /// the V4 runner guarantees it is only called for plugins that report /// API major 4 or newer. fn on_notification(&mut self, _command_id: Option, _notification: HostNotification) {} + + /// Lifecycle callback invoked once after the plugin has been loaded and its + /// manifest/ribbon have been accepted by the host. The plugin receives the + /// active document tab's `HostApi` so it can set up worker threads or cache + /// `plugin_request_sender()` before any user command runs. + /// + /// Added after API v4. The default implementation does nothing, so existing + /// plugins remain source-compatible. The runner only calls this for plugins + /// that report API major 4 or newer. + fn on_load(&mut self, _host: &mut dyn HostApi) {} } /// A point-driven interactive command a plugin starts via diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 03f0097fb..dc90e4b34 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -56,15 +56,27 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box= 4 { let client = V4Client::connect_handshake(socket_name, &token)?; + // Give the plugin a chance to set up worker threads / cache the request + // sender before the host starts dispatching user commands. + invoke_on_load(&mut *plugin, &client, &interactive); run_v4(&mut *plugin, &interactive, client) } else { let client = IpcClient::connect(socket_name)?; eprintln!("[runner] connected to host"); client.send_handshake(&token)?; + // V2/V3 plugins receive HostApi only during dispatch, so on_load is not + // meaningful there; skip it to keep the old protocol unchanged. run_v3(&*plugin, &interactive, client) } } +fn invoke_on_load(plugin: &mut dyn BuiltinPlugin, client: &V4Client, interactive: &InteractiveRegistry) { + let mut proxy = client.plugin_host_api(0, interactive.clone()); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + plugin.on_load(&mut proxy) + })); +} + fn run_v3( plugin: &dyn BuiltinPlugin, interactive: &InteractiveRegistry, From 02376529638003a93b59643a7160974e2637464f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 29 Aug 2026 12:20:24 +0300 Subject: [PATCH 3/8] style(icons): unify theme-aware palette --- assets/icons/add_scale.svg | 2 +- assets/icons/arc/arc.svg | 4 +-- assets/icons/arc/arc_3p.svg | 6 ++-- assets/icons/arc/arc_cont.svg | 4 +-- assets/icons/arc/arc_csa.svg | 10 +++--- assets/icons/arc/arc_cse.svg | 10 +++--- assets/icons/arc/arc_csl.svg | 10 +++--- assets/icons/arc/arc_sca.svg | 10 +++--- assets/icons/arc/arc_sce.svg | 10 +++--- assets/icons/arc/arc_scl.svg | 10 +++--- assets/icons/arc/arc_sea.svg | 6 ++-- assets/icons/arc/arc_sed.svg | 8 ++--- assets/icons/arc/arc_ser.svg | 10 +++--- assets/icons/array_path.svg | 8 ++--- assets/icons/array_polar.svg | 12 +++---- assets/icons/array_rect.svg | 10 +++--- assets/icons/audit.svg | 2 +- assets/icons/base_point.svg | 2 +- assets/icons/blocks/preview_size.svg | 6 ++-- assets/icons/bylayer.svg | 2 +- assets/icons/chamfer.svg | 4 +-- assets/icons/circle/circle.svg | 4 +-- assets/icons/circle/circle_2p.svg | 6 ++-- assets/icons/circle/circle_3p.svg | 12 +++---- assets/icons/circle/circle_cd.svg | 4 +-- assets/icons/circle/circle_cr.svg | 4 +-- assets/icons/circle/circle_ttr.svg | 4 +-- assets/icons/circle/circle_ttt.svg | 2 +- assets/icons/clear_tool.svg | 2 +- assets/icons/dim_angular.svg | 6 ++-- assets/icons/ellipse.svg | 4 +-- assets/icons/ellipse/ellipse_arc.svg | 6 ++-- assets/icons/ellipse/ellipse_axis.svg | 10 +++--- assets/icons/ellipse/ellipse_ctr.svg | 6 ++-- assets/icons/explode.svg | 10 +++--- assets/icons/extend.svg | 8 ++--- assets/icons/extrude.svg | 4 +-- assets/icons/fillet.svg | 8 ++--- assets/icons/hatch/hatch_boundary.svg | 10 +++--- assets/icons/hatch/hatch_cross.svg | 16 +++++----- assets/icons/hatch/hatch_gradient.svg | 4 +-- assets/icons/hatch/hatch_lines.svg | 12 +++---- assets/icons/hatch/hatch_solid.svg | 2 +- assets/icons/hidden.svg | 6 ++-- assets/icons/import_obj.svg | 2 +- assets/icons/layers/layfrz.svg | 10 +++--- assets/icons/layers/layiso.svg | 4 +-- assets/icons/layers/laylck.svg | 2 +- assets/icons/layers/laymatch.svg | 6 ++-- assets/icons/layers/laymcur.svg | 8 ++--- assets/icons/layers/layoff.svg | 2 +- assets/icons/layers/layon.svg | 2 +- assets/icons/layers/laythw.svg | 2 +- assets/icons/layers/layulk.svg | 2 +- assets/icons/layers/layuniso.svg | 6 ++-- assets/icons/line.svg | 4 +-- assets/icons/loft.svg | 4 +-- assets/icons/match_prop.svg | 45 +++++---------------------- assets/icons/mirror.svg | 6 ++-- assets/icons/move.svg | 6 ++-- assets/icons/offset.svg | 4 +-- assets/icons/orbit.svg | 4 +-- assets/icons/ortho.svg | 6 ++-- assets/icons/pagesetup.svg | 2 +- assets/icons/persp.svg | 10 +++--- assets/icons/plot.svg | 2 +- assets/icons/polyline.svg | 8 ++--- assets/icons/polysolid.svg | 4 +-- assets/icons/presspull.svg | 4 +-- assets/icons/purge.svg | 4 +-- assets/icons/pyramid3d.svg | 4 +-- assets/icons/recap.svg | 2 +- assets/icons/revolve.svg | 4 +-- assets/icons/rotate.svg | 4 +-- assets/icons/scale.svg | 8 ++--- assets/icons/shapes/poly_c.svg | 8 ++--- assets/icons/shapes/poly_e.svg | 6 ++-- assets/icons/shapes/poly_i.svg | 8 ++--- assets/icons/shapes/rect.svg | 6 ++-- assets/icons/shapes/rect_cen.svg | 6 ++-- assets/icons/shapes/rect_rot.svg | 6 ++-- assets/icons/solid.svg | 6 ++-- assets/icons/stretch.svg | 8 ++--- assets/icons/subtract.svg | 2 +- assets/icons/sweep.svg | 4 +-- assets/icons/sync.svg | 2 +- assets/icons/trim.svg | 2 +- assets/icons/ucs_icon.svg | 6 ++-- assets/icons/ui/redo.svg | 2 +- assets/icons/ui/undo.svg | 2 +- assets/icons/viewport.svg | 2 +- assets/icons/wedge3d.svg | 2 +- assets/icons/wipeout.svg | 4 +-- assets/icons/xadjust.svg | 10 +++--- assets/icons/xclip.svg | 6 ++-- assets/icons/xray.svg | 6 ++-- assets/icons/zoom_in.svg | 4 +-- assets/icons/zoom_out.svg | 2 +- assets/icons/zoom_window.svg | 2 +- src/ui/icons.rs | 15 +++++++-- src/ui/window/block_palette.rs | 7 ++--- 101 files changed, 293 insertions(+), 318 deletions(-) diff --git a/assets/icons/add_scale.svg b/assets/icons/add_scale.svg index 8524e711a..8e81f2e38 100644 --- a/assets/icons/add_scale.svg +++ b/assets/icons/add_scale.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/arc/arc.svg b/assets/icons/arc/arc.svg index f8df6193e..c02a74997 100644 --- a/assets/icons/arc/arc.svg +++ b/assets/icons/arc/arc.svg @@ -1,5 +1,5 @@ - - + + diff --git a/assets/icons/arc/arc_3p.svg b/assets/icons/arc/arc_3p.svg index dc7c0671c..02bda1efc 100644 --- a/assets/icons/arc/arc_3p.svg +++ b/assets/icons/arc/arc_3p.svg @@ -1,7 +1,7 @@ - - - + + + diff --git a/assets/icons/arc/arc_cont.svg b/assets/icons/arc/arc_cont.svg index de43fb9a3..3ed911bff 100644 --- a/assets/icons/arc/arc_cont.svg +++ b/assets/icons/arc/arc_cont.svg @@ -2,6 +2,6 @@ - - + + diff --git a/assets/icons/arc/arc_csa.svg b/assets/icons/arc/arc_csa.svg index 70a3aa50c..6948872c0 100644 --- a/assets/icons/arc/arc_csa.svg +++ b/assets/icons/arc/arc_csa.svg @@ -1,10 +1,10 @@ - - + + - - - + + + diff --git a/assets/icons/arc/arc_cse.svg b/assets/icons/arc/arc_cse.svg index d38120c06..f18a98ef0 100644 --- a/assets/icons/arc/arc_cse.svg +++ b/assets/icons/arc/arc_cse.svg @@ -1,9 +1,9 @@ - - - - - + + + + + diff --git a/assets/icons/arc/arc_csl.svg b/assets/icons/arc/arc_csl.svg index 3d9005332..ea64f9f8f 100644 --- a/assets/icons/arc/arc_csl.svg +++ b/assets/icons/arc/arc_csl.svg @@ -1,9 +1,9 @@ - - - - - + + + + + diff --git a/assets/icons/arc/arc_sca.svg b/assets/icons/arc/arc_sca.svg index bd1c5895b..a313a71ab 100644 --- a/assets/icons/arc/arc_sca.svg +++ b/assets/icons/arc/arc_sca.svg @@ -1,10 +1,10 @@ - - + + - - - + + + diff --git a/assets/icons/arc/arc_sce.svg b/assets/icons/arc/arc_sce.svg index f9773ff7a..6972a321a 100644 --- a/assets/icons/arc/arc_sce.svg +++ b/assets/icons/arc/arc_sce.svg @@ -1,9 +1,9 @@ - - - - - + + + + + diff --git a/assets/icons/arc/arc_scl.svg b/assets/icons/arc/arc_scl.svg index 99c7f213c..18d03dfd6 100644 --- a/assets/icons/arc/arc_scl.svg +++ b/assets/icons/arc/arc_scl.svg @@ -1,9 +1,9 @@ - - - - - + + + + + diff --git a/assets/icons/arc/arc_sea.svg b/assets/icons/arc/arc_sea.svg index dc7b2b0e4..9bc3d9c69 100644 --- a/assets/icons/arc/arc_sea.svg +++ b/assets/icons/arc/arc_sea.svg @@ -4,7 +4,7 @@ - - - + + + diff --git a/assets/icons/arc/arc_sed.svg b/assets/icons/arc/arc_sed.svg index d63a0dd71..62bcf4386 100644 --- a/assets/icons/arc/arc_sed.svg +++ b/assets/icons/arc/arc_sed.svg @@ -2,8 +2,8 @@ - - - - + + + + diff --git a/assets/icons/arc/arc_ser.svg b/assets/icons/arc/arc_ser.svg index e4b14386e..06280eb70 100644 --- a/assets/icons/arc/arc_ser.svg +++ b/assets/icons/arc/arc_ser.svg @@ -2,10 +2,10 @@ - + - - - - + + + + diff --git a/assets/icons/array_path.svg b/assets/icons/array_path.svg index 4bf13082a..a8289526e 100644 --- a/assets/icons/array_path.svg +++ b/assets/icons/array_path.svg @@ -16,7 +16,7 @@ - + diff --git a/assets/icons/base_point.svg b/assets/icons/base_point.svg index bcf094c08..55b15b85d 100644 --- a/assets/icons/base_point.svg +++ b/assets/icons/base_point.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/blocks/preview_size.svg b/assets/icons/blocks/preview_size.svg index 537a29d9c..76a81b474 100644 --- a/assets/icons/blocks/preview_size.svg +++ b/assets/icons/blocks/preview_size.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/bylayer.svg b/assets/icons/bylayer.svg index 5b8acbbe0..ec352aea7 100644 --- a/assets/icons/bylayer.svg +++ b/assets/icons/bylayer.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/chamfer.svg b/assets/icons/chamfer.svg index d8183370c..f2ce9d585 100644 --- a/assets/icons/chamfer.svg +++ b/assets/icons/chamfer.svg @@ -16,7 +16,7 @@ - - + + diff --git a/assets/icons/circle/circle_2p.svg b/assets/icons/circle/circle_2p.svg index 2589c3232..309ed1f5f 100644 --- a/assets/icons/circle/circle_2p.svg +++ b/assets/icons/circle/circle_2p.svg @@ -1,6 +1,6 @@ - - - + + + diff --git a/assets/icons/circle/circle_3p.svg b/assets/icons/circle/circle_3p.svg index 27fef5f24..980ba83c8 100644 --- a/assets/icons/circle/circle_3p.svg +++ b/assets/icons/circle/circle_3p.svg @@ -1,9 +1,9 @@ - - - - - - + + + + + + diff --git a/assets/icons/circle/circle_cd.svg b/assets/icons/circle/circle_cd.svg index 8aae115a5..f90491b55 100644 --- a/assets/icons/circle/circle_cd.svg +++ b/assets/icons/circle/circle_cd.svg @@ -1,5 +1,5 @@ - - + + diff --git a/assets/icons/circle/circle_cr.svg b/assets/icons/circle/circle_cr.svg index ee9fdfc31..fdc304364 100644 --- a/assets/icons/circle/circle_cr.svg +++ b/assets/icons/circle/circle_cr.svg @@ -1,5 +1,5 @@ - - + + diff --git a/assets/icons/circle/circle_ttr.svg b/assets/icons/circle/circle_ttr.svg index 6cfa73005..a3a8579cd 100644 --- a/assets/icons/circle/circle_ttr.svg +++ b/assets/icons/circle/circle_ttr.svg @@ -2,6 +2,6 @@ - - + + diff --git a/assets/icons/circle/circle_ttt.svg b/assets/icons/circle/circle_ttt.svg index 837ad538b..821dc0fea 100644 --- a/assets/icons/circle/circle_ttt.svg +++ b/assets/icons/circle/circle_ttt.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/clear_tool.svg b/assets/icons/clear_tool.svg index af63ef8ab..03edeaaf5 100644 --- a/assets/icons/clear_tool.svg +++ b/assets/icons/clear_tool.svg @@ -2,5 +2,5 @@ - + diff --git a/assets/icons/dim_angular.svg b/assets/icons/dim_angular.svg index 4929a339d..a81368280 100644 --- a/assets/icons/dim_angular.svg +++ b/assets/icons/dim_angular.svg @@ -3,9 +3,9 @@ - + - + - ∠ + ∠ diff --git a/assets/icons/ellipse.svg b/assets/icons/ellipse.svg index 9698d5901..535acb7a1 100644 --- a/assets/icons/ellipse.svg +++ b/assets/icons/ellipse.svg @@ -1,5 +1,5 @@ - - + + diff --git a/assets/icons/ellipse/ellipse_arc.svg b/assets/icons/ellipse/ellipse_arc.svg index 83e09c766..07edf2f6d 100644 --- a/assets/icons/ellipse/ellipse_arc.svg +++ b/assets/icons/ellipse/ellipse_arc.svg @@ -16,7 +16,7 @@ diff --git a/assets/icons/ellipse/ellipse_axis.svg b/assets/icons/ellipse/ellipse_axis.svg index 2a893ec86..86c31d3d4 100644 --- a/assets/icons/ellipse/ellipse_axis.svg +++ b/assets/icons/ellipse/ellipse_axis.svg @@ -2,13 +2,13 @@ - + - + - + - + - + diff --git a/assets/icons/ellipse/ellipse_ctr.svg b/assets/icons/ellipse/ellipse_ctr.svg index 581d00cde..e6ce7a9ee 100644 --- a/assets/icons/ellipse/ellipse_ctr.svg +++ b/assets/icons/ellipse/ellipse_ctr.svg @@ -2,9 +2,9 @@ - + - + - + diff --git a/assets/icons/explode.svg b/assets/icons/explode.svg index dc5e18e3d..eedbfa408 100644 --- a/assets/icons/explode.svg +++ b/assets/icons/explode.svg @@ -16,7 +16,7 @@ - - + + diff --git a/assets/icons/fillet.svg b/assets/icons/fillet.svg index bf4392df6..ea9829cdc 100644 --- a/assets/icons/fillet.svg +++ b/assets/icons/fillet.svg @@ -16,7 +16,7 @@ - - - - - + + + + + diff --git a/assets/icons/hatch/hatch_cross.svg b/assets/icons/hatch/hatch_cross.svg index 9b67237c2..0461f16ef 100644 --- a/assets/icons/hatch/hatch_cross.svg +++ b/assets/icons/hatch/hatch_cross.svg @@ -7,15 +7,15 @@ - - - - + + + + - - - - + + + + diff --git a/assets/icons/hatch/hatch_gradient.svg b/assets/icons/hatch/hatch_gradient.svg index e7d11c0db..199d8a941 100644 --- a/assets/icons/hatch/hatch_gradient.svg +++ b/assets/icons/hatch/hatch_gradient.svg @@ -1,8 +1,8 @@ - - + + diff --git a/assets/icons/hatch/hatch_lines.svg b/assets/icons/hatch/hatch_lines.svg index 9a25933e5..7f20cd492 100644 --- a/assets/icons/hatch/hatch_lines.svg +++ b/assets/icons/hatch/hatch_lines.svg @@ -6,12 +6,12 @@ - - - - - - + + + + + + diff --git a/assets/icons/hatch/hatch_solid.svg b/assets/icons/hatch/hatch_solid.svg index 27f289949..5cccf8100 100644 --- a/assets/icons/hatch/hatch_solid.svg +++ b/assets/icons/hatch/hatch_solid.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/hidden.svg b/assets/icons/hidden.svg index c1b996170..39f88088e 100644 --- a/assets/icons/hidden.svg +++ b/assets/icons/hidden.svg @@ -2,7 +2,7 @@ - - - + + + diff --git a/assets/icons/import_obj.svg b/assets/icons/import_obj.svg index 96f25eea5..4cdbd1b82 100644 --- a/assets/icons/import_obj.svg +++ b/assets/icons/import_obj.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/layers/layfrz.svg b/assets/icons/layers/layfrz.svg index 0ac9f8ecb..4d165d848 100644 --- a/assets/icons/layers/layfrz.svg +++ b/assets/icons/layers/layfrz.svg @@ -16,7 +16,7 @@ @@ -66,7 +66,7 @@ y1="4.1414943" x2="4.1414943" y2="19.858505" - stroke="#4cc9f0" + stroke="#6DB7ED" stroke-width="2.20091" stroke-linecap="round" id="line4" diff --git a/assets/icons/layers/layiso.svg b/assets/icons/layers/layiso.svg index 9337d6912..cc5db5535 100644 --- a/assets/icons/layers/layiso.svg +++ b/assets/icons/layers/layiso.svg @@ -8,9 +8,9 @@ stroke-dasharray="3 3"/> + stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/> + fill="#6DB7ED" stroke="#B4B6B9" stroke-width="1.25"/> diff --git a/assets/icons/layers/laymcur.svg b/assets/icons/layers/laymcur.svg index 97da73db7..5e0365b0e 100644 --- a/assets/icons/layers/laymcur.svg +++ b/assets/icons/layers/laymcur.svg @@ -16,7 +16,7 @@ + stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/> + stroke="#6DB7ED" stroke-width="2.25" stroke-linecap="round"/> + stroke="#B4B6B9" stroke-width="2.25" stroke-linecap="round"/> - - + + diff --git a/assets/icons/loft.svg b/assets/icons/loft.svg index 99e9cf624..11c04339b 100644 --- a/assets/icons/loft.svg +++ b/assets/icons/loft.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/match_prop.svg b/assets/icons/match_prop.svg index 1ff276498..6353e5a95 100644 --- a/assets/icons/match_prop.svg +++ b/assets/icons/match_prop.svg @@ -1,39 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/assets/icons/mirror.svg b/assets/icons/mirror.svg index 64611385a..b76995d59 100644 --- a/assets/icons/mirror.svg +++ b/assets/icons/mirror.svg @@ -16,7 +16,7 @@ - - + + diff --git a/assets/icons/ortho.svg b/assets/icons/ortho.svg index 8e807e43c..211991661 100644 --- a/assets/icons/ortho.svg +++ b/assets/icons/ortho.svg @@ -1,7 +1,7 @@ - + - - + + diff --git a/assets/icons/pagesetup.svg b/assets/icons/pagesetup.svg index 28b79d33c..f707ff1b9 100644 --- a/assets/icons/pagesetup.svg +++ b/assets/icons/pagesetup.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/persp.svg b/assets/icons/persp.svg index 746e74c64..3e90aa383 100644 --- a/assets/icons/persp.svg +++ b/assets/icons/persp.svg @@ -1,8 +1,8 @@ - - - - - + + + + + diff --git a/assets/icons/plot.svg b/assets/icons/plot.svg index 560575226..bcd78b90f 100644 --- a/assets/icons/plot.svg +++ b/assets/icons/plot.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/polyline.svg b/assets/icons/polyline.svg index 738337b43..4a0c8619d 100644 --- a/assets/icons/polyline.svg +++ b/assets/icons/polyline.svg @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/assets/icons/polysolid.svg b/assets/icons/polysolid.svg index 8532c376b..aa85c4e69 100644 --- a/assets/icons/polysolid.svg +++ b/assets/icons/polysolid.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/presspull.svg b/assets/icons/presspull.svg index 189e6909f..ce5bcb45e 100644 --- a/assets/icons/presspull.svg +++ b/assets/icons/presspull.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/purge.svg b/assets/icons/purge.svg index 2e0131c8c..185aa1cc2 100644 --- a/assets/icons/purge.svg +++ b/assets/icons/purge.svg @@ -2,6 +2,6 @@ - - + + diff --git a/assets/icons/pyramid3d.svg b/assets/icons/pyramid3d.svg index a7d26f91f..e83db6e7c 100644 --- a/assets/icons/pyramid3d.svg +++ b/assets/icons/pyramid3d.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/recap.svg b/assets/icons/recap.svg index b02d83d73..e466f086f 100644 --- a/assets/icons/recap.svg +++ b/assets/icons/recap.svg @@ -1,6 +1,6 @@ - + diff --git a/assets/icons/revolve.svg b/assets/icons/revolve.svg index 48073ba99..c621cb8c5 100644 --- a/assets/icons/revolve.svg +++ b/assets/icons/revolve.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/rotate.svg b/assets/icons/rotate.svg index 230814737..c63200912 100644 --- a/assets/icons/rotate.svg +++ b/assets/icons/rotate.svg @@ -16,7 +16,7 @@ - + - - - + + + diff --git a/assets/icons/shapes/poly_e.svg b/assets/icons/shapes/poly_e.svg index ac0de9313..c2ad056e4 100644 --- a/assets/icons/shapes/poly_e.svg +++ b/assets/icons/shapes/poly_e.svg @@ -1,6 +1,6 @@ - - - + + + diff --git a/assets/icons/shapes/poly_i.svg b/assets/icons/shapes/poly_i.svg index aaad3a4e1..82724a925 100644 --- a/assets/icons/shapes/poly_i.svg +++ b/assets/icons/shapes/poly_i.svg @@ -1,7 +1,7 @@ - + - - - + + + diff --git a/assets/icons/shapes/rect.svg b/assets/icons/shapes/rect.svg index 480ec1549..836755232 100644 --- a/assets/icons/shapes/rect.svg +++ b/assets/icons/shapes/rect.svg @@ -16,7 +16,7 @@ diff --git a/assets/icons/shapes/rect_cen.svg b/assets/icons/shapes/rect_cen.svg index 1a44c9ea7..2afff5fe6 100644 --- a/assets/icons/shapes/rect_cen.svg +++ b/assets/icons/shapes/rect_cen.svg @@ -1,6 +1,6 @@ - - - + + + diff --git a/assets/icons/shapes/rect_rot.svg b/assets/icons/shapes/rect_rot.svg index 79e48390b..c4a68ca4c 100644 --- a/assets/icons/shapes/rect_rot.svg +++ b/assets/icons/shapes/rect_rot.svg @@ -1,6 +1,6 @@ - - - + + + diff --git a/assets/icons/solid.svg b/assets/icons/solid.svg index c1dff7f59..adbf7c046 100644 --- a/assets/icons/solid.svg +++ b/assets/icons/solid.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/stretch.svg b/assets/icons/stretch.svg index 53d6598de..b8bb66a58 100644 --- a/assets/icons/stretch.svg +++ b/assets/icons/stretch.svg @@ -16,7 +16,7 @@ diff --git a/assets/icons/subtract.svg b/assets/icons/subtract.svg index 30bd26b4d..5339687c8 100644 --- a/assets/icons/subtract.svg +++ b/assets/icons/subtract.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/sweep.svg b/assets/icons/sweep.svg index 4db7d3736..736a8368a 100644 --- a/assets/icons/sweep.svg +++ b/assets/icons/sweep.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/sync.svg b/assets/icons/sync.svg index 0a1cb6c53..031275ec5 100644 --- a/assets/icons/sync.svg +++ b/assets/icons/sync.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/trim.svg b/assets/icons/trim.svg index 4d2fad8c4..abf2fb07f 100644 --- a/assets/icons/trim.svg +++ b/assets/icons/trim.svg @@ -16,7 +16,7 @@ diff --git a/assets/icons/ui/redo.svg b/assets/icons/ui/redo.svg index 79d952145..d2054eb7e 100644 --- a/assets/icons/ui/redo.svg +++ b/assets/icons/ui/redo.svg @@ -19,7 +19,7 @@ - + diff --git a/assets/icons/wedge3d.svg b/assets/icons/wedge3d.svg index 793b57468..9adfadf33 100644 --- a/assets/icons/wedge3d.svg +++ b/assets/icons/wedge3d.svg @@ -2,5 +2,5 @@ - + diff --git a/assets/icons/wipeout.svg b/assets/icons/wipeout.svg index 5665b83c5..01d32e41e 100644 --- a/assets/icons/wipeout.svg +++ b/assets/icons/wipeout.svg @@ -5,7 +5,7 @@ - + - + diff --git a/assets/icons/xadjust.svg b/assets/icons/xadjust.svg index 1aa63898d..4804c00d6 100644 --- a/assets/icons/xadjust.svg +++ b/assets/icons/xadjust.svg @@ -1,8 +1,8 @@ - - - - - + + + + + diff --git a/assets/icons/xclip.svg b/assets/icons/xclip.svg index ddf1df758..d8c0df758 100644 --- a/assets/icons/xclip.svg +++ b/assets/icons/xclip.svg @@ -1,6 +1,6 @@ - - - + + + diff --git a/assets/icons/xray.svg b/assets/icons/xray.svg index 6aa7d9c5f..7fa661779 100644 --- a/assets/icons/xray.svg +++ b/assets/icons/xray.svg @@ -2,7 +2,7 @@ - - - + + + diff --git a/assets/icons/zoom_in.svg b/assets/icons/zoom_in.svg index 78821bf99..958ba0d29 100644 --- a/assets/icons/zoom_in.svg +++ b/assets/icons/zoom_in.svg @@ -1,6 +1,6 @@ - - + + diff --git a/assets/icons/zoom_out.svg b/assets/icons/zoom_out.svg index 2ea3fe424..352fa0ae3 100644 --- a/assets/icons/zoom_out.svg +++ b/assets/icons/zoom_out.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/zoom_window.svg b/assets/icons/zoom_window.svg index bc4e46712..a04b2678c 100644 --- a/assets/icons/zoom_window.svg +++ b/assets/icons/zoom_window.svg @@ -1,5 +1,5 @@ - + diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 52c3990cc..3ef4cfc90 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -346,13 +346,19 @@ fn semantic_color<'a>( token: &[u8], colors: &'a SemanticColors, ) -> Option<&'a [u8; 7]> { - if is_one_of(token, &["#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"]) { + if is_one_of( + token, + &["#b4b6b9", "#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"], + ) { Some(&colors.text) } else if is_one_of(token, &["#cccccc", "#bdbdbd", "#aaaaaa"]) { Some(&colors.secondary_strong) } else if is_one_of( token, - &["#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a"], + &[ + "#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a", + "#777777", + ], ) { Some(&colors.secondary) } else if is_one_of( @@ -365,7 +371,10 @@ fn semantic_color<'a>( Some(&colors.secondary_weak) } else if is_one_of(token, &["#1a1a1a"]) { Some(&colors.background) - } else if is_one_of(token, &["#4cc9f0", "#4bc8f0", "#4a9eff", "#0099e5"]) { + } else if is_one_of( + token, + &["#6db7ed", "#4cc9f0", "#4bc8f0", "#4a9eff", "#0099e5"], + ) { Some(&colors.primary) } else if is_one_of(token, &["#1565c0"]) { Some(&colors.primary_strong) diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index 6ecf88717..57bf389bf 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -6,7 +6,7 @@ use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; use crate::ui::dock::{DockMsg, PanelId}; use iced::widget::canvas::{Frame, Path, Program, Stroke}; -use iced::widget::{button, canvas, column, container, mouse_area, row, scrollable, svg, text, text_input, tooltip}; +use iced::widget::{button, canvas, column, container, mouse_area, row, scrollable, text, text_input, tooltip}; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; const TOOL_H: f32 = 22.0; @@ -355,10 +355,7 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> { let icon_el: Element<'_, Message> = match icon { IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(), - IconKind::Svg(bytes) => svg(svg::Handle::from_memory(bytes)) - .width(Length::Fixed(TOOL_H)) - .height(Length::Fixed(TOOL_H)) - .into(), + IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), }; button(icon_el) .on_press(Message::BlockPalette(msg)) From 4668042df584d752048cf4eb54950ad9ca369510 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:29:41 +0200 Subject: [PATCH 4/8] fix(plugins): compare rustc version tokens instead of concatenating Tightens the rustc compatibility gate so that whitespace-normalised version strings are compared token-by-token. This prevents a corner case where 'rustc 1.98.0' and 'rustc1.98.0' would be considered equal. --- crates/ocs_plugin_api/src/version_info.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 4222205cb..f358d17da 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -73,15 +73,20 @@ pub fn host_rustc_version() -> &'static str { /// Returns whether two `rustc --version` outputs are considered compatible. /// /// The full version line (e.g. "rustc 1.98.0 (hash date)") is compared -/// verbatim after normalising whitespace. This is intentionally strict: Rust -/// has no stable ABI, so even the same nominal compiler built in different +/// token-by-token after normalising whitespace. This is intentionally strict: +/// Rust has no stable ABI, so even the same nominal compiler built in different /// environments can produce incompatible layouts. pub fn rustc_versions_compatible(a: &str, b: &str) -> bool { if a.is_empty() || b.is_empty() { return false; } - a.split_whitespace().collect::() - .eq_ignore_ascii_case(&b.split_whitespace().collect::()) + let a_tokens: Vec<&str> = a.split_whitespace().collect(); + let b_tokens: Vec<&str> = b.split_whitespace().collect(); + a_tokens.len() == b_tokens.len() + && a_tokens + .iter() + .zip(b_tokens) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) } /// Extracts the full git commit hash from a Cargo source. @@ -187,6 +192,13 @@ mod tests { assert!(!rustc_versions_compatible("", "")); } + #[test] + fn rustc_version_comparison_rejects_collapsed_tokens() { + // A concatenated version must not accidentally match a spaced one. + assert!(!rustc_versions_compatible("rustc 1.98.0", "rustc1.98.0")); + assert!(!rustc_versions_compatible("rustc1.98.0", "rustc 1.98.0")); + } + #[test] fn dependency_gate_starts_at_api_v4() { assert!(!uses_acadrust_gate(3)); From 452749f58c9a1620d90a0a89d6ee2bf76f7384db Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sat, 29 Aug 2026 11:35:06 +0100 Subject: [PATCH 5/8] fix(ribbon): align combo panel dropdowns to a shared baseline Style selectors (Standard, layer, Color/Linetype/Lineweight) sat at different heights across ribbon panels because each combo panel vertically centered its content: panels with fewer icon rows (Multiline Text, Table) centered their dropdown differently than panels with two rows (Dimensions, Multileader). - Pin all combo panels (Layers, Properties, style combos) to the top of the fixed 3-row panel with a shared COMBO_PANEL_PAD, so every primary dropdown shares one baseline regardless of icon-row count - Extract the duplicated tool-row and panel-container code into tool_row() / combo_panel_col() helpers + named spacing constants - Bundle the per-view render state into RenderCtx, shrinking render_large/render_large_dropdown from 12-14 positional args to (item, &ctx) - print_to_printer: silence the Windows-only dead-code warning on PrintOptions::quality with the platform split documented (the field is read by the CUPS `lp -o print-quality` path) --- src/io/print_to_printer.rs | 5 +- src/ui/ribbon/mod.rs | 93 +++++++------- src/ui/ribbon/widgets.rs | 245 +++++++++++++++++-------------------- 3 files changed, 166 insertions(+), 177 deletions(-) diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 7e3128101..6f4d3a680 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -45,7 +45,10 @@ pub struct PrintOptions { pub printer: Option, /// Number of copies (treated as at least 1). pub copies: u32, - /// Print quality label selected in the plot dialog. + /// Print quality label selected in the plot dialog. Read only on the CUPS + /// path (`lp -o print-quality=…`); on Windows the driver's own quality + /// setting wins, so the field is legitimately unread there. + #[cfg_attr(target_os = "windows", allow(dead_code))] pub quality: Option, /// Controls applied while building the intermediate PDF. pub render: crate::io::pdf_export::PdfPlotOptions, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 96815f642..5b3308611 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1303,6 +1303,20 @@ fn render_group<'a>( let mut items_row: Vec> = Vec::new(); let mut small_buf: Vec> = Vec::new(); + let ctx = widgets::RenderCtx { + active_tool, + open_dd, + last_cmd, + state, + layer_infos, + active_layer, + active_color, + active_linetype, + active_lineweight, + style_ctx, + compact, + }; + for item in &group.tools { let is_large = match item { RibbonItem::LargeTool(_) | RibbonItem::LargeDropdown { .. } => !compact, @@ -1314,20 +1328,7 @@ fn render_group<'a>( if is_large { flush_small_col(&mut small_buf, &mut items_row); - items_row.push(render_large( - item, - active_tool, - open_dd, - last_cmd, - state, - layer_infos, - active_layer, - active_color, - active_linetype, - active_lineweight, - style_ctx, - compact, - )); + items_row.push(render_large(item, &ctx)); } else { small_buf.push(render_small( item, @@ -1467,34 +1468,42 @@ fn collapse_button<'a>( // For a Properties panel the representative is its Match button. let rep = representative(group, last_used); let face: Element<'_, Message> = match rep { - Some(RibbonItem::PropertiesGroup { match_prop }) => render_large( - &RibbonItem::LargeTool(match_prop.clone()), - active_tool, - open_dd, - last_cmd, - state, - layer_infos, - active_layer, - active_color, - active_linetype, - active_lineweight, - style_ctx, - false, - ), - Some(item) => render_large( - item, - active_tool, - open_dd, - last_cmd, - state, - layer_infos, - active_layer, - active_color, - active_linetype, - active_lineweight, - style_ctx, - false, - ), + Some(RibbonItem::PropertiesGroup { match_prop }) => { + render_large( + &RibbonItem::LargeTool(match_prop.clone()), + &widgets::RenderCtx { + active_tool, + open_dd, + last_cmd, + state, + layer_infos, + active_layer, + active_color, + active_linetype, + active_lineweight, + style_ctx, + compact: false, + }, + ) + } + Some(item) => { + render_large( + item, + &widgets::RenderCtx { + active_tool, + open_dd, + last_cmd, + state, + layer_infos, + active_layer, + active_color, + active_linetype, + active_lineweight, + style_ctx, + compact: false, + }, + ) + } None => text("").into(), }; diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 29e9f44a3..edbc651d0 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -70,6 +70,18 @@ pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0; /// shorter than a full 3-row panel — the ribbon height follows it down. pub(super) const COLLAPSED_FACE_H: f32 = LARGE_ICON + 20.0; +/// Gap between the combo dropdown and the tool rows beneath it. +const COMBO_ROW_SPACING: f32 = 3.0; +/// Shared padding of combo-style panels (Layers, Properties, style selectors). +/// The top inset pins every primary dropdown to the same baseline regardless +/// of how many icon rows sit underneath it. +const COMBO_PANEL_PAD: Padding = Padding { + top: 6.0, + bottom: 4.0, + left: 4.0, + right: 4.0, +}; + // ── Automatic large-button sizing ──────────────────────────────────────── thread_local! { @@ -604,21 +616,40 @@ pub(super) fn render_small<'a>( // ── Large item renderer ──────────────────────────────────────────────────── +/// Shared render context threaded through the large-item render path. Bundling +/// the per-view state keeps new fields from rippling through every render +/// signature and call site (same idea as `ToggleState`). +#[derive(Clone, Copy)] +pub(super) struct RenderCtx<'a> { + pub active_tool: &'a Option, + pub open_dd: &'a Option, + pub last_cmd: &'a HashMap<&'static str, &'static str>, + pub state: ToggleState, + pub layer_infos: &'a [LayerInfo], + pub active_layer: &'a str, + pub active_color: AcadColor, + pub active_linetype: &'a str, + pub active_lineweight: LineWeight, + pub style_ctx: &'a StyleContext, + /// When compact, the Properties panel's Match button shrinks to a small icon. + pub compact: bool, +} + /// A large dropdown button: the current icon on top, its ▾ directly beneath the /// icon, then the label at the bottom. Shared by `LargeDropdown` / `Dropdown` in /// the full ribbon and by a collapsed panel whose representative is a dropdown. /// `explicit_label` overrides the derived (current-item) label when given. -#[allow(clippy::too_many_arguments)] pub(super) fn render_large_dropdown<'a>( id: &'static str, icon: IconKind, explicit_label: Option<&'a str>, items: &[(&'static str, &'static str, IconKind)], default: &'static str, - active_tool: &Option, - open_dd: &Option, - last_cmd: &HashMap<&'static str, &'static str>, + ctx: &RenderCtx<'_>, ) -> Element<'a, Message> { + let active_tool = ctx.active_tool; + let open_dd = ctx.open_dd; + let last_cmd = ctx.last_cmd; let active = active_tool.as_deref() == Some(id) || items .iter() @@ -708,22 +739,65 @@ pub(super) fn render_large_dropdown<'a>( .into() } +/// A row of small tool buttons beneath a combo dropdown. Shared by the +/// Layers, Properties and style-combo panels so their icon rows render +/// identically. +fn tool_row<'a>(tools: &[ToolDef], active_tool: &Option) -> Element<'a, Message> { + let btns: Vec> = tools + .iter() + .map(|t| { + let is_active = active_tool.as_deref() == Some(t.id); + let tip = t!(t.label); + let event = t.event.clone(); + let icon_el: Element = make_icon(t.icon, 16.0); + let msg = module_event_to_message(event); + tooltip( + button(icon_el) + .on_press(msg) + .style(move |theme: &Theme, status| { + tool_btn_style(theme, is_active, status) + }) + .padding([2, 5]), + make_tip(tip.to_string()), + TipPos::Right, + ) + .gap(4.0) + .delay(Duration::from_millis(400)) + .style(tip_style) + .into() + }) + .collect(); + row(btns).spacing(2).align_y(iced::Center).into() +} + +/// Fixed-height wrapper for a combo-style panel: dropdown on top, icon rows +/// below, all pinned to the panel's top edge so every panel's primary +/// dropdown shares one baseline. +fn combo_panel_col(width: f32, items: Vec>) -> Element<'_, Message> { + container(column(items).spacing(COMBO_ROW_SPACING).align_x(iced::Left)) + .width(Length::Fixed(width)) + .height(Fill) + .align_y(iced::Top) + .padding(COMBO_PANEL_PAD) + .into() +} + /// Render a full-height large button (LargeTool, LargeDropdown, LayerCombo, StyleCombo). pub(super) fn render_large<'a>( item: &RibbonItem, - active_tool: &Option, - open_dd: &Option, - last_cmd: &HashMap<&'static str, &'static str>, - state: ToggleState, - layer_infos: &'a [LayerInfo], - active_layer: &'a str, - active_color: AcadColor, - active_linetype: &'a str, - active_lineweight: LineWeight, - style_ctx: &StyleContext, - // When compact, the Properties panel's Match button shrinks to a small icon. - compact: bool, + ctx: &RenderCtx<'_>, ) -> Element<'a, Message> { + let active_tool = ctx.active_tool; + let open_dd = ctx.open_dd; + let last_cmd = ctx.last_cmd; + let state = ctx.state; + let layer_infos = ctx.layer_infos; + let active_layer = ctx.active_layer; + let active_color = ctx.active_color; + let active_linetype = ctx.active_linetype; + let active_lineweight = ctx.active_lineweight; + let style_ctx = ctx.style_ctx; + let compact = ctx.compact; match item { // A plain Tool renders large too, so a collapsed panel can show its // representative tool as a big icon. @@ -779,16 +853,7 @@ pub(super) fn render_large<'a>( items, default, } => { - render_large_dropdown( - *id, - *icon, - Some(*label), - items, - *default, - active_tool, - open_dd, - last_cmd, - ) + render_large_dropdown(*id, *icon, Some(*label), items, *default, ctx) } // A plain Dropdown renders large too (used by a collapsed panel whose @@ -799,9 +864,7 @@ pub(super) fn render_large<'a>( items, default, } => { - render_large_dropdown( - *id, *icon, None, items, *default, active_tool, open_dd, last_cmd, - ) + render_large_dropdown(*id, *icon, None, items, *default, ctx) } RibbonItem::LayerComboGroup { row2, row3 } => { @@ -868,56 +931,14 @@ pub(super) fn render_large<'a>( .padding([3, 8]) .width(Fill); - let make_tool_row = |tools: &[ToolDef]| -> Element { - let btns: Vec> = tools - .iter() - .map(|t| { - let is_active = active_tool.as_deref() == Some(t.id); - let tip = t!(t.label); - let event = t.event.clone(); - let icon_el: Element = make_icon(t.icon, 16.0); - let msg = module_event_to_message(event); - tooltip( - button(icon_el) - .on_press(msg) - .style(move |theme: &Theme, status| { - tool_btn_style(theme, is_active, status) - }) - .padding([2, 5]), - make_tip(tip.to_string()), - TipPos::Right, - ) - .gap(4.0) - .delay(Duration::from_millis(400)) - .style(tip_style) - .into() - }) - .collect(); - row(btns).spacing(2).align_y(iced::Center).into() - }; - - let tools_row2 = make_tool_row(row2); - let tools_row3 = make_tool_row(row3); - - container( - column![ - PosReport::new(LAYER_COMBO_ID, combo_btn), - tools_row2, - tools_row3 - ] - .spacing(3) - .align_x(iced::Left), + combo_panel_col( + combo_w, + vec![ + container(PosReport::new(LAYER_COMBO_ID, combo_btn)).width(Fill).into(), + tool_row(row2, active_tool), + tool_row(row3, active_tool), + ], ) - .width(Length::Fixed(combo_w)) - .height(Fill) - .align_y(iced::Center) - .padding(Padding { - top: 4.0, - bottom: 4.0, - left: 4.0, - right: 4.0, - }) - .into() } RibbonItem::PropertiesGroup { match_prop } => { @@ -934,17 +955,10 @@ pub(super) fn render_large<'a>( } else { render_large( &RibbonItem::LargeTool(match_prop.clone()), - active_tool, - open_dd, - last_cmd, - state, - layer_infos, - active_layer, - active_color, - active_linetype, - active_lineweight, - style_ctx, - false, + &RenderCtx { + compact: false, + ..*ctx + }, ) }; @@ -1005,6 +1019,8 @@ pub(super) fn render_large<'a>( ); let lw_row = prop_row(LwItem(active_lineweight).to_string(), PROP_LW_ID, None); + // Top-pinned like every combo panel, so the Color dropdown shares + // the same baseline as the Layers / style selectors. let combos = container( column![ PosReport::new(PROP_COLOR_ID, color_row), @@ -1015,12 +1031,12 @@ pub(super) fn render_large<'a>( .align_x(iced::Left), ) .height(Fill) - .align_y(iced::Center) + .align_y(iced::Top) .padding(Padding { - top: 4.0, - bottom: 4.0, + top: COMBO_PANEL_PAD.top, + bottom: COMBO_PANEL_PAD.bottom, left: 0.0, - right: 4.0, + right: COMBO_PANEL_PAD.right, }); row![mp_el, combos] @@ -1068,54 +1084,15 @@ pub(super) fn render_large<'a>( let items_panel: Element = iced::widget::Space::new().width(0).height(0).into(); - // ── tool rows below combo ── - let make_tool_row = |tools: &[ToolDef]| -> Element { - let btns: Vec> = tools - .iter() - .map(|t| { - let is_active = active_tool.as_deref() == Some(t.id); - let tip = t!(t.label); - let event = t.event.clone(); - let icon_el: Element = make_icon(t.icon, 16.0); - let msg = module_event_to_message(event); - tooltip( - button(icon_el) - .on_press(msg) - .style(move |theme: &Theme, status| { - tool_btn_style(theme, is_active, status) - }) - .padding([2, 5]), - make_tip(tip.to_string()), - TipPos::Right, - ) - .gap(4.0) - .delay(Duration::from_millis(400)) - .style(tip_style) - .into() - }) - .collect(); - row(btns).spacing(2).align_y(iced::Center).into() - }; - let mut col_items: Vec> = vec![container(row![PosReport::new(*combo_id, combo_btn), items_panel].spacing(0)) .width(Fill) .into()]; for row_tools in rows { - col_items.push(make_tool_row(row_tools)); + col_items.push(tool_row(row_tools, active_tool)); } - container(column(col_items).spacing(3).align_x(iced::Left)) - .width(Length::Fixed(STYLE_COMBO_W)) - .height(Fill) - .align_y(iced::Center) - .padding(Padding { - top: 4.0, - bottom: 4.0, - left: 4.0, - right: 4.0, - }) - .into() + combo_panel_col(STYLE_COMBO_W, col_items) } } } From 6bf84d8f5ead3afa3f2502f3729431e1e9d78b09 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sat, 29 Aug 2026 12:31:03 +0100 Subject: [PATCH 6/8] feat(ui): show color swatch and layer in selection cycling popup Improve the selection-cycling list box so overlapping objects can be identified at a glance: - Add a color swatch per row showing the object's effective drawn color - Show the layer name right-aligned next to the element type - Size the popup to fit its content with consistent padding Widen render_style_for_viewport visibility from scene-internal to crate-wide so the view layer resolves the same color the renderer uses. --- src/app/view/mod.rs | 20 ++++++--- src/scene/view/render.rs | 2 +- src/ui/popup/cycle_popup.rs | 87 ++++++++++++++++++++++++++++++++----- 3 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7a073e03d..f59c33cec 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1435,13 +1435,23 @@ impl OpenCADStudio { // Selection-cycling list box: pick among overlapping objects. if let Some((pt, cands)) = &self.cycle_candidates { if !tab.is_start { - let items: Vec<(acadrust::Handle, String)> = cands + let items: Vec = cands .iter() .filter_map(|&h| { - tab.scene - .document - .get_entity(h) - .map(|e| (h, crate::entities::traits::entity_type_name(e).to_string())) + tab.scene.document.get_entity(h).map(|e| { + let style = crate::scene::view::render::render_style_for_viewport( + &tab.scene.document, + e, + None, + ); + crate::ui::popup::cycle_popup::CycleCandidate { + handle: h, + type_name: crate::entities::traits::entity_type_name(e) + .to_string(), + layer: e.common().layer.clone(), + color: style.0, + } + }) }) .collect(); if !items.is_empty() { diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index feeff457d..0a4d074cd 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -2802,7 +2802,7 @@ fn viewport_override( .find_map(|(handle, value)| (handle == viewport).then_some(value)) } -pub(in crate::scene) fn render_style_for_viewport( +pub(crate) fn render_style_for_viewport( document: &CadDocument, e: &EntityType, viewport: Option, diff --git a/src/ui/popup/cycle_popup.rs b/src/ui/popup/cycle_popup.rs index 080669842..9cbf9df46 100644 --- a/src/ui/popup/cycle_popup.rs +++ b/src/ui/popup/cycle_popup.rs @@ -1,26 +1,43 @@ //! Selection-cycling list box — shown at the cursor when a click lands on -//! two or more overlapping objects. Each row names a candidate; clicking it -//! adds that object to the current selection. Clicking outside dismisses it. +//! two or more overlapping objects. Each row shows the object's drawn color +//! swatch, its type, and (right-aligned) its layer; clicking a row adds that +//! object to the current selection. Clicking outside dismisses it. -use iced::widget::{button, column, container, mouse_area, opaque, text}; -use iced::{Element, Fill, Length}; +use iced::alignment::Horizontal; +use iced::widget::{button, column, container, mouse_area, opaque, row, rule, space, text}; +use iced::{border::Radius, color, Element, Fill, Length, Theme}; use crate::app::Message; +/// Row content for one overlapping candidate. +pub struct CycleCandidate { + pub handle: acadrust::Handle, + pub type_name: String, + pub layer: String, + /// The color the object is drawn with (layer-inherited when ByLayer). + pub color: [f32; 4], +} + /// Full-canvas overlay: the list box anchored at `anchor` (canvas /// coordinates) plus a transparent click-catcher that cancels. pub fn cycle_popup_overlay( anchor: iced::Point, - items: Vec<(acadrust::Handle, String)>, + items: Vec, ) -> Element<'static, Message> { + // The type column and the layer column get one shared width each (the + // widest entry of their kind, clamped) so the separators line up. + let type_w = clamp_col_width(items.iter().map(|c| c.type_name.chars().count()), 11.0, 40.0, 96.0); + let layer_w = + clamp_col_width(items.iter().map(|c| c.layer.chars().count()), 10.0, 28.0, 84.0); + let rows: Vec> = items .into_iter() - .map(|(handle, label)| item_row(handle, label)) + .map(|c| item_row(c.handle, c.type_name, c.layer, c.color, type_w, layer_w)) .collect(); let panel = container(column(rows)) .style(container::bordered_box) - .width(Length::Fixed(150.0)); + .width(Length::Fixed(type_w + layer_w + 42.0)); let positioned = iced::widget::pin(opaque(panel)) .position(iced::Point::new(anchor.x.max(0.0), anchor.y.max(0.0))); @@ -28,13 +45,63 @@ pub fn cycle_popup_overlay( mouse_area(positioned).on_press(Message::CycleCancel).into() } -fn item_row(handle: acadrust::Handle, label: String) -> Element<'static, Message> { - let content = text(label).size(11).align_y(iced::Center); +/// Column width for the widest label, using a per-glyph estimate with a +/// minimum so short labels don't collapse and a maximum so long ones clip. +fn clamp_col_width(labels: impl Iterator, glyph_w: f32, min: f32, max: f32) -> f32 { + let widest = labels.max().unwrap_or(0) as f32 * glyph_w; + widest.clamp(min, max) +} + +fn item_row( + handle: acadrust::Handle, + type_name: String, + layer: String, + color: [f32; 4], + type_w: f32, + layer_w: f32, +) -> Element<'static, Message> { + let swatch = container( + space::Space::new() + .width(Length::Fixed(9.0)) + .height(Length::Fixed(9.0)), + ) + .style(move |_| container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + color[0], color[1], color[2], color[3], + ))), + border: iced::Border { + color: color!(0x6b6b6b), + width: 1.0, + radius: Radius::from(2.0), + }, + ..Default::default() + }); + + let content = row![ + swatch, + container(text(type_name).size(11)) + .width(Length::Fixed(type_w)) + .align_y(iced::alignment::Vertical::Center), + container(rule::vertical(1)).height(Length::Fixed(12.0)), + container( + text(layer) + .size(10) + .align_x(Horizontal::Right) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.72)), + }) + ) + .width(Length::Fixed(layer_w)) + .align_y(iced::alignment::Vertical::Center), + ] + .spacing(6) + .align_y(iced::Center); + let btn = button(content) .on_press(Message::CycleSelect(handle)) .style(button::subtle) .width(Fill) - .padding([4, 10]); + .padding([3, 8]); // Highlight the underlying object while the cursor is over this row. mouse_area(btn) .on_enter(Message::CycleHover(Some(handle))) From 466ab99c639f866a27c5e7d2dfea31d647c14eee Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 29 Aug 2026 15:54:27 +0300 Subject: [PATCH 7/8] fix(plugins): enforce rustc compatibility BREAKING CHANGE: API v4+ plugin packages must declare the exact rustc version used to build the binary. Refs #901 --- crates/ocs_plugin_api/ARCHITECTURE.md | 2 +- crates/ocs_plugin_api/build.rs | 3 ++- docs/plugin-architecture.md | 14 +++++------ .../.github/workflows/release.yml | 2 +- docs/plugin-template/README.md | 3 ++- docs/plugin-template/plugin.toml | 3 ++- src/plugin/external.rs | 8 +++--- src/plugin/marketplace.rs | 25 +++---------------- src/ui/window/plugin_manager.rs | 3 ++- 9 files changed, 23 insertions(+), 40 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 81b7b429e..9db51f42a 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -54,7 +54,7 @@ The runtime enforces three gates: 1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`. 2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true. -3. For v4+, when the plugin declares `rustc_version`, `rustc_versions_compatible(host_rustc_version(), plugin_rustc_version)` must be true. +3. For v4+, the plugin must declare `rustc_version`, and `rustc_versions_compatible(host_rustc_version(), plugin_rustc_version)` must be true. --- diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 5be2df03e..3a1a38e50 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -384,7 +384,8 @@ fn generate_version_info(out_dir: &Path) { .find(|p| p.name.as_str() == "acadrust") .expect("acadrust package in Cargo.lock"); - let rustc_version = Command::new("rustc") + let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()); + let rustc_version = Command::new(rustc) .arg("--version") .output() .ok() diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index d4a97e5a6..6cf934d7d 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -99,10 +99,10 @@ affect whether a prebuilt plugin can safely be loaded: - `rustc_version` — the output of `rustc --version` for the compiler that built the host. -A plugin's `plugin.toml` may declare the same values under `[opencad]`. When -either is declared, the host refuses to load the plugin unless it matches the -host exactly. This turns the current silent runner crash into a one-line -diagnosis such as: +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: ``` Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required @@ -229,12 +229,12 @@ version = "0.1.0" description = "…" [opencad] -api_version = 3 +api_version = 4 ribbon_order = 50 command_prefixes = ["EX_"] xdata_apps = [] -# For API v4+, also record the exact rustc used to build the release asset. -# rustc_version = "rustc 1.98.0 (hash date)" +# Filled with the exact compiler output while staging the release asset. +rustc_version = "rustc 1.98.0 (hash date)" ``` The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template); diff --git a/docs/plugin-template/.github/workflows/release.yml b/docs/plugin-template/.github/workflows/release.yml index 2ff8de5f3..76588a1eb 100644 --- a/docs/plugin-template/.github/workflows/release.yml +++ b/docs/plugin-template/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: run: | mkdir -p dist cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}" - cp plugin.toml dist/plugin.toml + sed "s|__RUSTC_VERSION__|$(rustc --version)|" plugin.toml > dist/plugin.toml - uses: softprops/action-gh-release@v2 with: files: | diff --git a/docs/plugin-template/README.md b/docs/plugin-template/README.md index f6113df1a..52583adaf 100644 --- a/docs/plugin-template/README.md +++ b/docs/plugin-template/README.md @@ -28,7 +28,8 @@ folder into a new repo and rename the placeholders. cargo build --release mkdir -p "/OpenCADStudio/plugins/opencad.my_plugin" cp target/release/*my_plugin*.so "/OpenCADStudio/plugins/opencad.my_plugin/" -cp plugin.toml "/OpenCADStudio/plugins/opencad.my_plugin/" +sed "s|__RUSTC_VERSION__|$(rustc --version)|" plugin.toml > \ + "/OpenCADStudio/plugins/opencad.my_plugin/plugin.toml" ``` Restart Open CAD Studio: the ribbon tab appears and `MP_` commands route to your diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml index f2718db54..c594522d9 100644 --- a/docs/plugin-template/plugin.toml +++ b/docs/plugin-template/plugin.toml @@ -8,7 +8,8 @@ license = "GPL-3.0-only" repository = "your-account/your-plugin-repo" [opencad] -api_version = 3 +api_version = 4 +rustc_version = "__RUSTC_VERSION__" ribbon_order = 60 command_prefixes = ["MP_"] xdata_apps = ["MYPLUGIN_RECORD"] diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 40f63bd1b..493696def 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -100,9 +100,6 @@ impl ExternalPlugin { if !ocs_plugin_api::version_info::uses_acadrust_gate(self.api_version) { return true; } - if !self.rustc_declared { - return true; - } match self.rustc_version.as_deref() { None | Some("") => false, Some(version) => ocs_plugin_api::version_info::rustc_versions_compatible( @@ -534,7 +531,7 @@ xdata_apps = ["MYPLUGIN_RECORD"] } #[test] - fn undeclared_rustc_falls_back_to_api_gate() { + fn undeclared_rustc_is_incompatible() { let toml = r#" [plugin] id = "opencad.test" @@ -545,7 +542,8 @@ api_version = 4 let p = parse_plugin_toml(toml).expect("parsed"); assert!(!p.rustc_declared); assert!(p.rustc_version.is_none()); - assert!(p.rustc_compatible(), "undeclared rustc is treated as compatible"); + assert!(!p.rustc_compatible()); + assert!(!p.loadable()); } #[test] diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index a89f4b4e5..1cd316e8e 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -242,8 +242,6 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { manifest.api_version, ) { true - } else if !rustc_declared { - true } else { match rustc_version.as_deref() { None | Some("") => false, @@ -281,17 +279,6 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { } } } - let any_rustc_declared = info.iter().any(|r| r.rustc_declared); - if any_rustc_declared { - for r in &mut info { - if !r.rustc_declared - && ocs_plugin_api::version_info::uses_acadrust_gate(r.api_version) - { - r.rustc_compatible = false; - } - } - } - if info.is_empty() { Err(last_error.unwrap_or_else(|| "no installable releases found".to_string())) } else { @@ -391,18 +378,12 @@ pub fn install(release: &Release, repository: &str) -> Result { } } - if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) - && manifest.rustc_declared - { + if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) { let Some(version) = manifest.rustc_version.as_deref() else { - return Err( - "Release declares rustc metadata but has no version; cannot verify ABI compatibility".to_string(), - ); + return Err("Release has no rustc version; cannot verify ABI compatibility".to_string()); }; if version.is_empty() { - return Err( - "Release declares rustc metadata but has no version; cannot verify ABI compatibility".to_string(), - ); + return Err("Release has no rustc version; cannot verify ABI compatibility".to_string()); } if !ocs_plugin_api::version_info::rustc_versions_compatible( version, diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 2db1bdd31..341fc09a6 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -226,7 +226,8 @@ fn external_card<'a>( ) -> Element<'a, Message> { let failed_old_api = load_error.is_some() && !p.api_compatible(); let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible(); - let rustc_mismatch = p.rustc_declared && !p.rustc_compatible(); + let rustc_mismatch = ocs_plugin_api::version_info::uses_acadrust_gate(p.api_version) + && !p.rustc_compatible(); let (status, kind) = if loaded && disabled { (t!("Disabled"), StatusKind::Muted) } else if loaded { From 2842f6dc58ace7f1bf4627bba8180198cbf60746 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 29 Aug 2026 15:57:26 +0300 Subject: [PATCH 8/8] fix(plugins): gate on_load behind API v5 BREAKING CHANGE: plugins using on_load must target API v5 and declare api_version = 5. Refs #901 --- crates/ocs_plugin_api/ARCHITECTURE.md | 12 ++++++++---- crates/ocs_plugin_api/README.md | 13 +++++++++---- crates/ocs_plugin_api/build.rs | 2 +- crates/ocs_plugin_api/src/host.rs | 12 +++++------- crates/ocs_plugin_api/src/manifest.rs | 10 +++++----- crates/ocs_plugin_api/src/runner.rs | 18 ++++++++++++------ crates/ocs_plugin_api/src/version_info.rs | 2 +- docs/plugin-architecture.md | 2 +- docs/plugin-template/plugin.toml | 2 +- 9 files changed, 43 insertions(+), 30 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 9db51f42a..08761e7db 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -44,11 +44,12 @@ The host and each plugin run in separate OS processes. The host re-executes itse ## Versioning and ABI stability -- `API_VERSION` (currently `4`) is the host's advertised major. +- `API_VERSION` (currently `5`) 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. `3` to disable V4). +- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `4` to disable V5). - 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`, invoked once after the runner connects. The runtime enforces three gates: @@ -72,18 +73,21 @@ sequenceDiagram PP->>R: spawn --ocs-plugin-runner R->>L: unsafe { load(cdylib_path) } L-->>R: Box - alt API v4 + alt API v4+ R->>PP: connect + RunnerHandshake::TokenV4 PP->>PP: verify token + V4 protocol/API gate else API v2/v3 R->>PP: connect + RunnerHandshake::Token PP->>PP: verify token end + opt API v5+ + R->>L: on_load(HostApi) + end PP->>R: HostToPlugin::Request(GetManifest) R->>L: manifest() L-->>R: PluginManifest { api_version } R-->>PP: HostResponse::Manifest - PP->>PP: verify api_version + acadrust gate + PP->>PP: verify api_version + ABI metadata gates alt accepted PP-->>PM: success, keep alive else rejected diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index 25021c8ec..1e01c6438 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -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 three major protocol generations: **V2**, **V3**, and **V4**. Each +supports four major generations: **V2**, **V3**, **V4**, and **V5**. 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. @@ -18,12 +18,17 @@ enum variants at the end, so older plugins continue to load on newer hosts. | **V2** | Out-of-process runner | Synchronous commands + interactive point collection (`HostApi::start_interactive`) | Baseline | | **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 `BuiltinPlugin::on_load` initialization | Lifecycle callback appended to `BuiltinPlugin` | The host controls which generations are accepted at runtime through -`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `3` to disable V4). The -current host advertises [`API_VERSION = 4`](src/manifest.rs) and supports plugins +`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 back to `API_VERSION_MIN_SUPPORTED = 2`. +V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the +first user command. Plugins can cache `HostApi::plugin_request_sender()` there +for worker-thread access. A panic in the callback fails plugin loading. + ### High-level layout ```mermaid @@ -263,7 +268,7 @@ static MANIFEST: PluginManifest = PluginManifest { name: "Hello Plugin", version: "0.1.0", description: "A minimal example plugin.", - api_version: ApiVersion { major: 4 }, + api_version: ApiVersion::CURRENT, ribbon_order: 100, xdata_apps: &[], command_prefixes: &["HELLO"], diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 3a1a38e50..867318fd0 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -404,7 +404,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": 4, + "api_version": 5, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 39ae9a04f..0d00c85e3 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -299,14 +299,12 @@ pub trait BuiltinPlugin: Send + Sync { /// API major 4 or newer. fn on_notification(&mut self, _command_id: Option, _notification: HostNotification) {} - /// Lifecycle callback invoked once after the plugin has been loaded and its - /// manifest/ribbon have been accepted by the host. The plugin receives the - /// active document tab's `HostApi` so it can set up worker threads or cache - /// `plugin_request_sender()` before any user command runs. + /// Lifecycle callback invoked once after the runner connects. The plugin + /// receives the active document tab's `HostApi` so it can set up worker + /// threads or cache `plugin_request_sender()` before any user command runs. /// - /// Added after API v4. The default implementation does nothing, so existing - /// plugins remain source-compatible. The runner only calls this for plugins - /// that report API major 4 or newer. + /// Added in API v5. The runner only calls this for plugins that report API + /// major 5 or newer. fn on_load(&mut self, _host: &mut dyn HostApi) {} } diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 39352e8ae..c0113e126 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -17,8 +17,9 @@ /// `document()` / `document_mut()` to local cached copies for out-of-process /// plugins and appends `document_reader` / `document_view` at the end of the /// 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. -pub const API_VERSION: u32 = 4; +/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 +/// adds the `BuiltinPlugin::on_load` lifecycle callback. +pub const API_VERSION: u32 = 5; /// 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 @@ -26,9 +27,8 @@ pub const API_VERSION: u32 = 4; pub const API_VERSION_MIN_SUPPORTED: u32 = 2; /// Environment variable that caps the API major accepted by the host at -/// runtime. Set to `3` to disable V4 plugins while keeping V2/V3 plugins -/// working; set to `2` for V2-only mode. The same mechanism will apply to -/// future API majors. +/// runtime. Set to `4` to disable V5 plugins, `3` to disable V4, or `2` for +/// V2-only mode. pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index dc90e4b34..b1d89ab0c 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -56,9 +56,9 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box= 4 { let client = V4Client::connect_handshake(socket_name, &token)?; - // Give the plugin a chance to set up worker threads / cache the request - // sender before the host starts dispatching user commands. - invoke_on_load(&mut *plugin, &client, &interactive); + if version >= 5 { + invoke_on_load(&mut *plugin, &client, &interactive)?; + } run_v4(&mut *plugin, &interactive, client) } else { let client = IpcClient::connect(socket_name)?; @@ -70,11 +70,17 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box Result<(), Box> { let mut proxy = client.plugin_host_api(0, interactive.clone()); - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { plugin.on_load(&mut proxy) - })); + })) + .map_err(|_| std::io::Error::other("plugin on_load() panicked"))?; + Ok(()) } fn run_v3( diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index f358d17da..aa74a81ab 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -147,7 +147,7 @@ mod tests { value["ocs_plugin_api_version"].as_str(), Some(env!("CARGO_PKG_VERSION")) ); - assert_eq!(value["api_version"].as_i64(), Some(4)); + assert_eq!(value["api_version"].as_i64(), Some(5)); assert_eq!(value["api_version_min_supported"].as_i64(), Some(2)); // acadrust_version should have a patch component (e.g. "0.4.0"). diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 6cf934d7d..d27612f24 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -229,7 +229,7 @@ version = "0.1.0" description = "…" [opencad] -api_version = 4 +api_version = 5 ribbon_order = 50 command_prefixes = ["EX_"] xdata_apps = [] diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml index c594522d9..d07f0b702 100644 --- a/docs/plugin-template/plugin.toml +++ b/docs/plugin-template/plugin.toml @@ -8,7 +8,7 @@ license = "GPL-3.0-only" repository = "your-account/your-plugin-repo" [opencad] -api_version = 4 +api_version = 5 rustc_version = "__RUSTC_VERSION__" ribbon_order = 60 command_prefixes = ["MP_"]