Merge pull request #751 from schoeller/ocs_plugin_api_v4
V4 plugin API and host integration
This commit is contained in:
commit
a9c6f03196
39 changed files with 6161 additions and 270 deletions
274
Cargo.lock
generated
274
Cargo.lock
generated
|
|
@ -925,6 +925,18 @@ dependencies = [
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cargo-lock"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50524592e6bfbb1bf6f94e8184a786f637faeaf1cf37bbe65502b9a2c5c48939"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"toml 1.1.3+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cavalier_contours"
|
name = "cavalier_contours"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -1564,6 +1576,15 @@ version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "erased-discriminant"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1a6df962265a53221f29081896c412ef325c17fa7d638cd9578febe53d3c82c"
|
||||||
|
dependencies = [
|
||||||
|
"typeid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
|
|
@ -1835,6 +1856,15 @@ version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "funty"
|
name = "funty"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
|
|
@ -2510,6 +2540,109 @@ dependencies = [
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_collections"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_core"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_normalizer_data",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"smallvec",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer_data"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_properties_data",
|
||||||
|
"icu_provider",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties_data"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locale_core",
|
||||||
|
"writeable",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||||
|
dependencies = [
|
||||||
|
"idna_adapter",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna_adapter"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||||
|
dependencies = [
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image"
|
name = "image"
|
||||||
version = "0.25.10"
|
version = "0.25.10"
|
||||||
|
|
@ -2928,6 +3061,12 @@ version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litrs"
|
name = "litrs"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -3760,16 +3899,19 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ocs_plugin_api"
|
name = "ocs_plugin_api"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"acadrust",
|
"acadrust",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
"cargo-lock",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"interprocess",
|
"interprocess",
|
||||||
"libloading",
|
"libloading",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde-reflection",
|
||||||
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -4086,6 +4228,15 @@ dependencies = [
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "potential_utf"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||||
|
dependencies = [
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -4938,6 +5089,10 @@ name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
|
|
@ -4949,6 +5104,20 @@ dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde-reflection"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af0fdf9c543af37502581e3d6cf4e6cd1628d6616d6a1f8f4ca88aae3a405f5c"
|
||||||
|
dependencies = [
|
||||||
|
"erased-discriminant",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"typeid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -5359,6 +5528,17 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sys-locale"
|
name = "sys-locale"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|
@ -5665,6 +5845,12 @@ dependencies = [
|
||||||
"rustc-hash 2.1.3",
|
"rustc-hash 2.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typeid"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
|
|
@ -5818,6 +6004,18 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "url"
|
||||||
|
version = "2.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"idna",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "usvg"
|
name = "usvg"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
|
|
@ -5846,6 +6044,12 @@ version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8_iter"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -6759,6 +6963,12 @@ dependencies = [
|
||||||
"wayland-protocols-wlr",
|
"wayland-protocols-wlr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wyz"
|
name = "wyz"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -6849,6 +7059,29 @@ version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
|
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
"yoke-derive",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke-derive"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "5.17.0"
|
version = "5.17.0"
|
||||||
|
|
@ -6941,6 +7174,21 @@ name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
|
dependencies = [
|
||||||
|
"zerofrom-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom-derive"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
|
|
@ -6948,6 +7196,17 @@ version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerotrie"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec"
|
name = "zerovec"
|
||||||
version = "0.11.6"
|
version = "0.11.6"
|
||||||
|
|
@ -6955,7 +7214,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
|
"zerovec-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec-derive"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
[package]
|
[package]
|
||||||
name = "ocs_plugin_api"
|
name = "ocs_plugin_api"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Stable, dependency-free contract for Open CAD Studio add-on packages: plugin manifest, version handshake, and ribbon/CadModule types."
|
description = "Stable, dependency-free contract for Open CAD Studio add-on packages: plugin manifest, version handshake, and ribbon/CadModule types."
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# Serde is used unconditionally by the build-time-generated embedded type
|
||||||
|
# registry types (`type_registry_types.rs`). The host feature no longer needs
|
||||||
|
# to enable it explicitly.
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
|
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
|
||||||
# `HostApi` runtime surface. The default crate stays dependency-free so engine
|
# `HostApi` runtime surface. The default crate stays dependency-free so engine
|
||||||
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
|
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
|
||||||
|
|
@ -13,7 +18,6 @@ acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c28b2c
|
||||||
|
|
||||||
# Runtime IPC and serialization (host feature only).
|
# Runtime IPC and serialization (host feature only).
|
||||||
interprocess = { version = "2", optional = true }
|
interprocess = { version = "2", optional = true }
|
||||||
serde = { version = "1", features = ["derive"], optional = true }
|
|
||||||
bincode = { version = "1", optional = true }
|
bincode = { version = "1", optional = true }
|
||||||
thiserror = { version = "1", optional = true }
|
thiserror = { version = "1", optional = true }
|
||||||
libloading = { version = "0.8", optional = true }
|
libloading = { version = "0.8", optional = true }
|
||||||
|
|
@ -25,4 +29,15 @@ getrandom = { version = "0.2", optional = true }
|
||||||
# Enables the runtime host surface (`HostApi` trait) and the out-of-process
|
# Enables the runtime host surface (`HostApi` trait) and the out-of-process
|
||||||
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
|
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
|
||||||
# leave it off.
|
# leave it off.
|
||||||
host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
|
host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
serde-reflection = "0.6"
|
||||||
|
serde_json = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
cargo-lock = "11"
|
||||||
|
# acadrust is scanned at build time to generate the embedded type registry.
|
||||||
|
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "c28b2cd", features = ["serde"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
|
|
|
||||||
426
crates/ocs_plugin_api/README.md
Normal file
426
crates/ocs_plugin_api/README.md
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
# `ocs_plugin_api`
|
||||||
|
|
||||||
|
Versioned, out-of-process plugin API for Open CAD Studio. This crate defines the
|
||||||
|
contract between the host CAD application and third-party plugins. It is
|
||||||
|
designed to stay small and dependency-free in its default configuration so that
|
||||||
|
plugin crates and external tooling can depend on the manifest/ribbon surface
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Version | Delivery model | Key capability | ABI / wire change |
|
||||||
|
|---------|----------------|----------------|-------------------|
|
||||||
|
| **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 |
|
||||||
|
|
||||||
|
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
|
||||||
|
back to `API_VERSION_MIN_SUPPORTED = 2`.
|
||||||
|
|
||||||
|
### High-level layout
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph HostProcess["Host process"]
|
||||||
|
App[OpenCADStudio]
|
||||||
|
PM[PluginManager]
|
||||||
|
PP[PluginProcess]
|
||||||
|
HA[HostApi impl]
|
||||||
|
end
|
||||||
|
subgraph RunnerProcess["Runner process"]
|
||||||
|
RL[Runner loop]
|
||||||
|
V4C[V4Client]
|
||||||
|
Proxy[HostApi proxy]
|
||||||
|
end
|
||||||
|
subgraph Library["Plugin cdylib"]
|
||||||
|
BP[BuiltinPlugin impl]
|
||||||
|
end
|
||||||
|
|
||||||
|
App --> PM
|
||||||
|
PM --> PP
|
||||||
|
PP -- local socket --> RL
|
||||||
|
RL --> V4C
|
||||||
|
V4C --> Proxy
|
||||||
|
Proxy --> BP
|
||||||
|
PP -. document view / shared memory .-> HA
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin is a Rust `cdylib` (or any language exposing the C symbols) that:
|
||||||
|
|
||||||
|
1. Exports `ocs_plugin_api_version()` — returns the API major the plugin was
|
||||||
|
built against.
|
||||||
|
2. Exports `ocs_plugin_register()` — returns a `Box<dyn BuiltinPlugin>`.
|
||||||
|
3. Implements `BuiltinPlugin` (manifest, ribbon, dispatch, optional REPL
|
||||||
|
execution, optional notifications).
|
||||||
|
|
||||||
|
The host does **not** load the plugin into its own address space. It re-executes
|
||||||
|
itself as `--ocs-runner <cdylib-path> <token>` and talks to the runner over a
|
||||||
|
local socket. This isolates plugin crashes from the CAD process.
|
||||||
|
|
||||||
|
### Version handshake
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant H as Host
|
||||||
|
participant R as Runner process
|
||||||
|
participant L as Plugin cdylib
|
||||||
|
|
||||||
|
H->>R: spawn --ocs-runner path token
|
||||||
|
R->>L: dlopen, ocs_plugin_register()
|
||||||
|
L-->>R: Box<dyn BuiltinPlugin>
|
||||||
|
R->>H: V3 or V4 handshake token
|
||||||
|
H->>R: GetManifest
|
||||||
|
R-->>H: PluginManifest { api_version }
|
||||||
|
alt api_version accepted
|
||||||
|
H->>R: Dispatch / ExecuteCode / InteractiveEvent ...
|
||||||
|
else api_version rejected
|
||||||
|
H->>R: Shutdown
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### V2/V3 wire protocol
|
||||||
|
|
||||||
|
V2 and V3 share the same request/response framing. The host sends a
|
||||||
|
`HostToPlugin::Request(HostRequest::*)`; the runner executes it and returns a
|
||||||
|
`PluginToHost::Response(HostResponse::*)`. Nested plugin-to-host requests (for
|
||||||
|
example `add_entity`) are allowed inside a dispatch: the runner creates a
|
||||||
|
temporary `HostApi` proxy, forwards the `PluginRequest`, awaits the host reply,
|
||||||
|
and resumes the original call.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant H as Host
|
||||||
|
participant R as V2/V3 Runner
|
||||||
|
participant P as BuiltinPlugin
|
||||||
|
|
||||||
|
H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" })
|
||||||
|
R->>P: dispatch(host, "LINE")
|
||||||
|
P->>R: PluginToHost::Request(AddEntity(...))
|
||||||
|
R->>H: forward AddEntity request
|
||||||
|
H-->>R: handle
|
||||||
|
R-->>P: handle
|
||||||
|
P-->>R: true
|
||||||
|
R-->>H: PluginToHost::Response(Bool(true))
|
||||||
|
```
|
||||||
|
|
||||||
|
### V4 wire protocol
|
||||||
|
|
||||||
|
V4 replaces the simple request/response pipe with a multiplexed local socket.
|
||||||
|
The frame format supports:
|
||||||
|
|
||||||
|
- Host → runner requests (`HostToPluginV4::Request { id, payload: HostRequest }`)
|
||||||
|
- Runner → host responses (`PluginToHostV4::Response { id, payload: HostResponse }`)
|
||||||
|
- Bi-directional, best-effort notifications (`NotificationEnvelope`)
|
||||||
|
|
||||||
|
Because the socket is full-duplex, the host can push
|
||||||
|
`HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and
|
||||||
|
the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a
|
||||||
|
long command is still running.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant H as Host
|
||||||
|
participant R as V4 Runner
|
||||||
|
participant P as BuiltinPlugin
|
||||||
|
|
||||||
|
H->>R: Request(id=7, ExecuteCode { code: "1+1", tab_index: 2 })
|
||||||
|
R->>P: start_execute_code(host, 7, "1+1", Editor, respond)
|
||||||
|
P-->>R: true (async)
|
||||||
|
Note over P: spawned on worker thread
|
||||||
|
P->>R: Notification(Output { text: "calculating..." })
|
||||||
|
R->>H: forward Output
|
||||||
|
P->>R: Response(id=7, CodeExecutionResult { success: true, output: "2" })
|
||||||
|
R->>H: delayed response
|
||||||
|
```
|
||||||
|
|
||||||
|
### V4 asynchronous REPL execution
|
||||||
|
|
||||||
|
V4 introduces `BuiltinPlugin::start_execute_code`. It is the plugin's entry point
|
||||||
|
for REPL-style code evaluation. The signature receives the active tab's
|
||||||
|
`HostApi` so the REPL session is tied to the document tab that invoked it, plus a
|
||||||
|
`respond` callback that the plugin calls when evaluation finishes (possibly from a
|
||||||
|
background thread).
|
||||||
|
|
||||||
|
V4 also introduces `PluginRequestSender`: a thread-safe, `'static` interface that
|
||||||
|
lets a plugin send `PluginRequest`s back to the host from any thread. This is
|
||||||
|
useful when the plugin spawns a background worker (for example, the Python REPL's
|
||||||
|
mutation thread) that needs to read the document snapshot or apply mutations
|
||||||
|
without blocking the runner loop. Obtain it from `HostApi::plugin_request_sender()`;
|
||||||
|
it is only available when the plugin is hosted out-of-process by a V4 runner.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ocs_plugin_api::host::{
|
||||||
|
BuiltinPlugin, CommandSource, ExecutionResult, HostApi, execute_code_guard,
|
||||||
|
PluginRequestSender,
|
||||||
|
};
|
||||||
|
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
|
||||||
|
|
||||||
|
struct ReplPlugin;
|
||||||
|
|
||||||
|
impl BuiltinPlugin for ReplPlugin {
|
||||||
|
// ... manifest, ribbon, dispatch ...
|
||||||
|
|
||||||
|
fn start_execute_code(
|
||||||
|
&mut self,
|
||||||
|
host: &mut dyn HostApi,
|
||||||
|
_command_id: u64,
|
||||||
|
code: &str,
|
||||||
|
_source: CommandSource,
|
||||||
|
respond: Box<dyn FnOnce(ExecutionResult) + Send>,
|
||||||
|
) -> bool {
|
||||||
|
let tab = host.tab_index();
|
||||||
|
let code = code.to_string();
|
||||||
|
|
||||||
|
// A V4 host can provide a sender for background threads.
|
||||||
|
let sender: Option<Arc<dyn PluginRequestSender>> = host.plugin_request_sender().map(Arc::from);
|
||||||
|
|
||||||
|
execute_code_guard(respond, move || {
|
||||||
|
// Long-running evaluation happens here without blocking the runner loop.
|
||||||
|
// If a sender is available, worker threads can use it to call back into
|
||||||
|
// the host, e.g. to refresh the document snapshot.
|
||||||
|
let result = evaluate(&code, tab, sender.as_deref());
|
||||||
|
ExecutionResult {
|
||||||
|
success: result.is_ok(),
|
||||||
|
output: result.ok(),
|
||||||
|
error: result.err(),
|
||||||
|
error_type: None,
|
||||||
|
traceback: None,
|
||||||
|
line_number: None,
|
||||||
|
column_number: None,
|
||||||
|
duration_ms: 0.0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Used from a background thread.
|
||||||
|
fn refresh_snapshot(sender: &dyn PluginRequestSender) -> Result<String, Box<dyn Error>> {
|
||||||
|
match sender.request(PluginRequest::OpenDocumentView)? {
|
||||||
|
PluginResponse::DocumentView { path, version } => Ok(path),
|
||||||
|
other => Err(format!("unexpected response: {other:?}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The host side calls it synchronously but with a long timeout:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ocs_plugin_api::host::CommandSource;
|
||||||
|
use ocs_plugin_api::process::{PluginError, PluginProcess};
|
||||||
|
|
||||||
|
fn run_editor_snippet(
|
||||||
|
plugin: &PluginProcess,
|
||||||
|
host: &mut dyn HostApi,
|
||||||
|
) -> Result<String, PluginError> {
|
||||||
|
let result = plugin.execute_code(host, 42, CommandSource::Editor, "1+1")?;
|
||||||
|
Ok(result.output.unwrap_or_default())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PluginProcess::execute_code` reads `host.tab_index()` and forwards it in the
|
||||||
|
`ExecuteCode` request, so the plugin always knows which document tab the REPL
|
||||||
|
session belongs to.
|
||||||
|
|
||||||
|
### Embedded build-time metadata
|
||||||
|
|
||||||
|
`ocs_plugin_api` embeds two JSON blobs at build time:
|
||||||
|
|
||||||
|
- **Type registry** — a stable, language-binding-friendly schema generated by
|
||||||
|
tracing a curated allow-list of `acadrust` types with `serde-reflection`.
|
||||||
|
- **Version info** — `ocs_version`, `ocs_plugin_api_version`, `acadrust_version`,
|
||||||
|
`api_version`, etc.
|
||||||
|
|
||||||
|
Access them without pulling the `host` feature:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let registry_json = ocs_plugin_api::get_embedded_type_registry_json();
|
||||||
|
let version_json = ocs_plugin_api::get_embedded_version_info_json();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Minimal plugin example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ocs_plugin_api::export_plugin;
|
||||||
|
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginManifest};
|
||||||
|
use ocs_plugin_api::manifest::ApiVersion;
|
||||||
|
use ocs_plugin_api::ribbon::{
|
||||||
|
CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef,
|
||||||
|
};
|
||||||
|
|
||||||
|
static MANIFEST: PluginManifest = PluginManifest {
|
||||||
|
id: "com.example.hello",
|
||||||
|
name: "Hello Plugin",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "A minimal example plugin.",
|
||||||
|
api_version: ApiVersion { major: 4 },
|
||||||
|
ribbon_order: 100,
|
||||||
|
xdata_apps: &[],
|
||||||
|
command_prefixes: &["HELLO"],
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HelloModule;
|
||||||
|
impl CadModule for HelloModule {
|
||||||
|
fn id(&self) -> &'static str { MANIFEST.id }
|
||||||
|
fn title(&self) -> &'static str { "Hello" }
|
||||||
|
fn ribbon_groups(&self) -> &[RibbonGroup] {
|
||||||
|
static GROUPS: &[RibbonGroup] = &[RibbonGroup {
|
||||||
|
title: "Example",
|
||||||
|
tools: vec![RibbonItem::Tool(ToolDef {
|
||||||
|
id: "HELLO",
|
||||||
|
label: "Say Hello",
|
||||||
|
icon: IconKind::Glyph("H"),
|
||||||
|
event: ModuleEvent::Command("HELLO".to_string()),
|
||||||
|
})],
|
||||||
|
}];
|
||||||
|
GROUPS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HelloPlugin;
|
||||||
|
impl BuiltinPlugin for HelloPlugin {
|
||||||
|
fn manifest(&self) -> &'static PluginManifest { &MANIFEST }
|
||||||
|
fn ribbon(&self) -> Box<dyn CadModule> { Box::new(HelloModule) }
|
||||||
|
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
|
||||||
|
if cmd == "HELLO" {
|
||||||
|
host.push_info("Hello from the plugin!");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export_plugin!(HelloPlugin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host-side loading example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use ocs_plugin_api::process::{PluginError, PluginManager};
|
||||||
|
|
||||||
|
fn main() -> Result<(), PluginError> {
|
||||||
|
let mut manager = PluginManager::new();
|
||||||
|
manager.set_notification_handler(Arc::new(|_id, _cmd, _notif| {}));
|
||||||
|
|
||||||
|
let mut host_api = /* host-provided HostApi implementation */;
|
||||||
|
let _plugin_id = manager.load(
|
||||||
|
Path::new("./target/release/libhello_plugin.dll"),
|
||||||
|
&mut host_api,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let result = manager.dispatch(&mut host_api, "HELLO", |_| false);
|
||||||
|
if result.handled {
|
||||||
|
println!("Plugin handled HELLO");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### V4 shared-memory document view example
|
||||||
|
|
||||||
|
V4 plugins can request a zero-copy snapshot of the active document and mutate it
|
||||||
|
from a background thread through `PluginRequestSender`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::sync::Arc;
|
||||||
|
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginRequestSender};
|
||||||
|
use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse};
|
||||||
|
use ocs_plugin_api::shm::DocumentViewDataV4;
|
||||||
|
use ocs_plugin_api::shm::SharedDocumentReader;
|
||||||
|
|
||||||
|
struct SnapshotWorker {
|
||||||
|
reader: SharedDocumentReader<DocumentViewDataV4>,
|
||||||
|
sender: Arc<dyn PluginRequestSender>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnapshotWorker {
|
||||||
|
fn add_point(&self, x: f64, y: f64, z: f64) -> anyhow::Result<u64> {
|
||||||
|
use acadrust::entities::Point;
|
||||||
|
use acadrust::EntityType;
|
||||||
|
|
||||||
|
let mut p = Point::from_coords(x, y, z);
|
||||||
|
p.common.layer = "0".to_string();
|
||||||
|
let entity = EntityType::Point(p);
|
||||||
|
|
||||||
|
match self.sender.request(PluginRequest::AddEntity(entity))? {
|
||||||
|
PluginResponse::Handle(h) => Ok(h.value()),
|
||||||
|
other => Err(anyhow::anyhow!("unexpected add response: {other:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `BuiltinPlugin` dispatch method:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
|
||||||
|
if cmd != "SNAPSHOT_DEMO" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(view) = host.document_view_v4(host.tab_id()) else {
|
||||||
|
host.push_error("host does not support V4 document views");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(sender) = host.plugin_request_sender() else {
|
||||||
|
host.push_error("host does not provide a V4 request sender");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let reader = SharedDocumentReader::<DocumentViewDataV4>::open(Path::new(&view.path))
|
||||||
|
.expect("open snapshot");
|
||||||
|
let worker = SnapshotWorker {
|
||||||
|
reader,
|
||||||
|
sender: Arc::from(sender),
|
||||||
|
};
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = worker.add_point(10.0, 20.0, 0.0);
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### V4 request proxy pattern
|
||||||
|
|
||||||
|
When a plugin spawns a child process (for example a Python REPL) that needs to
|
||||||
|
mutate the host document, the plugin can run a local TCP request proxy and pass
|
||||||
|
the port to the child. The child connects to the proxy; the proxy forwards
|
||||||
|
`PluginRequest` frames to the host's `PluginRequestSender` and returns the
|
||||||
|
responses.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use ocs_plugin_api::ipc::proxy::run_request_proxy_with_shutdown;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
|
||||||
|
let sender: Arc<dyn PluginRequestSender> = Arc::from(host.plugin_request_sender().unwrap());
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
run_request_proxy_with_shutdown(listener, sender, shutdown_rx).ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass `port` to the child process via environment variable.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Further reading
|
||||||
|
|
||||||
|
- Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template)
|
||||||
|
- Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md)
|
||||||
|
- REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md)
|
||||||
|
- Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md)
|
||||||
388
crates/ocs_plugin_api/build.rs
Normal file
388
crates/ocs_plugin_api/build.rs
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use cargo_lock::Lockfile;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde_reflection::{
|
||||||
|
ContainerFormat, Format, Named, Registry, Samples, Tracer, TracerConfig, VariantFormat,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include the stable schema types so the same definitions are used at build
|
||||||
|
// time and at runtime. The file is self-contained and only depends on serde.
|
||||||
|
include!("src/type_registry_types.rs");
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||||
|
generate_type_registry(&out_dir);
|
||||||
|
generate_version_info(&out_dir);
|
||||||
|
println!(
|
||||||
|
"cargo:rerun-if-changed={}",
|
||||||
|
workspace_cargo_lock_path().display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Type registry
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
fn generate_type_registry(out_dir: &Path) {
|
||||||
|
let mut tracer = Tracer::new(TracerConfig::default());
|
||||||
|
let mut samples = Samples::new();
|
||||||
|
add_enum_samples(&mut tracer, &mut samples);
|
||||||
|
|
||||||
|
// Curated allow-list of acadrust types. We intentionally exclude
|
||||||
|
// EntityType here to avoid pulling in all 41+ entity variants and any
|
||||||
|
// custom-serializer edge cases.
|
||||||
|
type TraceFn = fn(&mut Tracer, &Samples);
|
||||||
|
let types: Vec<(&str, TraceFn)> = vec![
|
||||||
|
("Point", trace::<acadrust::Point>),
|
||||||
|
("Line", trace::<acadrust::Line>),
|
||||||
|
("Circle", trace::<acadrust::Circle>),
|
||||||
|
("Arc", trace::<acadrust::Arc>),
|
||||||
|
("Ellipse", trace::<acadrust::Ellipse>),
|
||||||
|
("Polyline", trace::<acadrust::Polyline>),
|
||||||
|
("Polyline2D", trace::<acadrust::entities::Polyline2D>),
|
||||||
|
("Polyline3D", trace::<acadrust::entities::Polyline3D>),
|
||||||
|
("LwPolyline", trace::<acadrust::LwPolyline>),
|
||||||
|
("Spline", trace::<acadrust::Spline>),
|
||||||
|
("EntityCommon", trace::<acadrust::entities::EntityCommon>),
|
||||||
|
("Handle", trace::<acadrust::Handle>),
|
||||||
|
("Vector2", trace::<acadrust::Vector2>),
|
||||||
|
("Vector3", trace::<acadrust::Vector3>),
|
||||||
|
("Color", trace::<acadrust::Color>),
|
||||||
|
("Layer", trace::<acadrust::Layer>),
|
||||||
|
("XDataValue", trace::<acadrust::xdata::XDataValue>),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, f) in types {
|
||||||
|
f(&mut tracer, &samples);
|
||||||
|
// serde-reflection accumulates named types as it traces; we only need
|
||||||
|
// to ensure the seed types are recorded even if a nested trace fails.
|
||||||
|
eprintln!("[ocs_plugin_api build] traced type registry entry: {}", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let traced = tracer
|
||||||
|
.registry()
|
||||||
|
.expect("type registry tracing failed; see stderr for individual errors");
|
||||||
|
let registry = map_to_custom_schema(&traced);
|
||||||
|
let json = serde_json::to_string_pretty(®istry).unwrap();
|
||||||
|
fs::write(out_dir.join("type_registry.json"), json).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace<T>(tracer: &mut Tracer, samples: &Samples)
|
||||||
|
where
|
||||||
|
T: serde::Serialize + DeserializeOwned,
|
||||||
|
{
|
||||||
|
let name = std::any::type_name::<T>();
|
||||||
|
if let Err(e) = tracer.trace_type::<T>(samples) {
|
||||||
|
eprintln!("[ocs_plugin_api build] warning: tracing {} failed: {}", name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) {
|
||||||
|
// serde-reflection needs at least one sample value per enum variant in
|
||||||
|
// order to reconstruct the full schema. Provide samples for the enums that
|
||||||
|
// appear inside the allow-list types below.
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByLayer);
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByBlock);
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::LineWeight::Default);
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::LineWeight::Value(0));
|
||||||
|
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::None);
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::QuadraticBSpline);
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::CubicBSpline);
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::Bezier);
|
||||||
|
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::String(String::new()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::ControlString(String::new()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::LayerName(String::new()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::BinaryData(Vec::new()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Handle(acadrust::Handle::default()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Point3D(acadrust::Vector3::default()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Position3D(acadrust::Vector3::default()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Displacement3D(acadrust::Vector3::default()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Direction3D(acadrust::Vector3::default()));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Real(0.0));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Distance(0.0));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::ScaleFactor(0.0));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Integer16(0));
|
||||||
|
let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Integer32(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_to_custom_schema(traced: &Registry) -> TypeRegistry {
|
||||||
|
let mut types = BTreeMap::new();
|
||||||
|
for (name, format) in traced.iter() {
|
||||||
|
let info = map_container(name, format);
|
||||||
|
types.insert(TypeId(name.clone()), info);
|
||||||
|
}
|
||||||
|
TypeRegistry { types }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_container(name: &str, format: &ContainerFormat) -> TypeInfo {
|
||||||
|
match format {
|
||||||
|
ContainerFormat::Struct(fields) => TypeInfo {
|
||||||
|
name: TypeId(name.to_string()),
|
||||||
|
kind: TypeKind::Struct,
|
||||||
|
fields: fields.iter().map(map_field).collect(),
|
||||||
|
variants: vec![],
|
||||||
|
methods: vec![],
|
||||||
|
doc: None,
|
||||||
|
},
|
||||||
|
ContainerFormat::Enum(variants) => TypeInfo {
|
||||||
|
name: TypeId(name.to_string()),
|
||||||
|
kind: TypeKind::Enum,
|
||||||
|
fields: vec![],
|
||||||
|
variants: variants
|
||||||
|
.iter()
|
||||||
|
.map(|(idx, v)| map_variant(*idx, v))
|
||||||
|
.collect(),
|
||||||
|
methods: vec![],
|
||||||
|
doc: None,
|
||||||
|
},
|
||||||
|
ContainerFormat::NewTypeStruct(format) => TypeInfo {
|
||||||
|
name: TypeId(name.to_string()),
|
||||||
|
kind: TypeKind::Newtype,
|
||||||
|
fields: vec![FieldInfo {
|
||||||
|
name: "0".to_string(),
|
||||||
|
..map_format_field(format)
|
||||||
|
}],
|
||||||
|
variants: vec![],
|
||||||
|
methods: vec![],
|
||||||
|
doc: None,
|
||||||
|
},
|
||||||
|
ContainerFormat::TupleStruct(formats) => TypeInfo {
|
||||||
|
name: TypeId(name.to_string()),
|
||||||
|
kind: TypeKind::Tuple,
|
||||||
|
fields: formats
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, f)| FieldInfo {
|
||||||
|
name: i.to_string(),
|
||||||
|
..map_format_field(f)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
variants: vec![],
|
||||||
|
methods: vec![],
|
||||||
|
doc: None,
|
||||||
|
},
|
||||||
|
ContainerFormat::UnitStruct => TypeInfo {
|
||||||
|
name: TypeId(name.to_string()),
|
||||||
|
kind: TypeKind::Unit,
|
||||||
|
fields: vec![],
|
||||||
|
variants: vec![],
|
||||||
|
methods: vec![],
|
||||||
|
doc: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_field(named: &Named<Format>) -> FieldInfo {
|
||||||
|
let mut field = map_format_field(&named.value);
|
||||||
|
field.name = named.name.clone();
|
||||||
|
field
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_format_field(format: &Format) -> FieldInfo {
|
||||||
|
if let Some(name) = primitive_format_name(format) {
|
||||||
|
return FieldInfo {
|
||||||
|
name: String::new(),
|
||||||
|
type_id: TypeId(name),
|
||||||
|
optional: false,
|
||||||
|
is_sequence: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match format {
|
||||||
|
Format::TypeName(name) => FieldInfo {
|
||||||
|
name: String::new(),
|
||||||
|
type_id: TypeId(name.clone()),
|
||||||
|
optional: false,
|
||||||
|
is_sequence: false,
|
||||||
|
},
|
||||||
|
Format::Option(inner) => {
|
||||||
|
let mut field = map_format_field(inner);
|
||||||
|
field.optional = true;
|
||||||
|
field
|
||||||
|
}
|
||||||
|
Format::Seq(inner) => {
|
||||||
|
let mut field = map_format_field(inner);
|
||||||
|
field.is_sequence = true;
|
||||||
|
field
|
||||||
|
}
|
||||||
|
Format::TupleArray { content, size } => {
|
||||||
|
let mut field = map_format_field(content);
|
||||||
|
field.type_id = TypeId(format!("[{}; {}]", type_id_of_format(content), size));
|
||||||
|
field.is_sequence = true;
|
||||||
|
field
|
||||||
|
}
|
||||||
|
Format::Map { key, value } => FieldInfo {
|
||||||
|
name: String::new(),
|
||||||
|
type_id: TypeId(format!(
|
||||||
|
"Map<{}, {}>",
|
||||||
|
type_id_of_format(key),
|
||||||
|
type_id_of_format(value)
|
||||||
|
)),
|
||||||
|
optional: false,
|
||||||
|
is_sequence: false,
|
||||||
|
},
|
||||||
|
Format::Tuple(formats) => FieldInfo {
|
||||||
|
name: String::new(),
|
||||||
|
type_id: TypeId(format!(
|
||||||
|
"({})",
|
||||||
|
formats
|
||||||
|
.iter()
|
||||||
|
.map(type_id_of_format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
)),
|
||||||
|
optional: false,
|
||||||
|
is_sequence: false,
|
||||||
|
},
|
||||||
|
Format::Variable(_) => FieldInfo {
|
||||||
|
name: String::new(),
|
||||||
|
type_id: TypeId("Value".to_string()),
|
||||||
|
optional: false,
|
||||||
|
is_sequence: false,
|
||||||
|
},
|
||||||
|
_ => FieldInfo {
|
||||||
|
name: String::new(),
|
||||||
|
type_id: TypeId("unknown".to_string()),
|
||||||
|
optional: false,
|
||||||
|
is_sequence: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primitive_format_name(format: &Format) -> Option<String> {
|
||||||
|
let name = match format {
|
||||||
|
Format::Unit => "()",
|
||||||
|
Format::Bool => "bool",
|
||||||
|
Format::I8 => "i8",
|
||||||
|
Format::I16 => "i16",
|
||||||
|
Format::I32 => "i32",
|
||||||
|
Format::I64 => "i64",
|
||||||
|
Format::I128 => "i128",
|
||||||
|
Format::U8 => "u8",
|
||||||
|
Format::U16 => "u16",
|
||||||
|
Format::U32 => "u32",
|
||||||
|
Format::U64 => "u64",
|
||||||
|
Format::U128 => "u128",
|
||||||
|
Format::F32 => "f32",
|
||||||
|
Format::F64 => "f64",
|
||||||
|
Format::Char => "char",
|
||||||
|
Format::Str => "String",
|
||||||
|
Format::Bytes => "bytes",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_id_of_format(format: &Format) -> String {
|
||||||
|
if let Some(name) = primitive_format_name(format) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
match format {
|
||||||
|
Format::TypeName(name) => name.clone(),
|
||||||
|
Format::Option(inner) => format!("Option<{}>", type_id_of_format(inner)),
|
||||||
|
Format::Seq(inner) => format!("Vec<{}>", type_id_of_format(inner)),
|
||||||
|
Format::TupleArray { content, size } => {
|
||||||
|
format!("[{}; {}]", type_id_of_format(content), size)
|
||||||
|
}
|
||||||
|
Format::Map { key, value } => format!(
|
||||||
|
"Map<{}, {}>",
|
||||||
|
type_id_of_format(key),
|
||||||
|
type_id_of_format(value)
|
||||||
|
),
|
||||||
|
Format::Tuple(formats) => format!(
|
||||||
|
"({})",
|
||||||
|
formats
|
||||||
|
.iter()
|
||||||
|
.map(type_id_of_format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
),
|
||||||
|
Format::Variable(_) => "Value".to_string(),
|
||||||
|
_ => "unknown".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_variant(discriminant: u32, named: &Named<VariantFormat>) -> EnumVariantInfo {
|
||||||
|
let fields = match &named.value {
|
||||||
|
VariantFormat::Unit => vec![],
|
||||||
|
VariantFormat::Variable(_) => vec![],
|
||||||
|
VariantFormat::NewType(format) => vec![FieldInfo {
|
||||||
|
name: "0".to_string(),
|
||||||
|
..map_format_field(format)
|
||||||
|
}],
|
||||||
|
VariantFormat::Tuple(formats) => formats
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, f)| FieldInfo {
|
||||||
|
name: i.to_string(),
|
||||||
|
..map_format_field(f)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
VariantFormat::Struct(fields) => fields.iter().map(map_field).collect(),
|
||||||
|
};
|
||||||
|
EnumVariantInfo {
|
||||||
|
name: named.name.clone(),
|
||||||
|
discriminant,
|
||||||
|
fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Version info
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
fn generate_version_info(out_dir: &Path) {
|
||||||
|
let lock_path = workspace_cargo_lock_path();
|
||||||
|
let lockfile = Lockfile::load(&lock_path).expect("load Cargo.lock");
|
||||||
|
|
||||||
|
// Use Cargo.lock's mtime as the build timestamp so the embedded JSON stays
|
||||||
|
// stable across normal incremental builds and only changes when dependencies
|
||||||
|
// are updated. Stored as Unix seconds to avoid an extra date-formatting
|
||||||
|
// dependency in the build script.
|
||||||
|
let build_timestamp = fs::metadata(&lock_path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.and_then(|t| {
|
||||||
|
t.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.map_err(std::io::Error::other)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let ocs = lockfile
|
||||||
|
.packages
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name.as_str() == "OpenCADStudio")
|
||||||
|
.expect("OpenCADStudio package in Cargo.lock");
|
||||||
|
let acadrust = lockfile
|
||||||
|
.packages
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name.as_str() == "acadrust")
|
||||||
|
.expect("acadrust package in Cargo.lock");
|
||||||
|
|
||||||
|
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()),
|
||||||
|
"api_version": 4,
|
||||||
|
"api_version_min_supported": 2,
|
||||||
|
"build_timestamp": build_timestamp,
|
||||||
|
});
|
||||||
|
fs::write(out_dir.join("version_info.json"), info.to_string()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_cargo_lock_path() -> PathBuf {
|
||||||
|
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||||
|
// CARGO_MANIFEST_DIR is crates/ocs_plugin_api; walk up to the workspace root.
|
||||||
|
manifest_dir
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.map(|p| p.join("Cargo.lock"))
|
||||||
|
.expect("Cargo.lock in workspace root")
|
||||||
|
}
|
||||||
|
|
@ -13,12 +13,249 @@
|
||||||
|
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
|
||||||
use acadrust::xdata::ExtendedDataRecord;
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use acadrust::{CadDocument, EntityType, Handle};
|
|
||||||
|
|
||||||
use crate::manifest::PluginManifest;
|
use crate::manifest::PluginManifest;
|
||||||
use crate::ribbon::CadModule;
|
use crate::ribbon::CadModule;
|
||||||
|
|
||||||
|
// Re-export the acadrust crate and the types that appear in the HostApi trait
|
||||||
|
// so out-of-tree plugins can use them without adding their own acadrust
|
||||||
|
// dependency (which would risk an ABI-mismatching version).
|
||||||
|
pub use acadrust;
|
||||||
|
pub use acadrust::{CadDocument, EntityType, Handle};
|
||||||
|
pub use acadrust::xdata::ExtendedDataRecord;
|
||||||
|
|
||||||
|
use crate::ipc::protocol::{PluginRequest, PluginResponse};
|
||||||
|
|
||||||
|
/// Thread-safe handle that can issue host requests from plugin worker threads.
|
||||||
|
/// Out-of-process V4 plugins implement this; in-process hosts may return `None`.
|
||||||
|
pub trait PluginRequestSender: Send + Sync {
|
||||||
|
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error returned when a plugin worker thread cannot issue a host request.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginRequestError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for PluginRequestError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "PluginRequestError: {}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PluginRequestError {}
|
||||||
|
|
||||||
|
/// Log level carried by [`PluginNotification::Log`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum LogLevel {
|
||||||
|
Debug,
|
||||||
|
Info,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification the host sends to a plugin. These are best-effort,
|
||||||
|
/// full-duplex messages correlated with an optional `command_id`.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum HostNotification {
|
||||||
|
InputLine { line: String },
|
||||||
|
Cancel,
|
||||||
|
DocumentChanged { version: u64 },
|
||||||
|
SelectionChanged { handles: Vec<Handle> },
|
||||||
|
Raw(Vec<u8>),
|
||||||
|
/// V4 snapshot changed for a specific tab. Discriminant 5.
|
||||||
|
DocumentChangedV4 { tab_id: u64, version: u64 },
|
||||||
|
/// V4 tab closed notification. Discriminant 6.
|
||||||
|
DocumentTabClosed { tab_id: u64 },
|
||||||
|
/// Fallback for notification variants added in future minor revisions.
|
||||||
|
/// Carries the raw bincode payload so an older peer can ignore it without
|
||||||
|
/// failing deserialization.
|
||||||
|
Unknown(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for HostNotification {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
match self {
|
||||||
|
HostNotification::InputLine { line } => {
|
||||||
|
bytes.push(0);
|
||||||
|
bincode::serialize_into(&mut bytes, line)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
HostNotification::Cancel => bytes.push(1),
|
||||||
|
HostNotification::DocumentChanged { version } => {
|
||||||
|
bytes.push(2);
|
||||||
|
bincode::serialize_into(&mut bytes, version)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
HostNotification::SelectionChanged { handles } => {
|
||||||
|
bytes.push(3);
|
||||||
|
bincode::serialize_into(&mut bytes, handles)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
HostNotification::Raw(data) => {
|
||||||
|
bytes.push(4);
|
||||||
|
bincode::serialize_into(&mut bytes, data)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
HostNotification::DocumentChangedV4 { tab_id, version } => {
|
||||||
|
bytes.push(5);
|
||||||
|
bincode::serialize_into(&mut bytes, tab_id)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
bincode::serialize_into(&mut bytes, version)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
HostNotification::DocumentTabClosed { tab_id } => {
|
||||||
|
bytes.push(6);
|
||||||
|
bincode::serialize_into(&mut bytes, tab_id)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
|
||||||
|
}
|
||||||
|
bytes.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for HostNotification {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let bytes = Vec::<u8>::deserialize(deserializer)?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(serde::de::Error::custom("empty HostNotification"));
|
||||||
|
}
|
||||||
|
let discriminant = bytes[0];
|
||||||
|
let rest = &bytes[1..];
|
||||||
|
match discriminant {
|
||||||
|
0 => bincode::deserialize(rest)
|
||||||
|
.map(|line| HostNotification::InputLine { line })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
1 => Ok(HostNotification::Cancel),
|
||||||
|
2 => bincode::deserialize(rest)
|
||||||
|
.map(|version| HostNotification::DocumentChanged { version })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
3 => bincode::deserialize(rest)
|
||||||
|
.map(|handles| HostNotification::SelectionChanged { handles })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
4 => bincode::deserialize(rest)
|
||||||
|
.map(HostNotification::Raw)
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
5 => bincode::deserialize(rest)
|
||||||
|
.map(|(tab_id, version)| HostNotification::DocumentChangedV4 { tab_id, version })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
6 => bincode::deserialize(rest)
|
||||||
|
.map(|tab_id| HostNotification::DocumentTabClosed { tab_id })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
_ => Ok(HostNotification::Unknown(bytes)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A notification a plugin sends to the host. These are best-effort,
|
||||||
|
/// full-duplex messages correlated with an optional `command_id`.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum PluginNotification {
|
||||||
|
Output { text: String },
|
||||||
|
Error { text: String },
|
||||||
|
Prompt { text: String },
|
||||||
|
Progress { percent: u8 },
|
||||||
|
Log { level: LogLevel, text: String },
|
||||||
|
Raw(Vec<u8>),
|
||||||
|
/// V4 REPL status update. Discriminant 6.
|
||||||
|
ReplStatus { status: String, message: String },
|
||||||
|
/// Fallback for notification variants added in future minor revisions.
|
||||||
|
/// Carries the raw bincode payload so an older peer can ignore it without
|
||||||
|
/// failing deserialization.
|
||||||
|
Unknown(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for PluginNotification {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
match self {
|
||||||
|
PluginNotification::Output { text } => {
|
||||||
|
bytes.push(0);
|
||||||
|
bincode::serialize_into(&mut bytes, text)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
PluginNotification::Error { text } => {
|
||||||
|
bytes.push(1);
|
||||||
|
bincode::serialize_into(&mut bytes, text)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
PluginNotification::Prompt { text } => {
|
||||||
|
bytes.push(2);
|
||||||
|
bincode::serialize_into(&mut bytes, text)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
PluginNotification::Progress { percent } => {
|
||||||
|
bytes.push(3);
|
||||||
|
bincode::serialize_into(&mut bytes, percent)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
PluginNotification::Log { level, text } => {
|
||||||
|
bytes.push(4);
|
||||||
|
bincode::serialize_into(&mut bytes, level)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
bincode::serialize_into(&mut bytes, text)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
PluginNotification::Raw(data) => {
|
||||||
|
bytes.push(5);
|
||||||
|
bincode::serialize_into(&mut bytes, data)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
PluginNotification::ReplStatus { status, message } => {
|
||||||
|
bytes.push(6);
|
||||||
|
bincode::serialize_into(&mut bytes, status)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
bincode::serialize_into(&mut bytes, message)
|
||||||
|
.map_err(serde::ser::Error::custom)?;
|
||||||
|
}
|
||||||
|
PluginNotification::Unknown(raw) => bytes.extend_from_slice(raw),
|
||||||
|
}
|
||||||
|
bytes.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for PluginNotification {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let bytes = Vec::<u8>::deserialize(deserializer)?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err(serde::de::Error::custom("empty PluginNotification"));
|
||||||
|
}
|
||||||
|
let discriminant = bytes[0];
|
||||||
|
let rest = &bytes[1..];
|
||||||
|
match discriminant {
|
||||||
|
0 => bincode::deserialize(rest)
|
||||||
|
.map(|text| PluginNotification::Output { text })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
1 => bincode::deserialize(rest)
|
||||||
|
.map(|text| PluginNotification::Error { text })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
2 => bincode::deserialize(rest)
|
||||||
|
.map(|text| PluginNotification::Prompt { text })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
3 => bincode::deserialize(rest)
|
||||||
|
.map(|percent| PluginNotification::Progress { percent })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
4 => {
|
||||||
|
let (level, text): (LogLevel, String) = bincode::deserialize(rest)
|
||||||
|
.map_err(serde::de::Error::custom)?;
|
||||||
|
Ok(PluginNotification::Log { level, text })
|
||||||
|
}
|
||||||
|
5 => bincode::deserialize(rest)
|
||||||
|
.map(PluginNotification::Raw)
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
6 => bincode::deserialize(rest)
|
||||||
|
.map(|(status, message)| PluginNotification::ReplStatus { status, message })
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
_ => Ok(PluginNotification::Unknown(bytes)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An add-on package's entry point: its manifest, optional ribbon tab, and
|
/// An add-on package's entry point: its manifest, optional ribbon tab, and
|
||||||
/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins
|
/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins
|
||||||
/// implement the same trait from this crate, so an out-of-tree add-on targets
|
/// implement the same trait from this crate, so an out-of-tree add-on targets
|
||||||
|
|
@ -27,6 +264,28 @@ pub trait BuiltinPlugin: Send + Sync {
|
||||||
fn manifest(&self) -> &'static PluginManifest;
|
fn manifest(&self) -> &'static PluginManifest;
|
||||||
fn ribbon(&self) -> Box<dyn CadModule>;
|
fn ribbon(&self) -> Box<dyn CadModule>;
|
||||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
||||||
|
|
||||||
|
/// Long-running REPL code execution (API v4). The plugin may start the
|
||||||
|
/// work on another thread and call `respond` when finished; the runner
|
||||||
|
/// will forward the result to the host. The supplied `host` is the active
|
||||||
|
/// document tab's API surface, so the REPL session is tied to a tab.
|
||||||
|
///
|
||||||
|
/// Returning `false` means the plugin does not support code execution.
|
||||||
|
fn start_execute_code(
|
||||||
|
&mut self,
|
||||||
|
_host: &mut dyn HostApi,
|
||||||
|
_command_id: u64,
|
||||||
|
_code: &str,
|
||||||
|
_source: CommandSource,
|
||||||
|
_respond: Box<dyn FnOnce(ExecutionResult) + Send>,
|
||||||
|
) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State-only callback for host-to-plugin notifications. Added in API v4;
|
||||||
|
/// 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<u64>, _notification: HostNotification) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A point-driven interactive command a plugin starts via
|
/// A point-driven interactive command a plugin starts via
|
||||||
|
|
@ -203,6 +462,63 @@ pub trait HostApi {
|
||||||
fn document_view(&mut self) -> Option<crate::shm::DocumentViewInfo> {
|
fn document_view(&mut self) -> Option<crate::shm::DocumentViewInfo> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Notifications (added in API v4; appended at the end to keep vtable
|
||||||
|
// indices stable for V2/V3 plugins) ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Send a best-effort notification from the plugin to the host.
|
||||||
|
///
|
||||||
|
/// In-process plugins can override this to forward to the host event loop;
|
||||||
|
/// out-of-process plugins send a V4 notification frame.
|
||||||
|
fn notify_plugin(&mut self, _command_id: Option<u64>, _notification: PluginNotification) {}
|
||||||
|
|
||||||
|
/// Poll for a host-to-plugin notification, if any.
|
||||||
|
///
|
||||||
|
/// Returns the optional `command_id` used to correlate the notification
|
||||||
|
/// with a running command, and the notification payload. Long-running
|
||||||
|
/// plugins should call this periodically to drain the bounded queue.
|
||||||
|
fn try_recv_notification(
|
||||||
|
&mut self,
|
||||||
|
) -> Option<(Option<u64>, HostNotification)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a thread-safe handle that can issue host requests from worker
|
||||||
|
/// threads. Out-of-process V4 plugins implement this; in-process hosts may
|
||||||
|
/// return `None`.
|
||||||
|
fn plugin_request_sender(&self) -> Option<Box<dyn PluginRequestSender>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── V4 tab/document identity (added for REPL; appended at the end) ───────
|
||||||
|
|
||||||
|
/// Stable tab identifier for the active document tab.
|
||||||
|
fn tab_id(&self) -> u64 {
|
||||||
|
self.tab_index() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open (or refresh) the host-side V4 shared document view for `tab_id`
|
||||||
|
/// and return the mapping information. In-process hosts implement this;
|
||||||
|
/// out-of-process plugin proxies return `None`.
|
||||||
|
fn document_view_v4(&mut self, tab_id: u64) -> Option<crate::shm::DocumentViewInfo> {
|
||||||
|
let _ = tab_id;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the host-side V4 shared document view for `tab_id`.
|
||||||
|
fn close_document_view_v4(&mut self, tab_id: u64) {
|
||||||
|
let _ = tab_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch entities (added after API v4; appended at the very end so older
|
||||||
|
// plugins compiled without it keep stable vtable indices) ────────────────
|
||||||
|
|
||||||
|
/// Add multiple entities to the active document, returning their handles.
|
||||||
|
/// The default implementation calls [`add_entity`](Self::add_entity) for each
|
||||||
|
/// entity; hosts should override it for batch efficiency.
|
||||||
|
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||||
|
entities.into_iter().map(|e| self.add_entity(e)).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
||||||
|
|
@ -349,3 +665,95 @@ pub fn ensure_plugin_state<'a, T: Any + Send + Sync>(
|
||||||
any.downcast_mut::<T>()
|
any.downcast_mut::<T>()
|
||||||
.expect("plugin state type mismatch for plugin_id")
|
.expect("plugin state type mismatch for plugin_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "host")]
|
||||||
|
mod repl {
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Source surface that submitted a REPL code snippet.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum CommandSource {
|
||||||
|
CommandLine,
|
||||||
|
Script,
|
||||||
|
Editor,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a REPL `ExecuteCode` request.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct ExecutionResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub output: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub error_type: Option<String>,
|
||||||
|
pub traceback: Option<String>,
|
||||||
|
pub line_number: Option<u32>,
|
||||||
|
pub column_number: Option<u32>,
|
||||||
|
pub duration_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecutionResult {
|
||||||
|
/// Create a new execution result.
|
||||||
|
pub fn new(
|
||||||
|
success: bool,
|
||||||
|
output: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
error_type: Option<String>,
|
||||||
|
traceback: Option<String>,
|
||||||
|
line_number: Option<u32>,
|
||||||
|
column_number: Option<u32>,
|
||||||
|
duration_ms: f64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
success,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
error_type,
|
||||||
|
traceback,
|
||||||
|
line_number,
|
||||||
|
column_number,
|
||||||
|
duration_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a thread that runs `f` and calls `respond` with the returned
|
||||||
|
/// `ExecutionResult`. If `f` panics, `respond` is called with a panic error
|
||||||
|
/// result instead, so the host never waits indefinitely for a callback that
|
||||||
|
/// will never arrive.
|
||||||
|
pub fn execute_code_guard<F>(
|
||||||
|
respond: Box<dyn FnOnce(ExecutionResult) + Send>,
|
||||||
|
f: F,
|
||||||
|
) -> std::thread::JoinHandle<()>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> ExecutionResult + Send + 'static,
|
||||||
|
{
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(payload) => {
|
||||||
|
let msg = payload
|
||||||
|
.downcast_ref::<&str>()
|
||||||
|
.copied()
|
||||||
|
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
|
||||||
|
.unwrap_or("execute_code panicked");
|
||||||
|
ExecutionResult {
|
||||||
|
success: false,
|
||||||
|
output: None,
|
||||||
|
error: Some(format!("panic: {}", msg)),
|
||||||
|
error_type: Some("panic".to_string()),
|
||||||
|
traceback: None,
|
||||||
|
line_number: None,
|
||||||
|
column_number: None,
|
||||||
|
duration_ms: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
respond(result);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "host")]
|
||||||
|
pub use repl::{execute_code_guard, CommandSource, ExecutionResult};
|
||||||
|
|
|
||||||
91
crates/ocs_plugin_api/src/host_v4.rs
Normal file
91
crates/ocs_plugin_api/src/host_v4.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
//! Host-side V4 snapshot manager.
|
||||||
|
//!
|
||||||
|
//! Maintains a per-tab shared-memory document view keyed by the stable
|
||||||
|
//! `DocumentTab.id`. The manager is used by `src/plugin/v4_support.rs` to
|
||||||
|
//! open, publish, and close V4 snapshots independently of the V3 reader path.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use acadrust::CadDocument;
|
||||||
|
|
||||||
|
use crate::shm::{DocumentSnapshotStore, DocumentViewDataV4, DocumentViewInfo};
|
||||||
|
|
||||||
|
static MANAGER: OnceLock<Mutex<HostV4SnapshotManager>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn global_manager() -> &'static Mutex<HostV4SnapshotManager> {
|
||||||
|
MANAGER.get_or_init(|| Mutex::new(HostV4SnapshotManager::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tab V4 shared-memory snapshot manager.
|
||||||
|
pub struct HostV4SnapshotManager {
|
||||||
|
stores: HashMap<u64, DocumentSnapshotStore<DocumentViewDataV4>>,
|
||||||
|
segment_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostV4SnapshotManager {
|
||||||
|
/// Create a new manager with the default segment size.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
stores: HashMap::new(),
|
||||||
|
segment_size: default_segment_size(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open (or refresh) the V4 snapshot for `tab_id` and return the view info.
|
||||||
|
pub fn open(&mut self, tab_id: u64, doc: &CadDocument) -> Option<DocumentViewInfo> {
|
||||||
|
let store = match self.stores.get_mut(&tab_id) {
|
||||||
|
Some(store) => store,
|
||||||
|
None => {
|
||||||
|
let store = DocumentSnapshotStore::new(tab_id, self.segment_size).ok()?;
|
||||||
|
self.stores.entry(tab_id).or_insert(store)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = store.publish(&doc.into()) {
|
||||||
|
eprintln!("[host] failed to publish V4 document view for tab {tab_id}: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(DocumentViewInfo {
|
||||||
|
path: store.path().to_string_lossy().into_owned(),
|
||||||
|
version: store.version(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the latest document to the existing V4 snapshot for `tab_id`.
|
||||||
|
/// Returns the updated view info if the snapshot exists.
|
||||||
|
pub fn publish(&mut self, tab_id: u64, doc: &CadDocument) -> Option<DocumentViewInfo> {
|
||||||
|
let store = self.stores.get_mut(&tab_id)?;
|
||||||
|
if let Err(e) = store.publish(&doc.into()) {
|
||||||
|
eprintln!("[host] failed to publish V4 document view for tab {tab_id}: {e}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(DocumentViewInfo {
|
||||||
|
path: store.path().to_string_lossy().into_owned(),
|
||||||
|
version: store.version(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the V4 snapshot for `tab_id`.
|
||||||
|
pub fn close(&mut self, tab_id: u64) {
|
||||||
|
self.stores.remove(&tab_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HostV4SnapshotManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global accessor for the host V4 snapshot manager.
|
||||||
|
pub fn manager() -> &'static Mutex<HostV4SnapshotManager> {
|
||||||
|
global_manager()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_segment_size() -> usize {
|
||||||
|
std::env::var("OCS_V4_SNAPSHOT_SEGMENT_SIZE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(16 * 1024 * 1024)
|
||||||
|
.max(1024 * 1024)
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ use crate::ipc::protocol::{
|
||||||
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake,
|
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake,
|
||||||
};
|
};
|
||||||
use crate::ipc::transport::{recv, send};
|
use crate::ipc::transport::{recv, send};
|
||||||
use crate::shm::{DocumentViewInfo, SharedDocumentReader};
|
use crate::shm::{DocumentViewInfo, SharedDocumentReader, DocumentViewData};
|
||||||
|
|
||||||
/// Shared registry of active interactive commands, keyed by host-assigned id.
|
/// Shared registry of active interactive commands, keyed by host-assigned id.
|
||||||
pub type InteractiveRegistry = Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>;
|
pub type InteractiveRegistry = Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>;
|
||||||
|
|
@ -60,10 +60,10 @@ impl IpcClient {
|
||||||
&self,
|
&self,
|
||||||
req: PluginRequest,
|
req: PluginRequest,
|
||||||
) -> Result<PluginResponse, crate::ipc::transport::TransportError> {
|
) -> Result<PluginResponse, crate::ipc::transport::TransportError> {
|
||||||
send(&mut self.stream.borrow_mut(), &PluginToHost::Request(req))?;
|
send(&mut self.stream.borrow_mut(), &PluginToHost::Request(Box::new(req)))?;
|
||||||
loop {
|
loop {
|
||||||
match recv::<HostToPlugin>(&mut self.stream.borrow_mut())? {
|
match recv::<HostToPlugin>(&mut self.stream.borrow_mut())? {
|
||||||
HostToPlugin::Response(resp) => return Ok(resp),
|
HostToPlugin::Response(resp) => return Ok(*resp),
|
||||||
HostToPlugin::Request(host_req) => {
|
HostToPlugin::Request(host_req) => {
|
||||||
let resp = HostResponse::Error(format!(
|
let resp = HostResponse::Error(format!(
|
||||||
"unexpected nested host request: {host_req:?}"
|
"unexpected nested host request: {host_req:?}"
|
||||||
|
|
@ -107,7 +107,7 @@ impl PluginHostApi {
|
||||||
|
|
||||||
fn fetch_document(&self) -> CadDocument {
|
fn fetch_document(&self) -> CadDocument {
|
||||||
match self.client.request(PluginRequest::DocumentSnapshot) {
|
match self.client.request(PluginRequest::DocumentSnapshot) {
|
||||||
Ok(PluginResponse::Document(doc)) => doc,
|
Ok(PluginResponse::Document(doc)) => *doc,
|
||||||
Ok(other) => {
|
Ok(other) => {
|
||||||
eprintln!("[plugin] unexpected DocumentSnapshot response: {other:?}");
|
eprintln!("[plugin] unexpected DocumentSnapshot response: {other:?}");
|
||||||
CadDocument::default()
|
CadDocument::default()
|
||||||
|
|
@ -156,6 +156,25 @@ impl HostApi for PluginHostApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||||
|
match self.client.request(PluginRequest::AddEntities(entities)) {
|
||||||
|
Ok(PluginResponse::Handles(handles)) => {
|
||||||
|
// The cached snapshot is now stale; drop it so a later
|
||||||
|
// document() re-fetches the host's post-edit truth.
|
||||||
|
self.document_cache = OnceCell::new();
|
||||||
|
handles
|
||||||
|
}
|
||||||
|
Ok(other) => {
|
||||||
|
eprintln!("[plugin] unexpected AddEntities response: {other:?}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[plugin] AddEntities failed: {e}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_entity(&mut self, entity: EntityType) -> bool {
|
fn update_entity(&mut self, entity: EntityType) -> bool {
|
||||||
match self.client.request(PluginRequest::UpdateEntity(entity)) {
|
match self.client.request(PluginRequest::UpdateEntity(entity)) {
|
||||||
Ok(PluginResponse::Bool(b)) => {
|
Ok(PluginResponse::Bool(b)) => {
|
||||||
|
|
@ -369,7 +388,7 @@ impl HostApi for PluginHostApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match self.doc_view.borrow().as_ref() {
|
match self.doc_view.borrow().as_ref() {
|
||||||
Some(info) => match SharedDocumentReader::open(Path::new(&info.path)) {
|
Some(info) => match SharedDocumentReader::<DocumentViewData>::open(Path::new(&info.path)) {
|
||||||
Ok(reader) => Box::new(reader),
|
Ok(reader) => Box::new(reader),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
@ -455,10 +474,13 @@ mod tests {
|
||||||
let peer_handle = thread::spawn(move || {
|
let peer_handle = thread::spawn(move || {
|
||||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
PluginToHost::Request(PluginRequest::PushInfo(s)) => assert_eq!(s, "hello host"),
|
PluginToHost::Request(req) => match *req {
|
||||||
|
PluginRequest::PushInfo(s) => assert_eq!(s, "hello host"),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
send(&mut peer, &HostToPlugin::Response(PluginResponse::Ok)).unwrap();
|
send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Ok))).unwrap();
|
||||||
});
|
});
|
||||||
api.push_info("hello host");
|
api.push_info("hello host");
|
||||||
peer_handle.join().unwrap();
|
peer_handle.join().unwrap();
|
||||||
|
|
@ -470,12 +492,15 @@ mod tests {
|
||||||
let peer_handle = thread::spawn(move || {
|
let peer_handle = thread::spawn(move || {
|
||||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
PluginToHost::Request(PluginRequest::AddEntity(_)) => {}
|
PluginToHost::Request(req) => match *req {
|
||||||
|
PluginRequest::AddEntity(_) => {}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
send(
|
send(
|
||||||
&mut peer,
|
&mut peer,
|
||||||
&HostToPlugin::Response(PluginResponse::Handle(Handle::new(42))),
|
&HostToPlugin::Response(Box::new(PluginResponse::Handle(Handle::new(42)))),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
@ -484,16 +509,50 @@ mod tests {
|
||||||
assert_eq!(handle, Handle::new(42));
|
assert_eq!(handle, Handle::new(42));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_entities_awaits_handles_response() {
|
||||||
|
let (mut api, mut peer) = make_client();
|
||||||
|
let peer_handle = thread::spawn(move || {
|
||||||
|
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||||
|
match msg {
|
||||||
|
PluginToHost::Request(req) => match *req {
|
||||||
|
PluginRequest::AddEntities(v) => assert_eq!(v.len(), 3),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
send(
|
||||||
|
&mut peer,
|
||||||
|
&HostToPlugin::Response(Box::new(PluginResponse::Handles(vec![
|
||||||
|
Handle::new(10),
|
||||||
|
Handle::new(11),
|
||||||
|
Handle::new(12),
|
||||||
|
]))),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let handles = api.add_entities(vec![
|
||||||
|
EntityType::Point(Point::new()),
|
||||||
|
EntityType::Point(Point::new()),
|
||||||
|
EntityType::Point(Point::new()),
|
||||||
|
]);
|
||||||
|
peer_handle.join().unwrap();
|
||||||
|
assert_eq!(handles, vec![Handle::new(10), Handle::new(11), Handle::new(12)]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_entity_awaits_bool_response() {
|
fn update_entity_awaits_bool_response() {
|
||||||
let (mut api, mut peer) = make_client();
|
let (mut api, mut peer) = make_client();
|
||||||
let peer_handle = thread::spawn(move || {
|
let peer_handle = thread::spawn(move || {
|
||||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
PluginToHost::Request(PluginRequest::UpdateEntity(_)) => {}
|
PluginToHost::Request(req) => match *req {
|
||||||
|
PluginRequest::UpdateEntity(_) => {}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
send(&mut peer, &HostToPlugin::Response(PluginResponse::Bool(true))).unwrap();
|
send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Bool(true)))).unwrap();
|
||||||
});
|
});
|
||||||
assert!(api.update_entity(EntityType::Point(Point::new())));
|
assert!(api.update_entity(EntityType::Point(Point::new())));
|
||||||
peer_handle.join().unwrap();
|
peer_handle.join().unwrap();
|
||||||
|
|
@ -505,12 +564,15 @@ mod tests {
|
||||||
let peer_handle = thread::spawn(move || {
|
let peer_handle = thread::spawn(move || {
|
||||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
PluginToHost::Request(PluginRequest::RemoveEntity { handle }) => {
|
PluginToHost::Request(req) => match *req {
|
||||||
assert_eq!(handle, Handle::new(7));
|
PluginRequest::RemoveEntity { handle } => {
|
||||||
}
|
assert_eq!(handle, Handle::new(7));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
send(&mut peer, &HostToPlugin::Response(PluginResponse::Bool(true))).unwrap();
|
send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Bool(true)))).unwrap();
|
||||||
});
|
});
|
||||||
assert!(api.remove_entity(Handle::new(7)));
|
assert!(api.remove_entity(Handle::new(7)));
|
||||||
peer_handle.join().unwrap();
|
peer_handle.join().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ pub mod protocol;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
#[cfg(feature = "host")]
|
#[cfg(feature = "host")]
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
#[cfg(feature = "host")]
|
||||||
|
pub mod v4;
|
||||||
|
#[cfg(feature = "host")]
|
||||||
|
pub mod proxy;
|
||||||
|
|
||||||
#[cfg(all(test, feature = "host"))]
|
#[cfg(all(test, feature = "host"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
@ -58,7 +62,10 @@ mod tests {
|
||||||
send(&mut a, &HostToPlugin::Request(req)).unwrap();
|
send(&mut a, &HostToPlugin::Request(req)).unwrap();
|
||||||
let got = recv::<HostToPlugin>(&mut b).unwrap();
|
let got = recv::<HostToPlugin>(&mut b).unwrap();
|
||||||
match got {
|
match got {
|
||||||
HostToPlugin::Request(HostRequest::Dispatch { cmd }) => assert_eq!(cmd, "LINE"),
|
HostToPlugin::Request(req) => match req {
|
||||||
|
HostRequest::Dispatch { cmd } => assert_eq!(cmd, "LINE"),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,19 +73,95 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_round_trips_plugin_request() {
|
fn transport_round_trips_plugin_request() {
|
||||||
let (mut a, mut b) = connect_pair();
|
let (mut a, mut b) = connect_pair();
|
||||||
let req = PluginToHost::Request(crate::ipc::protocol::PluginRequest::PushInfo(
|
let req = PluginToHost::Request(Box::new(crate::ipc::protocol::PluginRequest::PushInfo(
|
||||||
"hello".to_string(),
|
"hello".to_string(),
|
||||||
));
|
)));
|
||||||
send(&mut a, &req).unwrap();
|
send(&mut a, &req).unwrap();
|
||||||
let got = recv::<PluginToHost>(&mut b).unwrap();
|
let got = recv::<PluginToHost>(&mut b).unwrap();
|
||||||
match got {
|
match got {
|
||||||
PluginToHost::Request(crate::ipc::protocol::PluginRequest::PushInfo(msg)) => {
|
PluginToHost::Request(req) => match *req {
|
||||||
assert_eq!(msg, "hello")
|
crate::ipc::protocol::PluginRequest::PushInfo(msg) => {
|
||||||
|
assert_eq!(msg, "hello")
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_code_variant_roundtrips_and_appends_to_host_request() {
|
||||||
|
// Serialize ExecuteCode and verify it deserializes back. The fact that
|
||||||
|
// existing variants still round-trip in v2_protocol_frame_roundtrips_after_v4_changes
|
||||||
|
// confirms the new variant is appended at the end and does not shift
|
||||||
|
// earlier discriminants.
|
||||||
|
let req = HostRequest::ExecuteCode {
|
||||||
|
command_id: 1,
|
||||||
|
source: crate::host::CommandSource::Editor,
|
||||||
|
code: "1+1".to_string(),
|
||||||
|
tab_index: 0,
|
||||||
|
};
|
||||||
|
let bytes = bincode::serialize(&req).unwrap();
|
||||||
|
let got: HostRequest = bincode::deserialize(&bytes).unwrap();
|
||||||
|
match got {
|
||||||
|
HostRequest::ExecuteCode {
|
||||||
|
command_id,
|
||||||
|
source,
|
||||||
|
code,
|
||||||
|
tab_index,
|
||||||
|
} => {
|
||||||
|
assert_eq!(command_id, 1);
|
||||||
|
assert_eq!(source, crate::host::CommandSource::Editor);
|
||||||
|
assert_eq!(code, "1+1");
|
||||||
|
assert_eq!(tab_index, 0);
|
||||||
}
|
}
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_execution_result_variant_roundtrips() {
|
||||||
|
let result = crate::host::ExecutionResult {
|
||||||
|
success: true,
|
||||||
|
output: Some("ok".to_string()),
|
||||||
|
error: None,
|
||||||
|
error_type: None,
|
||||||
|
traceback: None,
|
||||||
|
line_number: None,
|
||||||
|
column_number: None,
|
||||||
|
duration_ms: 0.0,
|
||||||
|
};
|
||||||
|
let resp = HostResponse::CodeExecutionResult(result);
|
||||||
|
let bytes = bincode::serialize(&resp).unwrap();
|
||||||
|
let got: HostResponse = bincode::deserialize(&bytes).unwrap();
|
||||||
|
match got {
|
||||||
|
HostResponse::CodeExecutionResult(r) => {
|
||||||
|
assert!(r.success);
|
||||||
|
assert_eq!(r.output, Some("ok".to_string()));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_additions_preserve_legacy_discriminants() {
|
||||||
|
fn discriminant<T: serde::Serialize>(value: &T) -> u32 {
|
||||||
|
let bytes = bincode::serialize(value).unwrap();
|
||||||
|
u32::from_le_bytes(bytes[..4].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(discriminant(&HostRequest::Shutdown), 6);
|
||||||
|
assert_eq!(discriminant(&HostResponse::Error(String::new())), 5);
|
||||||
|
assert_eq!(
|
||||||
|
discriminant(&crate::ipc::protocol::PluginRequest::BumpGeometry),
|
||||||
|
6
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
discriminant(&crate::ipc::protocol::PluginResponse::Record(None)),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_rejects_oversized_message() {
|
fn transport_rejects_oversized_message() {
|
||||||
let (mut a, _b) = connect_pair();
|
let (mut a, _b) = connect_pair();
|
||||||
|
|
@ -109,4 +192,31 @@ mod tests {
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_protocol_frame_roundtrips_after_v4_changes() {
|
||||||
|
// The V2/V3 wire format is unchanged by the V4 additions (new enum
|
||||||
|
// variants are appended). Verify a full request/response pair still
|
||||||
|
// serializes and deserializes correctly.
|
||||||
|
let req = HostToPlugin::Request(HostRequest::Dispatch {
|
||||||
|
cmd: "V2CMD".to_string(),
|
||||||
|
});
|
||||||
|
let bytes = bincode::serialize(&req).unwrap();
|
||||||
|
let got: HostToPlugin = bincode::deserialize(&bytes).unwrap();
|
||||||
|
match got {
|
||||||
|
HostToPlugin::Request(req) => match req {
|
||||||
|
HostRequest::Dispatch { cmd } => assert_eq!(cmd, "V2CMD"),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
},
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = PluginToHost::Response(HostResponse::Bool(true));
|
||||||
|
let bytes = bincode::serialize(&resp).unwrap();
|
||||||
|
let got: PluginToHost = bincode::deserialize(&bytes).unwrap();
|
||||||
|
match got {
|
||||||
|
PluginToHost::Response(HostResponse::Bool(b)) => assert!(b),
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::host::CommandStep;
|
use crate::host::{CommandSource, CommandStep};
|
||||||
use crate::manifest::ApiVersion;
|
use crate::manifest::ApiVersion;
|
||||||
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
||||||
|
|
||||||
|
|
@ -27,9 +27,14 @@ pub enum InteractiveEvent {
|
||||||
/// The runner proves it was spawned by this host by presenting a pre-shared
|
/// The runner proves it was spawned by this host by presenting a pre-shared
|
||||||
/// token delivered through the `OCS_PLUGIN_TOKEN` environment variable. A
|
/// token delivered through the `OCS_PLUGIN_TOKEN` environment variable. A
|
||||||
/// mismatch causes the host to close the connection.
|
/// mismatch causes the host to close the connection.
|
||||||
|
///
|
||||||
|
/// `TokenV4` is appended as the last variant so the existing `Token(String)`
|
||||||
|
/// variant keeps its bincode discriminant index (0), preserving V2/V3 wire
|
||||||
|
/// compatibility.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum RunnerHandshake {
|
pub enum RunnerHandshake {
|
||||||
Token(String),
|
Token(String),
|
||||||
|
TokenV4 { token: String, protocol_version: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Environment variable through which the host passes the pre-shared
|
/// Environment variable through which the host passes the pre-shared
|
||||||
|
|
@ -55,17 +60,24 @@ pub enum HostRequest {
|
||||||
command_id: u64,
|
command_id: u64,
|
||||||
},
|
},
|
||||||
Shutdown,
|
Shutdown,
|
||||||
|
ExecuteCode {
|
||||||
|
command_id: u64,
|
||||||
|
source: CommandSource,
|
||||||
|
code: String,
|
||||||
|
tab_index: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Responses the plugin runner sends back for `HostRequest`.
|
/// Responses the plugin runner sends back for `HostRequest`.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum HostResponse {
|
pub enum HostResponse {
|
||||||
Bool(bool),
|
Bool(bool),
|
||||||
CommandStep(CommandStep),
|
CommandStep(Box<CommandStep>),
|
||||||
Text(String),
|
Text(String),
|
||||||
Ribbon(Vec<OwnedRibbonGroup>),
|
Ribbon(Vec<OwnedRibbonGroup>),
|
||||||
Manifest(OwnedPluginManifest),
|
Manifest(OwnedPluginManifest),
|
||||||
Error(String),
|
Error(String),
|
||||||
|
CodeExecutionResult(crate::host::ExecutionResult),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests the plugin runner sends to the host.
|
/// Requests the plugin runner sends to the host.
|
||||||
|
|
@ -105,6 +117,15 @@ pub enum PluginRequest {
|
||||||
/// Ask the host to create/refresh a shared-memory document view and return
|
/// Ask the host to create/refresh a shared-memory document view and return
|
||||||
/// the file path + current version.
|
/// the file path + current version.
|
||||||
OpenDocumentView,
|
OpenDocumentView,
|
||||||
|
/// Add multiple entities in a single request.
|
||||||
|
AddEntities(Vec<EntityType>),
|
||||||
|
/// V4: ask the host to create/refresh a tab-keyed shared-memory document
|
||||||
|
/// view and return the file path + current version.
|
||||||
|
OpenDocumentViewV4 { tab_id: u64 },
|
||||||
|
/// V4: ask the host to close the tab-keyed shared-memory document view.
|
||||||
|
CloseDocumentViewV4 { tab_id: u64 },
|
||||||
|
/// V4: ask the host for the stable tab identifier of the active tab.
|
||||||
|
GetTabId,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Responses the host sends back for `PluginRequest`.
|
/// Responses the host sends back for `PluginRequest`.
|
||||||
|
|
@ -114,26 +135,34 @@ pub enum PluginResponse {
|
||||||
Bool(bool),
|
Bool(bool),
|
||||||
Handle(Handle),
|
Handle(Handle),
|
||||||
Record(Option<ExtendedDataRecord>),
|
Record(Option<ExtendedDataRecord>),
|
||||||
Document(CadDocument),
|
Document(Box<CadDocument>),
|
||||||
Error(String),
|
Error(String),
|
||||||
/// Path to the memory-mapped file and the current snapshot version.
|
/// Path to the memory-mapped file and the current snapshot version.
|
||||||
DocumentView {
|
DocumentView {
|
||||||
path: String,
|
path: String,
|
||||||
version: u64,
|
version: u64,
|
||||||
},
|
},
|
||||||
|
Handles(Vec<Handle>),
|
||||||
|
/// V4: path to the tab-keyed memory-mapped file and current version.
|
||||||
|
DocumentViewV4 {
|
||||||
|
path: String,
|
||||||
|
version: u64,
|
||||||
|
},
|
||||||
|
/// V4: stable tab identifier of the active tab.
|
||||||
|
TabId(u64),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages sent from the host to the plugin runner.
|
/// Messages sent from the host to the plugin runner.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum HostToPlugin {
|
pub enum HostToPlugin {
|
||||||
Request(HostRequest),
|
Request(HostRequest),
|
||||||
Response(PluginResponse),
|
Response(Box<PluginResponse>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages sent from the plugin runner to the host.
|
/// Messages sent from the plugin runner to the host.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum PluginToHost {
|
pub enum PluginToHost {
|
||||||
Request(PluginRequest),
|
Request(Box<PluginRequest>),
|
||||||
Response(HostResponse),
|
Response(HostResponse),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
334
crates/ocs_plugin_api/src/ipc/proxy.rs
Normal file
334
crates/ocs_plugin_api/src/ipc/proxy.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
//! Request proxy framing for out-of-process plugins that need a worker
|
||||||
|
//! process (e.g. a Python child) to issue host requests.
|
||||||
|
//!
|
||||||
|
//! The proxy is intentionally simple and separate from the V2/V3 local-socket
|
||||||
|
//! framing. A plugin runner opens a listener, passes the port to its child, and
|
||||||
|
//! runs [`run_request_proxy`] in a background thread. The child creates a
|
||||||
|
//! [`ProxyPluginRequestSender`] over a stream and uses it as a
|
||||||
|
//! [`PluginRequestSender`].
|
||||||
|
//!
|
||||||
|
//! This module is additive to the V4 API and does not change any V2/V3 wire
|
||||||
|
//! formats or enum variants.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::host::{PluginRequestError, PluginRequestSender};
|
||||||
|
use crate::ipc::protocol::{PluginRequest, PluginResponse};
|
||||||
|
|
||||||
|
const MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024;
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
/// Length of the pre-framing auth token exchanged when a child connects to
|
||||||
|
/// the request proxy. Any local process can see the port, so the token (shared
|
||||||
|
/// only with the spawned child via an environment variable) prevents arbitrary
|
||||||
|
/// peers from forwarding privileged host requests.
|
||||||
|
pub const PROXY_TOKEN_LEN: usize = 32;
|
||||||
|
/// Interval used by [`ProxyPluginRequestSender::request_with_poll`] between
|
||||||
|
/// response-read attempts so callers can check for signals/interrupts.
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||||||
|
|
||||||
|
/// Errors that can occur on the proxy stream.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ProxyError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Encode(#[from] bincode::Error),
|
||||||
|
#[error("empty message")]
|
||||||
|
Empty,
|
||||||
|
#[error("message too large: {0} bytes")]
|
||||||
|
TooLarge(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProxyError> for PluginRequestError {
|
||||||
|
fn from(e: ProxyError) -> Self {
|
||||||
|
PluginRequestError(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a length-framed bincode message over a generic stream.
|
||||||
|
pub fn send_framed<W: Write>(writer: &mut W, msg: &impl serde::Serialize) -> Result<(), ProxyError> {
|
||||||
|
let bytes = bincode::serialize(msg)?;
|
||||||
|
if bytes.len() > MAX_MESSAGE_SIZE {
|
||||||
|
return Err(ProxyError::TooLarge(bytes.len()));
|
||||||
|
}
|
||||||
|
let len = bytes.len() as u64;
|
||||||
|
writer.write_all(&len.to_le_bytes())?;
|
||||||
|
writer.write_all(&bytes)?;
|
||||||
|
writer.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receive a length-framed bincode message from a generic stream.
|
||||||
|
pub fn recv_framed<R: Read, T: serde::de::DeserializeOwned>(reader: &mut R) -> Result<T, ProxyError> {
|
||||||
|
let mut len_buf = [0u8; 8];
|
||||||
|
reader.read_exact(&mut len_buf)?;
|
||||||
|
let len = u64::from_le_bytes(len_buf) as usize;
|
||||||
|
if len == 0 {
|
||||||
|
return Err(ProxyError::Empty);
|
||||||
|
}
|
||||||
|
if len > MAX_MESSAGE_SIZE {
|
||||||
|
return Err(ProxyError::TooLarge(len));
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
reader.read_exact(&mut buf)?;
|
||||||
|
let msg = bincode::deserialize(&buf)?;
|
||||||
|
Ok(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe [`PluginRequestSender`] implementation that forwards requests
|
||||||
|
/// over a single TCP stream to a request proxy server.
|
||||||
|
pub struct ProxyPluginRequestSender {
|
||||||
|
stream: Arc<Mutex<TcpStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxyPluginRequestSender {
|
||||||
|
/// Connect to a request proxy server at `host:port` and authenticate with
|
||||||
|
/// `token`. The token must match the value the proxy was started with.
|
||||||
|
pub fn connect_with_token(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
token: &[u8; PROXY_TOKEN_LEN],
|
||||||
|
) -> Result<Self, ProxyError> {
|
||||||
|
let mut stream = TcpStream::connect((host, port))?;
|
||||||
|
stream.write_all(token)?;
|
||||||
|
stream.flush()?;
|
||||||
|
stream.set_read_timeout(Some(REQUEST_TIMEOUT))?;
|
||||||
|
Ok(Self {
|
||||||
|
stream: Arc::new(Mutex::new(stream)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a second handle that shares the connection and its request lock.
|
||||||
|
pub fn try_clone(&self) -> Result<Self, ProxyError> {
|
||||||
|
Ok(Self {
|
||||||
|
stream: Arc::clone(&self.stream),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request and wait for the response, calling `poll` every
|
||||||
|
/// [`POLL_INTERVAL`] while waiting. This lets Python callers run
|
||||||
|
/// `py.check_signals()` so that `Ctrl+C`/`KeyboardInterrupt` is raised
|
||||||
|
/// promptly instead of waiting for the full socket timeout.
|
||||||
|
pub fn request_with_poll(
|
||||||
|
&self,
|
||||||
|
req: PluginRequest,
|
||||||
|
poll: &mut dyn FnMut() -> Result<(), PluginRequestError>,
|
||||||
|
) -> Result<PluginResponse, PluginRequestError> {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
let mut stream = self
|
||||||
|
.stream
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||||
|
send_framed(&mut *stream, &req)?;
|
||||||
|
let original_timeout = stream
|
||||||
|
.read_timeout()
|
||||||
|
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(POLL_INTERVAL))
|
||||||
|
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||||
|
let result = loop {
|
||||||
|
match recv_framed(&mut *stream) {
|
||||||
|
Ok(resp) => break Ok(resp),
|
||||||
|
Err(ProxyError::Io(e))
|
||||||
|
if e.kind() == ErrorKind::WouldBlock
|
||||||
|
|| e.kind() == ErrorKind::TimedOut =>
|
||||||
|
{
|
||||||
|
if let Err(e) = poll() {
|
||||||
|
let _ = stream.shutdown(Shutdown::Both);
|
||||||
|
break Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => break Err(e.into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = stream.set_read_timeout(original_timeout);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginRequestSender for ProxyPluginRequestSender {
|
||||||
|
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
|
||||||
|
let mut stream = self
|
||||||
|
.stream
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||||
|
send_framed(&mut *stream, &req)?;
|
||||||
|
let resp: PluginResponse = recv_framed(&mut *stream)?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the first [`PROXY_TOKEN_LEN`] bytes from `stream` and verify they
|
||||||
|
/// match `expected`. Returns `false` if the peer sent the wrong token or
|
||||||
|
/// disconnected.
|
||||||
|
fn verify_token(stream: &mut TcpStream, expected: &[u8; PROXY_TOKEN_LEN]) -> bool {
|
||||||
|
let mut buf = [0u8; PROXY_TOKEN_LEN];
|
||||||
|
match stream.read_exact(&mut buf) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[plugin] proxy failed to read auth token: {e}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buf != *expected {
|
||||||
|
eprintln!("[plugin] proxy auth token mismatch; closing connection");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a request proxy server on `listener`. For each incoming connection a
|
||||||
|
/// thread is spawned that reads [`PluginRequest`]s, forwards them through
|
||||||
|
/// `sender`, and writes back [`PluginResponse`]s. The function returns only
|
||||||
|
/// when the listener is closed or encounters an error.
|
||||||
|
///
|
||||||
|
/// `token` must be sent by the client before the length-framed protocol begins.
|
||||||
|
pub fn run_request_proxy(
|
||||||
|
listener: TcpListener,
|
||||||
|
sender: Arc<dyn PluginRequestSender>,
|
||||||
|
token: [u8; PROXY_TOKEN_LEN],
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let mut stream = match stream {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
if !verify_token(&mut stream, &token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sender = Arc::clone(&sender);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
let req: PluginRequest = match recv_framed(&mut stream) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let resp = match sender.request(req) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => PluginResponse::Error(e.to_string()),
|
||||||
|
};
|
||||||
|
if send_framed(&mut stream, &resp).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a request proxy server on `listener` with an explicit shutdown signal.
|
||||||
|
/// Same behavior as [`run_request_proxy`], but returns when `shutdown` receives
|
||||||
|
/// a value, closing the listener and terminating the server thread.
|
||||||
|
pub fn run_request_proxy_with_shutdown(
|
||||||
|
listener: TcpListener,
|
||||||
|
sender: Arc<dyn PluginRequestSender>,
|
||||||
|
token: [u8; PROXY_TOKEN_LEN],
|
||||||
|
shutdown: std::sync::mpsc::Receiver<()>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
listener.set_nonblocking(true)?;
|
||||||
|
let mut incoming = listener.incoming();
|
||||||
|
loop {
|
||||||
|
match incoming.next() {
|
||||||
|
Some(Ok(mut stream)) => {
|
||||||
|
// Accepted streams may inherit the listener's non-blocking mode;
|
||||||
|
// force blocking mode so recv_framed/read_exact work.
|
||||||
|
let _ = stream.set_nonblocking(false);
|
||||||
|
if !verify_token(&mut stream, &token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sender = Arc::clone(&sender);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut stream = stream;
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
loop {
|
||||||
|
let req: PluginRequest = match recv_framed(&mut stream) {
|
||||||
|
Ok(r) => {
|
||||||
|
// Log only the variant; large payloads such
|
||||||
|
// as AddEntities(10000 points) are expensive
|
||||||
|
// to Debug-format and are not human-readable.
|
||||||
|
eprintln!(
|
||||||
|
"[plugin] proxy recv request: {:?}",
|
||||||
|
std::mem::discriminant(&r)
|
||||||
|
);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[plugin] proxy recv error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let resp = match sender.request(req) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => PluginResponse::Error(e.to_string()),
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"[plugin] proxy forwarding response: {:?}",
|
||||||
|
std::mem::discriminant(&resp)
|
||||||
|
);
|
||||||
|
if let Err(e) = send_framed(&mut stream, &resp) {
|
||||||
|
eprintln!("[plugin] proxy send error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if let Err(payload) = result {
|
||||||
|
let msg = payload
|
||||||
|
.downcast_ref::<&str>()
|
||||||
|
.map(|s| (*s).to_string())
|
||||||
|
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||||
|
.unwrap_or_else(|| "proxy connection thread panicked".to_string());
|
||||||
|
eprintln!("[plugin] request proxy connection panic: {msg}");
|
||||||
|
}
|
||||||
|
eprintln!("[plugin] proxy connection handler exiting");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(Err(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
if shutdown.try_recv().is_ok() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
Some(Err(e)) => return Err(e),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
struct EchoSender;
|
||||||
|
impl PluginRequestSender for EchoSender {
|
||||||
|
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
|
||||||
|
match req {
|
||||||
|
PluginRequest::PushInfo(_) => Ok(PluginResponse::Ok),
|
||||||
|
_ => Ok(PluginResponse::Bool(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_round_trips_plugin_request() {
|
||||||
|
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let token = [42u8; PROXY_TOKEN_LEN];
|
||||||
|
thread::spawn(move || {
|
||||||
|
let sender: Arc<dyn PluginRequestSender> = Arc::new(EchoSender);
|
||||||
|
run_request_proxy(listener, sender, token).ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sender = ProxyPluginRequestSender::connect_with_token("127.0.0.1", port, &token).unwrap();
|
||||||
|
let resp = sender
|
||||||
|
.request(PluginRequest::PushInfo("hello proxy".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(resp, PluginResponse::Ok));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ pub fn handle_plugin_request(
|
||||||
PluginResponse::Ok
|
PluginResponse::Ok
|
||||||
}
|
}
|
||||||
AddEntity(entity) => PluginResponse::Handle(host.add_entity(entity)),
|
AddEntity(entity) => PluginResponse::Handle(host.add_entity(entity)),
|
||||||
|
AddEntities(entities) => PluginResponse::Handles(host.add_entities(entities)),
|
||||||
UpdateEntity(entity) => PluginResponse::Bool(host.update_entity(entity)),
|
UpdateEntity(entity) => PluginResponse::Bool(host.update_entity(entity)),
|
||||||
RemoveEntity { handle } => PluginResponse::Bool(host.remove_entity(handle)),
|
RemoveEntity { handle } => PluginResponse::Bool(host.remove_entity(handle)),
|
||||||
BumpGeometry => {
|
BumpGeometry => {
|
||||||
|
|
@ -53,7 +54,7 @@ pub fn handle_plugin_request(
|
||||||
on_start_interactive(command_id);
|
on_start_interactive(command_id);
|
||||||
PluginResponse::Ok
|
PluginResponse::Ok
|
||||||
}
|
}
|
||||||
DocumentSnapshot => PluginResponse::Document(host.document().clone()),
|
DocumentSnapshot => PluginResponse::Document(Box::new(host.document().clone())),
|
||||||
OpenDocumentView => match host.document_view() {
|
OpenDocumentView => match host.document_view() {
|
||||||
Some(info) => PluginResponse::DocumentView {
|
Some(info) => PluginResponse::DocumentView {
|
||||||
path: info.path,
|
path: info.path,
|
||||||
|
|
@ -61,5 +62,17 @@ pub fn handle_plugin_request(
|
||||||
},
|
},
|
||||||
None => PluginResponse::Error("shared document view unavailable".to_string()),
|
None => PluginResponse::Error("shared document view unavailable".to_string()),
|
||||||
},
|
},
|
||||||
|
OpenDocumentViewV4 { tab_id } => match host.document_view_v4(tab_id) {
|
||||||
|
Some(info) => PluginResponse::DocumentViewV4 {
|
||||||
|
path: info.path,
|
||||||
|
version: info.version,
|
||||||
|
},
|
||||||
|
None => PluginResponse::Error("V4 shared document view unavailable".to_string()),
|
||||||
|
},
|
||||||
|
CloseDocumentViewV4 { tab_id } => {
|
||||||
|
host.close_document_view_v4(tab_id);
|
||||||
|
PluginResponse::Ok
|
||||||
|
}
|
||||||
|
GetTabId => PluginResponse::TabId(host.tab_id()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ pub enum TransportError {
|
||||||
Empty,
|
Empty,
|
||||||
#[error("message too large: {0} bytes")]
|
#[error("message too large: {0} bytes")]
|
||||||
TooLarge(usize),
|
TooLarge(usize),
|
||||||
|
#[error("peer disconnected")]
|
||||||
|
Disconnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a length-framed serialized message.
|
/// Send a length-framed serialized message.
|
||||||
|
|
@ -38,7 +40,13 @@ pub fn send<T: Serialize>(stream: &mut Stream, msg: &T) -> Result<(), TransportE
|
||||||
/// Receive a length-framed serialized message.
|
/// Receive a length-framed serialized message.
|
||||||
pub fn recv<T: DeserializeOwned>(stream: &mut Stream) -> Result<T, TransportError> {
|
pub fn recv<T: DeserializeOwned>(stream: &mut Stream) -> Result<T, TransportError> {
|
||||||
let mut len_buf = [0u8; 8];
|
let mut len_buf = [0u8; 8];
|
||||||
stream.read_exact(&mut len_buf)?;
|
if let Err(e) = stream.read_exact(&mut len_buf) {
|
||||||
|
return if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||||
|
Err(TransportError::Disconnected)
|
||||||
|
} else {
|
||||||
|
Err(TransportError::Io(e))
|
||||||
|
};
|
||||||
|
}
|
||||||
let len = u64::from_le_bytes(len_buf) as usize;
|
let len = u64::from_le_bytes(len_buf) as usize;
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
return Err(TransportError::Empty);
|
return Err(TransportError::Empty);
|
||||||
|
|
@ -47,6 +55,12 @@ pub fn recv<T: DeserializeOwned>(stream: &mut Stream) -> Result<T, TransportErro
|
||||||
return Err(TransportError::TooLarge(len));
|
return Err(TransportError::TooLarge(len));
|
||||||
}
|
}
|
||||||
let mut buf = vec![0u8; len];
|
let mut buf = vec![0u8; len];
|
||||||
stream.read_exact(&mut buf)?;
|
if let Err(e) = stream.read_exact(&mut buf) {
|
||||||
|
return if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||||
|
Err(TransportError::Disconnected)
|
||||||
|
} else {
|
||||||
|
Err(TransportError::Io(e))
|
||||||
|
};
|
||||||
|
}
|
||||||
Ok(bincode::deserialize(&buf)?)
|
Ok(bincode::deserialize(&buf)?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1005
crates/ocs_plugin_api/src/ipc/v4/client.rs
Normal file
1005
crates/ocs_plugin_api/src/ipc/v4/client.rs
Normal file
File diff suppressed because it is too large
Load diff
10
crates/ocs_plugin_api/src/ipc/v4/mod.rs
Normal file
10
crates/ocs_plugin_api/src/ipc/v4/mod.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
//! V4 full-duplex notification protocol for out-of-process plugins.
|
||||||
|
//!
|
||||||
|
//! V4 keeps the existing V3 request/response vocabulary but multiplexes
|
||||||
|
//! requests, responses, and best-effort notifications over a single local
|
||||||
|
//! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left
|
||||||
|
//! untouched; all V4 logic lives in this module.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod server;
|
||||||
131
crates/ocs_plugin_api/src/ipc/v4/protocol.rs
Normal file
131
crates/ocs_plugin_api/src/ipc/v4/protocol.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! V4 wire frames and envelope types.
|
||||||
|
|
||||||
|
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 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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
crates/ocs_plugin_api/src/ipc/v4/server.rs
Normal file
176
crates/ocs_plugin_api/src/ipc/v4/server.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
//! Host-side V4 IPC reader and dispatcher helpers.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use interprocess::local_socket::Stream;
|
||||||
|
|
||||||
|
use crate::host::PluginNotification;
|
||||||
|
use crate::ipc::transport::{recv, TransportError};
|
||||||
|
use crate::ipc::v4::protocol::PluginToHostV4;
|
||||||
|
|
||||||
|
/// Default plugin→host notification rate limit (notifications per second).
|
||||||
|
const DEFAULT_NOTIFY_RATE_LIMIT: f64 = 1000.0;
|
||||||
|
|
||||||
|
/// Minimum interval between verbose drop logs to avoid spam.
|
||||||
|
const DROP_LOG_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
macro_rules! vlog {
|
||||||
|
($($arg:tt)*) => {{
|
||||||
|
if crate::process::verbose() {
|
||||||
|
eprintln!($($arg)*);
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frame delivered from the V4 reader thread to the host main thread.
|
||||||
|
pub enum HostIncoming {
|
||||||
|
Response { id: u64, payload: crate::ipc::protocol::HostResponse },
|
||||||
|
Request {
|
||||||
|
id: u64,
|
||||||
|
tab_id: Option<u64>,
|
||||||
|
payload: Box<crate::ipc::protocol::PluginRequest>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token-bucket rate limiter.
|
||||||
|
pub struct RateLimiter {
|
||||||
|
rate: f64,
|
||||||
|
tokens: f64,
|
||||||
|
last: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
pub fn new(rate_per_second: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
rate: rate_per_second.max(0.0),
|
||||||
|
tokens: rate_per_second,
|
||||||
|
last: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allow(&mut self) -> bool {
|
||||||
|
if self.rate <= 0.0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let elapsed = now.duration_since(self.last).as_secs_f64();
|
||||||
|
self.tokens = (self.tokens + elapsed * self.rate).min(self.rate);
|
||||||
|
self.last = now;
|
||||||
|
if self.tokens >= 1.0 {
|
||||||
|
self.tokens -= 1.0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_notify_rate_limit() -> f64 {
|
||||||
|
std::env::var("OCS_PLUGIN_NOTIFY_RATE_LIMIT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.map(|n: f64| n.max(0.0))
|
||||||
|
.unwrap_or(DEFAULT_NOTIFY_RATE_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State shared between the host main thread and the V4 reader thread.
|
||||||
|
pub struct V4HostShared {
|
||||||
|
pub writer: Mutex<Option<Stream>>,
|
||||||
|
pub alive: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the host-side V4 reader thread until the socket closes or a fatal error
|
||||||
|
/// occurs. Responses and requests are forwarded to `incoming`; notifications
|
||||||
|
/// are dispatched immediately to `handler` subject to `rate_limiter`.
|
||||||
|
pub fn run_host_reader_thread(
|
||||||
|
mut reader: Stream,
|
||||||
|
shared: Arc<V4HostShared>,
|
||||||
|
incoming: mpsc::Sender<HostIncoming>,
|
||||||
|
handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync>,
|
||||||
|
mut rate_limiter: RateLimiter,
|
||||||
|
) {
|
||||||
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
let mut last_drop_log: Option<Instant> = None;
|
||||||
|
loop {
|
||||||
|
match recv::<PluginToHostV4>(&mut reader) {
|
||||||
|
Ok(PluginToHostV4::Response { id, payload }) => {
|
||||||
|
if incoming
|
||||||
|
.send(HostIncoming::Response { id, payload })
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(PluginToHostV4::Notification(envelope)) => {
|
||||||
|
if rate_limiter.allow() {
|
||||||
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
(handler)(envelope.command_id, envelope.payload)
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
let now = Instant::now();
|
||||||
|
let should_log = last_drop_log
|
||||||
|
.map(|t| now.duration_since(t) >= DROP_LOG_INTERVAL)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if should_log && crate::process::verbose() {
|
||||||
|
eprintln!(
|
||||||
|
"[plugin] plugin→host notification rate limit exceeded; dropping"
|
||||||
|
);
|
||||||
|
last_drop_log = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(PluginToHostV4::Request {
|
||||||
|
id,
|
||||||
|
tab_id,
|
||||||
|
payload,
|
||||||
|
}) => {
|
||||||
|
if incoming
|
||||||
|
.send(HostIncoming::Request {
|
||||||
|
id,
|
||||||
|
tab_id,
|
||||||
|
payload: Box::new(payload),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(TransportError::Disconnected) => {
|
||||||
|
// Peer closed the connection; this is the normal shutdown
|
||||||
|
// path, so don't log it as an error.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
vlog!("[plugin] V4 host reader error: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
shared.alive.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "host"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limiter_allows_burst_then_throttles() {
|
||||||
|
let mut limiter = RateLimiter::new(10.0);
|
||||||
|
for _ in 0..10 {
|
||||||
|
assert!(limiter.allow());
|
||||||
|
}
|
||||||
|
// Immediately after the burst we should be out of tokens.
|
||||||
|
assert!(!limiter.allow());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limiter_refills_over_time() {
|
||||||
|
let mut limiter = RateLimiter::new(100.0);
|
||||||
|
assert!(limiter.allow());
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
assert!(limiter.allow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,16 +13,28 @@
|
||||||
//! ([`RibbonGroup`], [`ToolDef`], …) a plugin uses to describe its tab.
|
//! ([`RibbonGroup`], [`ToolDef`], …) a plugin uses to describe its tab.
|
||||||
//!
|
//!
|
||||||
//! The runtime host surface a plugin uses at *dispatch* time (document access,
|
//! The runtime host surface a plugin uses at *dispatch* time (document access,
|
||||||
//! command line, undo) is `acadrust`-typed and therefore lives in the host
|
//! command line, undo) is `acadrust`-typed and therefore lives in the `host`
|
||||||
//! binary for now; see `docs/plugin-architecture.md` (phase 1b).
|
//! feature; it re-exports `acadrust` so plugins do not need to depend on it
|
||||||
|
//! directly and risk an ABI mismatch from a different version.
|
||||||
|
//!
|
||||||
|
//! For binary compatibility, the host and every plugin must resolve the same
|
||||||
|
//! `acadrust` source. The host does this via a `[patch.crates-io]` entry in
|
||||||
|
//! `Cargo.toml`; out-of-tree plugins should copy that exact patch.
|
||||||
|
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod ribbon;
|
pub mod ribbon;
|
||||||
|
pub mod type_registry;
|
||||||
|
pub mod type_registry_types;
|
||||||
|
pub mod version_info;
|
||||||
|
|
||||||
/// Runtime host surface — only built with the `host` feature (pulls `acadrust`).
|
/// Runtime host surface — only built with the `host` feature (pulls `acadrust`).
|
||||||
#[cfg(feature = "host")]
|
#[cfg(feature = "host")]
|
||||||
pub mod host;
|
pub mod host;
|
||||||
|
|
||||||
|
/// Host-side V4 snapshot manager — only built with the `host` feature.
|
||||||
|
#[cfg(feature = "host")]
|
||||||
|
pub mod host_v4;
|
||||||
|
|
||||||
/// Out-of-process plugin runtime — only built with the `host` feature.
|
/// Out-of-process plugin runtime — only built with the `host` feature.
|
||||||
#[cfg(feature = "host")]
|
#[cfg(feature = "host")]
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
|
|
@ -42,9 +54,15 @@ pub mod shm;
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
|
|
||||||
pub use manifest::{
|
pub use manifest::{
|
||||||
host_accepts_plugin_version, ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED,
|
effective_max_api_version, host_accepts_plugin_version, ApiVersion, PluginManifest,
|
||||||
|
API_VERSION, API_VERSION_MIN_SUPPORTED, MAX_API_VERSION_ENV,
|
||||||
};
|
};
|
||||||
pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef};
|
pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef};
|
||||||
|
pub use type_registry::{
|
||||||
|
get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo,
|
||||||
|
TypeId, TypeInfo, TypeKind, TypeRegistry,
|
||||||
|
};
|
||||||
|
pub use version_info::get_embedded_version_info_json;
|
||||||
|
|
||||||
#[cfg(feature = "host")]
|
#[cfg(feature = "host")]
|
||||||
pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess};
|
pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess};
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,21 @@
|
||||||
/// compatibility. v2 added `HostApi::start_interactive`. v3 changes
|
/// compatibility. v2 added `HostApi::start_interactive`. v3 changes
|
||||||
/// `document()` / `document_mut()` to local cached copies for out-of-process
|
/// `document()` / `document_mut()` to local cached copies for out-of-process
|
||||||
/// plugins and appends `document_reader` / `document_view` at the end of the
|
/// plugins and appends `document_reader` / `document_view` at the end of the
|
||||||
/// vtable so API v2 plugins keep working.
|
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
|
||||||
pub const API_VERSION: u32 = 3;
|
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched.
|
||||||
|
pub const API_VERSION: u32 = 4;
|
||||||
|
|
||||||
/// Oldest plugin API major the current host still loads. This keeps previously
|
/// 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
|
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
|
||||||
/// current `HostApi` trait.
|
/// current `HostApi` trait.
|
||||||
pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
|
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.
|
||||||
|
pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct ApiVersion {
|
pub struct ApiVersion {
|
||||||
pub major: u32,
|
pub major: u32,
|
||||||
|
|
@ -28,17 +35,33 @@ impl ApiVersion {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The effective maximum API major this host accepts, honoring
|
||||||
|
/// [`MAX_API_VERSION_ENV`]. This lets operators disable a new API major at
|
||||||
|
/// runtime without rebuilding.
|
||||||
|
pub fn effective_max_api_version() -> u32 {
|
||||||
|
std::env::var(MAX_API_VERSION_ENV)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.map(|max: u32| max.clamp(API_VERSION_MIN_SUPPORTED, API_VERSION))
|
||||||
|
.unwrap_or(API_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
/// True when a plugin built against `plugin_major` can be loaded by this host.
|
/// True when a plugin built against `plugin_major` can be loaded by this host.
|
||||||
/// The host supports majors from `API_VERSION_MIN_SUPPORTED` up to
|
/// The host supports majors from `API_VERSION_MIN_SUPPORTED` up to
|
||||||
/// `API_VERSION`.
|
/// [`effective_max_api_version`], which can be lowered at runtime via
|
||||||
|
/// `OCS_PLUGIN_MAX_API_VERSION`.
|
||||||
pub fn host_accepts_plugin_version(plugin_major: u32) -> bool {
|
pub fn host_accepts_plugin_version(plugin_major: u32) -> bool {
|
||||||
plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= API_VERSION
|
plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= effective_max_api_version()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_matches_const() {
|
fn current_matches_const() {
|
||||||
assert_eq!(ApiVersion::CURRENT.major, API_VERSION);
|
assert_eq!(ApiVersion::CURRENT.major, API_VERSION);
|
||||||
|
|
@ -61,6 +84,55 @@ mod tests {
|
||||||
fn api_v2_plugin_runs_on_api_v3_host() {
|
fn api_v2_plugin_runs_on_api_v3_host() {
|
||||||
assert!(ApiVersion { major: 2 }.is_compatible_with(ApiVersion { major: 3 }));
|
assert!(ApiVersion { major: 2 }.is_compatible_with(ApiVersion { major: 3 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_max_defaults_to_api_version() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||||
|
assert_eq!(effective_max_api_version(), API_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_max_can_disable_v4() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::set_var(MAX_API_VERSION_ENV, "3");
|
||||||
|
assert_eq!(effective_max_api_version(), 3);
|
||||||
|
assert!(!host_accepts_plugin_version(4));
|
||||||
|
assert!(host_accepts_plugin_version(3));
|
||||||
|
assert!(host_accepts_plugin_version(2));
|
||||||
|
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_max_v2_only_mode() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::set_var(MAX_API_VERSION_ENV, "2");
|
||||||
|
assert_eq!(effective_max_api_version(), 2);
|
||||||
|
assert!(!host_accepts_plugin_version(4));
|
||||||
|
assert!(!host_accepts_plugin_version(3));
|
||||||
|
assert!(host_accepts_plugin_version(2));
|
||||||
|
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_max_clamps_to_supported_range() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::set_var(MAX_API_VERSION_ENV, "99");
|
||||||
|
assert_eq!(effective_max_api_version(), API_VERSION);
|
||||||
|
std::env::set_var(MAX_API_VERSION_ENV, "1");
|
||||||
|
assert_eq!(effective_max_api_version(), API_VERSION_MIN_SUPPORTED);
|
||||||
|
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_accepts_supported_range() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||||
|
assert!(host_accepts_plugin_version(API_VERSION_MIN_SUPPORTED));
|
||||||
|
assert!(host_accepts_plugin_version(API_VERSION));
|
||||||
|
assert!(!host_accepts_plugin_version(API_VERSION + 1));
|
||||||
|
assert!(!host_accepts_plugin_version(API_VERSION_MIN_SUPPORTED - 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static metadata every plugin supplies at registration time.
|
/// Static metadata every plugin supplies at registration time.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +1,20 @@
|
||||||
//! Process manager for out-of-process plugins.
|
//! Process manager for out-of-process plugins.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Child;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::host::HostApi;
|
use crate::host::{HostApi, HostNotification, PluginNotification};
|
||||||
use crate::process::{PluginError, PluginProcess};
|
use crate::process::{PluginError, PluginProcess};
|
||||||
use crate::ribbon::owned::{to_shared_module, SharedCadModule};
|
use crate::ribbon::owned::{to_shared_module, SharedCadModule};
|
||||||
|
|
||||||
|
/// Callback the host installs to receive plugin-to-host notifications.
|
||||||
|
pub type NotificationHandler = Arc<dyn Fn(&str, Option<u64>, PluginNotification) + Send + Sync>;
|
||||||
|
|
||||||
/// Owner of every spawned plugin process.
|
/// Owner of every spawned plugin process.
|
||||||
pub struct PluginManager {
|
pub struct PluginManager {
|
||||||
plugins: Vec<LoadedPlugin>,
|
plugins: Vec<LoadedPlugin>,
|
||||||
|
notification_handler: Option<NotificationHandler>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoadedPlugin {
|
struct LoadedPlugin {
|
||||||
|
|
@ -42,9 +46,18 @@ impl PluginManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
plugins: Vec::new(),
|
plugins: Vec::new(),
|
||||||
|
notification_handler: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install a handler for plugin-to-host notifications. The handler runs on
|
||||||
|
/// the V4 reader thread and must not block or allocate heavily; forward to
|
||||||
|
/// a channel if heavy work is required. Should be set before [`load`] is
|
||||||
|
/// called so notifications from newly loaded plugins are delivered.
|
||||||
|
pub fn set_notification_handler(&mut self, handler: NotificationHandler) {
|
||||||
|
self.notification_handler = Some(handler);
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn `cdylib_path` as a separate plugin process, build its ribbon
|
/// Spawn `cdylib_path` as a separate plugin process, build its ribbon
|
||||||
/// module, and store it. Returns the plugin id on success.
|
/// module, and store it. Returns the plugin id on success.
|
||||||
pub fn load(
|
pub fn load(
|
||||||
|
|
@ -52,7 +65,11 @@ impl PluginManager {
|
||||||
cdylib_path: &Path,
|
cdylib_path: &Path,
|
||||||
host: &mut dyn HostApi,
|
host: &mut dyn HostApi,
|
||||||
) -> Result<String, PluginError> {
|
) -> Result<String, PluginError> {
|
||||||
let process = PluginProcess::spawn(cdylib_path, host)?;
|
let handler: NotificationHandler = match self.notification_handler.as_ref() {
|
||||||
|
Some(h) => Arc::clone(h),
|
||||||
|
None => Arc::new(|_id, _cmd, _notif| {}),
|
||||||
|
};
|
||||||
|
let process = PluginProcess::spawn(cdylib_path, host, handler)?;
|
||||||
let id = process.id().to_string();
|
let id = process.id().to_string();
|
||||||
let name = process.manifest().name.clone();
|
let name = process.manifest().name.clone();
|
||||||
let module = to_shared_module(id.clone(), name, process.ribbon().to_vec());
|
let module = to_shared_module(id.clone(), name, process.ribbon().to_vec());
|
||||||
|
|
@ -63,6 +80,26 @@ impl PluginManager {
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fan out a host notification to every alive V4 plugin. Per-process errors
|
||||||
|
/// are logged; this is best-effort.
|
||||||
|
pub fn broadcast_notification(
|
||||||
|
&self,
|
||||||
|
command_id: Option<u64>,
|
||||||
|
notification: HostNotification,
|
||||||
|
) {
|
||||||
|
for p in &self.plugins {
|
||||||
|
if !p.process.is_alive() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = p.process.notify_plugin(command_id, notification.clone()) {
|
||||||
|
eprintln!(
|
||||||
|
"[plugin] broadcast_notification failed for {}: {e}",
|
||||||
|
p.process.id()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ribbon modules for alive, non-disabled plugins, sorted by `ribbon_order`.
|
/// Ribbon modules for alive, non-disabled plugins, sorted by `ribbon_order`.
|
||||||
pub fn ribbon_modules<F: Fn(&str) -> bool>(
|
pub fn ribbon_modules<F: Fn(&str) -> bool>(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -140,6 +177,53 @@ impl PluginManager {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drain any plugin→host requests that have arrived asynchronously across
|
||||||
|
/// all loaded plugins. Errors are logged; this is best-effort.
|
||||||
|
pub fn drain_requests(
|
||||||
|
&self,
|
||||||
|
host: &mut dyn HostApi,
|
||||||
|
on_start_interactive: &mut dyn FnMut(u64),
|
||||||
|
) {
|
||||||
|
for p in &self.plugins {
|
||||||
|
if !p.process.is_alive() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = p.process.drain_requests(host, on_start_interactive) {
|
||||||
|
eprintln!("[plugin] drain_requests failed for {}: {e}", p.process.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain any plugin stdout/stderr lines that have accumulated across all
|
||||||
|
/// loaded plugins. Errors are logged; this is best-effort.
|
||||||
|
pub fn drain_io(&self) -> Vec<crate::process::PluginIoLine> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for p in &self.plugins {
|
||||||
|
if !p.process.is_alive() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.extend(p.process.drain_io());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shut down and remove the plugin with `id` from the manager.
|
||||||
|
///
|
||||||
|
/// Returns `true` if a plugin with that id was found and removed. The
|
||||||
|
/// process is killed and waited on so its DLL is released; this is intended
|
||||||
|
/// for uninstall, where the caller needs the files to become deletable on
|
||||||
|
/// Windows.
|
||||||
|
pub fn remove(&mut self, id: &str) -> bool {
|
||||||
|
let Some(index) = self.plugins.iter().position(|p| p.process.id() == id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let plugin = self.plugins.remove(index);
|
||||||
|
plugin
|
||||||
|
.process
|
||||||
|
.shutdown_and_wait(Duration::from_secs(5))
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Begin asynchronous shutdown of every plugin process.
|
/// Begin asynchronous shutdown of every plugin process.
|
||||||
///
|
///
|
||||||
/// Kills every child synchronously on the calling thread and moves the
|
/// Kills every child synchronously on the calling thread and moves the
|
||||||
|
|
@ -147,21 +231,8 @@ impl PluginManager {
|
||||||
/// shutdown is fast regardless of how many plugins are loaded.
|
/// shutdown is fast regardless of how many plugins are loaded.
|
||||||
pub fn shutdown_all(&mut self) {
|
pub fn shutdown_all(&mut self) {
|
||||||
let plugins = std::mem::take(&mut self.plugins);
|
let plugins = std::mem::take(&mut self.plugins);
|
||||||
let mut children: Vec<Child> = Vec::with_capacity(plugins.len());
|
|
||||||
for p in plugins {
|
for p in plugins {
|
||||||
let (stream, child) = p.process.take_resources();
|
p.process.shutdown();
|
||||||
drop(stream);
|
|
||||||
if let Some(mut child) = child {
|
|
||||||
let _ = child.kill();
|
|
||||||
children.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !children.is_empty() {
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
for mut child in children {
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
832
crates/ocs_plugin_api/src/process/v4.rs
Normal file
832
crates/ocs_plugin_api/src/process/v4.rs
Normal file
|
|
@ -0,0 +1,832 @@
|
||||||
|
//! Private V4 connection owned by [`PluginProcess`](crate::process::PluginProcess).
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{mpsc, Arc, Mutex, TryLockError};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use interprocess::local_socket::Stream;
|
||||||
|
use interprocess::TryClone;
|
||||||
|
|
||||||
|
use crate::host::{CommandSource, ExecutionResult, HostApi, HostNotification, PluginNotification};
|
||||||
|
use crate::ipc::protocol::{HostRequest, HostResponse, PluginRequest};
|
||||||
|
use crate::ipc::server::handle_plugin_request;
|
||||||
|
use crate::ipc::transport::send;
|
||||||
|
use crate::ipc::v4::protocol::{HostToPluginV4, NotificationEnvelope};
|
||||||
|
use crate::ipc::v4::server::{
|
||||||
|
default_notify_rate_limit, run_host_reader_thread, HostIncoming, RateLimiter, V4HostShared,
|
||||||
|
};
|
||||||
|
use crate::process::PluginError;
|
||||||
|
|
||||||
|
/// Default maximum time to wait for a plugin call to respond.
|
||||||
|
const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
fn call_timeout() -> Duration {
|
||||||
|
std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.map(Duration::from_secs)
|
||||||
|
.unwrap_or(CALL_TIMEOUT_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-request-kind timeout floors.
|
||||||
|
fn request_timeout(kind: &'static str) -> Duration {
|
||||||
|
base_max_floor(call_timeout(), kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_code_timeout() -> Duration {
|
||||||
|
const DEFAULT: Duration = Duration::from_secs(60);
|
||||||
|
std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.map(Duration::from_secs)
|
||||||
|
.unwrap_or(DEFAULT)
|
||||||
|
.max(DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_max_floor(base: Duration, kind: &'static str) -> Duration {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(secs) = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
{
|
||||||
|
return base.max(Duration::from_secs(secs));
|
||||||
|
}
|
||||||
|
let floor = match kind {
|
||||||
|
"GetManifest" | "GetRibbon" => Duration::from_secs(5),
|
||||||
|
"Dispatch" => Duration::from_secs(10),
|
||||||
|
"InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2),
|
||||||
|
"ExecuteCode" => execute_code_timeout(),
|
||||||
|
_ => Duration::from_secs(1),
|
||||||
|
};
|
||||||
|
base.max(floor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_kind(req: &HostRequest) -> &'static str {
|
||||||
|
match req {
|
||||||
|
HostRequest::GetManifest => "GetManifest",
|
||||||
|
HostRequest::GetRibbon => "GetRibbon",
|
||||||
|
HostRequest::Dispatch { .. } => "Dispatch",
|
||||||
|
HostRequest::InteractiveEvent { .. } => "InteractiveEvent",
|
||||||
|
HostRequest::GetPrompt { .. } => "GetPrompt",
|
||||||
|
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
|
||||||
|
HostRequest::ExecuteCode { .. } => "ExecuteCode",
|
||||||
|
HostRequest::Shutdown => "Shutdown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct V4Connection {
|
||||||
|
shared: Arc<V4HostShared>,
|
||||||
|
incoming: Mutex<mpsc::Receiver<HostIncoming>>,
|
||||||
|
deferred: Mutex<VecDeque<(u64, Option<u64>, Box<PluginRequest>)>>,
|
||||||
|
call_lock: Mutex<()>,
|
||||||
|
next_id: AtomicU64,
|
||||||
|
reader_handle: Mutex<Option<std::thread::JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl V4Connection {
|
||||||
|
/// Wrap a freshly accepted V4 stream. The stream is cloned so the reader
|
||||||
|
/// thread owns one half and the writer half stays on the calling thread.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub fn new(
|
||||||
|
stream: Stream,
|
||||||
|
handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync>,
|
||||||
|
) -> Result<Self, PluginError> {
|
||||||
|
let reader = stream.try_clone().map_err(|e| {
|
||||||
|
PluginError::Io(std::io::Error::other(format!(
|
||||||
|
"V4 stream try_clone failed: {e}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let shared = Arc::new(V4HostShared {
|
||||||
|
writer: Mutex::new(Some(stream)),
|
||||||
|
alive: AtomicBool::new(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
let (incoming_tx, incoming_rx) = mpsc::channel();
|
||||||
|
let rate_limiter = RateLimiter::new(default_notify_rate_limit());
|
||||||
|
|
||||||
|
let shared_for_thread = Arc::clone(&shared);
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
run_host_reader_thread(
|
||||||
|
reader,
|
||||||
|
shared_for_thread,
|
||||||
|
incoming_tx,
|
||||||
|
handler,
|
||||||
|
rate_limiter,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
shared,
|
||||||
|
incoming: Mutex::new(incoming_rx),
|
||||||
|
deferred: Mutex::new(VecDeque::new()),
|
||||||
|
call_lock: Mutex::new(()),
|
||||||
|
next_id: AtomicU64::new(1),
|
||||||
|
reader_handle: Mutex::new(Some(handle)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_alive(&self) -> bool {
|
||||||
|
self.shared.alive.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a host-to-plugin notification.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub fn notify_plugin(
|
||||||
|
&self,
|
||||||
|
command_id: Option<u64>,
|
||||||
|
notification: HostNotification,
|
||||||
|
) -> Result<(), PluginError> {
|
||||||
|
let envelope = NotificationEnvelope {
|
||||||
|
command_id,
|
||||||
|
payload: notification,
|
||||||
|
};
|
||||||
|
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let stream = writer.as_mut().ok_or_else(|| {
|
||||||
|
PluginError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotConnected,
|
||||||
|
"V4 connection is shut down",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
send(stream, &HostToPluginV4::Notification(envelope))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a V4 request and block on the matching response, handling any
|
||||||
|
/// nested plugin requests inline using `host`.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub fn call(
|
||||||
|
&self,
|
||||||
|
host: &mut dyn HostApi,
|
||||||
|
req: HostRequest,
|
||||||
|
on_start_interactive: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<HostResponse, PluginError> {
|
||||||
|
let _call_guard = self.call_lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let kind = request_kind(&req);
|
||||||
|
let timeout = request_timeout(kind);
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let stream = writer.as_mut().ok_or_else(|| {
|
||||||
|
PluginError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotConnected,
|
||||||
|
"V4 connection is shut down",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
send(stream, &HostToPluginV4::Request { id, payload: req })?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let incoming = self.incoming.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
self.shared.alive.store(false, Ordering::SeqCst);
|
||||||
|
return Err(PluginError::CallTimeout {
|
||||||
|
request: kind,
|
||||||
|
duration: timeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match incoming.recv_timeout(remaining) {
|
||||||
|
Ok(HostIncoming::Response { id: rid, payload }) if rid == id => return Ok(payload),
|
||||||
|
Ok(HostIncoming::Response { payload, .. }) => {
|
||||||
|
return Err(PluginError::UnexpectedResponse(Box::new(payload)))
|
||||||
|
}
|
||||||
|
Ok(HostIncoming::Request {
|
||||||
|
id: rid,
|
||||||
|
tab_id,
|
||||||
|
payload,
|
||||||
|
}) => {
|
||||||
|
if tab_id.map_or(true, |request_tab| request_tab == host.tab_id()) {
|
||||||
|
self.respond_to_plugin_request(
|
||||||
|
host,
|
||||||
|
rid,
|
||||||
|
payload,
|
||||||
|
on_start_interactive,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
self.deferred
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.push_back((rid, tab_id, payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
self.shared.alive.store(false, Ordering::SeqCst);
|
||||||
|
return Err(PluginError::CallTimeout {
|
||||||
|
request: kind,
|
||||||
|
duration: timeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
self.shared.alive.store(false, Ordering::SeqCst);
|
||||||
|
return Err(PluginError::Runner(
|
||||||
|
"V4 reader thread disconnected".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain any plugin→host requests that have arrived since the last call and
|
||||||
|
/// apply them using `host`. This lets long-lived plugin sessions (e.g. a
|
||||||
|
/// Python REPL) mutate the host document while the host is not otherwise in
|
||||||
|
/// a plugin call.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub fn drain_requests(
|
||||||
|
&self,
|
||||||
|
host: &mut dyn HostApi,
|
||||||
|
on_start_interactive: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<(), PluginError> {
|
||||||
|
let _call_guard = match self.call_lock.try_lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(TryLockError::Poisoned(error)) => error.into_inner(),
|
||||||
|
Err(TryLockError::WouldBlock) => return Ok(()),
|
||||||
|
};
|
||||||
|
let current_tab_id = host.tab_id();
|
||||||
|
let mut ready = VecDeque::new();
|
||||||
|
{
|
||||||
|
let mut deferred = self.deferred.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut waiting = VecDeque::new();
|
||||||
|
while let Some((id, tab_id, payload)) = deferred.pop_front() {
|
||||||
|
if tab_id.map_or(true, |request_tab| request_tab == current_tab_id) {
|
||||||
|
ready.push_back((id, payload));
|
||||||
|
} else {
|
||||||
|
waiting.push_back((id, tab_id, payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*deferred = waiting;
|
||||||
|
}
|
||||||
|
while let Some((id, payload)) = ready.pop_front() {
|
||||||
|
self.respond_to_plugin_request(host, id, payload, on_start_interactive)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let incoming = self.incoming.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
loop {
|
||||||
|
match incoming.try_recv() {
|
||||||
|
Ok(HostIncoming::Request {
|
||||||
|
id,
|
||||||
|
tab_id,
|
||||||
|
payload,
|
||||||
|
}) => {
|
||||||
|
if tab_id.map_or(true, |request_tab| request_tab == current_tab_id) {
|
||||||
|
self.respond_to_plugin_request(
|
||||||
|
host,
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
on_start_interactive,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
self.deferred
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.push_back((id, tab_id, payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(HostIncoming::Response { .. }) => {
|
||||||
|
// Responses without a matching active call should not
|
||||||
|
// happen; drop them to avoid blocking the queue.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => return Ok(()),
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
self.shared.alive.store(false, Ordering::SeqCst);
|
||||||
|
return Err(PluginError::Runner(
|
||||||
|
"V4 reader thread disconnected".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond_to_plugin_request(
|
||||||
|
&self,
|
||||||
|
host: &mut dyn HostApi,
|
||||||
|
id: u64,
|
||||||
|
payload: Box<PluginRequest>,
|
||||||
|
on_start_interactive: &mut dyn FnMut(u64),
|
||||||
|
) -> Result<(), PluginError> {
|
||||||
|
let response = handle_plugin_request(host, *payload, on_start_interactive);
|
||||||
|
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let stream = writer.as_mut().ok_or_else(|| {
|
||||||
|
PluginError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotConnected,
|
||||||
|
"V4 connection is shut down",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
send(
|
||||||
|
stream,
|
||||||
|
&HostToPluginV4::Response {
|
||||||
|
id,
|
||||||
|
payload: response,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an `ExecuteCode` request and block until the plugin returns the
|
||||||
|
/// result. The active document tab index is included so the REPL session is
|
||||||
|
/// tied to the tab that issued it.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
pub fn execute_code(
|
||||||
|
&self,
|
||||||
|
host: &mut dyn HostApi,
|
||||||
|
command_id: u64,
|
||||||
|
source: CommandSource,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<ExecutionResult, PluginError> {
|
||||||
|
let req = HostRequest::ExecuteCode {
|
||||||
|
command_id,
|
||||||
|
source,
|
||||||
|
code: code.to_string(),
|
||||||
|
tab_index: host.tab_index(),
|
||||||
|
};
|
||||||
|
match self.call(host, req, &mut |_| {})? {
|
||||||
|
HostResponse::CodeExecutionResult(result) => Ok(result),
|
||||||
|
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shut down the V4 connection. The writer half is dropped, which causes
|
||||||
|
/// the reader thread to exit, then the thread handle is joined.
|
||||||
|
pub fn shutdown(&self) {
|
||||||
|
{
|
||||||
|
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Ask the runner to shut down gracefully so the plugin has a chance
|
||||||
|
// to clean up spawned child processes (e.g. the Python REPL). The
|
||||||
|
// runner will send an Ack, then observe EOF on the next loop
|
||||||
|
// iteration and exit.
|
||||||
|
if let Some(stream) = writer.as_mut() {
|
||||||
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let _ = send(
|
||||||
|
stream,
|
||||||
|
&HostToPluginV4::Request {
|
||||||
|
id,
|
||||||
|
payload: HostRequest::Shutdown,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = writer.take();
|
||||||
|
}
|
||||||
|
// Dropping the writer closes the socket. The reader thread will then
|
||||||
|
// exit and clear `alive`. Joining can deadlock if a plugin child
|
||||||
|
// inherited the socket handle, so we only poll briefly. The host process
|
||||||
|
// will kill the runner afterwards, so a long wait only delays shutdown
|
||||||
|
// without improving cleanup.
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(100);
|
||||||
|
while self.shared.alive.load(Ordering::SeqCst) && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
let _ = self.reader_handle.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "host"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use crate::host::{CommandSource, DocumentReader, ExecutionResult, ReaderEntity};
|
||||||
|
use crate::ipc::protocol::{HostResponse, PluginRequest, PluginResponse};
|
||||||
|
use crate::ipc::transport::recv;
|
||||||
|
use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4};
|
||||||
|
use acadrust::{CadDocument, Handle};
|
||||||
|
use interprocess::local_socket::{
|
||||||
|
traits::{Listener, Stream as StreamTrait},
|
||||||
|
GenericNamespaced, ListenerOptions, ToNsName,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn unique_socket_name() -> String {
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
format!("ocs_plugin_v4_proc_test_{}_{}", std::process::id(), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_pair() -> (Stream, Stream) {
|
||||||
|
let name = unique_socket_name();
|
||||||
|
let name_ref = name
|
||||||
|
.clone()
|
||||||
|
.to_ns_name::<GenericNamespaced>()
|
||||||
|
.expect("valid name");
|
||||||
|
let listener = ListenerOptions::new()
|
||||||
|
.name(name_ref)
|
||||||
|
.create_sync()
|
||||||
|
.expect("listener");
|
||||||
|
let client_name = name.clone();
|
||||||
|
let client_thread = thread::spawn(move || {
|
||||||
|
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
|
||||||
|
.expect("connect")
|
||||||
|
});
|
||||||
|
let server = listener.accept().expect("accept");
|
||||||
|
let client = client_thread.join().expect("client thread");
|
||||||
|
(server, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyReader;
|
||||||
|
impl DocumentReader for EmptyReader {
|
||||||
|
fn entity_count(&self) -> usize {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
fn for_each_entity(&self, _f: &mut dyn FnMut(ReaderEntity<'_>)) {}
|
||||||
|
fn layer_name(&self, _handle: Handle) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn app_id_name(&self, _handle: Handle) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DummyHost {
|
||||||
|
push_info_messages: StdMutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
impl DummyHost {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
push_info_messages: StdMutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn take_push_info(&self) -> Vec<String> {
|
||||||
|
std::mem::take(&mut *self.push_info_messages.lock().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl HostApi for DummyHost {
|
||||||
|
fn tab_index(&self) -> usize {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
fn document(&self) -> &CadDocument {
|
||||||
|
panic!("not used")
|
||||||
|
}
|
||||||
|
fn document_mut(&mut self) -> &mut CadDocument {
|
||||||
|
panic!("not used")
|
||||||
|
}
|
||||||
|
fn document_reader(&self) -> Box<dyn DocumentReader + '_> {
|
||||||
|
Box::new(EmptyReader)
|
||||||
|
}
|
||||||
|
fn add_entity(&mut self, _entity: acadrust::EntityType) -> Handle {
|
||||||
|
panic!("not used")
|
||||||
|
}
|
||||||
|
fn bump_geometry(&mut self) {}
|
||||||
|
fn read_record(
|
||||||
|
&self,
|
||||||
|
_handle: Handle,
|
||||||
|
_app_name: &str,
|
||||||
|
) -> Option<&acadrust::xdata::ExtendedDataRecord> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn write_record(
|
||||||
|
&mut self,
|
||||||
|
_handle: Handle,
|
||||||
|
_record: acadrust::xdata::ExtendedDataRecord,
|
||||||
|
) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn push_undo(&mut self, _label: &str) {}
|
||||||
|
fn set_dirty(&mut self) {}
|
||||||
|
fn push_info(&mut self, msg: &str) {
|
||||||
|
self.push_info_messages.lock().unwrap().push(msg.to_string());
|
||||||
|
}
|
||||||
|
fn push_output(&mut self, _msg: &str) {}
|
||||||
|
fn push_error(&mut self, _msg: &str) {}
|
||||||
|
fn start_interactive(&mut self, _command: Box<dyn crate::host::InteractiveCommand>) {}
|
||||||
|
fn plugin_state_any(
|
||||||
|
&self,
|
||||||
|
_plugin_id: &str,
|
||||||
|
) -> Option<&(dyn std::any::Any + Send + Sync)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn plugin_state_any_mut(
|
||||||
|
&mut self,
|
||||||
|
_plugin_id: &str,
|
||||||
|
) -> Option<&mut (dyn std::any::Any + Send + Sync)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn ensure_plugin_state_any(
|
||||||
|
&mut self,
|
||||||
|
_plugin_id: &'static str,
|
||||||
|
_init: &mut dyn FnMut() -> Box<dyn std::any::Any + Send + Sync>,
|
||||||
|
) -> &mut (dyn std::any::Any + Send + Sync) {
|
||||||
|
panic!("not used")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
|
||||||
|
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
|
||||||
|
|
||||||
|
fn set_test_env() {
|
||||||
|
std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2");
|
||||||
|
std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_test_env() {
|
||||||
|
std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS");
|
||||||
|
std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_code_timeout_floor_is_at_least_60s_and_env_override() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS");
|
||||||
|
assert!(
|
||||||
|
request_timeout("ExecuteCode") >= Duration::from_secs(60),
|
||||||
|
"ExecuteCode floor should be at least 60 s"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", "120");
|
||||||
|
assert_eq!(
|
||||||
|
request_timeout("ExecuteCode"),
|
||||||
|
Duration::from_secs(120),
|
||||||
|
"env override should be respected"
|
||||||
|
);
|
||||||
|
std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_connection_request_response() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
set_test_env();
|
||||||
|
let (host_stream, mut runner_stream) = connect_pair();
|
||||||
|
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||||
|
Arc::new(|_, _| {});
|
||||||
|
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||||
|
|
||||||
|
let runner = thread::spawn(move || {
|
||||||
|
// Read the host's V4 request and respond.
|
||||||
|
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||||
|
match req {
|
||||||
|
HostToPluginV4::Request { id, payload: HostRequest::Dispatch { cmd } } => {
|
||||||
|
assert_eq!(cmd, "HELLO");
|
||||||
|
send(
|
||||||
|
&mut runner_stream,
|
||||||
|
&PluginToHostV4::Response {
|
||||||
|
id,
|
||||||
|
payload: HostResponse::Bool(true),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut host = DummyHost::new();
|
||||||
|
let resp = conn
|
||||||
|
.call(&mut host, HostRequest::Dispatch { cmd: "HELLO".to_string() }, &mut |_| {})
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(resp, HostResponse::Bool(true)));
|
||||||
|
runner.join().unwrap();
|
||||||
|
restore_test_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_connection_handles_nested_plugin_request() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
set_test_env();
|
||||||
|
let (host_stream, mut runner_stream) = connect_pair();
|
||||||
|
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||||
|
Arc::new(|_, _| {});
|
||||||
|
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||||
|
|
||||||
|
let runner = thread::spawn(move || {
|
||||||
|
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||||
|
match req {
|
||||||
|
HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } => {
|
||||||
|
// Send a nested plugin request.
|
||||||
|
send(
|
||||||
|
&mut runner_stream,
|
||||||
|
&PluginToHostV4::Request {
|
||||||
|
id: 99,
|
||||||
|
tab_id: None,
|
||||||
|
payload: PluginRequest::PushInfo("nested".to_string()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Read the response to the nested request.
|
||||||
|
let resp = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||||
|
match resp {
|
||||||
|
HostToPluginV4::Response { id: rid, payload: PluginResponse::Ok }
|
||||||
|
if rid == 99 => {}
|
||||||
|
other => panic!("unexpected nested response: {other:?}"),
|
||||||
|
}
|
||||||
|
// Now respond to the original dispatch.
|
||||||
|
send(
|
||||||
|
&mut runner_stream,
|
||||||
|
&PluginToHostV4::Response {
|
||||||
|
id,
|
||||||
|
payload: HostResponse::Bool(true),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut host = DummyHost::new();
|
||||||
|
let resp = conn
|
||||||
|
.call(&mut host, HostRequest::Dispatch { cmd: "NESTED".to_string() }, &mut |_| {})
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(resp, HostResponse::Bool(true)));
|
||||||
|
let infos = host.take_push_info();
|
||||||
|
assert_eq!(infos, vec!["nested".to_string()], "push_info should be delivered to host");
|
||||||
|
runner.join().unwrap();
|
||||||
|
restore_test_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_connection_notification_invokes_handler() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
set_test_env();
|
||||||
|
use crate::host::LogLevel;
|
||||||
|
|
||||||
|
let (host_stream, mut runner_stream) = connect_pair();
|
||||||
|
let received = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let received2 = Arc::clone(&received);
|
||||||
|
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||||
|
Arc::new(move |cmd_id, notif| {
|
||||||
|
received2.lock().unwrap().push((cmd_id, notif));
|
||||||
|
});
|
||||||
|
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||||
|
|
||||||
|
let runner = thread::spawn(move || {
|
||||||
|
send(
|
||||||
|
&mut runner_stream,
|
||||||
|
&PluginToHostV4::Notification(NotificationEnvelope {
|
||||||
|
command_id: Some(42),
|
||||||
|
payload: PluginNotification::Log {
|
||||||
|
level: LogLevel::Info,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a bit for the notification to be delivered.
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
let got = received.lock().unwrap().clone();
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].0, Some(42));
|
||||||
|
runner.join().unwrap();
|
||||||
|
drop(conn);
|
||||||
|
restore_test_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_connection_handler_panic_is_isolated() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
set_test_env();
|
||||||
|
use crate::host::LogLevel;
|
||||||
|
|
||||||
|
let (host_stream, mut runner_stream) = connect_pair();
|
||||||
|
let received = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let received2 = Arc::clone(&received);
|
||||||
|
let panic_next = AtomicBool::new(true);
|
||||||
|
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||||
|
Arc::new(move |cmd_id, notif| {
|
||||||
|
if panic_next.swap(false, Ordering::SeqCst) {
|
||||||
|
panic!("deliberate handler panic");
|
||||||
|
}
|
||||||
|
received2.lock().unwrap().push((cmd_id, notif));
|
||||||
|
});
|
||||||
|
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||||
|
|
||||||
|
let runner = thread::spawn(move || {
|
||||||
|
for i in 0..2 {
|
||||||
|
send(
|
||||||
|
&mut runner_stream,
|
||||||
|
&PluginToHostV4::Notification(NotificationEnvelope {
|
||||||
|
command_id: Some(i),
|
||||||
|
payload: PluginNotification::Log {
|
||||||
|
level: LogLevel::Info,
|
||||||
|
text: "hi".to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
let got = received.lock().unwrap().clone();
|
||||||
|
assert_eq!(got.len(), 1, "second notification should survive handler panic");
|
||||||
|
assert_eq!(got[0].0, Some(1));
|
||||||
|
runner.join().unwrap();
|
||||||
|
drop(conn);
|
||||||
|
restore_test_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_connection_notification_rate_limiter_drops_excess() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
set_test_env();
|
||||||
|
std::env::set_var("OCS_PLUGIN_NOTIFY_RATE_LIMIT", "2");
|
||||||
|
use crate::host::LogLevel;
|
||||||
|
|
||||||
|
let (host_stream, mut runner_stream) = connect_pair();
|
||||||
|
let received = Arc::new(Mutex::new(0u32));
|
||||||
|
let received2 = Arc::clone(&received);
|
||||||
|
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||||
|
Arc::new(move |_, _| {
|
||||||
|
*received2.lock().unwrap() += 1;
|
||||||
|
});
|
||||||
|
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||||
|
|
||||||
|
let runner = thread::spawn(move || {
|
||||||
|
for _ in 0..10 {
|
||||||
|
send(
|
||||||
|
&mut runner_stream,
|
||||||
|
&PluginToHostV4::Notification(NotificationEnvelope {
|
||||||
|
command_id: None,
|
||||||
|
payload: PluginNotification::Log {
|
||||||
|
level: LogLevel::Info,
|
||||||
|
text: "flood".to_string(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// Keep the stream open long enough for the host to verify the
|
||||||
|
// reader thread is still alive after the flood.
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
});
|
||||||
|
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
let count = *received.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
count <= 3,
|
||||||
|
"rate limiter should drop most notifications, got {count}"
|
||||||
|
);
|
||||||
|
assert!(conn.is_alive(), "connection should survive rate-limited flood");
|
||||||
|
runner.join().unwrap();
|
||||||
|
drop(conn);
|
||||||
|
std::env::remove_var("OCS_PLUGIN_NOTIFY_RATE_LIMIT");
|
||||||
|
restore_test_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_code_returns_delayed_result_with_tab_index() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
set_test_env();
|
||||||
|
|
||||||
|
let (host_stream, mut runner_stream) = connect_pair();
|
||||||
|
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||||
|
Arc::new(|_, _| {});
|
||||||
|
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||||
|
|
||||||
|
let runner = thread::spawn(move || {
|
||||||
|
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||||
|
match req {
|
||||||
|
HostToPluginV4::Request {
|
||||||
|
id,
|
||||||
|
payload:
|
||||||
|
HostRequest::ExecuteCode {
|
||||||
|
command_id,
|
||||||
|
source,
|
||||||
|
code,
|
||||||
|
tab_index,
|
||||||
|
},
|
||||||
|
} => {
|
||||||
|
assert_eq!(command_id, 1);
|
||||||
|
assert_eq!(source, CommandSource::Editor);
|
||||||
|
assert_eq!(code, "1+1");
|
||||||
|
assert_eq!(tab_index, 0, "ExecuteCode should be tied to the host's tab index");
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
send(
|
||||||
|
&mut runner_stream,
|
||||||
|
&PluginToHostV4::Response {
|
||||||
|
id,
|
||||||
|
payload: HostResponse::CodeExecutionResult(ExecutionResult {
|
||||||
|
success: true,
|
||||||
|
output: Some("42".to_string()),
|
||||||
|
error: None,
|
||||||
|
error_type: None,
|
||||||
|
traceback: None,
|
||||||
|
line_number: None,
|
||||||
|
column_number: None,
|
||||||
|
duration_ms: 100.0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
other => panic!("unexpected: {other:?}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut host = DummyHost::new();
|
||||||
|
let result = conn
|
||||||
|
.execute_code(&mut host, 1, CommandSource::Editor, "1+1")
|
||||||
|
.expect("execute_code should succeed");
|
||||||
|
assert!(result.success);
|
||||||
|
assert_eq!(result.output, Some("42".to_string()));
|
||||||
|
runner.join().unwrap();
|
||||||
|
restore_test_env();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ use crate::ipc::protocol::{
|
||||||
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV,
|
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV,
|
||||||
};
|
};
|
||||||
use crate::ipc::transport::{recv, send};
|
use crate::ipc::transport::{recv, send};
|
||||||
|
use crate::ipc::v4::client::{RunnerFrame, V4Client};
|
||||||
use crate::ribbon::owned::OwnedRibbonGroup;
|
use crate::ribbon::owned::OwnedRibbonGroup;
|
||||||
|
|
||||||
/// Entry point for the plugin runner child process.
|
/// Entry point for the plugin runner child process.
|
||||||
|
|
@ -26,7 +27,11 @@ use crate::ribbon::owned::OwnedRibbonGroup;
|
||||||
/// fatal error so the child does not fall through to the host's GUI main.
|
/// fatal error so the child does not fall through to the host's GUI main.
|
||||||
pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
eprintln!("[runner] starting for {cdylib_path:?} on {socket_name}");
|
eprintln!("[runner] starting for {cdylib_path:?} on {socket_name}");
|
||||||
let plugin = unsafe { load_plugin(cdylib_path)? };
|
// Let the plugin locate its own cdylib so it can copy it into generated
|
||||||
|
// workspaces (e.g., as a Python extension). This is set only in the child
|
||||||
|
// runner process, so it does not leak into the host environment.
|
||||||
|
std::env::set_var("OCS_PLUGIN_CDYLIB_PATH", cdylib_path.as_os_str());
|
||||||
|
let (version, mut plugin) = unsafe { load_plugin(cdylib_path)? };
|
||||||
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
|
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
|
||||||
let token = match std::env::var(PLUGIN_TOKEN_ENV) {
|
let token = match std::env::var(PLUGIN_TOKEN_ENV) {
|
||||||
|
|
@ -37,29 +42,66 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::err
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = IpcClient::connect(socket_name)?;
|
if version >= 4 {
|
||||||
eprintln!("[runner] connected to host");
|
let client = V4Client::connect_handshake(socket_name, &token)?;
|
||||||
client.send_handshake(&token)?;
|
run_v4(&mut *plugin, &interactive, client)
|
||||||
|
} else {
|
||||||
|
let client = IpcClient::connect(socket_name)?;
|
||||||
|
eprintln!("[runner] connected to host");
|
||||||
|
client.send_handshake(&token)?;
|
||||||
|
run_v3(&*plugin, &interactive, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_v3(
|
||||||
|
plugin: &dyn BuiltinPlugin,
|
||||||
|
interactive: &InteractiveRegistry,
|
||||||
|
client: IpcClient,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
loop {
|
loop {
|
||||||
let msg: HostToPlugin = recv(&mut client.stream_ref())?;
|
let msg: HostToPlugin = recv(&mut client.stream_ref())?;
|
||||||
eprintln!("[runner] host -> runner: {msg:?}");
|
eprintln!("[runner] host -> runner: {msg:?}");
|
||||||
match msg {
|
match msg {
|
||||||
HostToPlugin::Request(req) => {
|
HostToPlugin::Request(req) => {
|
||||||
let resp = handle_host_request(&*plugin, &interactive, &client, req);
|
let resp = handle_host_request(plugin, interactive, &client, req);
|
||||||
eprintln!("[runner] runner -> host: {resp:?}");
|
eprintln!("[runner] runner -> host: {resp:?}");
|
||||||
send(&mut client.stream_ref(), &PluginToHost::Response(resp))?;
|
send(&mut client.stream_ref(), &PluginToHost::Response(resp))?;
|
||||||
}
|
}
|
||||||
HostToPlugin::Response(_) => {
|
HostToPlugin::Response(_) => {
|
||||||
// Responses are consumed by PluginHostApi::request synchronously.
|
|
||||||
// Reaching here means the host sent a response without a pending
|
|
||||||
// plugin request.
|
|
||||||
eprintln!("[runner] unexpected HostToPlugin::Response");
|
eprintln!("[runner] unexpected HostToPlugin::Response");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_v4(
|
||||||
|
plugin: &mut dyn BuiltinPlugin,
|
||||||
|
interactive: &InteractiveRegistry,
|
||||||
|
client: V4Client,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
loop {
|
||||||
|
// Drain any host notifications and dispatch them to the plugin's
|
||||||
|
// state-only callback. Panics are caught so a bad callback does not
|
||||||
|
// tear down the connection.
|
||||||
|
while let Some((command_id, notification)) = client.try_recv_notification() {
|
||||||
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
plugin.on_notification(command_id, notification)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
match client.recv_runner_frame_timeout(std::time::Duration::from_millis(50)) {
|
||||||
|
Ok(RunnerFrame::Request { id, payload }) => {
|
||||||
|
if let Some(resp) = handle_host_request_v4(&mut *plugin, interactive, &client, id, payload) {
|
||||||
|
client.send_response(id, resp)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_host_request(
|
fn handle_host_request(
|
||||||
plugin: &dyn BuiltinPlugin,
|
plugin: &dyn BuiltinPlugin,
|
||||||
interactive: &InteractiveRegistry,
|
interactive: &InteractiveRegistry,
|
||||||
|
|
@ -115,7 +157,7 @@ fn handle_host_request(
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
match step {
|
match step {
|
||||||
Ok(s) => HostResponse::CommandStep(s),
|
Ok(s) => HostResponse::CommandStep(Box::new(s)),
|
||||||
Err(_) => HostResponse::Error("interactive command panicked".to_string()),
|
Err(_) => HostResponse::Error("interactive command panicked".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,14 +189,143 @@ fn handle_host_request(
|
||||||
None => HostResponse::Error(format!("unknown interactive command {command_id}")),
|
None => HostResponse::Error(format!("unknown interactive command {command_id}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
HostRequest::ExecuteCode { .. } => {
|
||||||
|
HostResponse::Error("ExecuteCode requires V4 protocol".to_string())
|
||||||
|
}
|
||||||
HostRequest::Shutdown => {
|
HostRequest::Shutdown => {
|
||||||
// The runner will exit after this response is sent.
|
// Return a positive Ack so the host knows we received the request.
|
||||||
std::process::exit(0);
|
// run_v3 will then observe the closed stream, break its loop, and
|
||||||
|
// return, allowing the plugin object to be dropped and clean up
|
||||||
|
// spawned child processes before the runner process exits.
|
||||||
|
HostResponse::Bool(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std::error::Error>> {
|
fn handle_host_request_v4(
|
||||||
|
plugin: &mut dyn BuiltinPlugin,
|
||||||
|
interactive: &InteractiveRegistry,
|
||||||
|
client: &V4Client,
|
||||||
|
id: u64,
|
||||||
|
req: HostRequest,
|
||||||
|
) -> Option<HostResponse> {
|
||||||
|
match req {
|
||||||
|
HostRequest::GetManifest => {
|
||||||
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) {
|
||||||
|
Ok(m) => Some(HostResponse::Manifest(m.into())),
|
||||||
|
Err(_) => Some(HostResponse::Error("plugin manifest() panicked".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostRequest::GetRibbon => {
|
||||||
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.ribbon())) {
|
||||||
|
Ok(groups) => Some(HostResponse::Ribbon(
|
||||||
|
groups
|
||||||
|
.ribbon_groups()
|
||||||
|
.iter()
|
||||||
|
.map(OwnedRibbonGroup::from)
|
||||||
|
.collect(),
|
||||||
|
)),
|
||||||
|
Err(_) => Some(HostResponse::Error("plugin ribbon() panicked".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostRequest::Dispatch { cmd } => {
|
||||||
|
let mut proxy = client.plugin_host_api(0, interactive.clone());
|
||||||
|
let handled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
plugin.dispatch(&mut proxy, &cmd)
|
||||||
|
}));
|
||||||
|
match handled {
|
||||||
|
Ok(b) => Some(HostResponse::Bool(b)),
|
||||||
|
Err(_) => Some(HostResponse::Error("plugin dispatch panicked".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostRequest::InteractiveEvent { command_id, event } => {
|
||||||
|
let step = {
|
||||||
|
let mut registry = interactive.borrow_mut();
|
||||||
|
let Some(cmd) = registry.get_mut(&command_id) else {
|
||||||
|
return Some(HostResponse::Error(format!(
|
||||||
|
"unknown interactive command {command_id}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut();
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event {
|
||||||
|
InteractiveEvent::Point(pt) => cmd_ref.on_point(pt),
|
||||||
|
InteractiveEvent::Enter => cmd_ref.on_enter(),
|
||||||
|
InteractiveEvent::ObjectPick { handle, pt } => {
|
||||||
|
cmd_ref.on_object_pick(handle, pt)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
match step {
|
||||||
|
Ok(s) => Some(HostResponse::CommandStep(Box::new(s))),
|
||||||
|
Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostRequest::GetPrompt { command_id } => {
|
||||||
|
let result = {
|
||||||
|
let registry = interactive.borrow();
|
||||||
|
registry.get(&command_id).map(|cmd| {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd.prompt()))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Some(Ok(s)) => Some(HostResponse::Text(s)),
|
||||||
|
Some(Err(_)) => Some(HostResponse::Error("prompt() panicked".to_string())),
|
||||||
|
None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostRequest::NeedsEntityPick { command_id } => {
|
||||||
|
let result = {
|
||||||
|
let registry = interactive.borrow();
|
||||||
|
registry.get(&command_id).map(|cmd| {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
cmd.needs_object_pick()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Some(Ok(b)) => Some(HostResponse::Bool(b)),
|
||||||
|
Some(Err(_)) => Some(HostResponse::Error("needs_object_pick() panicked".to_string())),
|
||||||
|
None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostRequest::ExecuteCode {
|
||||||
|
command_id,
|
||||||
|
source,
|
||||||
|
code,
|
||||||
|
tab_index,
|
||||||
|
} => {
|
||||||
|
let mut proxy = client.plugin_host_api(tab_index, interactive.clone());
|
||||||
|
let respond = client.execute_code_responder(id);
|
||||||
|
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
plugin.start_execute_code(
|
||||||
|
&mut proxy,
|
||||||
|
command_id,
|
||||||
|
&code,
|
||||||
|
source,
|
||||||
|
respond,
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
match started {
|
||||||
|
Ok(true) => None,
|
||||||
|
Ok(false) => Some(HostResponse::Error(
|
||||||
|
"plugin does not support code execution".to_string(),
|
||||||
|
)),
|
||||||
|
Err(_) => Some(HostResponse::Error(
|
||||||
|
"plugin start_execute_code panicked".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HostRequest::Shutdown => {
|
||||||
|
// Return a positive Ack so the host knows we received the request.
|
||||||
|
// run_v4 will then observe the closed V4 writer, break its loop,
|
||||||
|
// and return, allowing the plugin object to be dropped and clean up
|
||||||
|
// spawned child processes before the runner process exits.
|
||||||
|
Some(HostResponse::Bool(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn load_plugin(path: &Path) -> Result<(u32, Box<dyn BuiltinPlugin>), Box<dyn std::error::Error>> {
|
||||||
let lib = libloading::Library::new(path)?;
|
let lib = libloading::Library::new(path)?;
|
||||||
|
|
||||||
let version: libloading::Symbol<extern "C" fn() -> u32> = lib
|
let version: libloading::Symbol<extern "C" fn() -> u32> = lib
|
||||||
|
|
@ -165,7 +336,7 @@ unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"API version {v} is incompatible (host supports {}-{})",
|
"API version {v} is incompatible (host supports {}-{})",
|
||||||
crate::API_VERSION_MIN_SUPPORTED,
|
crate::API_VERSION_MIN_SUPPORTED,
|
||||||
crate::API_VERSION
|
crate::effective_max_api_version()
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
@ -183,5 +354,111 @@ unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std
|
||||||
// lifetime of the process. The runner exits when the host disconnects.
|
// lifetime of the process. The runner exits when the host disconnects.
|
||||||
let _ = std::mem::ManuallyDrop::new(lib);
|
let _ = std::mem::ManuallyDrop::new(lib);
|
||||||
|
|
||||||
Ok(plugin)
|
Ok((v, plugin))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "host"))]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use interprocess::local_socket::{
|
||||||
|
traits::{Listener, Stream as StreamTrait},
|
||||||
|
GenericNamespaced, ListenerOptions, Stream, ToNsName,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::host::{BuiltinPlugin, CommandSource, HostApi};
|
||||||
|
use crate::ipc::protocol::HostRequest;
|
||||||
|
use crate::ipc::v4::client::V4Client;
|
||||||
|
use crate::manifest::{ApiVersion, PluginManifest};
|
||||||
|
use crate::ribbon::{CadModule, RibbonGroup};
|
||||||
|
|
||||||
|
static TEST_MANIFEST: PluginManifest = PluginManifest {
|
||||||
|
id: "test.repl",
|
||||||
|
name: "Test REPL",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "test",
|
||||||
|
api_version: ApiVersion { major: 4 },
|
||||||
|
ribbon_order: 0,
|
||||||
|
xdata_apps: &[],
|
||||||
|
command_prefixes: &[],
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EmptyModule;
|
||||||
|
impl CadModule for EmptyModule {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"test.repl"
|
||||||
|
}
|
||||||
|
fn title(&self) -> &'static str {
|
||||||
|
"Test"
|
||||||
|
}
|
||||||
|
fn ribbon_groups(&self) -> &[RibbonGroup] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnsupportedReplPlugin;
|
||||||
|
impl BuiltinPlugin for UnsupportedReplPlugin {
|
||||||
|
fn manifest(&self) -> &'static PluginManifest {
|
||||||
|
&TEST_MANIFEST
|
||||||
|
}
|
||||||
|
fn ribbon(&self) -> Box<dyn CadModule> {
|
||||||
|
Box::new(EmptyModule)
|
||||||
|
}
|
||||||
|
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_socket_name() -> String {
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
format!("ocs_plugin_runner_test_{}_{}", std::process::id(), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect_pair() -> (Stream, Stream) {
|
||||||
|
let name = unique_socket_name();
|
||||||
|
let name_ref = name
|
||||||
|
.clone()
|
||||||
|
.to_ns_name::<GenericNamespaced>()
|
||||||
|
.expect("valid name");
|
||||||
|
let listener = ListenerOptions::new()
|
||||||
|
.name(name_ref)
|
||||||
|
.create_sync()
|
||||||
|
.expect("listener");
|
||||||
|
let client_name = name.clone();
|
||||||
|
let client_thread = thread::spawn(move || {
|
||||||
|
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
|
||||||
|
.expect("connect")
|
||||||
|
});
|
||||||
|
let server = listener.accept().expect("accept");
|
||||||
|
let client = client_thread.join().expect("client thread");
|
||||||
|
(server, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_runner_execute_code_unsupported_returns_error() {
|
||||||
|
let (host_stream, runner_stream) = connect_pair();
|
||||||
|
let client = V4Client::from_stream_for_test(runner_stream);
|
||||||
|
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
|
||||||
|
let mut plugin = UnsupportedReplPlugin;
|
||||||
|
let req = HostRequest::ExecuteCode {
|
||||||
|
command_id: 1,
|
||||||
|
source: CommandSource::Editor,
|
||||||
|
code: "1+1".to_string(),
|
||||||
|
tab_index: 0,
|
||||||
|
};
|
||||||
|
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, req);
|
||||||
|
match resp {
|
||||||
|
Some(HostResponse::Error(msg)) => {
|
||||||
|
assert!(msg.contains("does not support code execution"));
|
||||||
|
}
|
||||||
|
other => panic!("expected unsupported error, got {other:?}"),
|
||||||
|
}
|
||||||
|
drop(host_stream);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
//! rkyv-serialized view of the active document. The plugin maps the same file
|
//! rkyv-serialized view of the active document. The plugin maps the same file
|
||||||
//! read-only and reads entity/layer data directly from the mapping without
|
//! read-only and reads entity/layer data directly from the mapping without
|
||||||
//! copying the full `CadDocument` into its own address space.
|
//! copying the full `CadDocument` into its own address space.
|
||||||
|
//!
|
||||||
|
//! This module is generic over the payload type so that the V3 simplified view
|
||||||
|
//! and the V4 bincode-per-entity view can share the same double-buffered
|
||||||
|
//! control-page logic.
|
||||||
|
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
@ -33,29 +37,51 @@ pub struct DocumentViewInfo {
|
||||||
pub version: u64,
|
pub version: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker trait for types that can be placed in a shared-memory snapshot.
|
||||||
|
///
|
||||||
|
/// Implementers provide rkyv serialization/deserialization without exposing
|
||||||
|
/// rkyv's generic serializer types in the public API.
|
||||||
|
pub trait SnapshotData: Clone + std::fmt::Debug + Send + Sync + 'static {
|
||||||
|
/// The archived (zero-copy) type returned by [`Self::check_bytes`].
|
||||||
|
type Archived: 'static;
|
||||||
|
|
||||||
|
/// Serialize `self` into rkyv bytes.
|
||||||
|
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>>;
|
||||||
|
|
||||||
|
/// Validate `bytes` and return a reference to the archived type.
|
||||||
|
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Host-side, file-backed double buffer for the document view.
|
/// Host-side, file-backed double buffer for the document view.
|
||||||
pub struct DocumentSnapshotStore {
|
pub struct DocumentSnapshotStore<T: SnapshotData> {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
mmap: MmapMut,
|
mmap: MmapMut,
|
||||||
segment_size: usize,
|
segment_size: usize,
|
||||||
current_version: u64,
|
current_version: u64,
|
||||||
|
_phantom: std::marker::PhantomData<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DocumentSnapshotStore {
|
impl<T: SnapshotData> DocumentSnapshotStore<T> {
|
||||||
/// Create a new store for `tab`. `segment_size` is the maximum size of one
|
/// Create a new store for `tab_id`. `segment_size` is the maximum size of one
|
||||||
/// snapshot buffer; the file is sized to hold two segments plus the control
|
/// snapshot buffer; the file is sized to hold two segments plus the control
|
||||||
/// page.
|
/// page.
|
||||||
pub fn new(tab: usize, segment_size: usize) -> io::Result<Self> {
|
pub fn new(tab_id: u64, segment_size: usize) -> io::Result<Self> {
|
||||||
let segment_size = segment_size.next_multiple_of(4096);
|
let segment_size = segment_size.next_multiple_of(4096);
|
||||||
static STORE_ID: AtomicUsize = AtomicUsize::new(0);
|
static STORE_ID: AtomicUsize = AtomicUsize::new(0);
|
||||||
let id = STORE_ID.fetch_add(1, Ordering::Relaxed);
|
let id = STORE_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
let path = Self::temp_path(tab, id);
|
let path = Self::temp_path(tab_id, id);
|
||||||
let file = OpenOptions::new()
|
let file = OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
.open(&path)?;
|
.open(&path)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::fs::Permissions;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
file.set_permissions(Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
let total = CONTROL_SIZE + 2 * segment_size;
|
let total = CONTROL_SIZE + 2 * segment_size;
|
||||||
file.set_len(total as u64)?;
|
file.set_len(total as u64)?;
|
||||||
|
|
||||||
|
|
@ -72,15 +98,16 @@ impl DocumentSnapshotStore {
|
||||||
mmap,
|
mmap,
|
||||||
segment_size,
|
segment_size,
|
||||||
current_version: 0,
|
current_version: 0,
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn temp_path(tab: usize, id: usize) -> PathBuf {
|
fn temp_path(tab_id: u64, id: usize) -> PathBuf {
|
||||||
let mut path = std::env::temp_dir();
|
let mut path = std::env::temp_dir();
|
||||||
path.push(format!(
|
path.push(format!(
|
||||||
"ocs_plugin_doc_{}_{}_{}_{}.bin",
|
"ocs_plugin_doc_v4_{}_{}_{}_{}.bin",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
tab,
|
tab_id,
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|
@ -95,12 +122,9 @@ impl DocumentSnapshotStore {
|
||||||
&self.path
|
&self.path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize `doc` into the inactive segment and atomically publish it.
|
/// Serialize `data` into the inactive segment and atomically publish it.
|
||||||
pub fn publish(&mut self, doc: &CadDocument) -> io::Result<()> {
|
pub fn publish(&mut self, data: &T) -> io::Result<()> {
|
||||||
let data = DocumentViewData::from(doc);
|
let bytes = data.to_rkyv_bytes()?;
|
||||||
let bytes = to_bytes::<_, 256>(&data).map_err(|e| {
|
|
||||||
io::Error::new(io::ErrorKind::InvalidData, format!("rkyv serialize: {e}"))
|
|
||||||
})?;
|
|
||||||
if bytes.len() > self.segment_size {
|
if bytes.len() > self.segment_size {
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::OutOfMemory,
|
io::ErrorKind::OutOfMemory,
|
||||||
|
|
@ -143,20 +167,21 @@ impl DocumentSnapshotStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for DocumentSnapshotStore {
|
impl<T: SnapshotData> Drop for DocumentSnapshotStore<T> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = std::fs::remove_file(&self.path);
|
let _ = std::fs::remove_file(&self.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plugin-side read-only mapping of the host's document view.
|
/// Plugin-side read-only mapping of the host's document view.
|
||||||
pub struct SharedDocumentReader {
|
pub struct SharedDocumentReader<T: SnapshotData> {
|
||||||
mmap: Mmap,
|
mmap: Mmap,
|
||||||
segment_size: usize,
|
segment_size: usize,
|
||||||
cached_version: u64,
|
cached_version: u64,
|
||||||
|
_phantom: std::marker::PhantomData<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SharedDocumentReader {
|
impl<T: SnapshotData> SharedDocumentReader<T> {
|
||||||
/// Open the file at `path` read-only and map it. The mapping may initially
|
/// Open the file at `path` read-only and map it. The mapping may initially
|
||||||
/// contain no valid snapshot; the caller should `refresh()` before use.
|
/// contain no valid snapshot; the caller should `refresh()` before use.
|
||||||
pub fn open(path: &Path) -> io::Result<Self> {
|
pub fn open(path: &Path) -> io::Result<Self> {
|
||||||
|
|
@ -172,6 +197,7 @@ impl SharedDocumentReader {
|
||||||
mmap,
|
mmap,
|
||||||
segment_size,
|
segment_size,
|
||||||
cached_version: 0,
|
cached_version: 0,
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,13 +227,28 @@ impl SharedDocumentReader {
|
||||||
&self.mmap[offset..offset + len]
|
&self.mmap[offset..offset + len]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn archived(&self) -> Option<&ArchivedDocumentViewData> {
|
fn archived(&self) -> Option<&T::Archived> {
|
||||||
let bytes = self.active_segment_bytes();
|
let bytes = self.active_segment_bytes();
|
||||||
check_archived_root::<DocumentViewData>(bytes).ok()
|
T::check_rkyv_bytes(bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DocumentReader for SharedDocumentReader {
|
impl<T: SnapshotData> SharedDocumentReader<T> {
|
||||||
|
/// Public accessor for the archived payload. Used by V4 consumers that
|
||||||
|
/// need typed access to `DocumentViewDataV4` entities.
|
||||||
|
pub fn payload(&self) -> Option<&T::Archived> {
|
||||||
|
self.archived()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedDocumentReader<DocumentViewData> {
|
||||||
|
/// V3 convenience accessor for the entity count.
|
||||||
|
pub fn entity_count(&self) -> usize {
|
||||||
|
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentReader for SharedDocumentReader<DocumentViewData> {
|
||||||
fn entity_count(&self) -> usize {
|
fn entity_count(&self) -> usize {
|
||||||
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
|
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
@ -251,6 +292,12 @@ impl DocumentReader for SharedDocumentReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V3 alias: host-side snapshot store for the simplified document view.
|
||||||
|
pub type DocumentSnapshotStoreV3 = DocumentSnapshotStore<DocumentViewData>;
|
||||||
|
|
||||||
|
/// V3 alias: plugin-side reader for the simplified document view.
|
||||||
|
pub type SharedDocumentReaderV3 = SharedDocumentReader<DocumentViewData>;
|
||||||
|
|
||||||
/// Raw control page shared between host and plugin.
|
/// Raw control page shared between host and plugin.
|
||||||
#[repr(C, align(8))]
|
#[repr(C, align(8))]
|
||||||
struct ControlPage {
|
struct ControlPage {
|
||||||
|
|
@ -276,6 +323,8 @@ impl ControlPage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── V3 simplified document view ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// Serializable document view. This is the only data type placed in shared
|
/// Serializable document view. This is the only data type placed in shared
|
||||||
/// memory, so it must contain no pointers into host memory.
|
/// memory, so it must contain no pointers into host memory.
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
|
@ -296,6 +345,20 @@ impl From<&CadDocument> for DocumentViewData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SnapshotData for DocumentViewData {
|
||||||
|
type Archived = ArchivedDocumentViewData;
|
||||||
|
|
||||||
|
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>> {
|
||||||
|
to_bytes::<_, 256>(self)
|
||||||
|
.map(|av| av.into_vec())
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> {
|
||||||
|
check_archived_root::<Self>(bytes).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
#[archive(check_bytes)]
|
#[archive(check_bytes)]
|
||||||
pub struct LayerView {
|
pub struct LayerView {
|
||||||
|
|
@ -395,6 +458,59 @@ impl ReaderEntityKind {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── V4 full-entity document view ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Serializable V4 document view. The outer structure is rkyv; each entity's
|
||||||
|
/// `data` is a bincode-encoded `acadrust::EntityType` so that the plugin can
|
||||||
|
/// reconstruct the full typed entity without relying on acadrust's own rkyv
|
||||||
|
/// support.
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[archive(check_bytes)]
|
||||||
|
pub struct DocumentViewDataV4 {
|
||||||
|
pub layers: Vec<LayerView>,
|
||||||
|
pub app_ids: Vec<AppIdView>,
|
||||||
|
pub entities: Vec<EntityViewV4>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&CadDocument> for DocumentViewDataV4 {
|
||||||
|
fn from(doc: &CadDocument) -> Self {
|
||||||
|
Self {
|
||||||
|
layers: doc.layers.iter().map(LayerView::from).collect(),
|
||||||
|
app_ids: doc.app_ids.iter().map(AppIdView::from).collect(),
|
||||||
|
entities: doc.entities().map(EntityViewV4::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnapshotData for DocumentViewDataV4 {
|
||||||
|
type Archived = ArchivedDocumentViewDataV4;
|
||||||
|
|
||||||
|
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>> {
|
||||||
|
to_bytes::<_, 256>(self)
|
||||||
|
.map(|av| av.into_vec())
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> {
|
||||||
|
check_archived_root::<Self>(bytes).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[archive(check_bytes)]
|
||||||
|
pub struct EntityViewV4 {
|
||||||
|
pub handle: u64,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&EntityType> for EntityViewV4 {
|
||||||
|
fn from(entity: &EntityType) -> Self {
|
||||||
|
let handle = entity.common().handle.value();
|
||||||
|
let data = bincode::serialize(entity).unwrap_or_default();
|
||||||
|
Self { handle, data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -415,10 +531,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_document_reader_roundtrip() {
|
fn shared_document_reader_roundtrip() {
|
||||||
let doc = sample_doc();
|
let doc = sample_doc();
|
||||||
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
|
||||||
store.publish(&doc).unwrap();
|
store.publish(&(&doc).into()).unwrap();
|
||||||
|
|
||||||
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
|
||||||
assert_eq!(reader.entity_count(), 1);
|
assert_eq!(reader.entity_count(), 1);
|
||||||
|
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
|
|
@ -445,17 +561,17 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_document_reader_updates_after_publish() {
|
fn shared_document_reader_updates_after_publish() {
|
||||||
let doc = sample_doc();
|
let doc = sample_doc();
|
||||||
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
|
||||||
store.publish(&doc).unwrap();
|
store.publish(&(&doc).into()).unwrap();
|
||||||
|
|
||||||
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
|
||||||
assert_eq!(reader.entity_count(), 1);
|
assert_eq!(reader.entity_count(), 1);
|
||||||
|
|
||||||
let mut doc2 = doc;
|
let mut doc2 = doc;
|
||||||
let mut point2 = Point::from_coords(1.0, 2.0, 3.0);
|
let mut point2 = Point::from_coords(1.0, 2.0, 3.0);
|
||||||
point2.common.layer = "SURVEY".to_string();
|
point2.common.layer = "SURVEY".to_string();
|
||||||
doc2.add_entity(EntityType::Point(point2)).unwrap();
|
doc2.add_entity(EntityType::Point(point2)).unwrap();
|
||||||
store.publish(&doc2).unwrap();
|
store.publish(&(&doc2).into()).unwrap();
|
||||||
|
|
||||||
assert_eq!(reader.entity_count(), 2);
|
assert_eq!(reader.entity_count(), 2);
|
||||||
}
|
}
|
||||||
|
|
@ -463,11 +579,28 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn layer_name_lookup_by_handle() {
|
fn layer_name_lookup_by_handle() {
|
||||||
let doc = sample_doc();
|
let doc = sample_doc();
|
||||||
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
|
||||||
store.publish(&doc).unwrap();
|
store.publish(&(&doc).into()).unwrap();
|
||||||
|
|
||||||
let survey = doc.layers.iter().find(|l| l.name == "SURVEY").unwrap();
|
let survey = doc.layers.iter().find(|l| l.name == "SURVEY").unwrap();
|
||||||
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
|
||||||
assert_eq!(reader.layer_name(survey.handle), Some("SURVEY"));
|
assert_eq!(reader.layer_name(survey.handle), Some("SURVEY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_document_view_roundtrip() {
|
||||||
|
let doc = sample_doc();
|
||||||
|
let mut store = DocumentSnapshotStore::<DocumentViewDataV4>::new(7, 1024 * 1024).unwrap();
|
||||||
|
let view: DocumentViewDataV4 = (&doc).into();
|
||||||
|
store.publish(&view).unwrap();
|
||||||
|
|
||||||
|
let reader = SharedDocumentReader::<DocumentViewDataV4>::open(store.path()).unwrap();
|
||||||
|
assert_eq!(reader.archived().map(|d| d.entities.len()).unwrap_or(0), 1);
|
||||||
|
|
||||||
|
let archived = reader.archived().unwrap();
|
||||||
|
let entity = &archived.entities[0];
|
||||||
|
assert_eq!(entity.handle, doc.entities().next().unwrap().common().handle.value());
|
||||||
|
let decoded: EntityType = bincode::deserialize(&entity.data).expect("bincode decode");
|
||||||
|
assert!(matches!(decoded, EntityType::Point(_)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
crates/ocs_plugin_api/src/type_registry.rs
Normal file
88
crates/ocs_plugin_api/src/type_registry.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
//! Embedded type registry generated at build time.
|
||||||
|
//!
|
||||||
|
//! The JSON embedded here is produced by tracing a curated allow-list of
|
||||||
|
//! `acadrust` types with `serde-reflection` and mapping the result into a
|
||||||
|
//! stable, language-binding-friendly schema defined in
|
||||||
|
//! [`crate::type_registry_types`].
|
||||||
|
|
||||||
|
pub use crate::type_registry_types::*;
|
||||||
|
|
||||||
|
/// The embedded type registry as a JSON string.
|
||||||
|
pub const EMBEDDED_TYPE_REGISTRY_JSON: &str =
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"));
|
||||||
|
|
||||||
|
/// Convenience accessor for the embedded type registry JSON.
|
||||||
|
pub fn get_embedded_type_registry_json() -> &'static str {
|
||||||
|
EMBEDDED_TYPE_REGISTRY_JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_type_registry_is_valid_json() {
|
||||||
|
let json = get_embedded_type_registry_json();
|
||||||
|
assert!(!json.is_empty());
|
||||||
|
let registry: TypeRegistry = serde_json::from_str(json).expect("valid TypeRegistry JSON");
|
||||||
|
assert!(!registry.types.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_type_registry_contains_expected_acadrust_types() {
|
||||||
|
let registry: TypeRegistry =
|
||||||
|
serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON");
|
||||||
|
|
||||||
|
for name in ["Point", "Line", "Circle", "EntityCommon"] {
|
||||||
|
assert!(
|
||||||
|
registry.types.contains_key(&TypeId::new(name)),
|
||||||
|
"registry should contain {}",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_type_registry_point_is_struct_with_expected_fields() {
|
||||||
|
let registry: TypeRegistry =
|
||||||
|
serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON");
|
||||||
|
|
||||||
|
let point = registry
|
||||||
|
.types
|
||||||
|
.get(&TypeId::new("Point"))
|
||||||
|
.expect("Point in registry");
|
||||||
|
assert_eq!(point.kind, TypeKind::Struct);
|
||||||
|
|
||||||
|
let field_names: Vec<&str> = point.fields.iter().map(|f| f.name.as_str()).collect();
|
||||||
|
for expected in ["common", "location", "thickness", "normal"] {
|
||||||
|
assert!(
|
||||||
|
field_names.contains(&expected),
|
||||||
|
"Point should have field {}",
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let common = point
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.name == "common")
|
||||||
|
.expect("common field");
|
||||||
|
assert_eq!(common.type_id.as_str(), "EntityCommon");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_type_registry_does_not_contain_entity_type() {
|
||||||
|
let registry: TypeRegistry =
|
||||||
|
serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON");
|
||||||
|
assert!(
|
||||||
|
!registry.types.contains_key(&TypeId::new("EntityType")),
|
||||||
|
"EntityType should not be in the allow-list registry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn type_id_as_str_returns_inner_value() {
|
||||||
|
let id = TypeId::new("Point");
|
||||||
|
assert_eq!(id.as_str(), "Point");
|
||||||
|
}
|
||||||
|
}
|
||||||
77
crates/ocs_plugin_api/src/type_registry_types.rs
Normal file
77
crates/ocs_plugin_api/src/type_registry_types.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Stable identifier for a type in the embedded registry.
|
||||||
|
///
|
||||||
|
/// `TypeId` serializes as a plain string so it can be used as a JSON object
|
||||||
|
/// key. The inner value is intentionally private; consumers use `as_str()`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct TypeId(String);
|
||||||
|
|
||||||
|
impl TypeId {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classification of a type in the registry.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum TypeKind {
|
||||||
|
Struct,
|
||||||
|
Enum,
|
||||||
|
Newtype,
|
||||||
|
Tuple,
|
||||||
|
Unit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Description of one field in a struct or enum variant.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FieldInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub type_id: TypeId,
|
||||||
|
pub optional: bool,
|
||||||
|
pub is_sequence: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Description of one enum variant.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EnumVariantInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub discriminant: u32,
|
||||||
|
pub fields: Vec<FieldInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Description of one method parameter.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ParameterInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub type_id: TypeId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Description of one method on a type.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MethodInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub parameters: Vec<ParameterInfo>,
|
||||||
|
pub return_type: Option<TypeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full description of one type in the registry.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TypeInfo {
|
||||||
|
pub name: TypeId,
|
||||||
|
pub kind: TypeKind,
|
||||||
|
pub fields: Vec<FieldInfo>,
|
||||||
|
pub variants: Vec<EnumVariantInfo>,
|
||||||
|
pub methods: Vec<MethodInfo>,
|
||||||
|
pub doc: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collection of all traced types, keyed by their `TypeId`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TypeRegistry {
|
||||||
|
pub types: std::collections::BTreeMap<TypeId, TypeInfo>,
|
||||||
|
}
|
||||||
57
crates/ocs_plugin_api/src/version_info.rs
Normal file
57
crates/ocs_plugin_api/src/version_info.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
//! Embedded version metadata generated at build time.
|
||||||
|
|
||||||
|
/// The embedded version info as a JSON string.
|
||||||
|
pub const EMBEDDED_VERSION_INFO_JSON: &str =
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/version_info.json"));
|
||||||
|
|
||||||
|
/// Convenience accessor for the embedded version info JSON.
|
||||||
|
pub fn get_embedded_version_info_json() -> &'static str {
|
||||||
|
EMBEDDED_VERSION_INFO_JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_version_info_is_valid_json() {
|
||||||
|
let json = get_embedded_version_info_json();
|
||||||
|
assert!(!json.is_empty());
|
||||||
|
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
|
||||||
|
assert!(value.get("ocs_version").is_some());
|
||||||
|
assert!(value.get("acadrust_version").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_version_info_has_expected_keys() {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(get_embedded_version_info_json()).expect("valid JSON");
|
||||||
|
for key in [
|
||||||
|
"ocs_version",
|
||||||
|
"ocs_plugin_api_version",
|
||||||
|
"acadrust_version",
|
||||||
|
"acadrust_source",
|
||||||
|
"api_version",
|
||||||
|
"api_version_min_supported",
|
||||||
|
"build_timestamp",
|
||||||
|
] {
|
||||||
|
assert!(value.get(key).is_some(), "missing version info key {}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_version_info_matches_package_versions() {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(get_embedded_version_info_json()).expect("valid JSON");
|
||||||
|
assert_eq!(
|
||||||
|
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_min_supported"].as_i64(), Some(2));
|
||||||
|
|
||||||
|
// acadrust_version should have a patch component (e.g. "0.4.0").
|
||||||
|
let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string");
|
||||||
|
assert_eq!(acadrust.split('.').count(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1692,6 +1692,9 @@ pub enum ArrowKey {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
Tick(Instant),
|
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
|
/// Web: periodic check for per-script fonts a drawing needs but hasn't
|
||||||
/// fetched yet (#141). Native: never emitted.
|
/// fetched yet (#141). Native: never emitted.
|
||||||
PollWebFonts,
|
PollWebFonts,
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,18 @@ use std::any::Any;
|
||||||
|
|
||||||
use acadrust::tables::AppId;
|
use acadrust::tables::AppId;
|
||||||
use acadrust::xdata::ExtendedDataRecord;
|
use acadrust::xdata::ExtendedDataRecord;
|
||||||
use acadrust::{CadDocument, EntityType, Handle};
|
use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi};
|
||||||
use ocs_plugin_api::host::HostApi;
|
use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::plugin::v4_support;
|
||||||
use super::OpenCADStudio;
|
use super::OpenCADStudio;
|
||||||
|
|
||||||
/// Session adapter: one active document tab, command line, undo.
|
/// Session adapter: one active document tab, command line, undo.
|
||||||
pub(crate) struct HostSession<'a> {
|
pub(crate) struct HostSession<'a> {
|
||||||
app: &'a mut OpenCADStudio,
|
app: &'a mut OpenCADStudio,
|
||||||
tab: usize,
|
tab: usize,
|
||||||
doc_store: Option<ocs_plugin_api::shm::DocumentSnapshotStore>,
|
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> HostSession<'a> {
|
impl<'a> HostSession<'a> {
|
||||||
|
|
@ -29,6 +31,25 @@ impl<'a> HostSession<'a> {
|
||||||
self.tab
|
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> {
|
||||||
|
if tab_id != self.tab_id() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if tab_id == self.tab_id() {
|
||||||
|
v4_support::close_document_view_v4(tab_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn document(&self) -> &CadDocument {
|
pub fn document(&self) -> &CadDocument {
|
||||||
&self.app.tabs[self.tab].scene.document
|
&self.app.tabs[self.tab].scene.document
|
||||||
}
|
}
|
||||||
|
|
@ -38,10 +59,9 @@ impl<'a> HostSession<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||||
use ocs_plugin_api::shm::DocumentSnapshotStore;
|
|
||||||
if self.doc_store.is_none() {
|
if self.doc_store.is_none() {
|
||||||
let mut store = DocumentSnapshotStore::new(self.tab, 8 * 1024 * 1024).ok()?;
|
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(self.tab as u64, 8 * 1024 * 1024).ok()?;
|
||||||
store.publish(self.document()).ok()?;
|
store.publish(&self.document().into()).ok()?;
|
||||||
self.doc_store = Some(store);
|
self.doc_store = Some(store);
|
||||||
}
|
}
|
||||||
let store = self.doc_store.as_ref()?;
|
let store = self.doc_store.as_ref()?;
|
||||||
|
|
@ -53,14 +73,18 @@ impl<'a> HostSession<'a> {
|
||||||
|
|
||||||
fn publish_document_view(&mut self) {
|
fn publish_document_view(&mut self) {
|
||||||
let doc = &self.app.tabs[self.tab].scene.document;
|
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 Some(store) = self.doc_store.as_mut() {
|
||||||
if let Err(e) = store.publish(doc) {
|
if let Err(e) = store.publish(&doc.into()) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[host] failed to publish document view for tab {}: {e}",
|
"[host] failed to publish document view for tab {}: {e}",
|
||||||
self.tab
|
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 {
|
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||||
|
|
@ -69,6 +93,12 @@ impl<'a> HostSession<'a> {
|
||||||
handle
|
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) {
|
pub fn bump_geometry(&mut self) {
|
||||||
self.app.tabs[self.tab].scene.bump_geometry();
|
self.app.tabs[self.tab].scene.bump_geometry();
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +269,9 @@ impl HostApi for HostSession<'_> {
|
||||||
fn add_entity(&mut self, entity: EntityType) -> Handle {
|
fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||||
self.add_entity(entity)
|
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 {
|
fn update_entity(&mut self, entity: EntityType) -> bool {
|
||||||
self.update_entity(entity)
|
self.update_entity(entity)
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +338,17 @@ impl HostApi for HostSession<'_> {
|
||||||
fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
|
||||||
self.document_view()
|
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)
|
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
|
||||||
|
|
@ -506,6 +550,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]
|
#[test]
|
||||||
fn update_entity_replaces_in_place_preserving_handle() {
|
fn update_entity_replaces_in_place_preserving_handle() {
|
||||||
let mut app = OpenCADStudio::new_for_test();
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
|
@ -704,7 +776,7 @@ mod tests {
|
||||||
let mut host = HostSession::new(&mut app, 0);
|
let mut host = HostSession::new(&mut app, 0);
|
||||||
let info = host.document_view().unwrap();
|
let info = host.document_view().unwrap();
|
||||||
let reader =
|
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();
|
.unwrap();
|
||||||
assert_eq!(reader.entity_count(), 0);
|
assert_eq!(reader.entity_count(), 0);
|
||||||
|
|
||||||
|
|
@ -755,7 +827,7 @@ mod tests {
|
||||||
let mut host = HostSession::new(&mut app, 0);
|
let mut host = HostSession::new(&mut app, 0);
|
||||||
let info = host.document_view().unwrap();
|
let info = host.document_view().unwrap();
|
||||||
let reader =
|
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();
|
.unwrap();
|
||||||
|
|
||||||
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
|
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::{
|
use crate::scene::pick::grip::{
|
||||||
find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode,
|
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::model::object::GripApply;
|
||||||
use crate::scene::{
|
use crate::scene::{
|
||||||
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
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));
|
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
||||||
return self.open_unsaved_dialog_window();
|
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.
|
// This tab is closing for good — drop its autosave recovery copy.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
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_layers();
|
||||||
self.sync_ribbon_styles();
|
self.sync_ribbon_styles();
|
||||||
self.sync_ribbon_from_selection();
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -477,12 +477,22 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Background task: fetch `owner/repo`'s installable releases and their
|
/// Background task: fetch `owner/repo`'s installable releases and their
|
||||||
/// manifest API versions.
|
/// manifest API versions. The fetch runs on its own OS thread because the
|
||||||
|
/// several sequential HTTP requests inside `fetch_release_info` would
|
||||||
|
/// otherwise block the async executor and serialise all repo fetches.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
|
pub(in crate::app) fn fetch_releases_task(&self, repo: String) -> Task<Message> {
|
||||||
let label = repo.clone();
|
let label = repo.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async move { crate::plugin::marketplace::fetch_release_info(&repo) },
|
async move {
|
||||||
|
let (tx, rx) = iced::futures::channel::oneshot::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = crate::plugin::marketplace::fetch_release_info(&repo);
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
rx.await
|
||||||
|
.unwrap_or_else(|_| Err("release fetch thread died".into()))
|
||||||
|
},
|
||||||
move |res| Message::PluginReleasesFetched(label, res),
|
move |res| Message::PluginReleasesFetched(label, res),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,40 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
fn update_inner(&mut self, msg: Message) -> Task<Message> {
|
fn update_inner(&mut self, msg: Message) -> Task<Message> {
|
||||||
match msg {
|
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 => {
|
||||||
|
for tab in 0..self.tabs.len() {
|
||||||
|
let mut host = crate::app::plugin_host::HostSession::new(self, tab);
|
||||||
|
crate::plugin::external::with_manager(|mgr| {
|
||||||
|
mgr.drain_requests(&mut host, &mut |_| {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
crate::plugin::external::with_manager(|mgr| {
|
||||||
|
for line in mgr.drain_io() {
|
||||||
|
if line.text.starts_with("[runner]")
|
||||||
|
|| line.text.starts_with("[plugin] ")
|
||||||
|
|| line.text.starts_with("[python-repl]")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let prefixed = format!("[{} {}] {}", line.plugin_id, line.source, line.text);
|
||||||
|
match line.source {
|
||||||
|
ocs_plugin_api::process::IoStream::Stderr => {
|
||||||
|
self.command_line.push_error(&prefixed);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.command_line.push_output(&prefixed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
// Web: fetch every script queued by startup language selection or
|
// Web: fetch every script queued by startup language selection or
|
||||||
// drawing text discovery. Each script has one shared store entry.
|
// drawing text discovery. Each script has one shared store entry.
|
||||||
Message::PollWebFonts => {
|
Message::PollWebFonts => {
|
||||||
|
|
@ -6007,11 +6041,20 @@ impl OpenCADStudio {
|
||||||
Message::PluginUninstall(id) => {
|
Message::PluginUninstall(id) => {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
|
// Stop the plugin runner first so Windows releases the DLL
|
||||||
|
// and allows the package directory to be deleted.
|
||||||
|
if !crate::plugin::external::remove_plugin(&id) {
|
||||||
|
self.marketplace_status =
|
||||||
|
format!("Uninstall failed: plugin '{id}' did not stop in time");
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
match crate::plugin::external::uninstall(&id) {
|
match crate::plugin::external::uninstall(&id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.marketplace_status =
|
self.marketplace_status =
|
||||||
format!("Uninstalled '{id}'. Restart to unload it.");
|
format!("Uninstalled '{id}'.");
|
||||||
self.plugin_load_errors.remove(&id);
|
self.plugin_load_errors.remove(&id);
|
||||||
|
self.loaded_plugin_ids.remove(&id);
|
||||||
|
self.rebuild_ribbon_modules();
|
||||||
self.external_plugins = crate::plugin::external::discover();
|
self.external_plugins = crate::plugin::external::discover();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -2104,6 +2104,18 @@ impl OpenCADStudio {
|
||||||
let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal);
|
let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal);
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let single_instance = Subscription::none();
|
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]
|
let hatch_pattern_keys = if self.tabs[self.active_tab]
|
||||||
.properties
|
.properties
|
||||||
.hatch_pattern_picker_open
|
.hatch_pattern_picker_open
|
||||||
|
|
@ -2121,6 +2133,7 @@ impl OpenCADStudio {
|
||||||
caret_blink,
|
caret_blink,
|
||||||
web_fonts,
|
web_fonts,
|
||||||
autosave,
|
autosave,
|
||||||
|
plugin_drain,
|
||||||
single_instance,
|
single_instance,
|
||||||
hatch_pattern_keys,
|
hatch_pattern_keys,
|
||||||
event::listen_with(|ev, status, win_id| {
|
event::listen_with(|ev, status, win_id| {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ pub struct ExternalPlugin {
|
||||||
impl ExternalPlugin {
|
impl ExternalPlugin {
|
||||||
/// True when the package's API version is supported by this host.
|
/// True when the package's API version is supported by this host.
|
||||||
pub fn api_compatible(&self) -> bool {
|
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
|
/// True when the package can be loaded today: compatible API *and* a native
|
||||||
|
|
@ -267,12 +267,13 @@ fn parse_string_array(s: &str) -> Vec<String> {
|
||||||
pub(crate) use loader::{shutdown_plugins, with_manager};
|
pub(crate) use loader::{shutdown_plugins, with_manager};
|
||||||
|
|
||||||
#[cfg(all(not(target_arch = "wasm32"), not(test)))]
|
#[cfg(all(not(target_arch = "wasm32"), not(test)))]
|
||||||
pub(crate) use loader::{load_at_startup, loaded_ids};
|
pub(crate) use loader::{load_at_startup, loaded_ids, remove_plugin};
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
#[cfg_attr(test, allow(dead_code))]
|
#[cfg_attr(test, allow(dead_code))]
|
||||||
mod loader {
|
mod loader {
|
||||||
use super::lib_extension;
|
use super::lib_extension;
|
||||||
|
use crate::plugin::v4_support;
|
||||||
use ocs_plugin_api::process::PluginManager;
|
use ocs_plugin_api::process::PluginManager;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -291,6 +292,7 @@ mod loader {
|
||||||
) -> Vec<(String, Result<(), String>)> {
|
) -> Vec<(String, Result<(), String>)> {
|
||||||
let discovered = super::discover();
|
let discovered = super::discover();
|
||||||
let mut manager = PluginManager::new();
|
let mut manager = PluginManager::new();
|
||||||
|
manager.set_notification_handler(v4_support::notification_handler());
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for d in &discovered {
|
for d in &discovered {
|
||||||
if !d.api_compatible() || !d.lib_present {
|
if !d.api_compatible() || !d.lib_present {
|
||||||
|
|
@ -340,6 +342,23 @@ mod loader {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shut down the loaded plugin with `id` and remove it from the manager so
|
||||||
|
/// its files can be deleted on Windows. Returns true if the plugin was
|
||||||
|
/// loaded and has been removed.
|
||||||
|
pub fn remove_plugin(id: &str) -> bool {
|
||||||
|
MANAGER.with(|m| {
|
||||||
|
if let Some(manager) = m.borrow_mut().as_mut() {
|
||||||
|
if manager.ids().iter().any(|loaded| loaded == id) {
|
||||||
|
manager.remove(id)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Path to the native library beside `plugin.toml`, if any.
|
/// Path to the native library beside `plugin.toml`, if any.
|
||||||
fn lib_file(dir: &Path) -> Option<PathBuf> {
|
fn lib_file(dir: &Path) -> Option<PathBuf> {
|
||||||
let ext = lib_extension();
|
let ext = lib_extension();
|
||||||
|
|
@ -350,12 +369,13 @@ mod loader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::plugin::v4_support;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn api_v2_plugin_from_template_is_incompatible() {
|
fn api_v2_plugin_from_template_is_compatible() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
[plugin]
|
[plugin]
|
||||||
id = "opencad.my_plugin"
|
id = "opencad.my_plugin"
|
||||||
|
|
@ -374,7 +394,7 @@ xdata_apps = ["MYPLUGIN_RECORD"]
|
||||||
assert_eq!(p.api_version, 2);
|
assert_eq!(p.api_version, 2);
|
||||||
assert_eq!(p.repository.as_deref(), Some("example/opencad-my-plugin"));
|
assert_eq!(p.repository.as_deref(), Some("example/opencad-my-plugin"));
|
||||||
assert!(p.command_prefixes.contains(&"MP_".to_string()));
|
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]
|
#[test]
|
||||||
|
|
@ -415,7 +435,11 @@ xdata_apps = ["MYPLUGIN_RECORD"]
|
||||||
|
|
||||||
let mut app = crate::app::OpenCADStudio::new_for_test();
|
let mut app = crate::app::OpenCADStudio::new_for_test();
|
||||||
let mut host = crate::app::plugin_host::HostSession::new(&mut app, 0);
|
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");
|
.expect("spawn test plugin");
|
||||||
assert_eq!(process.id(), "opencad.my_plugin");
|
assert_eq!(process.id(), "opencad.my_plugin");
|
||||||
let mut started = false;
|
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 toml_text = download_string(&toml.url)?;
|
||||||
let manifest = external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?;
|
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!(
|
return Err(format!(
|
||||||
"API version {} is incompatible (host requires {})",
|
"API version {} is incompatible (host supports {}-{})",
|
||||||
manifest.api_version,
|
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 host;
|
||||||
pub mod marketplace;
|
pub mod marketplace;
|
||||||
pub mod registry;
|
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 use registry::{all_ribbon_modules, plugin_command_names, ribbon_modules_enabled};
|
||||||
pub(crate) use registry::try_dispatch;
|
pub(crate) use registry::try_dispatch;
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bo
|
||||||
manager.dispatch(&mut host, cmd, |id| disabled.contains(id))
|
manager.dispatch(&mut host, cmd, |id| disabled.contains(id))
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
for id in result.dead_plugins {
|
for id in result.dead_plugins {
|
||||||
app.push_plugin_error(&format!("Plugin '{id}' process died; skipping dispatch"));
|
app.push_plugin_error(&format!("Plugin '{id}' process died; skipping dispatch"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
src/plugin/v4_support.rs
Normal file
61
src/plugin/v4_support.rs
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -111,7 +111,44 @@ impl Scene {
|
||||||
let _ = self.document.layers.add(layer);
|
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
|
// Only block sentinels mutate a block definition and require rebuilding
|
||||||
// the block cache. A top-level INSERT merely references an existing
|
// the block cache. A top-level INSERT merely references an existing
|
||||||
// definition, so adding it can patch just that new render handle.
|
// definition, so adding it can patch just that new render handle.
|
||||||
|
|
@ -256,12 +293,14 @@ impl Scene {
|
||||||
self.poison_undo_recording();
|
self.poison_undo_recording();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if affects_blocks {
|
if bump {
|
||||||
self.bump_geometry();
|
if affects_blocks {
|
||||||
} else {
|
self.bump_geometry();
|
||||||
// Plain top-level add: name the new handle so every derived cache
|
} else {
|
||||||
// patches in just this one entity instead of rebuilding.
|
// Plain top-level add: name the new handle so every derived cache
|
||||||
self.bump_entities(&[(handle, ChangeKind::Added)]);
|
// patches in just this one entity instead of rebuilding.
|
||||||
|
self.bump_entities(&[(handle, ChangeKind::Added)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle
|
handle
|
||||||
|
|
|
||||||
|
|
@ -218,8 +218,7 @@ fn external_card<'a>(
|
||||||
disabled: bool,
|
disabled: bool,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let failed_old_api =
|
let failed_old_api = load_error.is_some() && !p.api_compatible();
|
||||||
load_error.is_some() && p.api_version != ocs_plugin_api::API_VERSION;
|
|
||||||
let (status, kind) = if loaded && disabled {
|
let (status, kind) = if loaded && disabled {
|
||||||
(t!("Disabled"), StatusKind::Muted)
|
(t!("Disabled"), StatusKind::Muted)
|
||||||
} else if loaded {
|
} else if loaded {
|
||||||
|
|
@ -369,7 +368,9 @@ fn install_controls<'a>(
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
let action = match selected_api {
|
let action = match selected_api {
|
||||||
Some(api_version) if api_version == ocs_plugin_api::API_VERSION => {
|
Some(api_version)
|
||||||
|
if ocs_plugin_api::manifest::host_accepts_plugin_version(api_version) =>
|
||||||
|
{
|
||||||
pill_button(
|
pill_button(
|
||||||
t!("Install"),
|
t!("Install"),
|
||||||
Message::PluginInstall(repo_s.clone()),
|
Message::PluginInstall(repo_s.clone()),
|
||||||
|
|
@ -845,7 +846,9 @@ pub fn view_window<'a>(
|
||||||
let compatible_tags = releases
|
let compatible_tags = releases
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|release| {
|
.filter(|release| {
|
||||||
release.api_version == ocs_plugin_api::API_VERSION
|
ocs_plugin_api::manifest::host_accepts_plugin_version(
|
||||||
|
release.api_version,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.map(|release| release.tag.clone())
|
.map(|release| release.tag.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue