Files
OneCAD-Draft/crates/ocs_plugin_api/src/ipc/v4/protocol.rs
T
Felix RiestraandClaude Sonnet 5 5e6a830782 Complete Ole2Frame: embed a picture file from a script
EmbedPicture builds the frame with OCS's own EmbeddedImage and
build_embedded_ole, so the opaque storage is never scripted. PNG, JPEG and
BMP are stored as read and other formats are re-encoded as PNG. The lifecycle
test now creates through Python; a new test covers formats, aspect, origin,
layer and refusals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 18:12:31 +02:00

214 lines
9.0 KiB
Rust

//! V4 wire frames and envelope types.
//!
//! V4 replaces the simple request/response pipe with a multiplexed frame layer.
//! Every request and response carries a monotonically increasing correlation
//! `id` so a plugin can send/receive out of order. Notifications carry an
//! optional `command_id` so a plugin can associate a host notification with the
//! long-running command that caused it.
//!
//! The two frame enums are:
//!
//! - [`HostToPluginV4`] — host → runner requests, responses to runner requests,
//! and host notifications.
//! - [`PluginToHostV4`] — runner → host requests, responses to host requests,
//! and plugin notifications.
//!
//! Notifications are wrapped in [`NotificationEnvelope`] and are best-effort:
//! a process that fails to accept a notification is logged and skipped, but the
//! connection stays alive. Unknown host-notification discriminants deserialize
//! to [`crate::host::HostNotification::Unknown`] so newer host notifications do
//! not break older plugins.
use serde::{Deserialize, Serialize};
use crate::host::{HostNotification, PluginNotification};
use crate::ipc::protocol::{HostRequest, HostResponse, PluginRequest, PluginResponse};
/// Protocol version carried in the V4 handshake.
pub const V4_PROTOCOL_VERSION: u32 = 4;
/// Best-effort notification envelope carrying an optional command correlation
/// ID in addition to the typed payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationEnvelope<T> {
pub command_id: Option<u64>,
pub payload: T,
}
/// Messages sent from the host to the plugin runner on the V4 socket.
#[derive(Debug, Serialize, Deserialize)]
#[allow(clippy::large_enum_variant)]
pub enum HostToPluginV4 {
Request { id: u64, payload: HostRequest },
Response { id: u64, payload: PluginResponse },
Notification(NotificationEnvelope<HostNotification>),
}
/// Messages sent from the plugin runner to the host on the V4 socket.
#[derive(Debug, Serialize, Deserialize)]
#[allow(clippy::large_enum_variant)]
pub enum PluginToHostV4 {
Request {
id: u64,
tab_id: Option<u64>,
payload: PluginRequest,
},
Response { id: u64, payload: HostResponse },
Notification(NotificationEnvelope<PluginNotification>),
}
#[cfg(all(test, feature = "host"))]
mod tests {
use super::*;
use crate::host::LogLevel;
#[test]
fn v4_frame_roundtrip() {
let frame = HostToPluginV4::Notification(NotificationEnvelope {
command_id: Some(7),
payload: HostNotification::InputLine {
line: "hello".to_string(),
},
});
let bytes = bincode::serialize(&frame).unwrap();
let got: HostToPluginV4 = bincode::deserialize(&bytes).unwrap();
match got {
HostToPluginV4::Notification(env) => {
assert_eq!(env.command_id, Some(7));
assert_eq!(
env.payload,
HostNotification::InputLine {
line: "hello".to_string()
}
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn command_state_notification_roundtrips() {
let notification = HostNotification::CommandStateChanged {
tab_id: 42,
command: Some("LINE".to_owned()),
};
let bytes = bincode::serialize(&notification).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, notification);
let stopped = HostNotification::CommandStateChanged { tab_id: 42, command: None };
let bytes = bincode::serialize(&stopped).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, stopped);
let changed = HostNotification::DrawingChanged { tab_id: 42, epoch: 7 };
let bytes = bincode::serialize(&changed).unwrap();
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
assert_eq!(decoded, changed);
}
#[test]
fn solid_operation_request_and_response_roundtrip() {
use crate::host::{SolidOperation, SolidPrimitive};
for operation in [
SolidOperation::Create {
primitive: SolidPrimitive::Pyramid { center: [1.0, 2.0, 3.0], radius: 4.0, height: 5.0, sides: 6 },
layer: Some("SOLIDS".into()),
},
SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] },
SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true },
SolidOperation::EmbedPicture { path: "p.png".into(), origin: [0.0; 3], width: 5.0, layer: None },
SolidOperation::Boolean {
first: acadrust::Handle::new(1),
second: acadrust::Handle::new(2),
operation: crate::host::SolidBoolean::Subtract,
layer: None,
keep_operands: false,
},
SolidOperation::SurfaceFromProfile { source: acadrust::Handle::new(4), layer: Some("S".into()), delete_source: false },
SolidOperation::Extrude { source: acadrust::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true },
] {
let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::SolidOperation { operation: decoded } if decoded == operation));
}
for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] {
let bytes = bincode::serialize(&PluginResponse::SolidResult(result.clone())).unwrap();
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::SolidResult(decoded) if decoded == result));
}
}
#[test]
fn selection_request_and_response_roundtrip() {
let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] };
let bytes = bincode::serialize(&request).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::SetSelection { handles } if handles == vec![acadrust::Handle::new(9)]));
let response = PluginResponse::Selection(vec![acadrust::Handle::new(9)]);
let bytes = bincode::serialize(&response).unwrap();
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::Selection(handles) if handles == vec![acadrust::Handle::new(9)]));
}
#[test]
fn plugin_notification_roundtrip() {
let frame = PluginToHostV4::Notification(NotificationEnvelope {
command_id: None,
payload: PluginNotification::Log {
level: LogLevel::Info,
text: "hi".to_string(),
},
});
let bytes = bincode::serialize(&frame).unwrap();
let got: PluginToHostV4 = bincode::deserialize(&bytes).unwrap();
match got {
PluginToHostV4::Notification(env) => {
assert_eq!(env.command_id, None);
assert_eq!(
env.payload,
PluginNotification::Log {
level: LogLevel::Info,
text: "hi".to_string()
}
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn unknown_notification_deserializes_as_unknown() {
// Build a payload with an unknown discriminant (99) followed by some
// bytes. The custom deserializer should return Unknown(raw_bytes).
let mut payload = vec![99u8];
bincode::serialize_into(&mut payload, &"future".to_string()).unwrap();
let envelope = NotificationEnvelope {
command_id: Some(1),
payload: HostNotification::Unknown(payload.clone()),
};
let frame = HostToPluginV4::Notification(envelope);
let bytes = bincode::serialize(&frame).unwrap();
let got: HostToPluginV4 = bincode::deserialize(&bytes).unwrap();
match got {
HostToPluginV4::Notification(env) => match env.payload {
HostNotification::Unknown(raw) => {
assert_eq!(raw[0], 99);
}
other => panic!("expected Unknown, got {other:?}"),
},
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn v3_runner_handshake_token_roundtrips_after_tokenv4() {
use crate::ipc::protocol::RunnerHandshake;
let original = RunnerHandshake::Token("abc".to_string());
let bytes = bincode::serialize(&original).unwrap();
let got: RunnerHandshake = bincode::deserialize(&bytes).unwrap();
match got {
RunnerHandshake::Token(s) => assert_eq!(s, "abc"),
other => panic!("unexpected: {other:?}"),
}
}
}