forked from 1b-agent/opencadstudio-upstream
Snapshot: V4 plugin API and host integration
This commit is contained in:
1 parent
448869ac35
commit
29c41a4041
36 files changed
+5463
-253
No files matched your search
@@ -1692,6 +1692,9 @@ pub enum ArrowKey {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
Tick(Instant),
|
||||
/// Periodic drain of plugin-to-host requests that arrived outside a host
|
||||
/// call (e.g. mutations from the Python REPL).
|
||||
DrainPluginRequests,
|
||||
/// Web: periodic check for per-script fonts a drawing needs but hasn't
|
||||
/// fetched yet (#141). Native: never emitted.
|
||||
PollWebFonts,
|
||||
|
||||
+76
-9
@@ -4,16 +4,18 @@ use std::any::Any;
|
||||
|
||||
use acadrust::tables::AppId;
|
||||
use acadrust::xdata::ExtendedDataRecord;
|
||||
use acadrust::{CadDocument, EntityType, Handle};
|
||||
use ocs_plugin_api::host::HostApi;
|
||||
use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi};
|
||||
use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::plugin::v4_support;
|
||||
use super::OpenCADStudio;
|
||||
|
||||
/// Session adapter: one active document tab, command line, undo.
|
||||
pub(crate) struct HostSession<'a> {
|
||||
app: &'a mut OpenCADStudio,
|
||||
tab: usize,
|
||||
doc_store: Option<ocs_plugin_api::shm::DocumentSnapshotStore>,
|
||||
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
|
||||
}
|
||||
|
||||
impl<'a> HostSession<'a> {
|
||||
@@ -29,6 +31,20 @@ impl<'a> HostSession<'a> {
|
||||
self.tab
|
||||
}
|
||||
|
||||
pub fn tab_id(&self) -> u64 {
|
||||
self.app.tabs[self.tab].id
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||
v4_support::open_document_view_v4(tab_id, self.document())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn close_document_view_v4(&mut self, tab_id: u64) {
|
||||
v4_support::close_document_view_v4(tab_id);
|
||||
}
|
||||
|
||||
pub fn document(&self) -> &CadDocument {
|
||||
&self.app.tabs[self.tab].scene.document
|
||||
}
|
||||
@@ -38,10 +54,9 @@ impl<'a> HostSession<'a> {
|
||||
}
|
||||
|
||||
pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||
use ocs_plugin_api::shm::DocumentSnapshotStore;
|
||||
if self.doc_store.is_none() {
|
||||
let mut store = DocumentSnapshotStore::new(self.tab, 8 * 1024 * 1024).ok()?;
|
||||
store.publish(self.document()).ok()?;
|
||||
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(self.tab as u64, 8 * 1024 * 1024).ok()?;
|
||||
store.publish(&self.document().into()).ok()?;
|
||||
self.doc_store = Some(store);
|
||||
}
|
||||
let store = self.doc_store.as_ref()?;
|
||||
@@ -53,14 +68,18 @@ impl<'a> HostSession<'a> {
|
||||
|
||||
fn publish_document_view(&mut self) {
|
||||
let doc = &self.app.tabs[self.tab].scene.document;
|
||||
// Only publish views that have been opened by a consumer. V3 is lazily
|
||||
// created by document_view(); V4 is tracked per-tab by the V4 manager.
|
||||
if let Some(store) = self.doc_store.as_mut() {
|
||||
if let Err(e) = store.publish(doc) {
|
||||
if let Err(e) = store.publish(&doc.into()) {
|
||||
eprintln!(
|
||||
"[host] failed to publish document view for tab {}: {e}",
|
||||
self.tab
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v4_support::publish_document_view_v4(self.tab_id(), doc);
|
||||
}
|
||||
|
||||
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||
@@ -69,6 +88,12 @@ impl<'a> HostSession<'a> {
|
||||
handle
|
||||
}
|
||||
|
||||
pub fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||
let handles = self.app.tabs[self.tab].scene.add_entities(entities);
|
||||
self.publish_document_view();
|
||||
handles
|
||||
}
|
||||
|
||||
pub fn bump_geometry(&mut self) {
|
||||
self.app.tabs[self.tab].scene.bump_geometry();
|
||||
}
|
||||
@@ -239,6 +264,9 @@ impl HostApi for HostSession<'_> {
|
||||
fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||
self.add_entity(entity)
|
||||
}
|
||||
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||
self.add_entities(entities)
|
||||
}
|
||||
fn update_entity(&mut self, entity: EntityType) -> bool {
|
||||
self.update_entity(entity)
|
||||
}
|
||||
@@ -305,6 +333,17 @@ impl HostApi for HostSession<'_> {
|
||||
fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||
self.document_view()
|
||||
}
|
||||
fn tab_id(&self) -> u64 {
|
||||
self.tab_id()
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||
self.document_view_v4(tab_id)
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn close_document_view_v4(&mut self, tab_id: u64) {
|
||||
self.close_document_view_v4(tab_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
|
||||
@@ -506,6 +545,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_entities_batch_assigns_handles_and_publishes_once() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
|
||||
let pts: Vec<EntityType> = (0..5)
|
||||
.map(|i| {
|
||||
EntityType::Point(Point::at(acadrust::types::Vector3::new(
|
||||
i as f64, i as f64, 0.0,
|
||||
)))
|
||||
})
|
||||
.collect();
|
||||
let handles = host.add_entities(pts);
|
||||
|
||||
assert_eq!(handles.len(), 5);
|
||||
assert!(handles.iter().all(|h| !h.is_null()));
|
||||
// Each handle is unique.
|
||||
let mut set = std::collections::HashSet::new();
|
||||
for h in &handles {
|
||||
assert!(set.insert(h.value()));
|
||||
}
|
||||
// All entities are in the document.
|
||||
for h in &handles {
|
||||
assert!(host.document().get_entity(*h).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_entity_replaces_in_place_preserving_handle() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
@@ -704,7 +771,7 @@ mod tests {
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let info = host.document_view().unwrap();
|
||||
let reader =
|
||||
ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path))
|
||||
ocs_plugin_api::shm::SharedDocumentReader::<ocs_plugin_api::shm::DocumentViewData>::open(std::path::Path::new(&info.path))
|
||||
.unwrap();
|
||||
assert_eq!(reader.entity_count(), 0);
|
||||
|
||||
@@ -755,7 +822,7 @@ mod tests {
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let info = host.document_view().unwrap();
|
||||
let reader =
|
||||
ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path))
|
||||
ocs_plugin_api::shm::SharedDocumentReader::<ocs_plugin_api::shm::DocumentViewData>::open(std::path::Path::new(&info.path))
|
||||
.unwrap();
|
||||
|
||||
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::modules::ModuleEvent;
|
||||
use crate::scene::pick::grip::{
|
||||
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::plugin::v4_support;
|
||||
use crate::scene::model::object::GripApply;
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
@@ -109,6 +111,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
||||
return self.open_unsaved_dialog_window();
|
||||
}
|
||||
let tab_id = self.tabs.get(idx).map(|t| t.id);
|
||||
// This tab is closing for good — drop its autosave recovery copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
||||
@@ -132,6 +135,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.sync_ribbon_layers();
|
||||
self.sync_ribbon_styles();
|
||||
self.sync_ribbon_from_selection();
|
||||
if let Some(tab_id) = tab_id {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v4_support::on_tab_closed(tab_id);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
@@ -302,6 +302,21 @@ impl OpenCADStudio {
|
||||
|
||||
fn update_inner(&mut self, msg: Message) -> Task<Message> {
|
||||
match msg {
|
||||
// Drain plugin-to-host requests that arrived outside of a host call.
|
||||
// This runs on a periodic timer so long-lived plugin sessions such
|
||||
// as the Python REPL can mutate the document without requiring a
|
||||
// user-generated message. Desktop only.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Message::DrainPluginRequests => {
|
||||
if self.active_tab < self.tabs.len() {
|
||||
let mut host = crate::app::plugin_host::HostSession::new(self, self.active_tab);
|
||||
crate::plugin::external::with_manager(|mgr| {
|
||||
mgr.drain_requests(&mut host, &mut |_| {});
|
||||
});
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// Web: fetch every script queued by startup language selection or
|
||||
// drawing text discovery. Each script has one shared store entry.
|
||||
Message::PollWebFonts => {
|
||||
|
||||
@@ -2094,6 +2094,18 @@ impl OpenCADStudio {
|
||||
let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let single_instance = Subscription::none();
|
||||
// Drain plugin-to-host requests on a timer when any plugin is loaded.
|
||||
// This lets long-lived plugin sessions (e.g. the Python REPL) mutate the
|
||||
// host document without requiring a user-generated message.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let plugin_drain = if crate::plugin::external::with_manager(|mgr| !mgr.ids().is_empty()) {
|
||||
iced::time::every(std::time::Duration::from_millis(100))
|
||||
.map(|_| Message::DrainPluginRequests)
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let plugin_drain = Subscription::none();
|
||||
let hatch_pattern_keys = if self.tabs[self.active_tab]
|
||||
.properties
|
||||
.hatch_pattern_picker_open
|
||||
@@ -2111,6 +2123,7 @@ impl OpenCADStudio {
|
||||
caret_blink,
|
||||
web_fonts,
|
||||
autosave,
|
||||
plugin_drain,
|
||||
single_instance,
|
||||
hatch_pattern_keys,
|
||||
event::listen_with(|ev, status, win_id| {
|
||||
|
||||
+12
-5
@@ -55,7 +55,7 @@ pub struct ExternalPlugin {
|
||||
impl ExternalPlugin {
|
||||
/// True when the package's API version is supported by this host.
|
||||
pub fn api_compatible(&self) -> bool {
|
||||
self.api_version == ocs_plugin_api::API_VERSION
|
||||
ocs_plugin_api::manifest::host_accepts_plugin_version(self.api_version)
|
||||
}
|
||||
|
||||
/// True when the package can be loaded today: compatible API *and* a native
|
||||
@@ -273,6 +273,7 @@ pub(crate) use loader::{load_at_startup, loaded_ids};
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
mod loader {
|
||||
use super::lib_extension;
|
||||
use crate::plugin::v4_support;
|
||||
use ocs_plugin_api::process::PluginManager;
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -291,6 +292,7 @@ mod loader {
|
||||
) -> Vec<(String, Result<(), String>)> {
|
||||
let discovered = super::discover();
|
||||
let mut manager = PluginManager::new();
|
||||
manager.set_notification_handler(v4_support::notification_handler());
|
||||
let mut out = Vec::new();
|
||||
for d in &discovered {
|
||||
if !d.api_compatible() || !d.lib_present {
|
||||
@@ -350,12 +352,13 @@ mod loader {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugin::v4_support;
|
||||
|
||||
#[test]
|
||||
fn api_v2_plugin_from_template_is_incompatible() {
|
||||
fn api_v2_plugin_from_template_is_compatible() {
|
||||
let toml = r#"
|
||||
[plugin]
|
||||
id = "opencad.my_plugin"
|
||||
@@ -374,7 +377,7 @@ xdata_apps = ["MYPLUGIN_RECORD"]
|
||||
assert_eq!(p.api_version, 2);
|
||||
assert_eq!(p.repository.as_deref(), Some("example/opencad-my-plugin"));
|
||||
assert!(p.command_prefixes.contains(&"MP_".to_string()));
|
||||
assert!(!p.api_compatible(), "old API plugins must be rebuilt");
|
||||
assert!(p.api_compatible(), "V2 plugins must be accepted by the V4 host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -415,7 +418,11 @@ xdata_apps = ["MYPLUGIN_RECORD"]
|
||||
|
||||
let mut app = crate::app::OpenCADStudio::new_for_test();
|
||||
let mut host = crate::app::plugin_host::HostSession::new(&mut app, 0);
|
||||
let process = ocs_plugin_api::process::PluginProcess::spawn(&path, &mut host)
|
||||
let process = ocs_plugin_api::process::PluginProcess::spawn(
|
||||
&path,
|
||||
&mut host,
|
||||
v4_support::notification_handler(),
|
||||
)
|
||||
.expect("spawn test plugin");
|
||||
assert_eq!(process.id(), "opencad.my_plugin");
|
||||
let mut started = false;
|
||||
|
||||
@@ -291,11 +291,12 @@ pub fn install(release: &Release, repository: &str) -> Result<String, String> {
|
||||
|
||||
let toml_text = download_string(&toml.url)?;
|
||||
let manifest = external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?;
|
||||
if manifest.api_version != ocs_plugin_api::API_VERSION {
|
||||
if !ocs_plugin_api::manifest::host_accepts_plugin_version(manifest.api_version) {
|
||||
return Err(format!(
|
||||
"API version {} is incompatible (host requires {})",
|
||||
"API version {} is incompatible (host supports {}-{})",
|
||||
manifest.api_version,
|
||||
ocs_plugin_api::API_VERSION
|
||||
ocs_plugin_api::manifest::API_VERSION_MIN_SUPPORTED,
|
||||
ocs_plugin_api::manifest::effective_max_api_version()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ pub mod external;
|
||||
pub mod host;
|
||||
pub mod marketplace;
|
||||
pub mod registry;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod v4_support;
|
||||
|
||||
pub use registry::{all_ribbon_modules, plugin_command_names, ribbon_modules_enabled};
|
||||
pub(crate) use registry::try_dispatch;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Host-side V4 snapshot and notification support for the Python REPL plugin.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ocs_plugin_api::host::{HostNotification, PluginNotification};
|
||||
use ocs_plugin_api::host_v4::{manager, HostV4SnapshotManager};
|
||||
use ocs_plugin_api::process::NotificationHandler;
|
||||
use ocs_plugin_api::shm::DocumentViewInfo;
|
||||
|
||||
fn mgr() -> std::sync::MutexGuard<'static, HostV4SnapshotManager> {
|
||||
manager().lock().unwrap()
|
||||
}
|
||||
|
||||
/// Open (or refresh) the V4 shared document view for `tab_id`.
|
||||
pub fn open_document_view_v4(
|
||||
tab_id: u64,
|
||||
doc: &acadrust::CadDocument,
|
||||
) -> Option<DocumentViewInfo> {
|
||||
mgr().open(tab_id, doc)
|
||||
}
|
||||
|
||||
/// Close the V4 shared document view for `tab_id`.
|
||||
pub fn close_document_view_v4(tab_id: u64) {
|
||||
mgr().close(tab_id);
|
||||
}
|
||||
|
||||
/// Publish the latest document to the V4 shared view for `tab_id` and broadcast
|
||||
/// a change notification when successful.
|
||||
pub fn publish_document_view_v4(tab_id: u64, doc: &acadrust::CadDocument) {
|
||||
let info = mgr().publish(tab_id, doc);
|
||||
if let Some(info) = info {
|
||||
broadcast(HostNotification::DocumentChangedV4 {
|
||||
tab_id,
|
||||
version: info.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify V4 plugins that a tab has been closed and clean up its snapshot.
|
||||
pub fn on_tab_closed(tab_id: u64) {
|
||||
close_document_view_v4(tab_id);
|
||||
broadcast(HostNotification::DocumentTabClosed { tab_id });
|
||||
}
|
||||
|
||||
/// V4 notification handler installed on the plugin manager.
|
||||
///
|
||||
/// Forwards REPL status messages to the log; other notifications are handled
|
||||
/// by the manager's per-plugin routing.
|
||||
pub fn notification_handler() -> NotificationHandler {
|
||||
Arc::new(|plugin_id, _command_id, notification| {
|
||||
if let PluginNotification::ReplStatus { status, message } = notification {
|
||||
eprintln!("[{plugin_id}] REPL status {status}: {message}");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn broadcast(notification: HostNotification) {
|
||||
crate::plugin::external::with_manager(|mgr| {
|
||||
mgr.broadcast_notification(None, notification);
|
||||
});
|
||||
}
|
||||
+46
-7
@@ -111,7 +111,44 @@ impl Scene {
|
||||
let _ = self.document.layers.add(layer);
|
||||
}
|
||||
|
||||
pub fn add_entity(&mut self, mut entity: EntityType) -> Handle {
|
||||
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||
self.add_entity_internal(entity, true)
|
||||
}
|
||||
|
||||
/// Batch-add several entities, publishing geometry changes once at the end.
|
||||
/// This is the fast path used by plugin `add_entities` requests.
|
||||
pub fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||
let mut handles = Vec::with_capacity(entities.len());
|
||||
let mut changes = Vec::with_capacity(entities.len());
|
||||
let mut needs_geometry_bump = false;
|
||||
|
||||
for entity in entities {
|
||||
let affects_blocks = matches!(
|
||||
&entity,
|
||||
EntityType::Block(_) | EntityType::BlockEnd(_)
|
||||
);
|
||||
let handle = self.add_entity_internal(entity, false);
|
||||
handles.push(handle);
|
||||
if handle.is_null() {
|
||||
continue;
|
||||
}
|
||||
if affects_blocks {
|
||||
needs_geometry_bump = true;
|
||||
} else {
|
||||
changes.push((handle, ChangeKind::Added));
|
||||
}
|
||||
}
|
||||
|
||||
if needs_geometry_bump {
|
||||
self.bump_geometry();
|
||||
} else if !changes.is_empty() {
|
||||
self.bump_entities(&changes);
|
||||
}
|
||||
|
||||
handles
|
||||
}
|
||||
|
||||
fn add_entity_internal(&mut self, mut entity: EntityType, bump: bool) -> Handle {
|
||||
// Only block sentinels mutate a block definition and require rebuilding
|
||||
// the block cache. A top-level INSERT merely references an existing
|
||||
// definition, so adding it can patch just that new render handle.
|
||||
@@ -256,12 +293,14 @@ impl Scene {
|
||||
self.poison_undo_recording();
|
||||
}
|
||||
}
|
||||
if affects_blocks {
|
||||
self.bump_geometry();
|
||||
} else {
|
||||
// Plain top-level add: name the new handle so every derived cache
|
||||
// patches in just this one entity instead of rebuilding.
|
||||
self.bump_entities(&[(handle, ChangeKind::Added)]);
|
||||
if bump {
|
||||
if affects_blocks {
|
||||
self.bump_geometry();
|
||||
} else {
|
||||
// Plain top-level add: name the new handle so every derived cache
|
||||
// patches in just this one entity instead of rebuilding.
|
||||
self.bump_entities(&[(handle, ChangeKind::Added)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
handle
|
||||
|
||||
Reference in new issue
Block a user