diff --git a/.gitignore b/.gitignore index 294e071..9d95c25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target/ /.tools/ /.worktrees +/sdk/node_modules/ +/sdk/dist/ diff --git a/Cargo.lock b/Cargo.lock index e388b01..60e012b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.6.1" @@ -118,6 +133,18 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -178,6 +205,148 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "boa_ast" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9221b0a090572b4a118b46c5874c0781fe4bc9546b85f0a615b8bfb609ca4ed7" +dependencies = [ + "bitflags 2.13.1", + "boa_interner", + "boa_macros", + "boa_string", + "indexmap", + "num-bigint", + "rustc-hash 2.1.3", + "strum", +] + +[[package]] +name = "boa_engine" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6aaf7fdc4299df68adb67e1a5440a51d34630a1f5271b9698fd74bc97b60d9" +dependencies = [ + "aligned-vec", + "arrayvec", + "async-channel", + "bitflags 2.13.1", + "boa_ast", + "boa_gc", + "boa_interner", + "boa_macros", + "boa_parser", + "boa_string", + "bytemuck", + "cfg-if", + "cow-utils", + "dashmap", + "dynify", + "fast-float2", + "futures-concurrency", + "futures-lite", + "hashbrown 0.17.1", + "icu_normalizer", + "indexmap", + "intrusive-collections", + "itertools", + "num-bigint", + "num-integer", + "num-traits", + "num_enum", + "oneshot", + "pastey", + "portable-atomic", + "rand", + "regress", + "rustc-hash 2.1.3", + "ryu-js", + "serde", + "serde_json", + "small_btree", + "static_assertions", + "tag_ptr", + "tap", + "thin-vec", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "boa_gc" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af82119558ab1002d1978c8459b53a3ebb506177d29a3aca37c07c1777b1d966" +dependencies = [ + "arrayvec", + "boa_macros", + "boa_string", + "hashbrown 0.17.1", + "thin-vec", +] + +[[package]] +name = "boa_interner" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317c631e791f5cb7d0017e43c0a758b0eaffd40c34c4c360e2a6be7752b78cd1" +dependencies = [ + "boa_gc", + "boa_macros", + "hashbrown 0.17.1", + "indexmap", + "once_cell", + "phf", + "rustc-hash 2.1.3", + "static_assertions", +] + +[[package]] +name = "boa_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b882fd440dc0f4b7441367cf89567bb077920356750c9a76a16c002d8ec2fa5c" +dependencies = [ + "cfg-if", + "cow-utils", + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure 0.14.0", +] + +[[package]] +name = "boa_parser" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641e81b3c8cbb7a2140547fb3ea534e9edf210b4e8c920b5fa864a919e11fbcf" +dependencies = [ + "bitflags 2.13.1", + "boa_ast", + "boa_interner", + "boa_macros", + "fast-float2", + "icu_properties", + "num-bigint", + "num-traits", + "regress", + "rustc-hash 2.1.3", +] + +[[package]] +name = "boa_string" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bf86c2bcff5bf2547acbfc516c5d3108ca527cb131511d749bf6fbef762c0e" +dependencies = [ + "fast-float2", + "itoa", + "pastey", + "rustc-hash 2.1.3", + "ryu-js", + "static_assertions", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -306,6 +475,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -413,6 +593,21 @@ dependencies = [ "libm", ] +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -440,6 +635,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dispatch" version = "0.2.0" @@ -497,6 +712,26 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dynify" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747" +dependencies = [ + "dynify-macros", +] + +[[package]] +name = "dynify-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ecolor" version = "0.33.3" @@ -618,6 +853,12 @@ dependencies = [ "winit", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "emath" version = "0.33.3" @@ -651,6 +892,26 @@ version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -673,6 +934,38 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fax" version = "0.2.7" @@ -705,6 +998,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -787,12 +1086,44 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -842,6 +1173,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -984,6 +1316,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1007,6 +1345,17 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" @@ -1058,6 +1407,8 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "write16", "zerovec", ] @@ -1160,6 +1511,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "intrusive-collections" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -1505,6 +1877,32 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1537,6 +1935,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -1823,6 +2230,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" + [[package]] name = "orbclient" version = "0.3.55" @@ -1851,6 +2264,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1880,12 +2299,60 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2002,6 +2469,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2080,6 +2553,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2113,6 +2603,15 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "regress" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" +dependencies = [ + "memchr", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -2178,6 +2677,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -2235,6 +2740,19 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2299,6 +2817,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2312,6 +2836,7 @@ dependencies = [ "ab_glyph", "anyhow", "base64", + "boa_engine", "eframe", "egui", "fontdb", @@ -2319,8 +2844,10 @@ dependencies = [ "png", "portable-pty", "serde", + "serde_json", "shell-words", "toml", + "ts-rs", "wgpu", ] @@ -2333,6 +2860,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "small_btree" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a" +dependencies = [ + "arrayvec", +] + [[package]] name = "smallvec" version = "1.16.0" @@ -2432,6 +2968,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "syn" version = "2.0.119" @@ -2465,6 +3022,44 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tag_ptr" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thin-vec" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79def32ffcd477db1ff26f76dab9e3a91f0bd42a85ca96577089b24623056f9d" + [[package]] name = "thiserror" version = "1.0.69" @@ -2519,6 +3114,38 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2621,6 +3248,28 @@ version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.20", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -2669,6 +3318,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3419,6 +4074,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" @@ -3508,7 +4169,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -3549,7 +4210,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -3591,6 +4252,12 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index e55f371..d270a2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" [dependencies] anyhow = "1" ab_glyph = "0.2" +boa_engine = { version = "0.22", default-features = false } fontdb = "0.23" eframe = { version = "0.33", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11"] } egui = "0.33" @@ -16,8 +17,10 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] } png = "0.18" portable-pty = "0.9" serde = { version = "1", features = ["derive"] } +serde_json = "1" shell-words = "1" toml = "0.9" +ts-rs = "12" wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] } [dev-dependencies] diff --git a/config.example.toml b/config.example.toml index ec83b48..f92dc61 100644 --- a/config.example.toml +++ b/config.example.toml @@ -78,3 +78,10 @@ floating_max_height = 640 "Ctrl+Shift+R" = "reload_config" "Ctrl+Shift+C" = "copy" "Ctrl+Shift+V" = "paste" + +# TypeScript plugins compile to JavaScript bundles and run in embedded Boa. +# See sdk/README.md. Relative paths resolve beside this configuration file. +# Restart Slerm after changing a plugin bundle or these entries. +# [[plugins]] +# id = "workspace" +# path = "plugins/workspace.js" diff --git a/flake.nix b/flake.nix index 6e9c0a9..66d830c 100644 --- a/flake.nix +++ b/flake.nix @@ -177,6 +177,9 @@ ./Cargo.lock ./config.example.toml ./src + # Embedded runtime and the bindings checked by Rust tests. + ./sdk/runtime.js + ./sdk/src/generated.ts ]; }; cargoLock.lockFile = ./Cargo.lock; diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..19d1640 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,133 @@ +# Slerm TypeScript plugin SDK + +Slerm runs JavaScript bundles in **embedded Boa**, with a separate worker thread +and context for each plugin. End users do not need Node.js or Bun. Plugin authors +use TypeScript and a bundler during development. + +## Try the example + +From the Slerm repository root: + +```sh +npm ci --prefix sdk +npm run example --prefix sdk +``` + +Add this to your Slerm configuration, replacing the absolute path: + +```toml +[[plugins]] +id = "workspace" +path = "/absolute/path/to/slerm/sdk/dist/workspace.js" +``` + +Start Slerm. The example opens **Workspace tools**, with controls to create a +floating pane, run a shell command, and inspect terminal text. Reopen the panel +from **Plugins → workspace → Show workspace tools**. + +Bundle paths can also be relative to the configuration file's directory. Plugins +load at startup; restart Slerm after changing a bundle or the plugin configuration. + +## Write a plugin + +Build the SDK with `npm run build --prefix sdk`. In a separate plugin project, +install the local package with `npm install /absolute/path/to/slerm/sdk` and add +TypeScript and esbuild as development dependencies. The package has not been +published to a registry. + +```ts +import { definePlugin } from "@slerm/plugin-sdk"; + +definePlugin({ + async activate(slerm) { + await slerm.registerCommand({ + command: { id: "scratch", title: "New scratch pane" }, + }); + }, + async onEvent(event, slerm) { + if (event.event === "command" && event.command === "scratch") { + await slerm.execute({ action: "new_floating" }); + } + }, +}); +``` + +Type-check your entry point, then bundle it: + +```sh +npx tsc --noEmit --strict --target es2022 --module nodenext plugin.ts +npx esbuild plugin.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=plugin.js +``` + +Point a `[[plugins]]` entry at `plugin.js`. Each bundle calls `definePlugin` once. +All API calls return promises that resolve after Slerm applies the operation. +Events invoke `onEvent(event, slerm)`; callbacks can be async. + +## Generated API + +The Rust definitions in [`src/plugin_api.rs`](../src/plugin_api.rs) are the source +of truth. A Rust method list declares parameters and results; `ts-rs` generates +`src/generated.ts`, and TypeScript derives the client method signatures from those +types. Actions come from Slerm's existing Rust action enum. State, UI elements, +and events also use the same Rust types for serialization and type generation. +The generic runtime bridge does not duplicate a method list or handwritten types. + +Regenerate after changing the Rust API: + +```sh +nix develop -c cargo run -- --generate-plugin-sdk +npm test --prefix sdk +nix develop -c cargo test +nix develop -c cargo test bundled_typescript_example_runs_in_boa -- --ignored +``` + +Without Nix, run Cargo in an environment containing the project's Rust and Zig +build dependencies. Commit `sdk/src/generated.ts` with API changes. A Rust test +fails if the checked-in bindings differ from the schema. Bump `API_VERSION` for +incompatible changes; Slerm rejects bundles built for another API version. + +| Method | Purpose | +| --- | --- | +| `getState()` | Read tabs, panes, titles, focus, floating state, and zoom | +| `execute({ action })` | Execute a built-in pane, tab, clipboard, or settings action | +| `focusPane({ pane })`, `focusTab({ tab })` | Focus by ID from `getState()` | +| `readTerminal({ pane })` | Read a plain-text screen and scrollback snapshot, up to 128 KiB | +| `writeTerminal({ pane, text })` | Send raw PTY input; include `\r` to submit a shell command | +| `registerCommand({ command })`, `unregisterCommand({ command })` | Manage entries in the Plugins menu | +| `setPanel({ panel })`, `removePanel({ panel })` | Create, replace, or remove a native panel | +| `reportError({ message })` | Display an error in Slerm | + +Command and panel IDs are scoped to the plugin. Control IDs must be unique within +a panel. Pane and tab IDs come from Slerm and should be treated as opaque numbers. + +Events are discriminated by `event.event`: + +- `command`: `event.command` is the selected command ID. +- `state`: `event.state` is a snapshot when workspace metadata changes. Terminal + output changes alone do not produce this event; use `readTerminal` for snapshots. +- `ui`: `event.data` contains the panel/control IDs and either a `click`, or a + `change` with the new input `value`. + +## Native UI + +`setPanel` renders an egui floating window. Its `elements` accept `text`, `button`, +`input`, and `separator` variants. Inputs retain edits until replaced by another +`setPanel` call. UI focus receives typing without forwarding it to the terminal. +See [`examples/workspace.ts`](examples/workspace.ts) for a complete example. + +## Runtime boundaries + +The runtime supports ECMAScript and promises. This initial host does not expose +Node.js APIs, a DOM, filesystem/network APIs, or timers. Bundle imports ahead of +time and use libraries compatible with this environment. Report diagnostics with +`reportError`; `console` is not installed. + +Plugins are trusted local extensions: terminal writes can run shell commands. +Boa contexts run in the same OS process as Slerm; worker threads and execution +limits are not an OS security sandbox. Slerm bounds loops, recursion, promise jobs, +request waits, bundle/message sizes, and UI counts. An uncaught callback error +stops that plugin and removes its commands and panels. Host-operation errors +reject the API promise and can be handled by the plugin. + +Implementation references: [Boa](https://boajs.dev/docs/intro) and +[ts-rs](https://docs.rs/ts-rs/latest/ts_rs/). diff --git a/sdk/examples/workspace.ts b/sdk/examples/workspace.ts new file mode 100644 index 0000000..4f2e955 --- /dev/null +++ b/sdk/examples/workspace.ts @@ -0,0 +1,40 @@ +import { definePlugin, type Slerm } from "../src/index.js"; + +let command = "pwd"; +const show = (slerm: Slerm) => slerm.setPanel({ panel: { + id: "workspace", title: "Workspace tools", elements: [ + { type: "text", text: "Create a scratch terminal or run a command in the active pane." }, + { type: "button", id: "scratch", label: "New floating pane" }, + { type: "separator" }, + { type: "input", id: "command", label: "Shell command", value: command }, + { type: "button", id: "run", label: "Run in active pane" }, + { type: "button", id: "read", label: "Show terminal text" }, + { type: "button", id: "close", label: "Close tools" }, + ], +} }); + +definePlugin({ + async activate(slerm) { + await slerm.registerCommand({ command: { id: "show-tools", title: "Show workspace tools" } }); + await show(slerm); + }, + async onEvent(event, slerm) { + if (event.event === "command" && event.command === "show-tools") await show(slerm); + if (event.event !== "ui" || event.data.panel !== "workspace") return; + const control = event.data; + if (control.kind === "change" && control.control === "command") command = control.value; + if (control.kind !== "click") return; + if (control.control === "scratch") await slerm.execute({ action: "new_floating" }); + if (control.control === "close") await slerm.removePanel({ panel: "workspace" }); + if (control.control === "run" || control.control === "read") { + const state = await slerm.getState(); + const pane = state.tabs.find(tab => tab.id === state.activeTab)?.activePane; + if (pane === undefined) return; + if (control.control === "run") await slerm.writeTerminal({ pane, text: command + "\r" }); + else await slerm.setPanel({ panel: { + id: "output", title: "Terminal snapshot", + elements: [{ type: "text", text: await slerm.readTerminal({ pane }) }], + } }); + } + }, +}); diff --git a/sdk/package-lock.json b/sdk/package-lock.json new file mode 100644 index 0000000..b005489 --- /dev/null +++ b/sdk/package-lock.json @@ -0,0 +1,515 @@ +{ + "name": "@slerm/plugin-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@slerm/plugin-sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "esbuild": "^0.27.0", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..5dd9d54 --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,29 @@ +{ + "name": "@slerm/plugin-sdk", + "version": "0.1.0", + "description": "Generated TypeScript API for Slerm's embedded Boa plugins", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "generate": "cargo run --manifest-path ../Cargo.toml -- --generate-plugin-sdk", + "build": "tsc -p tsconfig.json", + "check": "npm run build && tsc -p tsconfig.examples.json", + "example": "npm run check && esbuild examples/workspace.ts --bundle --format=iife --platform=neutral --target=es2022 --outfile=dist/workspace.js", + "test": "npm run example && node --test test/*.test.mjs", + "prepack": "npm run build" + }, + "devDependencies": { + "esbuild": "^0.27.0", + "typescript": "^6.0.3" + } +} diff --git a/sdk/runtime.js b/sdk/runtime.js new file mode 100644 index 0000000..4b47d4c --- /dev/null +++ b/sdk/runtime.js @@ -0,0 +1,28 @@ +// Generic Boa bridge. API methods and their types come from the Rust schema. +(() => { + const call = __slerm_call; + const fail = __slerm_error; + let plugin; + const api = new Proxy(Object.create(null), { + get: (_, method) => method === "then" ? undefined : async (params = {}) => { + const reply = JSON.parse(call(JSON.stringify({ ...params, method }))); + if (reply.error !== undefined) throw new Error(reply.error); + return reply.result; + }, + }); + const invoke = (callback, ...args) => { + if (callback) Promise.resolve().then(() => callback(...args)).catch(error => fail(String(error))); + }; + Object.defineProperty(globalThis, "__slerm", { value: Object.freeze({ + activate(definition, version) { + if (version !== __slerm_version) throw new Error(`Unsupported Slerm API version ${version}`); + if (plugin) throw new Error("A bundle must define exactly one plugin"); + plugin = definition; + invoke(plugin.activate, api); + }, + }) }); + Object.defineProperty(globalThis, "__slerm_dispatch", { value: event => { + if (!plugin) throw new Error("Bundle did not call definePlugin"); + invoke(plugin.onEvent, event, api); + } }); +})(); diff --git a/sdk/src/generated.ts b/sdk/src/generated.ts new file mode 100644 index 0000000..bccc03b --- /dev/null +++ b/sdk/src/generated.ts @@ -0,0 +1,30 @@ +// Generated by cargo run -- --generate-plugin-sdk. Do not edit. + +export const apiVersion = 1 as const; + +export type Action = "split_right" | "split_down" | "new_floating" | "new_pane" | "toggle_floating" | "toggle_floats" | "next_pane" | "previous_pane" | "focus_left" | "focus_down" | "focus_up" | "focus_right" | "close_pane" | "zoom_pane" | "new_tab" | "next_tab" | "previous_tab" | "reload_config" | "copy" | "paste" | "open_scrollback"; + +export type Operation = { "method": "getState", } | { "method": "execute", action: Action, } | { "method": "focusPane", pane: number, } | { "method": "focusTab", tab: number, } | { "method": "readTerminal", pane: number, } | { "method": "writeTerminal", pane: number, text: string, } | { "method": "registerCommand", command: PluginCommand, } | { "method": "unregisterCommand", command: string, } | { "method": "setPanel", panel: Panel, } | { "method": "removePanel", panel: string, } | { "method": "reportError", message: string, }; + +export type Results = { getState: State, execute: null, focusPane: null, focusTab: null, readTerminal: string, writeTerminal: null, registerCommand: null, unregisterCommand: null, setPanel: null, removePanel: null, reportError: null, }; + +export type State = { activeTab: number | null, tabs: Array, }; + +export type TabState = { id: number, activePane: number, floatsVisible: boolean, zoomed: boolean, panes: Array, }; + +export type PaneState = { id: number, title: string, floating: boolean, exited: boolean, }; + +export type PluginCommand = { id: string, title: string, }; + +export type Panel = { id: string, title: string, elements: Array, }; + +export type Element = { "type": "text", text: string, } | { "type": "button", id: string, label: string, } | { "type": "input", id: string, label: string, value: string, } | { "type": "separator" }; + +export type UIEvent = { "kind": "click", panel: string, control: string, } | { "kind": "change", panel: string, control: string, value: string, }; + +export type Event = { "event": "command", command: string, } | { "event": "state", state: State, } | { "event": "ui", data: UIEvent, }; + +type Params = Omit, "method">; +export type Slerm = { + [M in Operation["method"]]: (...args: keyof Params extends never ? [] : [params: Params]) => Promise; +}; diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000..a85f325 --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,15 @@ +import { apiVersion } from "./generated.js"; +import type { Event, Slerm } from "./generated.js"; +export * from "./generated.js"; + +export interface Plugin { + activate?(slerm: Slerm): void | Promise; + onEvent?(event: Event, slerm: Slerm): void | Promise; +} + +declare const __slerm: { activate(plugin: Plugin, version: number): void }; + +/** Register one plugin in its embedded Boa runtime. Bundle this entry point. */ +export function definePlugin(plugin: Plugin): void { + __slerm.activate(plugin, apiVersion); +} diff --git a/sdk/test/sdk.test.mjs b/sdk/test/sdk.test.mjs new file mode 100644 index 0000000..9ab44e9 --- /dev/null +++ b/sdk/test/sdk.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { runInNewContext, runInContext } from "node:vm"; +import test from "node:test"; + +const bridge = readFileSync(new URL("../runtime.js", import.meta.url), "utf8"); +const example = readFileSync(new URL("../dist/workspace.js", import.meta.url), "utf8"); +const settle = () => new Promise(resolve => setImmediate(resolve)); + +function start(reply = () => ({ result: null })) { + const operations = []; + const errors = []; + const context = { + __slerm_version: 1, + __slerm_call: text => { + const operation = JSON.parse(text); + operations.push(operation); + return JSON.stringify(reply(operation)); + }, + __slerm_error: error => errors.push(error), + }; + runInNewContext(bridge, context); + return { context, operations, errors }; +} + +test("bundled TypeScript example uses generated methods and handles UI and command events", async () => { + const host = start(operation => ({ result: operation.method === "getState" + ? { activeTab: 1, tabs: [{ id: 1, activePane: 2 }] } + : operation.method === "readTerminal" ? "hello from terminal" : null })); + runInContext(example, host.context); + await settle(); + assert.deepEqual(host.operations.map(op => op.method), ["registerCommand", "setPanel"]); + assert.equal(host.operations[1].panel.elements[3].type, "input"); + const ui = data => host.context.__slerm_dispatch({ event: "ui", data: { panel: "workspace", ...data } }); + ui({ kind: "change", control: "command", value: "echo hello" }); + await settle(); + ui({ kind: "click", control: "run" }); + await settle(); + assert.deepEqual(host.operations.at(-1), { method: "writeTerminal", pane: 2, text: "echo hello\r" }); + ui({ kind: "click", control: "scratch" }); + await settle(); + assert.deepEqual(host.operations.at(-1), { method: "execute", action: "new_floating" }); + ui({ kind: "click", control: "read" }); + await settle(); + assert.equal(host.operations.at(-1).panel.elements[0].text, "hello from terminal"); + ui({ kind: "click", control: "close" }); + await settle(); + assert.equal(host.operations.at(-1).method, "removePanel"); + host.context.__slerm_dispatch({ event: "command", command: "show-tools" }); + await settle(); + assert.equal(host.operations.at(-1).method, "setPanel"); + assert.deepEqual(host.errors, []); +}); + +test("API mismatch and duplicate registration fail before activation", () => { + const { context } = start(); + assert.throws(() => context.__slerm.activate({}, 2), /Unsupported Slerm API/); + context.__slerm.activate({}, 1); + assert.throws(() => context.__slerm.activate({}, 1), /exactly one plugin/); +}); + +test("host errors reject generated calls and surface callback failures", async () => { + const host = start(() => ({ error: "unknown pane" })); + runInContext(example, host.context); + await settle(); + assert.deepEqual(host.errors, ["Error: unknown pane"]); +}); diff --git a/sdk/test/types.ts b/sdk/test/types.ts new file mode 100644 index 0000000..2d29f1c --- /dev/null +++ b/sdk/test/types.ts @@ -0,0 +1,19 @@ +import type { Slerm, State } from "../src/index.js"; + +declare const api: Slerm; +const state: Promise = api.getState(); +const text: Promise = api.readTerminal({ pane: 1 }); +void state; void text; +// @ts-expect-error Generated methods reject unknown actions. +api.execute({ action: "missing" }); +// @ts-expect-error Pane IDs are numbers in the JSON API. +api.focusPane({ pane: "1" }); +// @ts-expect-error A terminal write requires text. +api.writeTerminal({ pane: 1 }); +// @ts-expect-error Parameterless methods take no parameters. +api.getState({}); +// @ts-expect-error Panel elements are generated from the Rust enum. +api.setPanel({ panel: { id: "test", title: "Test", elements: [{ type: "html", html: "" }] } }); +// @ts-expect-error Return types come from the Rust method list. +const invalid: Promise = api.readTerminal({ pane: 1 }); +void invalid; diff --git a/sdk/tsconfig.examples.json b/sdk/tsconfig.examples.json new file mode 100644 index 0000000..8db0f44 --- /dev/null +++ b/sdk/tsconfig.examples.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "rootDir": "." }, + "include": ["examples/**/*.ts", "test/types.ts"] +} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000..a5d1ffa --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "types": [], + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/src/app.rs b/src/app.rs index ae8a2d4..4a54866 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,8 @@ use crate::{ input, layout::{Axis, Direction, Layout, PaneId, Tab}, pane::{self, Pane}, + plugin_api::{Event, Operation, PaneState, State, TabState}, + plugins::{self, Host}, theme::{self, Theme}, }; use anyhow::{Result, ensure}; @@ -22,6 +24,7 @@ pub struct App { next_id: u64, error: Option, suppressed: HashSet, + plugins: Host, } /// Width kept free at the left of the tab bar for the macOS traffic lights, @@ -69,6 +72,11 @@ impl App { cc.egui_ctx.set_visuals(theme.visuals()); cc.egui_ctx.set_fonts(fonts(&config)?); let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?; + let config_dir = config_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(std::path::Path::new(".")); + let (plugins, errors) = Host::start(&config.plugins, config_dir, &cc.egui_ctx); Ok(Self { config, theme, @@ -77,8 +85,9 @@ impl App { tabs: vec![Tab::new(1, 1)], current: 0, next_id: 2, - error: None, + error: (!errors.is_empty()).then(|| errors.join("\n")), suppressed: HashSet::new(), + plugins, }) } @@ -292,6 +301,175 @@ impl App { } } + fn plugin_state(&self) -> State { + State { + active_tab: self.tabs.get(self.current).map(|tab| tab.id), + tabs: self + .tabs + .iter() + .map(|tab| TabState { + id: tab.id, + active_pane: tab.active, + floats_visible: tab.floats_visible, + zoomed: tab.zoomed, + panes: tab + .ids() + .iter() + .map(|id| PaneState { + id: *id, + floating: tab.floats.contains(id), + title: self.panes[id].terminal.title().unwrap_or("").into(), + exited: self.panes[id].dead, + }) + .collect(), + }) + .collect(), + } + } + + fn plugin_request( + &mut self, + index: usize, + operation: Operation, + ctx: &egui::Context, + ) -> Result { + use serde_json::json; + match operation { + Operation::GetState {} => return Ok(serde_json::to_value(self.plugin_state())?), + Operation::Execute { action } => { + ensure!(!self.tabs.is_empty(), "no active tab"); + self.action(action, ctx)?; + } + Operation::FocusPane { pane } => { + let index = self + .tabs + .iter() + .position(|tab| tab.ids().contains(&pane)) + .ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?; + self.current = index; + let tab = &mut self.tabs[index]; + tab.active = pane; + tab.floats_visible = tab.floats.contains(&pane); + } + Operation::FocusTab { tab } => { + self.current = self + .tabs + .iter() + .position(|candidate| candidate.id == tab) + .ok_or_else(|| anyhow::anyhow!("unknown tab {tab}"))?; + } + Operation::ReadTerminal { pane } => { + use libghostty_vt::fmt::{Formatter, FormatterOptions}; + let pane = self + .panes + .get(&pane) + .ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?; + let bytes = Formatter::new( + &pane.terminal, + FormatterOptions::new().with_trim(true).with_unwrap(true), + )? + .format_alloc(None)?; + ensure!( + bytes.len() <= 128 * 1024, + "terminal snapshot exceeds 128 KiB" + ); + return Ok(json!(String::from_utf8_lossy(&bytes))); + } + Operation::WriteTerminal { pane, text } => { + self.panes + .get_mut(&pane) + .ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))? + .send(text.into_bytes())?; + } + Operation::RegisterCommand { command } => { + ensure!(plugins::valid_id(&command.id), "invalid command id"); + ensure!( + !command.title.trim().is_empty() && command.title.len() <= 200, + "invalid command title" + ); + let commands = &mut self.plugins.runtimes[index].commands; + ensure!( + commands.contains_key(&command.id) || commands.len() < 64, + "command limit reached" + ); + commands.insert(command.id.clone(), command); + } + Operation::UnregisterCommand { command } => { + self.plugins.runtimes[index].commands.remove(&command); + } + Operation::SetPanel { panel } => { + panel.validate()?; + let panels = &mut self.plugins.runtimes[index].panels; + ensure!( + panels.contains_key(&panel.id) || panels.len() < 8, + "panel limit reached" + ); + panels.insert(panel.id.clone(), panel); + } + Operation::RemovePanel { panel } => { + self.plugins.runtimes[index].panels.remove(&panel); + } + Operation::ReportError { message } => { + self.error = Some(format!( + "Plugin {}: {message}", + self.plugins.runtimes[index].id + )); + } + } + Ok(serde_json::Value::Null) + } + + fn poll_plugins(&mut self, ctx: &egui::Context) { + use serde_json::json; + if self.plugins.runtimes.is_empty() { + return; + } + for index in 0..self.plugins.runtimes.len() { + // Bound work per frame even when a plugin continuously sends requests. + for count in 0..32 { + let Some(request) = self.plugins.runtimes[index].poll() else { + break; + }; + let response = match request { + Ok(request) => { + let response = match self.plugin_request(index, request.operation, ctx) { + Ok(result) => json!({"result": result}), + Err(error) => json!({"error": error.to_string()}), + }; + request + .reply + .try_send(response) + .map_err(|e| anyhow::anyhow!(e.to_string())) + } + Err(error) => Err(anyhow::anyhow!(error)), + }; + if let Err(e) = response { + let plugin = &mut self.plugins.runtimes[index]; + self.error = Some(format!("Plugin {}: {e}", plugin.id)); + plugin.stop(); + break; + } + if count == 31 { + ctx.request_repaint(); + } + } + } + let state = self.plugin_state(); + let serialized = serde_json::to_value(&state).unwrap(); + for plugin in &mut self.plugins.runtimes { + if plugin.is_running() && plugin.last_state.as_ref() != Some(&serialized) { + if let Err(e) = plugin.send(Event::State { + state: state.clone(), + }) { + self.error = Some(format!("Plugin {}: {e}", plugin.id)); + plugin.stop(); + } else { + plugin.last_state = Some(serialized.clone()); + } + } + } + } + fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> { let events = ctx.input(|i| i.events.clone()); let modifiers = ctx.input(|i| i.modifiers); @@ -388,15 +566,24 @@ impl eframe::App for App { for pane in self.panes.values_mut() { pane.drain(ctx); } - grab_keys(ctx); - if let Err(e) = self.keyboard(ctx) { - self.error = Some(e.to_string()); + self.poll_plugins(ctx); + if self.tabs.is_empty() { + return; + } + // Native plugin text fields must receive typing without forwarding it to the PTY. + let editing_ui = ctx.memory(|m| m.focused().is_some_and(|id| id != egui::Id::new("keys"))); + if !editing_ui { + grab_keys(ctx); + if let Err(e) = self.keyboard(ctx) { + self.error = Some(e.to_string()); + } } if self.tabs.is_empty() { return; } let mut action = None; let mut picked = None; + let mut plugin_command = None; egui::TopBottomPanel::top("tabs").show(ctx, |ui| { ui.horizontal_wrapped(|ui| { if cfg!(target_os = "macos") { @@ -440,6 +627,20 @@ impl eframe::App for App { } } }); + if self.plugins.runtimes.iter().any(|p| !p.commands.is_empty()) { + ui.menu_button("Plugins", |ui| { + for (index, plugin) in self.plugins.runtimes.iter().enumerate() { + ui.menu_button(&plugin.id, |ui| { + for command in plugin.commands.values() { + if ui.button(&command.title).clicked() { + plugin_command = Some((index, command.id.clone())); + ui.close(); + } + } + }); + } + }); + } ui.menu_button("Settings", |ui| { if ui.button("Reload config").clicked() { action = Some(Action::ReloadConfig); @@ -538,9 +739,18 @@ impl eframe::App for App { } } let active = tab.active; + let active_visible = tab.is_visible(active); + for error in self.plugins.show_panels(ctx) { + self.error = Some(error); + } + if let Some((index, command)) = plugin_command + && let Err(e) = self.plugins.invoke(index, &command) + { + self.error = Some(e.to_string()); + } for (id, pane) in &mut self.panes { - if let Err(e) = - pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused)) + if let Err(e) = pane + .focus(*id == active && active_visible && !editing_ui && ctx.input(|i| i.focused)) { pane.error = Some(e.to_string()); } @@ -728,6 +938,117 @@ fn pane_title(id: PaneId, pane: &Pane) -> String { mod tests { use super::*; + fn pump_plugins(app: &mut App, ctx: &egui::Context, done: impl Fn(&App) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); + loop { + for pane in app.panes.values_mut() { + pane.drain(ctx); + } + app.poll_plugins(ctx); + assert!(app.error.is_none(), "plugin error: {:?}", app.error); + if done(app) { + break; + } + assert!(std::time::Instant::now() < deadline, "plugin timed out"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + #[test] + fn embedded_plugin_controls_panes_and_native_ui() { + let ctx = egui::Context::default(); + let mut app = test_app(&ctx); + let path = std::env::temp_dir().join(format!("slerm-plugin-{}.js", std::process::id())); + std::fs::write(&path, r#" + __slerm.activate({ + async activate(api) { + let rejected = false; + try { await api.focusPane({pane:999}); } catch (_) { rejected = true; } + if (!rejected) throw Error('missing validation'); + await api.execute({action:'new_floating'}); + const state = await api.getState(); + if (state.tabs[0].panes.length !== 2) throw Error('wrong state'); + await api.writeTerminal({pane:state.tabs[0].activePane, text:'echo SDK_INPUT\r'}); + if (typeof await api.readTerminal({pane:1}) !== 'string') throw Error('wrong terminal result'); + await api.setPanel({panel:{id:'test', title:'Tools', elements:[ + {type:'button', id:'done', label:'Done'}, + {type:'input', id:'name', label:'Name', value:'Slerm'} + ]}}); + await api.registerCommand({command:{id:'ready',title:'Ready'}}); + }, + async onEvent(event, api) { + if (event.event === 'command') await api.execute({action:'new_tab'}); + if (event.event === 'ui') { + await api.removePanel({panel:event.data.panel}); + await api.registerCommand({command:{id:'passed',title:'Passed'}}); + } + } + }, 1); + "#).unwrap(); + let (host, errors) = Host::start( + &[config::Plugin { + id: "test".into(), + path: path.clone(), + }], + std::path::Path::new("."), + &ctx, + ); + std::fs::remove_file(path).unwrap(); + assert!(errors.is_empty(), "{errors:?}"); + app.plugins = host; + pump_plugins(&mut app, &ctx, |app| { + app.plugins.runtimes[0].commands.contains_key("ready") + }); + assert_eq!(app.tabs[0].floats.len(), 1); + assert_eq!(app.plugins.runtimes[0].panels.len(), 1); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + assert!(app.plugins.show_panels(ctx).is_empty()); + }); + app.plugins.invoke(0, "ready").unwrap(); + pump_plugins(&mut app, &ctx, |app| app.tabs.len() == 2); + app.plugins.runtimes[0] + .send(Event::Ui { + data: crate::plugin_api::UIEvent::Click { + panel: "test".into(), + control: "done".into(), + }, + }) + .unwrap(); + pump_plugins(&mut app, &ctx, |app| { + app.plugins.runtimes[0].commands.contains_key("passed") + }); + assert!(app.plugins.runtimes[0].panels.is_empty()); + } + + #[test] + #[ignore = "build the example first: npm run example --prefix sdk"] + fn bundled_typescript_example_runs_in_boa() { + let ctx = egui::Context::default(); + let mut app = test_app(&ctx); + let (host, errors) = Host::start( + &[config::Plugin { + id: "workspace".into(), + path: "sdk/dist/workspace.js".into(), + }], + std::path::Path::new(env!("CARGO_MANIFEST_DIR")), + &ctx, + ); + assert!(errors.is_empty(), "{errors:?}"); + app.plugins = host; + pump_plugins(&mut app, &ctx, |app| { + app.plugins.runtimes[0].panels.contains_key("workspace") + }); + app.plugins.runtimes[0] + .send(Event::Ui { + data: crate::plugin_api::UIEvent::Click { + panel: "workspace".into(), + control: "scratch".into(), + }, + }) + .unwrap(); + pump_plugins(&mut app, &ctx, |app| app.tabs[0].floats.len() == 1); + } + fn test_app(ctx: &egui::Context) -> App { let config = Config { shell: vec!["/bin/sh".into()], @@ -745,6 +1066,7 @@ mod tests { next_id: 2, error: None, suppressed: HashSet::new(), + plugins: Host::default(), } } diff --git a/src/config.rs b/src/config.rs index 905cf38..4ed80b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,15 @@ pub struct Config { pub kitty: Kitty, pub multiplexer: Multiplexer, pub keys: BTreeMap, + pub plugins: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plugin { + pub id: String, + /// JavaScript bundle path, relative to the config file. + pub path: PathBuf, } #[derive(Clone, Debug, Deserialize)] @@ -55,7 +64,7 @@ pub struct Multiplexer { pub floating_max_height: f32, } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ts_rs::TS)] #[serde(rename_all = "snake_case")] pub enum Action { SplitRight, @@ -127,6 +136,7 @@ impl Default for Config { colors: BTreeMap::new(), kitty: Kitty::default(), multiplexer: Multiplexer::default(), + plugins: Vec::new(), keys: [ ("Alt+F", Action::ToggleFloats), ("Alt+M", Action::NewPane), @@ -174,6 +184,18 @@ impl Config { pub fn parse(text: &str) -> Result { let config: Self = toml::from_str(text)?; + let mut plugin_ids = std::collections::HashSet::new(); + for plugin in &config.plugins { + ensure!( + crate::plugins::valid_id(&plugin.id) && plugin_ids.insert(&plugin.id), + "plugin ids must be unique and contain only letters, digits, '.', '-' or '_'" + ); + ensure!( + !plugin.path.as_os_str().is_empty(), + "plugin {} needs a bundle path", + plugin.id + ); + } ensure!( config.font_path.is_none() || config.font_family.is_none(), "set either font_family or font_path, not both" diff --git a/src/main.rs b/src/main.rs index dd964c4..7a02eda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,8 @@ mod fonts; mod input; mod layout; mod pane; +mod plugin_api; +mod plugins; mod pty; mod render; #[cfg(test)] @@ -29,13 +31,20 @@ fn main() -> Result<()> { } return Ok(()); } + "--generate-plugin-sdk" => { + let target = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts"); + std::fs::write(&target, plugin_api::typescript())?; + println!("Generated {}", target.display()); + return Ok(()); + } "--version" => { println!("slerm {}", env!("CARGO_PKG_VERSION")); return Ok(()); } "--help" | "-h" => { println!( - "slerm [--config PATH] [--check-config | --init-config | --list-themes]\n\nDefault config: {}", + "slerm [--config PATH] [--check-config | --init-config | --list-themes | --generate-plugin-sdk]\n\nDefault config: {}", path.display() ); return Ok(()); diff --git a/src/plugin_api.rs b/src/plugin_api.rs new file mode 100644 index 0000000..27c0a6e --- /dev/null +++ b/src/plugin_api.rs @@ -0,0 +1,163 @@ +//! The source of truth for the plugin API and its generated TypeScript bindings. +use crate::config::Action; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +pub const API_VERSION: u32 = 1; + +// Declare each method, its parameters and its result once. The enum drives Rust +// dispatch; the generated result map drives the TypeScript client's signatures. +macro_rules! plugin_api { + ($($variant:ident => $method:ident { $($(#[$attr:meta])* $arg:ident: $ty:ty),* } -> $result:ty;)+) => { + #[derive(Debug, Deserialize, TS)] + #[serde(tag = "method", rename_all = "camelCase", deny_unknown_fields)] + pub enum Operation { + $($variant { $($(#[$attr])* $arg: $ty),* },)+ + } + #[derive(TS)] + #[allow(dead_code, non_snake_case)] + pub struct Results { $(pub $method: $result,)+ } + }; +} +plugin_api! { + GetState => getState {} -> State; + Execute => execute { action: Action } -> (); + FocusPane => focusPane { #[ts(type = "number")] pane: u64 } -> (); + FocusTab => focusTab { #[ts(type = "number")] tab: u64 } -> (); + ReadTerminal => readTerminal { #[ts(type = "number")] pane: u64 } -> String; + WriteTerminal => writeTerminal { #[ts(type = "number")] pane: u64, text: String } -> (); + RegisterCommand => registerCommand { command: PluginCommand } -> (); + UnregisterCommand => unregisterCommand { command: String } -> (); + SetPanel => setPanel { panel: Panel } -> (); + RemovePanel => removePanel { panel: String } -> (); + ReportError => reportError { message: String } -> (); +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct State { + #[ts(type = "number | null")] + pub active_tab: Option, + pub tabs: Vec, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct TabState { + #[ts(type = "number")] + pub id: u64, + #[ts(type = "number")] + pub active_pane: u64, + pub floats_visible: bool, + pub zoomed: bool, + pub panes: Vec, +} + +#[derive(Clone, Debug, Serialize, TS)] +pub struct PaneState { + #[ts(type = "number")] + pub id: u64, + pub title: String, + pub floating: bool, + pub exited: bool, +} + +#[derive(Clone, Debug, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct PluginCommand { + pub id: String, + pub title: String, +} + +#[derive(Clone, Debug, Deserialize, TS)] +#[serde(deny_unknown_fields)] +pub struct Panel { + pub id: String, + pub title: String, + pub elements: Vec, +} + +#[derive(Clone, Debug, Deserialize, TS)] +#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] +pub enum Element { + Text { + text: String, + }, + Button { + id: String, + label: String, + }, + Input { + id: String, + label: String, + value: String, + }, + Separator, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum UIEvent { + Click { + panel: String, + control: String, + }, + Change { + panel: String, + control: String, + value: String, + }, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(tag = "event", rename_all = "camelCase")] +pub enum Event { + Command { command: String }, + State { state: State }, + Ui { data: UIEvent }, +} + +pub fn typescript() -> String { + let mut output = + String::from("// Generated by cargo run -- --generate-plugin-sdk. Do not edit.\n\n"); + output.push_str(&format!( + "export const apiVersion = {API_VERSION} as const;\n\n" + )); + let config = ts_rs::Config::default(); + for declaration in [ + Action::decl(&config), + Operation::decl(&config), + Results::decl(&config), + State::decl(&config), + TabState::decl(&config), + PaneState::decl(&config), + PluginCommand::decl(&config), + Panel::decl(&config), + Element::decl(&config), + UIEvent::decl(&config), + Event::decl(&config), + ] { + output.push_str("export "); + output.push_str(&declaration); + output.push_str("\n\n"); + } + output.push_str(r#"type Params = Omit, "method">; +export type Slerm = { + [M in Operation["method"]]: (...args: keyof Params extends never ? [] : [params: Params]) => Promise; +}; +"#); + output +} + +#[cfg(test)] +mod tests { + #[test] + fn generated_sdk_matches_rust_api() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts"); + assert_eq!( + std::fs::read_to_string(path).unwrap(), + super::typescript(), + "Regenerate bindings with cargo run -- --generate-plugin-sdk" + ); + } +} diff --git a/src/plugins.rs b/src/plugins.rs new file mode 100644 index 0000000..ad46024 --- /dev/null +++ b/src/plugins.rs @@ -0,0 +1,457 @@ +//! Embedded Boa runtimes, one worker thread per plugin. UI mutations stay on the UI thread. +use crate::{ + config::Plugin, + plugin_api::{API_VERSION, Element, Event, Operation, Panel, PluginCommand, UIEvent}, +}; +use anyhow::{Context as _, Result, ensure}; +use boa_engine::{ + Context, JsNativeError, JsResult, JsValue, NativeFunction, Source, + job::{Job, JobExecutor}, + js_string, + property::Attribute, +}; +use serde_json::Value; +use std::{ + cell::RefCell, + collections::{BTreeMap, VecDeque}, + path::Path, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, SyncSender, TryRecvError}, + }, + thread, + time::{Duration, Instant}, +}; + +const MAX_MESSAGE: usize = 1024 * 1024; + +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 80 + && id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) +} + +pub struct Pending { + pub operation: Operation, + pub reply: SyncSender, +} + +struct Bridge { + requests: SyncSender>, + repaint: egui::Context, + stopped: Arc, +} + +fn host_call(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let text = args + .first() + .unwrap_or(&JsValue::undefined()) + .to_string(ctx)? + .to_std_string_escaped(); + if text.len() > MAX_MESSAGE { + return Err(JsNativeError::range() + .with_message("plugin request exceeds 1 MiB") + .into()); + } + let operation = serde_json::from_str(&text) + .map_err(|e| JsNativeError::typ().with_message(e.to_string()))?; + let bridge = ctx.get_data::().unwrap(); + let (reply, response) = mpsc::sync_channel(1); + bridge + .requests + .try_send(Ok(Pending { operation, reply })) + .map_err(|_| JsNativeError::error().with_message("plugin host is unavailable"))?; + bridge.repaint.request_repaint(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if bridge.stopped.load(Ordering::Relaxed) || Instant::now() >= deadline { + return Err(JsNativeError::error() + .with_message("plugin stopped or host request timed out") + .into()); + } + match response.recv_timeout(Duration::from_millis(50)) { + Ok(value) => return Ok(js_string!(serde_json::to_string(&value).unwrap()).into()), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(_) => { + return Err(JsNativeError::error() + .with_message("plugin host disconnected") + .into()); + } + } + } +} + +fn host_error(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let text = args + .first() + .unwrap_or(&JsValue::undefined()) + .to_string(ctx)? + .to_std_string_escaped(); + let bridge = ctx.get_data::().unwrap(); + let _ = bridge.requests.try_send(Err(text)); + bridge.repaint.request_repaint(); + Ok(JsValue::undefined()) +} + +// This runtime exposes promises, but no timers or native async jobs. Bound each +// promise checkpoint as well as JS loops, so runaway callbacks can be stopped. +struct Jobs { + queue: RefCell>, + stopped: Arc, +} +impl JobExecutor for Jobs { + fn enqueue_job(self: Rc, job: Job, _: &mut Context) { + self.queue.borrow_mut().push_back(job); + } + fn run_jobs(self: Rc, ctx: &mut Context) -> JsResult<()> { + for _ in 0..10_000 { + if self.stopped.load(Ordering::Relaxed) { + return Err(JsNativeError::error().with_message("plugin stopped").into()); + } + let Some(job) = self.queue.borrow_mut().pop_front() else { + return Ok(()); + }; + match job { + Job::PromiseJob(job) => { + job.call(ctx)?; + } + Job::GenericJob(job) => { + job.call(ctx)?; + } + Job::FinalizationRegistryCleanupJob(_) => {} + _ => { + return Err(JsNativeError::error() + .with_message("unsupported plugin job") + .into()); + } + } + } + Err(JsNativeError::error() + .with_message("plugin promise job limit exceeded") + .into()) + } +} + +fn run(source: String, events: Receiver, bridge: Bridge) -> Result<()> { + let jobs = Rc::new(Jobs { + queue: RefCell::new(VecDeque::new()), + stopped: bridge.stopped.clone(), + }); + let mut ctx = Context::builder() + .job_executor(jobs) + .build() + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + ctx.runtime_limits_mut().set_loop_iteration_limit(1_000_000); + ctx.runtime_limits_mut().set_recursion_limit(128); + ctx.insert_data(bridge); + let execute = |ctx: &mut Context, source: &str| -> Result<()> { + ctx.eval(Source::from_bytes(source)) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + ctx.run_jobs().map_err(|e| anyhow::anyhow!(e.to_string()))?; + Ok(()) + }; + ctx.register_global_builtin_callable( + js_string!("__slerm_call"), + 1, + NativeFunction::from_fn_ptr(host_call), + ) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + ctx.register_global_builtin_callable( + js_string!("__slerm_error"), + 1, + NativeFunction::from_fn_ptr(host_error), + ) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + ctx.register_global_property( + js_string!("__slerm_version"), + API_VERSION, + Attribute::READONLY, + ) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + execute(&mut ctx, include_str!("../sdk/runtime.js"))?; + execute(&mut ctx, &source)?; + while let Ok(event) = events.recv() { + if ctx + .get_data::() + .unwrap() + .stopped + .load(Ordering::Relaxed) + { + break; + } + execute( + &mut ctx, + &format!("__slerm_dispatch({});", serde_json::to_string(&event)?), + )?; + } + Ok(()) +} + +pub struct Runtime { + pub id: String, + pub commands: BTreeMap, + pub panels: BTreeMap, + pub last_state: Option, + outgoing: Option>, + incoming: Option>>, + stopped: Arc, +} + +impl Runtime { + pub fn spawn(config: &Plugin, config_dir: &Path, ctx: &egui::Context) -> Result { + let path = config_dir.join(&config.path); + let source = std::fs::read_to_string(&path) + .with_context(|| format!("Cannot read plugin {} at {}", config.id, path.display()))?; + ensure!( + source.len() <= 4 * MAX_MESSAGE, + "plugin bundle exceeds 4 MiB" + ); + let (outgoing, events) = mpsc::sync_channel(64); + let (requests, incoming) = mpsc::sync_channel(64); + let stopped = Arc::new(AtomicBool::new(false)); + let bridge = Bridge { + requests: requests.clone(), + repaint: ctx.clone(), + stopped: stopped.clone(), + }; + let repaint = ctx.clone(); + thread::Builder::new() + .name(format!("plugin-{}", config.id)) + .spawn(move || { + if let Err(error) = run(source, events, bridge) { + let _ = requests.try_send(Err(error.to_string())); + repaint.request_repaint(); + } + })?; + Ok(Self { + id: config.id.clone(), + commands: BTreeMap::new(), + panels: BTreeMap::new(), + last_state: None, + outgoing: Some(outgoing), + incoming: Some(incoming), + stopped, + }) + } + + pub fn poll(&mut self) -> Option> { + match self.incoming.as_ref()?.try_recv() { + Ok(request) => Some(request), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(Err("plugin runtime stopped".into())), + } + } + + pub fn send(&self, event: Event) -> Result<()> { + let outgoing = self + .outgoing + .as_ref() + .ok_or_else(|| anyhow::anyhow!("plugin is stopped"))?; + ensure!( + serde_json::to_vec(&event)?.len() < MAX_MESSAGE, + "plugin event exceeds 1 MiB" + ); + outgoing + .try_send(event) + .context("plugin event queue is full or disconnected") + } + + pub fn is_running(&self) -> bool { + !self.stopped.load(Ordering::Relaxed) + } + + pub fn stop(&mut self) { + self.stopped.store(true, Ordering::Relaxed); + self.commands.clear(); + self.panels.clear(); + self.outgoing.take(); + self.incoming.take(); + } +} +impl Drop for Runtime { + fn drop(&mut self) { + self.stop(); + } +} +impl Panel { + pub fn validate(&self) -> Result<()> { + ensure!(valid_id(&self.id), "invalid panel id"); + ensure!( + !self.title.trim().is_empty() && self.title.len() <= 200, + "invalid panel title" + ); + ensure!( + self.elements.len() <= 128, + "a panel supports at most 128 elements" + ); + let mut ids = std::collections::HashSet::new(); + for element in &self.elements { + let id = match element { + Element::Button { id, .. } | Element::Input { id, .. } => Some(id), + _ => None, + }; + if let Some(id) = id { + ensure!( + valid_id(id) && ids.insert(id), + "invalid or duplicate control id" + ); + } + } + Ok(()) + } +} + +#[derive(Default)] +pub struct Host { + pub runtimes: Vec, +} + +impl Host { + pub fn start( + configs: &[Plugin], + config_dir: &Path, + ctx: &egui::Context, + ) -> (Self, Vec) { + let mut host = Self::default(); + let mut errors = Vec::new(); + for config in configs { + match Runtime::spawn(config, config_dir, ctx) { + Ok(process) => host.runtimes.push(process), + Err(e) => errors.push(e.to_string()), + } + } + (host, errors) + } + + pub fn invoke(&self, index: usize, command: &str) -> Result<()> { + let plugin = &self.runtimes[index]; + ensure!( + plugin.commands.contains_key(command), + "unknown plugin command" + ); + plugin.send(Event::Command { + command: command.into(), + }) + } + + pub fn show_panels(&mut self, ctx: &egui::Context) -> Vec { + let mut errors = Vec::new(); + for plugin in &mut self.runtimes { + let mut events = Vec::new(); + for panel in plugin.panels.values_mut() { + egui::Window::new(&panel.title) + .id(egui::Id::new(("plugin panel", &plugin.id, &panel.id))) + .order(egui::Order::Foreground) + .default_width(280.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(500.0) + .show(ui, |ui| { + for element in &mut panel.elements { + match element { + Element::Text { text } => { + ui.label(text.as_str()); + } + Element::Separator => { + ui.separator(); + } + Element::Button { id, label } => { + if ui + .push_id(id.as_str(), |ui| { + ui.button(label.as_str()) + }) + .inner + .clicked() + { + events.push(Event::Ui { + data: UIEvent::Click { + panel: panel.id.clone(), + control: id.clone(), + }, + }); + } + } + Element::Input { id, label, value } => { + ui.label(label.as_str()); + if ui + .add( + egui::TextEdit::singleline(value) + .id_source(id.as_str()), + ) + .changed() + { + events.push(Event::Ui { + data: UIEvent::Change { + panel: panel.id.clone(), + control: id.clone(), + value: value.clone(), + }, + }); + } + } + } + } + }); + }); + } + for event in events { + if let Err(e) = plugin.send(event) { + errors.push(format!("{}: {e}", plugin.id)); + } + } + } + errors + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn rejects_invalid_configuration_and_ui() { + assert!(crate::config::Config::parse("[[plugins]]\nid='x'\npath=''\n").is_err()); + assert!( + crate::config::Config::parse( + "[[plugins]]\nid='x'\npath='a.js'\n[[plugins]]\nid='x'\npath='b.js'\n" + ) + .is_err() + ); + assert!(serde_json::from_str::(r#"{"method":"unknown"}"#).is_err()); + let panel: Panel = + serde_json::from_value(json!({"id":"panel", "title":"Panel", "elements":[ + {"type":"button", "id":"same", "label":"One"}, + {"type":"input", "id":"same", "label":"Two", "value":""} + ]})) + .unwrap(); + assert!(panel.validate().is_err()); + } + + #[test] + fn boa_reports_script_errors_and_limits_runaway_loops_and_promises() { + for source in [ + "throw Error('broken plugin')", + "while (true) {}", + "function again() { Promise.resolve().then(again); } again();", + "__slerm.activate({}, 999)", + "process.exit(0)", + ] { + let (_, events) = mpsc::sync_channel(1); + let (requests, _) = mpsc::sync_channel(1); + let bridge = Bridge { + requests, + repaint: egui::Context::default(), + stopped: Arc::new(AtomicBool::new(false)), + }; + assert!( + run(source.into(), events, bridge).is_err(), + "source should fail: {source}" + ); + } + } +}