From e5425001ec08ceda0e301c155a6df3ab0935f02a Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 19:39:38 -0700 Subject: [PATCH 01/94] refactor: begin kernal-api filesystem capability migration Refs #229, #230, #231, #232. Replace direct fs2, globset, and notify usage with kernel-owned filesystem capabilities. Keep the sibling dependency patch on this development branch until a verified release is available. --- Cargo.lock | 511 ++++++------------ Cargo.toml | 16 +- crates/fastled-cli/Cargo.toml | 4 +- crates/fastled-cli/src/cache_upgrade.rs | 3 +- crates/fastled-cli/src/dynamic_cache.rs | 97 +++- crates/fastled-cli/src/install.rs | 4 +- crates/fastled-cli/src/runtime.rs | 11 +- crates/fastled-cli/src/sketch_preprocessor.rs | 5 +- crates/fastled-cli/src/wasm_build.rs | 2 +- crates/fastled-cli/src/watcher.rs | 116 +++- docs/kernal-api-migration.md | 70 +++ pyproject.toml | 1 + tests/unit/test_kernal_boundary.py | 22 + 13 files changed, 447 insertions(+), 415 deletions(-) create mode 100644 docs/kernal-api-migration.md create mode 100644 tests/unit/test_kernal_boundary.py diff --git a/Cargo.lock b/Cargo.lock index 8705f7b5..4c48c949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,12 +117,6 @@ dependencies = [ "derive_arbitrary", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.8" @@ -231,12 +225,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "beef" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" - [[package]] name = "bit-set" version = "0.8.0" @@ -269,11 +257,10 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", @@ -431,7 +418,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -489,12 +476,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "chrono" version = "0.4.44" @@ -857,7 +838,7 @@ dependencies = [ "anyhow", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1021,7 +1002,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1091,12 +1072,6 @@ dependencies = [ "tendril 0.5.0", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dpi" version = "0.1.2" @@ -1150,7 +1125,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg 0.55.0", + "winreg", ] [[package]] @@ -1192,7 +1167,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1206,14 +1181,13 @@ dependencies = [ "ctcb-core", "dirs 5.0.1", "flate2", - "fs2", "getrandom 0.3.4", - "globset", "gtk", "indexmap 1.9.3", "libc", "notify", "portable-pty", + "kernal-api", "reqwest 0.12.28", "running-process", "serde", @@ -1227,7 +1201,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", - "toml 0.8.2", + "toml 0.8.23", "tower-http", "tree-sitter", "tree-sitter-cpp", @@ -1263,17 +1237,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "filetime" version = "0.2.27" @@ -1291,12 +1254,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[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.9" @@ -1595,6 +1552,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1706,7 +1673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.2", + "proc-macro-crate 2.0.0", "proc-macro-error", "proc-macro2", "quote", @@ -2278,15 +2245,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -2414,6 +2372,29 @@ dependencies = [ "rayon", ] +[[package]] +name = "kernal-api" +version = "0.1.0" +dependencies = [ + "blake3", + "dirs 6.0.0", + "globset", + "jwalk", + "libc", + "mach2", + "memmap2", + "notify", + "reflink-copy", + "running-process", + "sysinfo", + "thiserror 2.0.20", + "tokio", + "toml 0.8.23", + "widestring", + "winapi", + "windows-sys 0.61.2", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2457,12 +2438,6 @@ dependencies = [ "selectors 0.24.0", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2495,9 +2470,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -2554,40 +2529,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "logos" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" -dependencies = [ - "logos-derive", -] - -[[package]] -name = "logos-codegen" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" -dependencies = [ - "beef", - "fnv", - "lazy_static", - "proc-macro2", - "quote", - "regex-syntax", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "logos-derive" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" -dependencies = [ - "logos-codegen", -] - [[package]] name = "lzma-rs" version = "0.3.0" @@ -2696,28 +2637,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "cfg-if", - "miette-derive", - "unicode-width", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "mime" version = "0.3.17" @@ -2773,16 +2692,10 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.60.2", ] -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - [[package]] name = "native-tls" version = "0.2.18" @@ -2836,18 +2749,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nodrop" version = "0.1.14" @@ -3177,17 +3078,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap 2.14.0", -] - [[package]] name = "phf" version = "0.8.0" @@ -3419,27 +3309,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3492,11 +3361,10 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" dependencies = [ - "toml_datetime 0.6.3", "toml_edit 0.20.2", ] @@ -3548,96 +3416,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" -dependencies = [ - "heck 0.4.1", - "itertools", - "log", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 2.0.117", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "prost-reflect" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89455ef41ed200cafc47c76c552ee7792370ac420497e551f16123a9135f76e" -dependencies = [ - "logos", - "miette", - "prost", - "prost-types", -] - -[[package]] -name = "prost-types" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" -dependencies = [ - "prost", -] - -[[package]] -name = "protox" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1" -dependencies = [ - "bytes", - "miette", - "prost", - "prost-reflect", - "prost-types", - "protox-parse", - "thiserror 2.0.18", -] - -[[package]] -name = "protox-parse" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366" -dependencies = [ - "logos", - "miette", - "prost-types", - "thiserror 2.0.18", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -3847,7 +3625,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3870,6 +3648,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix 1.1.4", + "windows 0.61.3", +] + [[package]] name = "regex" version = "1.12.3" @@ -3991,20 +3781,34 @@ dependencies = [ [[package]] name = "running-process" -version = "4.0.0" +version = "4.10.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64479526e50ccc2d842a78361bcf452e12e09329159e0beafd19d6e9d659d9e" +checksum = "2d49ccdbfa59730688bbbeca9c9f3f4ee4c8976e38e9e5929fee8128bbd046db" dependencies = [ "libc", - "portable-pty", - "prost-build", - "protox", - "serde", - "serde_json", + "running-process-platform-internal", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tokio", "winapi", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "running-process-platform-internal" +version = "4.10.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee9be8d7fb159393e54586b070fec01912fab04444f98f4db54fd3ceebdae59" +dependencies = [ + "libc", + "png", + "sysinfo", + "thiserror 2.0.20", + "tokio", + "widestring", + "winapi", + "windows-sys 0.61.2", + "x11rb", ] [[package]] @@ -4045,7 +3849,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4388,17 +4192,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serial2" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" -dependencies = [ - "cfg-if", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4462,16 +4255,6 @@ dependencies = [ "digest", ] -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - [[package]] name = "shell-words" version = "1.1.1" @@ -4709,6 +4492,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4774,7 +4568,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.2", + "toml 0.8.23", "version-compare", ] @@ -4885,7 +4679,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -4937,7 +4731,7 @@ dependencies = [ "sha2", "syn 2.0.117", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -4976,7 +4770,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -5039,7 +4833,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", + "thiserror 2.0.20", "toml 0.9.12+spec-1.1.0", "url", "urlpattern", @@ -5068,7 +4862,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5103,11 +4897,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -5123,13 +4917,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -5175,9 +4969,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -5260,14 +5054,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.2" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -5287,9 +5081,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.3" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -5319,7 +5113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.6.3", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -5328,12 +5122,24 @@ name = "toml_edit" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", ] [[package]] @@ -5357,6 +5163,12 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -5469,7 +5281,7 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.60.2", ] @@ -5597,12 +5409,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -5956,7 +5762,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-core 0.61.2", ] @@ -5989,7 +5795,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6511,6 +6317,9 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" @@ -6521,15 +6330,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - [[package]] name = "winreg" version = "0.55.0" @@ -6667,7 +6467,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -6699,6 +6499,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6749,7 +6566,7 @@ dependencies = [ "crash-handler", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "zccache-platform", ] @@ -6765,7 +6582,7 @@ dependencies = [ "rayon", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "zccache-core", "zccache-hash", @@ -6909,7 +6726,7 @@ dependencies = [ "memchr", "pbkdf2", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "xz2", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index b4d61727..96190ffa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ [workspace.package] version = "2.0.20" edition = "2021" -rust-version = "1.85" +rust-version = "1.95" license = "MIT" repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" @@ -15,9 +15,8 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } thiserror = "2" -notify = { version = "7", features = ["macos_fsevent"] } sha2 = "0.10" -fs2 = "0.4" +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch"] } reqwest = { version = "0.12", features = ["blocking"] } running-process = { version = "4.0.0", default-features = false } zip = "2" @@ -42,7 +41,6 @@ windows-sys = "0.61" # Parallel content-authoritative walker/hasher; remains compatible with Rust 1.85. zccache-fingerprint = { git = "https://github.com/zackees/zccache", rev = "a7c84de53105ce41b2060bd9dd7730ef226e78a1" } shell-words = "1" -globset = "0.4" tree-sitter = "0.26.9" tree-sitter-cpp = "0.23.4" @@ -50,3 +48,13 @@ tree-sitter-cpp = "0.23.4" debug = "line-tables-only" split-debuginfo = "packed" strip = "none" + +# Temporary migration override; replace with the verified published release. +[patch.crates-io] +kernal-api = { path = "../fastled-wasm-extern/kernal-api" } + +[profile.dev.package.kernal-api] +codegen-units = 1 + +[profile.test.package.kernal-api] +codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 0019db8c..f5d6a817 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -22,9 +22,8 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } -notify = { workspace = true } sha2 = { workspace = true } -fs2 = { workspace = true } +kernal-api = { workspace = true } reqwest = { workspace = true } running-process = { workspace = true } zip = { workspace = true } @@ -48,7 +47,6 @@ tauri = { workspace = true, optional = true } tempfile = "3" zccache-fingerprint = { workspace = true } shell-words = { workspace = true } -globset = { workspace = true } getrandom = "0.3" tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } diff --git a/crates/fastled-cli/src/cache_upgrade.rs b/crates/fastled-cli/src/cache_upgrade.rs index f5fba56e..f3e3a4a6 100644 --- a/crates/fastled-cli/src/cache_upgrade.rs +++ b/crates/fastled-cli/src/cache_upgrade.rs @@ -3,7 +3,6 @@ use std::io::{self, IsTerminal}; use std::path::Path; use anyhow::{Context, Result}; -use fs2::FileExt; use crate::path::NormalizedPath; @@ -93,7 +92,7 @@ where .write(true) .open(&lock_path) .with_context(|| format!("open cache upgrade lock {}", lock_path.display()))?; - FileExt::lock_exclusive(&lock) + let _guard = kernal_api::platform::fs::lock_exclusive(&lock) .with_context(|| format!("lock cache upgrade state {}", lock_path.display()))?; // Another process may have completed the cleanup while this process was diff --git a/crates/fastled-cli/src/dynamic_cache.rs b/crates/fastled-cli/src/dynamic_cache.rs index fae77e26..c22c85e5 100644 --- a/crates/fastled-cli/src/dynamic_cache.rs +++ b/crates/fastled-cli/src/dynamic_cache.rs @@ -2,12 +2,12 @@ use std::collections::BTreeMap; use std::fs::{self, File, OpenOptions}; use std::io::Read; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use anyhow::{Context, Result}; -use globset::{Glob, GlobSet, GlobSetBuilder}; -use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use kernal_api::platform::fs::{PatternSet, PatternSetBuilder}; +use kernal_api::platform::fs_watch::{ChangeKind, RecursiveMode, WatchNotification, Watcher}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -29,20 +29,21 @@ struct WatchedFingerprint { value: String, observed_generation: u64, generation: Arc, - _watcher: RecommendedWatcher, + watch_lost: Arc, + _watcher: Watcher, } static FINGERPRINT_CACHE: OnceLock< Mutex>, > = OnceLock::new(); -fn build_glob_set(patterns: &[String], default_all: bool) -> Result { - let mut builder = GlobSetBuilder::new(); +fn build_glob_set(patterns: &[String], default_all: bool) -> Result { + let mut builder = PatternSetBuilder::new(); if patterns.is_empty() && default_all { - builder.add(Glob::new("**/*")?); + builder = builder.add_pattern("**/*"); } else { for pattern in patterns { - builder.add(Glob::new(pattern)?); + builder = builder.add_pattern(pattern); } } Ok(builder.build()?) @@ -55,25 +56,27 @@ fn mark_fingerprint_dirty(generation: &AtomicU64) { fn create_fingerprint_watcher( spec: &FingerprintSpec, generation: Arc, -) -> Result { + watch_lost: Arc, +) -> Result { let root = spec.root.clone(); let include = build_glob_set(&spec.include, true)?; let exclude = build_glob_set(&spec.exclude, false)?; let callback_generation = Arc::clone(&generation); - let mut watcher = notify::recommended_watcher(move |result: notify::Result| { + let mut watcher = Watcher::new(move |result| { let relevant = match result { Err(_) => true, // overflow/backend uncertainty: force a full rescan - Ok(event) => { - matches!( - event.kind, - EventKind::Create(_) - | EventKind::Modify(_) - | EventKind::Remove(_) - | EventKind::Any - ) && event.paths.iter().any(|path| { - let relative = path.strip_prefix(root.as_path()).unwrap_or(path); - include.is_match(relative) && !exclude.is_match(relative) - }) + Ok(WatchNotification::RescanRequired(rescan)) => { + if rescan.watch_lost() { + watch_lost.store(true, Ordering::Release); + } + true + } + Ok(WatchNotification::Change(event)) => { + event.kind() != ChangeKind::Accessed + && event.paths().iter().any(|path| { + let relative = path.strip_prefix(root.as_path()).unwrap_or(path); + include.is_match(relative) && !exclude.is_match(relative) + }) } }; if relevant { @@ -112,6 +115,15 @@ fn fingerprint_tree_persistent(root: &Path, include: &[&str], exclude: &[&str]) .lock() .map_err(|_| anyhow::anyhow!("persistent fingerprint cache lock poisoned"))?; + // Recreate dead watchers before trusting a cached value. If registration + // fails, the normal construction path below performs an authoritative scan. + if cache + .get(&spec) + .is_some_and(|entry| entry.watch_lost.load(Ordering::Acquire)) + { + cache.remove(&spec); + } + if let Some(entry) = cache.get_mut(&spec) { let current = entry.generation.load(Ordering::Acquire); if current == entry.observed_generation { @@ -128,10 +140,12 @@ fn fingerprint_tree_persistent(root: &Path, include: &[&str], exclude: &[&str]) } let generation = Arc::new(AtomicU64::new(0)); - let watcher = match create_fingerprint_watcher(&spec, Arc::clone(&generation)) { - Ok(watcher) => watcher, - Err(_) => return compute_tree_fingerprint(root, include, exclude), - }; + let watch_lost = Arc::new(AtomicBool::new(false)); + let watcher = + match create_fingerprint_watcher(&spec, Arc::clone(&generation), Arc::clone(&watch_lost)) { + Ok(watcher) => watcher, + Err(_) => return compute_tree_fingerprint(root, include, exclude), + }; let before = generation.load(Ordering::Acquire); let value = compute_tree_fingerprint(root, include, exclude)?; let after = generation.load(Ordering::Acquire); @@ -141,6 +155,7 @@ fn fingerprint_tree_persistent(root: &Path, include: &[&str], exclude: &[&str]) value: value.clone(), observed_generation: if before == after { after } else { before }, generation, + watch_lost, _watcher: watcher, }, ); @@ -350,7 +365,7 @@ pub(crate) fn staging_dir(cache_root: &Path, prefix: &str) -> Result(base: &Path, action: impl FnOnce() -> Result) -> Re let lock_path = base.join(".fastled-toolchain.lock"); let lock = fs::File::create(&lock_path) .with_context(|| format!("create toolchain lock {}", lock_path.display()))?; - lock.lock_exclusive() + let guard = kernal_api::platform::fs::lock_exclusive(&lock) .with_context(|| format!("lock toolchain state {}", lock_path.display()))?; let result = action(); + drop(guard); drop(lock); result } diff --git a/crates/fastled-cli/src/runtime.rs b/crates/fastled-cli/src/runtime.rs index e7b03ae8..b286a817 100644 --- a/crates/fastled-cli/src/runtime.rs +++ b/crates/fastled-cli/src/runtime.rs @@ -7,10 +7,9 @@ use crate::archive; use anyhow::{Context, Result}; -use fs2::FileExt; use std::cmp::Ordering; use std::ffi::{OsStr, OsString}; -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, OpenOptions}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; @@ -543,7 +542,7 @@ fn purge_stale_update_files_in_dir(dir: &Path, cutoff: u64, summary: &mut GcSumm Ok(()) } -fn lock_runtime_root(run_root: &Path) -> Result { +fn lock_runtime_root(run_root: &Path) -> Result { fs::create_dir_all(run_root) .with_context(|| format!("cannot create {}", run_root.display()))?; let lock_path = run_root.join(LOCK_FILENAME); @@ -554,9 +553,8 @@ fn lock_runtime_root(run_root: &Path) -> Result { .truncate(false) .open(&lock_path) .with_context(|| format!("cannot open {}", lock_path.display()))?; - file.lock_exclusive() - .with_context(|| format!("cannot lock {}", lock_path.display()))?; - Ok(file) + kernal_api::platform::fs::lock_exclusive_owned(file) + .with_context(|| format!("cannot lock {}", lock_path.display())) } fn exe_hash_matches(path: &Path, expected_hash: &str) -> bool { @@ -672,6 +670,7 @@ fn version_from_name(name: &str) -> Option<(String, ParsedVersion)> { #[cfg(test)] mod tests { use super::*; + use std::fs::File; use std::io::Write; use tempfile::TempDir; diff --git a/crates/fastled-cli/src/sketch_preprocessor.rs b/crates/fastled-cli/src/sketch_preprocessor.rs index a79e3d3d..969b14b4 100644 --- a/crates/fastled-cli/src/sketch_preprocessor.rs +++ b/crates/fastled-cli/src/sketch_preprocessor.rs @@ -11,7 +11,6 @@ use std::io::{self, Read}; use std::path::Path; use anyhow::{bail, Context, Result}; -use fs2::FileExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tree_sitter::{Node, Parser}; @@ -148,8 +147,8 @@ fn write_snapshot(request: &SnapshotRequest, force_disk_refresh: bool) -> Result .write(true) .open(&lock_path) .with_context(|| format!("open IntelliSense lock {}", lock_path.display()))?; - lock.lock_exclusive() - .context("lock IntelliSense snapshot")?; + let _guard = + kernal_api::platform::fs::lock_exclusive(&lock).context("lock IntelliSense snapshot")?; if let Some(existing) = read_manifest(&cache_dir) { if !force_disk_refresh && existing.generation > request.generation { diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index 1da9d630..d0d7bd03 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -460,7 +460,7 @@ fn direct_clang_cflags( .truncate(false) .open(&lock_path) .with_context(|| format!("open direct cflags lock {}", lock_path.display()))?; - fs2::FileExt::lock_exclusive(&lock) + let _guard = kernal_api::platform::fs::lock_exclusive(&lock) .with_context(|| format!("lock direct cflags cache {}", cache_path.display()))?; let key = direct_cflags_key(toolchain_fingerprint, driver_args); diff --git a/crates/fastled-cli/src/watcher.rs b/crates/fastled-cli/src/watcher.rs index bbe3c6e4..2df2d3da 100644 --- a/crates/fastled-cli/src/watcher.rs +++ b/crates/fastled-cli/src/watcher.rs @@ -1,7 +1,7 @@ //! File-system watcher that ports the core logic from `filewatcher.py`. //! //! Key behaviours mirrored from Python: -//! * Watch a directory recursively via the [`notify`] crate. +//! * Watch a directory recursively through kernal-api. //! * Filter out paths that contain any of the standard ignored segments. //! * Detect *real* changes by comparing SHA-256 digests (avoids spurious //! events from editors that touch mtime without changing content). @@ -19,9 +19,7 @@ use std::{ time::{Duration, Instant}, }; -use notify::{ - event::ModifyKind, Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, -}; +use kernal_api::platform::fs_watch::{ChangeKind, RecursiveMode, WatchNotification, Watcher}; use sha2::{Digest, Sha256}; // --------------------------------------------------------------------------- @@ -114,13 +112,14 @@ pub struct FileWatcher { debounce_ms: u64, ignored_segments: Vec, stop_flag: Arc, + watch_lost: Arc, // Keep the notify watcher alive for the lifetime of FileWatcher. - _watcher: Option, + _watcher: Option>>, } impl FileWatcher { /// Create a new watcher (does not start watching yet — call [`start`]). - pub fn new(watch_dir: PathBuf, debounce_ms: u64) -> Result { + pub fn new(watch_dir: PathBuf, debounce_ms: u64) -> std::io::Result { Ok(Self { watch_dir, debounce_ms, @@ -129,6 +128,7 @@ impl FileWatcher { .map(|s| s.to_string()) .collect(), stop_flag: Arc::new(AtomicBool::new(false)), + watch_lost: Arc::new(AtomicBool::new(false)), _watcher: None, }) } @@ -152,11 +152,22 @@ impl FileWatcher { let state = Arc::new(Mutex::new(State::new())); let state_for_cb = Arc::clone(&state); let ignored = self.ignored_segments.clone(); + let watch_lost = Arc::clone(&self.watch_lost); + let callback_watch_lost = Arc::clone(&watch_lost); // --- notify event callback ------------------------------------------- - let notify_cb = move |res: notify::Result| { + let notify_cb = move |res| { let event = match res { - Ok(e) => e, + Ok(WatchNotification::Change(e)) => e, + Ok(WatchNotification::RescanRequired(rescan)) => { + if rescan.watch_lost() { + callback_watch_lost.store(true, Ordering::Release); + } + let mut s = state_for_cb.lock().unwrap(); + s.force_rescan = true; + s.last_event = Some(Instant::now()); + return; + } Err(_) => { let mut s = state_for_cb.lock().unwrap(); s.force_rescan = true; @@ -166,18 +177,15 @@ impl FileWatcher { }; // Only act on create / modify / remove events (not access). - let relevant = matches!( - event.kind, - EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any - ); + let relevant = event.kind() != ChangeKind::Accessed; if !relevant { return; } - let is_remove = matches!(event.kind, EventKind::Remove(_)); - let is_rename = matches!(event.kind, EventKind::Modify(ModifyKind::Name(_))); + let is_remove = matches!(event.kind(), ChangeKind::Removed(_)); + let is_rename = matches!(event.kind(), ChangeKind::NameModified(_)); - for path in event.paths { + for path in event.paths().iter().cloned() { // Filter ignored segments. if path_contains_ignored(&path, &ignored) { continue; @@ -228,24 +236,41 @@ impl FileWatcher { }; // --- start notify watcher -------------------------------------------- - let mut watcher = - RecommendedWatcher::new(notify_cb, Config::default()).expect("notify watcher failed"); + let mut watcher = Watcher::new(notify_cb).expect("filesystem watcher failed"); watcher .watch(&self.watch_dir, RecursiveMode::Recursive) - .expect("notify watch failed"); - self._watcher = Some(watcher); + .expect("filesystem watch failed"); + let watcher = Arc::new(Mutex::new(watcher)); + self._watcher = Some(Arc::clone(&watcher)); + let watch_dir = self.watch_dir.clone(); // --- debounce thread ------------------------------------------------- let debounce = Duration::from_millis(self.debounce_ms); let stop_flag = Arc::clone(&self.stop_flag); std::thread::spawn(move || { + let mut next_restore = Instant::now(); loop { if stop_flag.load(Ordering::Relaxed) { break; } std::thread::sleep(Duration::from_millis(50)); + if Instant::now() >= next_restore && watch_lost.swap(false, Ordering::AcqRel) { + // Registration cannot run in the backend callback. Restore + // it here and invalidate changes from the unobserved window. + let mut watcher = watcher.lock().unwrap(); + let _ = watcher.unwatch(&watch_dir); + if let Err(error) = watcher.watch(&watch_dir, RecursiveMode::Recursive) { + eprintln!("Cannot restore filesystem watch: {error}"); + watch_lost.store(true, Ordering::Release); + next_restore = Instant::now() + Duration::from_secs(1); + } + let mut s = state.lock().unwrap(); + s.force_rescan = true; + s.last_event.get_or_insert_with(Instant::now); + } + let should_flush = { let s = state.lock().unwrap(); match s.last_event { @@ -291,6 +316,12 @@ impl FileWatcher { // Private helpers // --------------------------------------------------------------------------- +impl Drop for FileWatcher { + fn drop(&mut self) { + self.stop(); + } +} + fn path_contains_ignored(path: &Path, ignored: &[String]) -> bool { path.components().any(|c| { let s = c.as_os_str().to_string_lossy(); @@ -382,6 +413,53 @@ mod tests { // FileWatcher integration tests // ------------------------------------------------------------------ + #[test] + fn dropping_watcher_stops_monitoring_and_closes_receiver() { + let dir = temp_dir(); + let mut watcher = FileWatcher::new(dir.path().to_path_buf(), 50).unwrap(); + let rx = watcher.start(); + drop(watcher); + assert_eq!( + rx.recv_timeout(Duration::from_secs(2)), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) + ); + } + + #[test] + fn lost_watch_forces_rescan_and_restores_change_delivery() { + let dir = temp_dir(); + let root = dir.path().canonicalize().unwrap(); + let mut watcher = FileWatcher::new(root.clone(), 50).unwrap(); + let rx = watcher.start(); + watcher + ._watcher + .as_ref() + .unwrap() + .lock() + .unwrap() + .unwatch(&root) + .unwrap(); + watcher.watch_lost.store(true, Ordering::Release); + assert!( + rx.recv_timeout(Duration::from_secs(2)) + .unwrap() + .force_rescan + ); + + let file = root.join("restored.ino"); + fs::write(&file, b"void setup() {}").unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let batch = rx + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .expect("restored watch must deliver file changes"); + if batch.paths.contains(&file) { + break; + } + } + watcher.stop(); + } + /// Creating / modifying a file triggers a change event. #[test] fn test_watcher_detects_file_change() { diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md new file mode 100644 index 00000000..81eb40b0 --- /dev/null +++ b/docs/kernal-api-migration.md @@ -0,0 +1,70 @@ +# kernal-api migration + +Tracking: https://github.com/zackees/fastled-wasm/issues/229 +Upstream: https://github.com/zackees/kernal-api/issues/155 + +The target is for fastled-wasm to own FastLED business logic while kernal-api +owns reusable capabilities and their implementation dependencies and tests. +Backend crate re-exports do not satisfy this boundary. Dependency count alone +does not prove faster builds; measure clean and incremental builds after the +capability migration, with the same toolchain, features, and cache conditions. + +## Capability inventory + +| Capability | Current direct dependencies | Migration work | +| --- | --- | --- | +| Advisory locks | fs2 | #230: all six call sites migrated to kernal-api guards; headless Rust suite passes | +| Glob matching and watchers | globset, notify | #231/#232: migrated to filesystem patterns and fs-watch; lost watches trigger recovery and rescan | +| Paths and tree fingerprints | dirs, zccache-fingerprint | Adapt existing filesystem/hash facade; preserve content-authoritative invalidation | +| Process lifecycle and native containment | running-process, windows-sys | Adapt semantic process API; preserve pipe draining, cancellation, and descendant cleanup | +| Async runtime, channels, clocks | tokio, tokio-stream | Use async_engine types and operations; coordinate HTTP/SSE consumer types | +| HTTP download, archive extraction, hashes | reqwest, zip, tar, zstd, flate2, sha2 | Add shared streaming APIs and move generic archive tests upstream; retain toolchain URLs/configuration here | +| HTTP server and SSE | axum, tower-http | Add shared server capability; retain routes, payloads, and FastLED policy here | +| Native viewer and terminal | tauri, tauri-build, gtk, webkit2gtk, crossterm | Extend semantic hosting as needed for test capture, microphone, graphics fixes, keyboard handling | +| Compiler provisioning and C++ parsing | ctcb-core, tree-sitter, tree-sitter-cpp | Move reusable tool/parse mechanisms; retain FastLED build and preprocessing policy | +| CLI, configuration, and utility support | clap, serde, serde_json, toml, anyhow, strsim, shell-words, tempfile, getrandom | Establish facade operations appropriate to actual consumers; remove obsolete dependencies | +| Build-only support | indexmap | Determine whether still required by build graph | +| Python launcher/tool provisioning | meson, ninja, uv, typeguard, zcmds_win32 | Audit runtime necessity and move shared provisioning into the base without breaking wheel installation | + +## Development state + +Work is on `feat/kernal-api-migration`. The migration uses an exact `=0.1.0` +declaration plus a temporary sibling path patch to +`../fastled-wasm-extern/kernal-api`, initially checked out at +`76172bf3ee41ec601d3fa834291dfbe6bfc11789`. Remove the path patch and verify +an exact published release before landing on a release branch. The application +MSRV now matches its existing 1.95 toolchain and kernal-api's requirement. + +The boundary test failed before each dependency migration and passed afterward. +All Python unit tests pass (24 passed, one skipped). The default-feature Rust +suite passes (255 library tests, two binary tests, one CLI integration test, +one doctest), including lost-watch recovery, watcher teardown, and existing +cache contention coverage. The headless suite also passed before the final +teardown test was added. Headless and default-feature Clippy pass with warnings denied. Rust +builds on this NixOS host need the installed OpenSSL/GTK/WebKit/liblzma +pkg-config and shared-library search paths, plus the bzip2 library path. +The new Python boundary check passes Ruff, Black, isort, and Pyright. +No build-speed improvement has been established yet. Cross-platform execution, +the complete repository lint script, and native viewer/browser smoke checks +remain part of the final migration audit. + +To reproduce the default-feature Rust test run on this NixOS host: + +```bash +FASTLED_PKG_CONFIG_PATH=$(printf '%s:' /nix/store/*-dev/lib/pkgconfig /nix/store/*/share/pkgconfig) +FASTLED_NATIVE_LIB_PATH=$(PKG_CONFIG_PATH="$FASTLED_PKG_CONFIG_PATH" pkg-config --libs-only-L gtk+-3.0 webkit2gtk-4.1 openssl liblzma | sed 's/-L//g; s/ /:/g') +FASTLED_BZIP_LIB_PATH=$(printf '%s:' /nix/store/*bzip2*/lib) +PKG_CONFIG_PATH="$FASTLED_PKG_CONFIG_PATH" LD_LIBRARY_PATH="$FASTLED_NATIVE_LIB_PATH:$FASTLED_BZIP_LIB_PATH" soldr cargo test --workspace +``` + +## Completion checks + +- Resolve every inventory row with code and dependency-graph evidence. +- Move generic mechanism tests upstream while retaining application integration + and product-policy coverage here. +- Consume an exact published kernal-api release without a local path patch. +- Enforce the final dependency boundary in CI. +- Run lint, Rust workspace tests, Python smoke tests, and native compiler/viewer + integration checks; verify supported-platform behavior and Safari invariants. +- Record comparable clean and incremental build measurements and explain any + remaining implementation dependencies. diff --git a/pyproject.toml b/pyproject.toml index 226c5bc2..d42abfc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dev = [ "build>=1", "setuptools>=69", "wheel>=0.43", + "tomli>=2", ] [project] diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py new file mode 100644 index 00000000..634d1b6f --- /dev/null +++ b/tests/unit/test_kernal_boundary.py @@ -0,0 +1,22 @@ +"""Keep migrated infrastructure behind the shared kernal-api boundary.""" + +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +def test_migrated_filesystem_capabilities_are_owned_by_kernal_api(): + root = Path(__file__).resolve().parents[2] + manifest = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) + dependencies = manifest["dependencies"] + assert "kernal-api" in dependencies + assert "fs2" not in dependencies + assert "globset" not in dependencies + assert "notify" not in dependencies + for source in (root / "crates/fastled-cli/src").rglob("*.rs"): + assert "fs2::" not in source.read_text(), source + assert "globset::" not in source.read_text(), source + assert "notify::" not in source.read_text(), source From a4ccb2c6f8b6d5d84dcf2c896df03cd320b1d448 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 19:49:33 -0700 Subject: [PATCH 02/94] refactor: move tree fingerprinting and mechanism tests into kernal-api Refs #229, #233. Depends on zackees/kernal-api#158. Remove the zccache dependency graph; retain FastLED source-selection and cache invalidation policy. --- Cargo.lock | 125 ------------------------ Cargo.toml | 2 - crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/dynamic_cache.rs | 34 +------ docs/kernal-api-migration.md | 30 +++++- tests/unit/test_kernal_boundary.py | 2 + 6 files changed, 34 insertions(+), 160 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c48c949..896084de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,30 +650,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crash-context" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" -dependencies = [ - "cfg-if", - "libc", - "mach2", -] - -[[package]] -name = "crash-handler" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3" -dependencies = [ - "cfg-if", - "crash-context", - "libc", - "mach2", - "parking_lot", -] - [[package]] name = "crc" version = "3.4.0" @@ -1051,12 +1027,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "doctest-file" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" - [[package]] name = "dom_query" version = "0.27.0" @@ -1207,7 +1177,6 @@ dependencies = [ "tree-sitter-cpp", "webkit2gtk", "windows-sys 0.61.2", - "zccache-fingerprint", "zip", "zstd", ] @@ -1333,16 +1302,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2210,19 +2169,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "interprocess" -version = "2.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a" -dependencies = [ - "doctest-file", - "libc", - "recvmsg", - "widestring", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -3582,12 +3528,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "recvmsg" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4976,7 +4916,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -5239,21 +5178,9 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -6558,58 +6485,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zccache-core" -version = "1.13.22" -source = "git+https://github.com/zackees/zccache?rev=a7c84de53105ce41b2060bd9dd7730ef226e78a1#a7c84de53105ce41b2060bd9dd7730ef226e78a1" -dependencies = [ - "crash-handler", - "serde", - "serde_json", - "thiserror 2.0.20", - "tracing", - "zccache-platform", -] - -[[package]] -name = "zccache-fingerprint" -version = "1.13.22" -source = "git+https://github.com/zackees/zccache?rev=a7c84de53105ce41b2060bd9dd7730ef226e78a1#a7c84de53105ce41b2060bd9dd7730ef226e78a1" -dependencies = [ - "fs2", - "globset", - "jwalk", - "rayon", - "serde", - "serde_json", - "thiserror 2.0.20", - "tracing", - "zccache-core", - "zccache-hash", -] - -[[package]] -name = "zccache-hash" -version = "1.13.22" -source = "git+https://github.com/zackees/zccache?rev=a7c84de53105ce41b2060bd9dd7730ef226e78a1#a7c84de53105ce41b2060bd9dd7730ef226e78a1" -dependencies = [ - "blake3", - "memmap2", - "serde", -] - -[[package]] -name = "zccache-platform" -version = "1.13.22" -source = "git+https://github.com/zackees/zccache?rev=a7c84de53105ce41b2060bd9dd7730ef226e78a1#a7c84de53105ce41b2060bd9dd7730ef226e78a1" -dependencies = [ - "crash-handler", - "interprocess", - "libc", - "tokio", - "windows-sys 0.61.2", -] - [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index 96190ffa..903aecec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,6 @@ toml = "0.8" tauri = { version = "2", features = ["devtools"] } tauri-build = { version = "2", features = [] } windows-sys = "0.61" -# Parallel content-authoritative walker/hasher; remains compatible with Rust 1.85. -zccache-fingerprint = { git = "https://github.com/zackees/zccache", rev = "a7c84de53105ce41b2060bd9dd7730ef226e78a1" } shell-words = "1" tree-sitter = "0.26.9" tree-sitter-cpp = "0.23.4" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index f5d6a817..646b2c3e 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -45,7 +45,6 @@ dirs = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } tempfile = "3" -zccache-fingerprint = { workspace = true } shell-words = { workspace = true } getrandom = "0.3" tree-sitter = { workspace = true } diff --git a/crates/fastled-cli/src/dynamic_cache.rs b/crates/fastled-cli/src/dynamic_cache.rs index c22c85e5..7e9f2213 100644 --- a/crates/fastled-cli/src/dynamic_cache.rs +++ b/crates/fastled-cli/src/dynamic_cache.rs @@ -88,13 +88,12 @@ fn create_fingerprint_watcher( } fn compute_tree_fingerprint(root: &Path, include: &[&str], exclude: &[&str]) -> Result { - let files = zccache_fingerprint::walk_files_glob(root, include, exclude) - .with_context(|| format!("scan fingerprint inputs under {}", root.display()))?; - zccache_fingerprint::compute_aggregate_hash(&files) + kernal_api::hash::blake3_tree(root, include, exclude, Default::default()) + .map(|digest| digest.to_hex()) .with_context(|| format!("hash fingerprint inputs under {}", root.display())) } -/// Hash a selected source tree with zccache's content-authoritative scanner. +/// Hash a selected source tree with the kernel's content-authoritative scanner. /// Paths, file count, and bytes all participate, so additions, deletions, and /// same-size edits with restored mtimes cannot produce a false cache hit. pub(crate) fn fingerprint_tree(root: &Path, include: &[&str], exclude: &[&str]) -> Result { @@ -470,33 +469,6 @@ mod tests { bytes } - #[test] - fn fingerprint_detects_same_size_edit_even_with_unchanged_mtime() { - let temp = tempfile::tempdir().unwrap(); - let source = temp.path().join("sketch.ino"); - fs::write(&source, "aaaa").unwrap(); - let original_mtime = source.metadata().unwrap().modified().unwrap(); - let first = fingerprint_tree(temp.path(), &["**/*.ino"], &[]).unwrap(); - - fs::write(&source, "bbbb").unwrap(); - let file = OpenOptions::new().write(true).open(&source).unwrap(); - file.set_modified(original_mtime).unwrap(); - let second = fingerprint_tree(temp.path(), &["**/*.ino"], &[]).unwrap(); - - assert_ne!(first, second); - } - - #[test] - fn fingerprint_detects_source_deletion() { - let temp = tempfile::tempdir().unwrap(); - fs::write(temp.path().join("a.cpp"), "a").unwrap(); - fs::write(temp.path().join("b.cpp"), "b").unwrap(); - let first = fingerprint_tree(temp.path(), &["**/*.cpp"], &[]).unwrap(); - fs::remove_file(temp.path().join("b.cpp")).unwrap(); - let second = fingerprint_tree(temp.path(), &["**/*.cpp"], &[]).unwrap(); - assert_ne!(first, second); - } - // Regression coverage for #193: the rebuild-triggering event must dirty // the persistent fingerprint before the next lookup. #[test] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 81eb40b0..536ac414 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -15,7 +15,8 @@ capability migration, with the same toolchain, features, and cache conditions. | --- | --- | --- | | Advisory locks | fs2 | #230: all six call sites migrated to kernal-api guards; headless Rust suite passes | | Glob matching and watchers | globset, notify | #231/#232: migrated to filesystem patterns and fs-watch; lost watches trigger recovery and rescan | -| Paths and tree fingerprints | dirs, zccache-fingerprint | Adapt existing filesystem/hash facade; preserve content-authoritative invalidation | +| Tree fingerprints | zccache-fingerprint | #233 / kernal-api#157: moved content hashing and generic tests upstream; removed the zccache dependency graph | +| Paths | dirs | Adapt existing filesystem facade while preserving FastLED directory layout | | Process lifecycle and native containment | running-process, windows-sys | Adapt semantic process API; preserve pipe draining, cancellation, and descendant cleanup | | Async runtime, channels, clocks | tokio, tokio-stream | Use async_engine types and operations; coordinate HTTP/SSE consumer types | | HTTP download, archive extraction, hashes | reqwest, zip, tar, zstd, flate2, sha2 | Add shared streaming APIs and move generic archive tests upstream; retain toolchain URLs/configuration here | @@ -59,6 +60,33 @@ PKG_CONFIG_PATH="$FASTLED_PKG_CONFIG_PATH" LD_LIBRARY_PATH="$FASTLED_NATIVE_LIB_ ## Completion checks +### Tree fingerprint slice + +The sibling kernel branch `feat/tree-fingerprint` (commit `87e937d`, +https://github.com/zackees/kernal-api/pull/158) adds the fs-gated +`hash::blake3_tree` capability without new dependencies. Generic same-size-edit +and deletion coverage moved upstream; FastLED retains its persistent +invalidation, source-selection, and cache matrix tests. The kernel encoding is +versioned and frames relative paths and fixed-size content hashes explicitly; +existing zccache-format cache keys receive a one-time cache miss. + +The default-feature application suite passes: 253 library tests, two binary +tests, one integration test, and one doctest. Python: 24 passed, one skipped. +The upstream `fs,fs-watch` suite passes, including seven tree-hash tests; a +subsequent invalid-filename regression also passes (eight focused tests). On +this host it needs `RUSTFLAGS='-C link-arg=-Wl,--build-id=sha1'` because an +existing process identity test requires the linker to emit a GNU build ID. +Strict Clippy passes in both repositories. The one-agent pre-push review found +no blockers; its documentation precision suggestion was addressed and tested. + +The lockfile loses 11 packages relative to `909740b`: `zccache-fingerprint`, +`zccache-core`, `zccache-hash`, `zccache-platform`, `crash-context`, +`crash-handler`, `doctest-file`, `fs2`, `interprocess`, `recvmsg`, and +`tracing-attributes`. This is graph reduction evidence, not a build-time +comparison. Publication and exact release consumption remain pending. + +### Final migration audit + - Resolve every inventory row with code and dependency-graph evidence. - Move generic mechanism tests upstream while retaining application integration and product-policy coverage here. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 634d1b6f..fee11e66 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -16,7 +16,9 @@ def test_migrated_filesystem_capabilities_are_owned_by_kernal_api(): assert "fs2" not in dependencies assert "globset" not in dependencies assert "notify" not in dependencies + assert "zccache-fingerprint" not in dependencies for source in (root / "crates/fastled-cli/src").rglob("*.rs"): assert "fs2::" not in source.read_text(), source assert "globset::" not in source.read_text(), source assert "notify::" not in source.read_text(), source + assert "zccache_fingerprint::" not in source.read_text(), source From 5d3569d4f7c6ba6dcd8c58941f1d772ed9148296 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 19:57:47 -0700 Subject: [PATCH 03/94] refactor: delegate contained process lifecycle to kernal-api Refs #234. Preserve command policy and cancellation coverage; track upstream Windows startup cleanup prerequisite in kernal-api#159. --- Cargo.lock | 2 - Cargo.toml | 2 - crates/fastled-cli/Cargo.toml | 8 - crates/fastled-cli/src/test_mode.rs | 41 ++-- crates/fastled-cli/src/viewer.rs | 324 +++------------------------- docs/kernal-api-migration.md | 24 ++- tests/unit/test_kernal_boundary.py | 7 +- 7 files changed, 83 insertions(+), 325 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 896084de..16eefccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1159,7 +1159,6 @@ dependencies = [ "portable-pty", "kernal-api", "reqwest 0.12.28", - "running-process", "serde", "serde_json", "sha2", @@ -1176,7 +1175,6 @@ dependencies = [ "tree-sitter", "tree-sitter-cpp", "webkit2gtk", - "windows-sys 0.61.2", "zip", "zstd", ] diff --git a/Cargo.toml b/Cargo.toml index 903aecec..e2e8fbb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ thiserror = "2" sha2 = "0.10" kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch"] } reqwest = { version = "0.12", features = ["blocking"] } -running-process = { version = "4.0.0", default-features = false } zip = "2" tar = "0.4" zstd = "0.13" @@ -37,7 +36,6 @@ tokio-stream = { version = "0.1", features = ["sync"] } toml = "0.8" tauri = { version = "2", features = ["devtools"] } tauri-build = { version = "2", features = [] } -windows-sys = "0.61" shell-words = "1" tree-sitter = "0.26.9" tree-sitter-cpp = "0.23.4" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 646b2c3e..6069e3db 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -25,7 +25,6 @@ clap = { workspace = true } sha2 = { workspace = true } kernal-api = { workspace = true } reqwest = { workspace = true } -running-process = { workspace = true } zip = { workspace = true } tar = { workspace = true } zstd = { workspace = true } @@ -63,13 +62,6 @@ gtk = { version = "0.18", optional = true } # Same webkit2gtk that wry links; used to grant microphone access to the page. webkit2gtk = { version = "2.0", features = ["v2_40"], optional = true } -[target.'cfg(windows)'.dependencies] -windows-sys = { workspace = true, features = [ - "Win32_Foundation", - "Win32_System_JobObjects", - "Win32_System_Threading", -] } - [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/fastled-{ target }{ archive-suffix }" bin-dir = "fastled{ binary-ext }" diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index be0883e6..412e4fee 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -108,20 +108,22 @@ async fn run_test_commands_inner( return Ok(()); } let mut command = shell_command(command_text, sketch_dir.as_path()); - let group = running_process::ContainedProcessGroup::with_originator("FASTLED_TEST_CMD") - .map_err(|e| format!("could not create command process group: {e}"))?; - let mut child = group - .spawn( - &mut command, - running_process::SpawnStdio { - stdin: running_process::StdioSource::Null, - stdout: running_process::StdioSource::Pipe, - stderr: running_process::StdioSource::Pipe, - drain_timeout: Some(std::time::Duration::from_secs(2)), - show_console: false, - }, - ) - .map_err(|e| format!("could not spawn test command {index}: {e}"))?; + command.env( + "RUNNING_PROCESS_ORIGINATOR", + format!("FASTLED_TEST_CMD:{}", std::process::id()), + ); + let mut child = kernal_api::platform::process::spawn_sync( + &mut command, + kernal_api::platform::process::SpawnStdio { + stdin: kernal_api::platform::process::StdioSource::Null, + stdout: kernal_api::platform::process::StdioSource::Pipe, + stderr: kernal_api::platform::process::StdioSource::Pipe, + drain_timeout: Some(std::time::Duration::from_secs(2)), + show_console: false, + }, + kernal_api::platform::process::SyncEnvironment::Inherit, + ) + .map_err(|e| format!("could not spawn test command {index}: {e}"))?; let (output_tx, mut output_rx) = mpsc::channel::<(CommandStream, String)>(256); let mut readers = 0usize; if let Some(stdout) = child.stdout.take() { @@ -251,8 +253,15 @@ pub(crate) async fn run_contained_command( command: &mut Command, timeout: Duration, ) -> std::io::Result { - let group = running_process::ContainedProcessGroup::with_originator("FASTLED_TEST")?; - let mut child = group.spawn(command, running_process::SpawnStdio::default())?; + command.env( + "RUNNING_PROCESS_ORIGINATOR", + format!("FASTLED_TEST:{}", std::process::id()), + ); + let mut child = kernal_api::platform::process::spawn_sync( + command, + kernal_api::platform::process::SpawnStdio::default(), + kernal_api::platform::process::SyncEnvironment::Inherit, + )?; let deadline = tokio::time::Instant::now() + timeout; let ctrl_c = tokio::signal::ctrl_c(); tokio::pin!(ctrl_c); diff --git a/crates/fastled-cli/src/viewer.rs b/crates/fastled-cli/src/viewer.rs index 546c5e05..4d02ea1e 100644 --- a/crates/fastled-cli/src/viewer.rs +++ b/crates/fastled-cli/src/viewer.rs @@ -6,12 +6,12 @@ use std::path::{Path, PathBuf}; use std::process::Command; -#[cfg(any(not(windows), test))] use std::process::Stdio; use anyhow::{Context, Result}; -#[cfg(not(windows))] -use running_process::{ContainedProcessGroup, SpawnStdio, SpawnedChild, StdioSource}; +use kernal_api::platform::process::{ + spawn_sync, SpawnStdio, SpawnedChild, StdioSource, SyncEnvironment, +}; // --------------------------------------------------------------------------- // Binary names (platform-aware) @@ -121,74 +121,23 @@ pub fn viewer_available() -> bool { // Launch // --------------------------------------------------------------------------- -#[cfg(windows)] -const VIEWER_CREATION_FLAGS: u32 = 0x0800_0000 | 0x0000_0200; // CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP - -#[cfg(windows)] -pub struct ViewerProcess { - job: windows_sys::Win32::Foundation::HANDLE, - process: windows_sys::Win32::Foundation::HANDLE, - thread: windows_sys::Win32::Foundation::HANDLE, - pid: u32, -} - -#[cfg(not(windows))] +/// The kernel owns native containment and process-handle lifetimes. pub struct ViewerProcess { - _group: ContainedProcessGroup, child: SpawnedChild, } impl ViewerProcess { - #[cfg(windows)] - pub fn pid(&self) -> u32 { - self.pid - } - - #[cfg(not(windows))] pub fn pid(&self) -> u32 { self.child.id() } - /// Non-blocking liveness probe: `true` while the viewer process is still - /// running, `false` once it has exited (window closed or crashed). - #[cfg(windows)] + /// Probe liveness without blocking. Observation errors stop the CLI rather + /// than allowing it to serve indefinitely after the viewer disappears. pub fn is_alive(&mut self) -> bool { - process_handle_is_alive(self.process) - } - - /// Non-blocking liveness probe: `true` while the viewer process is still - /// running, `false` once it has exited (window closed or crashed). - /// - /// Reaping the child here is safe: the surviving - /// [`ContainedProcessGroup`] kill-on-drop is a best-effort `killpg`, so a - /// reaped leader does not break group cleanup. - #[cfg(not(windows))] - pub fn is_alive(&mut self) -> bool { - // An error from try_wait means we can no longer observe the child; - // treat it as dead so the CLI shuts down instead of running forever. !matches!(self.child.try_wait(), Ok(Some(_)) | Err(_)) } } -#[cfg(windows)] -fn process_handle_is_alive(process: windows_sys::Win32::Foundation::HANDLE) -> bool { - use windows_sys::Win32::Foundation::WAIT_TIMEOUT; - use windows_sys::Win32::System::Threading::WaitForSingleObject; - unsafe { WaitForSingleObject(process, 0) == WAIT_TIMEOUT } -} - -#[cfg(windows)] -impl Drop for ViewerProcess { - fn drop(&mut self) { - unsafe { - windows_sys::Win32::Foundation::CloseHandle(self.thread); - windows_sys::Win32::Foundation::CloseHandle(self.process); - windows_sys::Win32::Foundation::CloseHandle(self.job); - } - } -} - -#[cfg(any(not(windows), test))] fn viewer_command(binary: &Path, url: &str, inject_test_runtime: bool) -> Command { let mut command = Command::new(binary); command.arg("--internal-viewer").arg(url); @@ -200,180 +149,13 @@ fn viewer_command(binary: &Path, url: &str, inject_test_runtime: bool) -> Comman .stdout(Stdio::null()) .stderr(Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(VIEWER_CREATION_FLAGS); - } + // Preserve the former unlabelled process group's discovery policy. + #[cfg(not(windows))] + command.env_remove("RUNNING_PROCESS_ORIGINATOR"); command } -#[cfg(windows)] -fn quote_windows_arg(arg: &std::ffi::OsStr) -> String { - let text = arg.to_string_lossy(); - if !text.is_empty() - && !text - .chars() - .any(|ch| matches!(ch, ' ' | '\t' | '"' | '\n' | '\r')) - { - return text.into_owned(); - } - - let mut quoted = String::from("\""); - let mut backslashes = 0usize; - for ch in text.chars() { - match ch { - '\\' => backslashes += 1, - '"' => { - quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); - quoted.push('"'); - backslashes = 0; - } - _ => { - quoted.push_str(&"\\".repeat(backslashes)); - backslashes = 0; - quoted.push(ch); - } - } - } - quoted.push_str(&"\\".repeat(backslashes * 2)); - quoted.push('"'); - quoted -} - -#[cfg(windows)] -fn viewer_command_line(binary: &Path, url: &str, inject_test_runtime: bool) -> Vec { - use std::os::windows::ffi::OsStrExt; - - let mut args = vec![ - binary.as_os_str(), - std::ffi::OsStr::new("--internal-viewer"), - std::ffi::OsStr::new(url), - ]; - if inject_test_runtime { - args.push(std::ffi::OsStr::new("--viewer-inject-test-runtime")); - } - let command_line = args - .iter() - .map(|arg| quote_windows_arg(arg)) - .collect::>() - .join(" "); - std::ffi::OsStr::new(&command_line) - .encode_wide() - .chain(std::iter::once(0)) - .collect() -} - -#[cfg(windows)] -fn spawn_hidden_viewer( - binary: &Path, - url: &str, - inject_test_runtime: bool, -) -> Result { - use std::mem::{size_of, zeroed}; - use std::ptr::{null, null_mut}; - - use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; - use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, - SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - }; - use windows_sys::Win32::System::Threading::{ - CreateProcessW, ResumeThread, TerminateProcess, CREATE_SUSPENDED, PROCESS_INFORMATION, - STARTF_USESHOWWINDOW, STARTUPINFOW, - }; - - let job = unsafe { CreateJobObjectW(null_mut(), null()) }; - if job.is_null() { - return Err(std::io::Error::last_os_error()).context("failed to create viewer job object"); - } - - let mut job_info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; - job_info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - let set_job_ok = unsafe { - SetInformationJobObject( - job, - JobObjectExtendedLimitInformation, - (&mut job_info as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), - size_of::() as u32, - ) - }; - if set_job_ok == FALSE { - let err = std::io::Error::last_os_error(); - unsafe { - CloseHandle(job); - } - return Err(err).context("failed to configure viewer job object"); - } - - let mut startup_info: STARTUPINFOW = unsafe { zeroed() }; - startup_info.cb = size_of::() as u32; - startup_info.dwFlags = STARTF_USESHOWWINDOW; - startup_info.wShowWindow = 0; // SW_HIDE - - let mut process_info: PROCESS_INFORMATION = unsafe { zeroed() }; - let mut command_line = viewer_command_line(binary, url, inject_test_runtime); - let flags = VIEWER_CREATION_FLAGS | CREATE_SUSPENDED; - let create_ok = unsafe { - CreateProcessW( - null(), - command_line.as_mut_ptr(), - null(), - null(), - FALSE, - flags, - null(), - null(), - &startup_info, - &mut process_info, - ) - }; - if create_ok == FALSE { - let err = std::io::Error::last_os_error(); - unsafe { - CloseHandle(job); - } - return Err(err).with_context(|| { - format!( - "failed to create FastLED viewer from '{}'", - binary.display() - ) - }); - } - - let assign_ok = unsafe { AssignProcessToJobObject(job, process_info.hProcess) }; - if assign_ok == FALSE { - let err = std::io::Error::last_os_error(); - unsafe { - TerminateProcess(process_info.hProcess, 1); - CloseHandle(process_info.hThread); - CloseHandle(process_info.hProcess); - CloseHandle(job); - } - return Err(err).context("failed to assign viewer to job object"); - } - - let resume_result = unsafe { ResumeThread(process_info.hThread) }; - if resume_result == u32::MAX { - let err = std::io::Error::last_os_error(); - unsafe { - CloseHandle(process_info.hThread); - CloseHandle(process_info.hProcess); - CloseHandle(job); - } - return Err(err).context("failed to resume viewer process"); - } - - Ok(ViewerProcess { - job, - process: process_info.hProcess, - thread: process_info.hThread, - pid: process_info.dwProcessId, - }) -} - /// Spawn the Tauri viewer, pointing it at the FastLED HTTP server `url`. /// /// The viewer is launched without inheriting or creating a terminal window, but @@ -405,31 +187,17 @@ fn launch_tauri_viewer_with_options(url: &str, inject_test_runtime: bool) -> Res let binary = find_tauri_viewer().context("fastled binary not found; cannot launch Tauri viewer")?; - #[cfg(windows)] - { - spawn_hidden_viewer(&binary, url, inject_test_runtime) - } - - #[cfg(not(windows))] - { - let group = - ContainedProcessGroup::new().context("failed to create viewer process group")?; - let mut command = viewer_command(&binary, url, inject_test_runtime); - let stdio = SpawnStdio { - stdin: StdioSource::Null, - stdout: StdioSource::Null, - stderr: StdioSource::Null, - ..SpawnStdio::default() - }; - let child = group.spawn(&mut command, stdio).with_context(|| { - format!("failed to spawn FastLED viewer from '{}'", binary.display()) - })?; - - Ok(ViewerProcess { - _group: group, - child, - }) - } + let mut command = viewer_command(&binary, url, inject_test_runtime); + let stdio = SpawnStdio { + stdin: StdioSource::Null, + stdout: StdioSource::Null, + stderr: StdioSource::Null, + show_console: false, + ..SpawnStdio::default() + }; + let child = spawn_sync(&mut command, stdio, SyncEnvironment::Inherit) + .with_context(|| format!("failed to spawn FastLED viewer from '{}'", binary.display()))?; + Ok(ViewerProcess { child }) } // --------------------------------------------------------------------------- @@ -619,58 +387,24 @@ mod tests { } #[test] - #[cfg(windows)] - fn test_viewer_uses_hidden_process_creation_flags() { - assert_eq!(VIEWER_CREATION_FLAGS & 0x0800_0000, 0x0800_0000); - assert_eq!(VIEWER_CREATION_FLAGS & 0x0000_0200, 0x0000_0200); - } - - #[test] - #[cfg(windows)] - fn test_process_handle_liveness_alive_to_dead() { - use std::os::windows::io::AsRawHandle; - - // ping -n 5 keeps the child alive for several seconds. - let mut child = Command::new("ping") - .args(["-n", "5", "127.0.0.1"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ping"); - let handle = child.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE; - - assert!( - process_handle_is_alive(handle), - "expected freshly spawned process to be alive" - ); - - child.kill().expect("kill ping"); - child.wait().expect("wait ping"); - - assert!( - !process_handle_is_alive(handle), - "expected exited process to be dead" - ); - } - - #[test] - #[cfg(not(windows))] fn test_viewer_process_is_alive_alive_to_dead() { - let group = ContainedProcessGroup::new().expect("process group"); + #[cfg(not(windows))] let mut command = Command::new("sleep"); + #[cfg(not(windows))] command.arg("30"); + #[cfg(windows)] + let mut command = Command::new("ping"); + #[cfg(windows)] + command.args(["-n", "30", "127.0.0.1"]); let stdio = SpawnStdio { stdin: StdioSource::Null, stdout: StdioSource::Null, stderr: StdioSource::Null, ..SpawnStdio::default() }; - let child = group.spawn(&mut command, stdio).expect("spawn sleep"); - let mut viewer = ViewerProcess { - _group: group, - child, - }; + let child = + spawn_sync(&mut command, stdio, SyncEnvironment::Inherit).expect("spawn viewer child"); + let mut viewer = ViewerProcess { child }; assert!( viewer.is_alive(), diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 536ac414..c4617898 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -17,7 +17,7 @@ capability migration, with the same toolchain, features, and cache conditions. | Glob matching and watchers | globset, notify | #231/#232: migrated to filesystem patterns and fs-watch; lost watches trigger recovery and rescan | | Tree fingerprints | zccache-fingerprint | #233 / kernal-api#157: moved content hashing and generic tests upstream; removed the zccache dependency graph | | Paths | dirs | Adapt existing filesystem facade while preserving FastLED directory layout | -| Process lifecycle and native containment | running-process, windows-sys | Adapt semantic process API; preserve pipe draining, cancellation, and descendant cleanup | +| Process lifecycle and native containment | running-process, windows-sys | #234: command runner and viewer use kernel-owned contained children; removed direct backend dependencies and native handle code | | Async runtime, channels, clocks | tokio, tokio-stream | Use async_engine types and operations; coordinate HTTP/SSE consumer types | | HTTP download, archive extraction, hashes | reqwest, zip, tar, zstd, flate2, sha2 | Add shared streaming APIs and move generic archive tests upstream; retain toolchain URLs/configuration here | | HTTP server and SSE | axum, tower-http | Add shared server capability; retain routes, payloads, and FastLED policy here | @@ -85,6 +85,28 @@ The lockfile loses 11 packages relative to `909740b`: `zccache-fingerprint`, `tracing-attributes`. This is graph reduction evidence, not a build-time comparison. Publication and exact release consumption remain pending. +### Process containment slice + +Tracking: https://github.com/zackees/fastled-wasm/issues/234 + +The test-command runner and native viewer use kernel `spawn_sync` and its +facade-owned child and stdio types. FastLED retains command construction, +discovery labels, timeout/cancellation policy, and output delivery. The kernel +owns process groups, Windows job handles, argument quoting, and child cleanup. +The existing runner cancellation and inherited-output-pipe regressions pass; +the viewer liveness regression now covers the same facade on every platform. +Direct `running-process` and `windows-sys` dependencies are removed, although +their private kernel implementations remain in the transitive graph. + +The default-feature Rust suite passes (253 library tests, two binary tests, +one integration test, one doctest), as do Python tests (24 passed, one skipped). +Strict default-feature Clippy passes with warnings denied. The Windows MSVC +headless all-targets cross-check passes with `-j1`; the initial attempt was +terminated by the shared build scheduler, not a compiler diagnostic. Native +Windows execution remains pending. Review found a kernel startup-error cleanup +gap tracked in https://github.com/zackees/kernal-api/issues/159; preserving the +old viewer's failed-resume handling is a prerequisite for release consumption. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index fee11e66..6203cd2e 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -8,7 +8,7 @@ import tomli as tomllib -def test_migrated_filesystem_capabilities_are_owned_by_kernal_api(): +def test_migrated_capabilities_are_owned_by_kernal_api(): root = Path(__file__).resolve().parents[2] manifest = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) dependencies = manifest["dependencies"] @@ -17,8 +17,13 @@ def test_migrated_filesystem_capabilities_are_owned_by_kernal_api(): assert "globset" not in dependencies assert "notify" not in dependencies assert "zccache-fingerprint" not in dependencies + assert "running-process" not in dependencies + for target in manifest.get("target", {}).values(): + assert "windows-sys" not in target.get("dependencies", {}) for source in (root / "crates/fastled-cli/src").rglob("*.rs"): assert "fs2::" not in source.read_text(), source assert "globset::" not in source.read_text(), source assert "notify::" not in source.read_text(), source assert "zccache_fingerprint::" not in source.read_text(), source + assert "running_process::" not in source.read_text(), source + assert "windows_sys::" not in source.read_text(), source From 1fdb7e276f7ec70cb83033defe489aed8d13e9de Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:02:54 -0700 Subject: [PATCH 04/94] docs: track upstream process cleanup validation and hash migration --- docs/kernal-api-migration.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index c4617898..fc9d8684 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -19,7 +19,7 @@ capability migration, with the same toolchain, features, and cache conditions. | Paths | dirs | Adapt existing filesystem facade while preserving FastLED directory layout | | Process lifecycle and native containment | running-process, windows-sys | #234: command runner and viewer use kernel-owned contained children; removed direct backend dependencies and native handle code | | Async runtime, channels, clocks | tokio, tokio-stream | Use async_engine types and operations; coordinate HTTP/SSE consumer types | -| HTTP download, archive extraction, hashes | reqwest, zip, tar, zstd, flate2, sha2 | Add shared streaming APIs and move generic archive tests upstream; retain toolchain URLs/configuration here | +| HTTP download, archive extraction, hashes | reqwest, zip, tar, zstd, flate2, sha2 | #235 tracks SHA-256 facade migration; add shared streaming APIs and move generic archive tests upstream; retain toolchain URLs/configuration here | | HTTP server and SSE | axum, tower-http | Add shared server capability; retain routes, payloads, and FastLED policy here | | Native viewer and terminal | tauri, tauri-build, gtk, webkit2gtk, crossterm | Extend semantic hosting as needed for test capture, microphone, graphics fixes, keyboard handling | | Compiler provisioning and C++ parsing | ctcb-core, tree-sitter, tree-sitter-cpp | Move reusable tool/parse mechanisms; retain FastLED build and preprocessing policy | @@ -107,6 +107,15 @@ Windows execution remains pending. Review found a kernel startup-error cleanup gap tracked in https://github.com/zackees/kernal-api/issues/159; preserving the old viewer's failed-resume handling is a prerequisite for release consumption. +The fix is implemented on sibling branch `feat/windows-spawn-cleanup`, commit +`f23da2a`, in https://github.com/zackees/kernal-api/pull/160 (stacked on #158). +Job creation now precedes the suspended child, owned cleanup covers later +startup failures, and failed resume is reported. Linux kernel tests and strict +Windows cross-target Clippy pass. The source ownership regression was observed +RED then GREEN. Native Windows fault-injection tests for assignment, resume, +and duplication failures are added but await CI execution. One-agent pre-push +review found no actionable issues. Neither upstream PR is a published release. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. From b78952ea2e2e438b822a9e1a94b9b46b4d4d2c86 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:08:47 -0700 Subject: [PATCH 05/94] refactor: move SHA-256 mechanisms behind kernal-api Refs #235. Preserve checksum and cache encodings; move generic hash coverage upstream. --- Cargo.lock | 2 +- Cargo.toml | 3 +- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/archive.rs | 55 ++----------------- crates/fastled-cli/src/dynamic_cache.rs | 2 +- .../src/dynamic_cache_matrix_tests.rs | 2 +- crates/fastled-cli/src/frontend.rs | 2 +- crates/fastled-cli/src/sketch_preprocessor.rs | 2 +- crates/fastled-cli/src/wasm_build.rs | 2 +- crates/fastled-cli/src/watcher.rs | 8 +-- docs/kernal-api-migration.md | 27 +++++++++ tests/unit/test_kernal_boundary.py | 2 + 12 files changed, 45 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16eefccd..d3114569 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1161,7 +1161,6 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", "shell-words", "strsim", "tar", @@ -2330,6 +2329,7 @@ dependencies = [ "notify", "reflink-copy", "running-process", + "sha2", "sysinfo", "thiserror 2.0.20", "tokio", diff --git a/Cargo.toml b/Cargo.toml index e2e8fbb7..bbb7fb6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,7 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } thiserror = "2" -sha2 = "0.10" -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256"] } reqwest = { version = "0.12", features = ["blocking"] } zip = "2" tar = "0.4" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 6069e3db..a6e1acfb 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -22,7 +22,6 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } -sha2 = { workspace = true } kernal-api = { workspace = true } reqwest = { workspace = true } zip = { workspace = true } diff --git a/crates/fastled-cli/src/archive.rs b/crates/fastled-cli/src/archive.rs index 5d3f1ea1..bb9ec0e1 100644 --- a/crates/fastled-cli/src/archive.rs +++ b/crates/fastled-cli/src/archive.rs @@ -11,7 +11,6 @@ use std::io::{BufReader, BufWriter, Read, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; // --------------------------------------------------------------------------- // Download @@ -69,23 +68,10 @@ pub fn download(url: &str, dest: &Path) -> Result<()> { /// Compute the SHA-256 hex digest of `path`. pub fn sha256_file(path: &Path) -> Result { - let file = - File::open(path).with_context(|| format!("cannot open {} for hashing", path.display()))?; - let mut reader = BufReader::new(file); - let mut hasher = Sha256::new(); - - let mut buf = vec![0u8; 1024 * 1024]; - loop { - let n = reader - .read(&mut buf) - .with_context(|| format!("read error hashing {}", path.display()))?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - - Ok(format!("{:x}", hasher.finalize())) + // FastLED toolchain artifacts must fit within this product-level bound. + kernal_api::hash::sha256_file(path, 16 * 1024 * 1024 * 1024) + .map(|digest| digest.to_hex()) + .with_context(|| format!("cannot hash {}", path.display())) } /// Return `true` when the SHA-256 digest of `path` matches `expected`. @@ -335,19 +321,9 @@ mod tests { /// A known SHA-256 digest of the bytes `b"hello fastled"`. /// - /// Computed via: `sha2::Sha256::digest(b"hello fastled")` rendered as hex. + /// Fixed fixture; generic SHA-256 known-vector tests live in kernal-api. const HELLO_SHA256: &str = "9371e29d4390b284420993a4161cbda766dc36ce4d8396398a849d1de4d652d6"; - #[test] - fn test_sha256_known_value() { - // Use sha2 directly to verify the constant matches the real digest. - let content = b"hello fastled"; - let mut h = Sha256::new(); - h.update(content); - let actual = format!("{:x}", h.finalize()); - assert_eq!(actual, HELLO_SHA256, "HELLO_SHA256 constant is incorrect"); - } - #[test] fn test_verify_sha256_correct() { let dir = temp_dir(); @@ -355,12 +331,7 @@ mod tests { let content = b"hello fastled"; fs::write(&file, content).unwrap(); - // Compute expected digest independently. - let mut h = Sha256::new(); - h.update(content); - let expected = format!("{:x}", h.finalize()); - - let ok = verify_sha256(&file, &expected).expect("verify_sha256"); + let ok = verify_sha256(&file, HELLO_SHA256).expect("verify_sha256"); assert!(ok, "digest should match"); } @@ -374,20 +345,6 @@ mod tests { assert!(!ok, "digest should not match different content"); } - #[test] - fn test_sha256_file_changes_on_content_change() { - let dir = temp_dir(); - let file = dir.path().join("test.bin"); - - fs::write(&file, b"version one").unwrap(); - let h1 = sha256_file(&file).expect("hash1"); - - fs::write(&file, b"version two").unwrap(); - let h2 = sha256_file(&file).expect("hash2"); - - assert_ne!(h1, h2, "hash must differ after content change"); - } - // ------------------------------------------------------------------ // ZIP extraction // ------------------------------------------------------------------ diff --git a/crates/fastled-cli/src/dynamic_cache.rs b/crates/fastled-cli/src/dynamic_cache.rs index 7e9f2213..8eca0674 100644 --- a/crates/fastled-cli/src/dynamic_cache.rs +++ b/crates/fastled-cli/src/dynamic_cache.rs @@ -6,10 +6,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use anyhow::{Context, Result}; +use kernal_api::hash::Sha256Hasher as Sha256; use kernal_api::platform::fs::{PatternSet, PatternSetBuilder}; use kernal_api::platform::fs_watch::{ChangeKind, RecursiveMode, WatchNotification, Watcher}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::path::NormalizedPath; diff --git a/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs b/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs index 0e66bade..201f91cd 100644 --- a/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs +++ b/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use anyhow::Result; -use sha2::{Digest, Sha256}; +use kernal_api::hash::Sha256Hasher as Sha256; use crate::dynamic_cache; use crate::path::NormalizedPath; diff --git a/crates/fastled-cli/src/frontend.rs b/crates/fastled-cli/src/frontend.rs index 3734f133..f1c5d7b7 100644 --- a/crates/fastled-cli/src/frontend.rs +++ b/crates/fastled-cli/src/frontend.rs @@ -13,7 +13,7 @@ use std::process::Command; use std::time::UNIX_EPOCH; use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; +use kernal_api::hash::Sha256Hasher as Sha256; use crate::install; diff --git a/crates/fastled-cli/src/sketch_preprocessor.rs b/crates/fastled-cli/src/sketch_preprocessor.rs index 969b14b4..accbaba0 100644 --- a/crates/fastled-cli/src/sketch_preprocessor.rs +++ b/crates/fastled-cli/src/sketch_preprocessor.rs @@ -11,8 +11,8 @@ use std::io::{self, Read}; use std::path::Path; use anyhow::{bail, Context, Result}; +use kernal_api::hash::Sha256Hasher as Sha256; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use tree_sitter::{Node, Parser}; use crate::path::NormalizedPath; diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index d0d7bd03..060185d9 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -12,8 +12,8 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; +use kernal_api::hash::Sha256Hasher as Sha256; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::cli::LinkMode; use crate::{archive, debug_symbols, dynamic_cache, frontend, install, source}; diff --git a/crates/fastled-cli/src/watcher.rs b/crates/fastled-cli/src/watcher.rs index 2df2d3da..a6e48900 100644 --- a/crates/fastled-cli/src/watcher.rs +++ b/crates/fastled-cli/src/watcher.rs @@ -10,7 +10,6 @@ use std::{ collections::HashMap, - fs, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, @@ -20,7 +19,6 @@ use std::{ }; use kernal_api::platform::fs_watch::{ChangeKind, RecursiveMode, WatchNotification, Watcher}; -use sha2::{Digest, Sha256}; // --------------------------------------------------------------------------- // Constants @@ -59,9 +57,9 @@ pub(crate) struct WatchBatch { /// Return the SHA-256 hex digest of a file, or `None` if the file cannot be /// read (e.g. it was deleted between the notification and the read). fn file_hash(path: &Path) -> Option { - let bytes = fs::read(path).ok()?; - let digest = Sha256::digest(&bytes); - Some(format!("{digest:x}")) + kernal_api::hash::sha256_file(path, 16 * 1024 * 1024 * 1024) + .ok() + .map(|digest| digest.to_hex()) } // --------------------------------------------------------------------------- diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index fc9d8684..ba927770 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -116,6 +116,33 @@ RED then GREEN. Native Windows fault-injection tests for assignment, resume, and duplication failures are added but await CI execution. One-agent pre-push review found no actionable issues. Neither upstream PR is a published release. +### SHA-256 slice + +Tracking: https://github.com/zackees/fastled-wasm/issues/235 and +https://github.com/zackees/kernal-api/issues/161. + +The sibling `feat/sha256-facade` branch adds optional `hash-sha256`, reusing +the kernel's existing private SHA implementation dependency. Owned digests, +incremental state, and bounded reader/file operations preserve all SHA-256 +encodings. The application removes direct `sha2` and uses the facade for cache +keys, preprocessing, frontend/build fingerprints, archive seals, and watchers. +Archive and watcher file hashing use an explicit 16 GiB application limit and +64 KiB kernel streaming buffer. Other existing incremental application hash +encodings remain unchanged. Two generic archive hash tests move upstream; +artifact verification and FastLED cache-policy coverage remain here. + +The full application suite passes (251 library tests, two binary tests, one +integration test, one doctest), as do Python tests (24 passed, one skipped). +The kernel `fs,fs-watch,hash-sha256` suite passes, including known vectors, +chunking, changed file contents, interruptions, read failures, and byte limits. +Dependency-isolation checks prove SHA is absent by default and present with +its feature. The one-agent review found no actionable issues. App strict +Clippy passes with `-j1` in both repositories; initial concurrent lint attempts failed without +preserved compiler diagnostics and required sequential retries. + +Tree PR #158 has now merged after all CI checks passed. Cleanup PR #160 targets +main. Exact published release consumption remains pending for all slices. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 6203cd2e..eb66ef12 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -18,6 +18,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "notify" not in dependencies assert "zccache-fingerprint" not in dependencies assert "running-process" not in dependencies + assert "sha2" not in dependencies for target in manifest.get("target", {}).values(): assert "windows-sys" not in target.get("dependencies", {}) for source in (root / "crates/fastled-cli/src").rglob("*.rs"): @@ -27,3 +28,4 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "zccache_fingerprint::" not in source.read_text(), source assert "running_process::" not in source.read_text(), source assert "windows_sys::" not in source.read_text(), source + assert "sha2::" not in source.read_text(), source From 5f14ec761b13be152fd9b732a6304463e5b3f0b5 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:13:12 -0700 Subject: [PATCH 06/94] refactor: resolve user homes through kernal-api filesystem Refs #236. Preserve FastLED layout and remove duplicate dirs dependency graph. --- Cargo.lock | 113 ++--------------------- Cargo.toml | 1 - crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/cache_upgrade.rs | 2 +- crates/fastled-cli/src/compile_stream.rs | 2 +- crates/fastled-cli/src/install.rs | 3 +- crates/fastled-cli/src/runtime.rs | 2 +- crates/fastled-cli/src/source.rs | 3 +- docs/kernal-api-migration.md | 19 +++- tests/unit/test_kernal_boundary.py | 2 + 10 files changed, 34 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3114569..84885c7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,34 +939,13 @@ dependencies = [ "subtle", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -977,7 +956,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -1149,7 +1128,6 @@ dependencies = [ "clap", "crossterm", "ctcb-core", - "dirs 5.0.1", "flate2", "getrandom 0.3.4", "gtk", @@ -2320,7 +2298,7 @@ name = "kernal-api" version = "0.1.0" dependencies = [ "blake3", - "dirs 6.0.0", + "dirs", "globset", "jwalk", "libc", @@ -3544,17 +3522,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -4585,7 +4552,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4635,7 +4602,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs", "glob", "heck 0.5.0", "json-patch", @@ -5195,7 +5162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs", "libappindicator", "muda", "objc2", @@ -5922,15 +5889,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -5982,21 +5940,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6054,12 +5997,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6078,12 +6015,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6102,12 +6033,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6138,12 +6063,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6162,12 +6081,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6186,12 +6099,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6210,12 +6117,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6369,7 +6270,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs", "dom_query", "dpi", "dunce", diff --git a/Cargo.toml b/Cargo.toml index bbb7fb6d..5a91efc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" ctcb-core = "0.4.1" -dirs = "5" strsim = "0.11" crossterm = "0.28" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "signal"] } diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index a6e1acfb..1aea803b 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -39,7 +39,6 @@ axum = { workspace = true, features = ["ws"] } portable-pty = "=0.9.0" tower-http = { workspace = true } tokio-stream = { workspace = true } -dirs = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } tempfile = "3" diff --git a/crates/fastled-cli/src/cache_upgrade.rs b/crates/fastled-cli/src/cache_upgrade.rs index f3e3a4a6..920bdac8 100644 --- a/crates/fastled-cli/src/cache_upgrade.rs +++ b/crates/fastled-cli/src/cache_upgrade.rs @@ -22,7 +22,7 @@ fn fastled_root() -> Result { return Ok(NormalizedPath::new(root)); } Ok(NormalizedPath::new( - dirs::home_dir() + kernal_api::platform::fs::user_home_dir() .context("cannot resolve home directory for cache upgrade cleanup")? .join(".fastled"), )) diff --git a/crates/fastled-cli/src/compile_stream.rs b/crates/fastled-cli/src/compile_stream.rs index 9bb9e90e..42f7758d 100644 --- a/crates/fastled-cli/src/compile_stream.rs +++ b/crates/fastled-cli/src/compile_stream.rs @@ -132,7 +132,7 @@ pub(crate) fn announce_link_mode( } pub(crate) fn purge_fastled_cache(fastled_path: Option<&str>) { - if let Some(home) = dirs::home_dir() { + if let Some(home) = kernal_api::platform::fs::user_home_dir() { let cache_dir = home.join(".fastled").join("cache"); if cache_dir.exists() { match std::fs::remove_dir_all(&cache_dir) { diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index c15c6f57..e03bb1cc 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -61,7 +61,8 @@ const FASTLED_LATEST_RELEASE_API: &str = "https://api.github.com/repos/FastLED/FastLED/releases/latest"; fn fastled_root() -> Result { - let home = dirs::home_dir().context("cannot resolve home directory")?; + let home = + kernal_api::platform::fs::user_home_dir().context("cannot resolve home directory")?; Ok(home.join(".fastled")) } diff --git a/crates/fastled-cli/src/runtime.rs b/crates/fastled-cli/src/runtime.rs index b286a817..bcf540bb 100644 --- a/crates/fastled-cli/src/runtime.rs +++ b/crates/fastled-cli/src/runtime.rs @@ -154,7 +154,7 @@ impl RuntimePaths { let root = if let Some(root) = std::env::var_os(ROOT_ENV_VAR) { PathBuf::from(root) } else { - dirs::home_dir() + kernal_api::platform::fs::user_home_dir() .context("could not determine home directory")? .join(".fastled") }; diff --git a/crates/fastled-cli/src/source.rs b/crates/fastled-cli/src/source.rs index 468304f2..c0ea155c 100644 --- a/crates/fastled-cli/src/source.rs +++ b/crates/fastled-cli/src/source.rs @@ -81,7 +81,8 @@ impl SourceStatus { /// Resolve the `~/.fastled/cache` directory. pub(crate) fn default_cache_base() -> Result { - let home = dirs::home_dir().context("cannot resolve home directory")?; + let home = + kernal_api::platform::fs::user_home_dir().context("cannot resolve home directory")?; Ok(NormalizedPath::new(home.join(".fastled").join("cache"))) } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index ba927770..1325638e 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -16,7 +16,7 @@ capability migration, with the same toolchain, features, and cache conditions. | Advisory locks | fs2 | #230: all six call sites migrated to kernal-api guards; headless Rust suite passes | | Glob matching and watchers | globset, notify | #231/#232: migrated to filesystem patterns and fs-watch; lost watches trigger recovery and rescan | | Tree fingerprints | zccache-fingerprint | #233 / kernal-api#157: moved content hashing and generic tests upstream; removed the zccache dependency graph | -| Paths | dirs | Adapt existing filesystem facade while preserving FastLED directory layout | +| Paths | dirs | #236 / kernal-api#163: filesystem user-home capability preserves account fallback; five callers migrated without changing layout | | Process lifecycle and native containment | running-process, windows-sys | #234: command runner and viewer use kernel-owned contained children; removed direct backend dependencies and native handle code | | Async runtime, channels, clocks | tokio, tokio-stream | Use async_engine types and operations; coordinate HTTP/SSE consumer types | | HTTP download, archive extraction, hashes | reqwest, zip, tar, zstd, flate2, sha2 | #235 tracks SHA-256 facade migration; add shared streaming APIs and move generic archive tests upstream; retain toolchain URLs/configuration here | @@ -143,6 +143,23 @@ preserved compiler diagnostics and required sequential retries. Tree PR #158 has now merged after all CI checks passed. Cleanup PR #160 targets main. Exact published release consumption remains pending for all slices. +### User-home slice + +Tracking: https://github.com/zackees/fastled-wasm/issues/236 and +https://github.com/zackees/kernal-api/issues/163. + +Kernel `platform::fs::user_home_dir` uses the existing private directory backend +and preserves native account fallback; the environment-only host fact API is +unchanged. FastLED's five callers retain all overrides, product path components, +and caller fallback policies. The direct `dirs` dependency is removed. The +lockfile drops its version-5 directory stack and obsolete Windows bindings. +Kernel tests cover native backend parity and nonempty/empty/missing `HOME`, +isolated in child test processes. The application suite passes (251 library, +two binary, one integration, one doctest) and Python passes (24, one skipped). +The full kernel `fs,fs-watch,hash-sha256` suite and strict Clippy in both +repositories pass. One-agent pre-push review found no actionable issues. +Archive extraction migration is next, tracked in application issue #237. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index eb66ef12..50c43c07 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -19,6 +19,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "zccache-fingerprint" not in dependencies assert "running-process" not in dependencies assert "sha2" not in dependencies + assert "dirs" not in dependencies for target in manifest.get("target", {}).values(): assert "windows-sys" not in target.get("dependencies", {}) for source in (root / "crates/fastled-cli/src").rglob("*.rs"): @@ -29,3 +30,4 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "running_process::" not in source.read_text(), source assert "windows_sys::" not in source.read_text(), source assert "sha2::" not in source.read_text(), source + assert "dirs::" not in source.read_text(), source From d4b7d1ce58f300f1f5edc2326c020e4488aa5c5c Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:20:17 -0700 Subject: [PATCH 07/94] docs: record archive migration foundation and remaining gates --- docs/kernal-api-migration.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 1325638e..9e497952 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -160,6 +160,24 @@ The full kernel `fs,fs-watch,hash-sha256` suite and strict Clippy in both repositories pass. One-agent pre-push review found no actionable issues. Archive extraction migration is next, tracked in application issue #237. +### Archive slice (in progress; not adopted) + +Tracking: application #237 and https://github.com/zackees/kernal-api/issues/165. +The sibling `feat/archive-facade` branch contains an unpublished ZIP foundation: +owned format/limit types, empty caller-exclusive destination contract, input, +metadata, entry, path and output ceilings, fixed-buffer copying, and executable +bit preservation. Four focused tests and strict archive-feature Clippy pass. +The focused test first failed because the archive feature did not exist. + +This is not yet the required archive capability: ZIP symlinks currently fail +explicitly; tar.zst, selected tgz members, internal symlinks/hardlinks, extended +tar metadata bounds, ZIP64 adversarial metadata validation, and real artifact +checks remain required before adoption. No archive dependencies or tests have +been removed from FastLED yet. All installer full-extraction call sites use +empty staging directories. A real esbuild fixture is cached at +`~/.fastled/toolchains/archives/esbuild-linux-x64-0.28.0.tgz`; use it read-only +and extract into a fresh temporary directory for the acceptance check. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. From 2f303bc7827bbc4d40ace719dea3d7668d394b88 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:25:38 -0700 Subject: [PATCH 08/94] docs: record ZIP link and real-archive validation progress --- docs/kernal-api-migration.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 9e497952..86288a3e 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -169,9 +169,16 @@ metadata, entry, path and output ceilings, fixed-buffer copying, and executable bit preservation. Four focused tests and strict archive-feature Clippy pass. The focused test first failed because the archive feature did not exist. -This is not yet the required archive capability: ZIP symlinks currently fail -explicitly; tar.zst, selected tgz members, internal symlinks/hardlinks, extended -tar metadata bounds, ZIP64 adversarial metadata validation, and real artifact +ZIP links now use a deferred, bounded virtual graph: relative internal chains +are preserved, while escaping/cyclic/dangling links fail before link creation. +ZIP64 preflight validates metadata before backend allocation. Eight regressions +pass, plus read-only acceptance of a cached PlatformIO ZIP extracted into fresh +temporary staging. Strict archive-feature Clippy passes. Native Windows link +creation still needs execution coverage; post-creation resolution semantics and +additional malformed ZIP64 cases remain review targets. + +This is not yet the required archive capability: tar.zst, selected tgz members, +tar symlinks/hardlinks, bounded extended tar metadata, and real toolchain artifact checks remain required before adoption. No archive dependencies or tests have been removed from FastLED yet. All installer full-extraction call sites use empty staging directories. A real esbuild fixture is cached at From 0259c32dbf10174e7dfdb0739d082946410b4835 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:45:16 -0700 Subject: [PATCH 09/94] docs: record archive facade acceptance and review progress (refs #237) --- docs/kernal-api-migration.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 86288a3e..4f7d7385 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -177,13 +177,26 @@ temporary staging. Strict archive-feature Clippy passes. Native Windows link creation still needs execution coverage; post-creation resolution semantics and additional malformed ZIP64 cases remain review targets. -This is not yet the required archive capability: tar.zst, selected tgz members, -tar symlinks/hardlinks, bounded extended tar metadata, and real toolchain artifact -checks remain required before adoption. No archive dependencies or tests have -been removed from FastLED yet. All installer full-extraction call sites use -empty staging directories. A real esbuild fixture is cached at -`~/.fastled/toolchains/archives/esbuild-linux-x64-0.28.0.tgz`; use it read-only -and extract into a fresh temporary directory for the acceptance check. +The draft now also handles tar.zst, full/selected tgz extraction, tar symlinks +and hardlinks, and bounded GNU/PAX metadata. Fourteen focused regressions pass. +Review exposed a local/global PAX size-state mismatch and invalid link-prefix +normalization; both were reproduced with failing regressions and fixed, +including terminal directory suffixes. The default rejects dangling links; an +explicit `PreserveMissingLeaf` policy preserves missing final targets under +existing directories without allowing missing intermediate paths or hardlinks. + +Read-only acceptance passed for cached esbuild tgz member extraction and the +catalog-pinned Emscripten 4.0.21 Linux x86-64 tar.zst. The latter's SHA-256 matched +`5cd3cbe0316d37c9b39bdc63691c014f136a5d82a9f08ed29bb7ad62f7a83655`; extracted +file hashes, executable modes, and literal symlink targets matched the archive. +Its npm symlinks include literal backslashes and are dangling on Linux, requiring +the explicit policy above to preserve the old extractor's behavior. No archive +contents were executed. Fixtures were extracted only into fresh temporary dirs. + +No archive dependencies or tests have been removed from FastLED yet. Application +adoption, isolated-feature CI, native Windows coverage, and upstream publication +remain pending. All installer full-extraction call sites use empty staging +directories; esbuild removes an existing selected binary before extraction. ### Final migration audit From f4f8bd05a6a10a0cc18317050edb1de6aa04d4ff Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:50:05 -0700 Subject: [PATCH 10/94] refactor: delegate archive extraction to kernal-api (refs #237) --- Cargo.lock | 23 ++-- Cargo.toml | 6 +- crates/fastled-cli/Cargo.toml | 4 - crates/fastled-cli/src/archive.rs | 166 ++++++----------------------- docs/kernal-api-migration.md | 32 ++++-- tests/unit/test_kernal_boundary.py | 4 + 6 files changed, 68 insertions(+), 167 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84885c7a..e5b947d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,7 +1128,6 @@ dependencies = [ "clap", "crossterm", "ctcb-core", - "flate2", "getrandom 0.3.4", "gtk", "indexmap 1.9.3", @@ -1141,7 +1140,6 @@ dependencies = [ "serde_json", "shell-words", "strsim", - "tar", "tauri", "tauri-build", "tempfile", @@ -1152,8 +1150,6 @@ dependencies = [ "tree-sitter", "tree-sitter-cpp", "webkit2gtk", - "zip", - "zstd", ] [[package]] @@ -2299,6 +2295,7 @@ version = "0.1.0" dependencies = [ "blake3", "dirs", + "flate2", "globset", "jwalk", "libc", @@ -2309,12 +2306,15 @@ dependencies = [ "running-process", "sha2", "sysinfo", + "tar", "thiserror 2.0.20", "tokio", "toml 0.8.23", "widestring", "winapi", "windows-sys 0.61.2", + "zip", + "zstd", ] [[package]] @@ -4528,13 +4528,12 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", - "xattr", ] [[package]] @@ -6342,16 +6341,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.4", -] - [[package]] name = "xz2" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 5a91efc5..2026bf8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,8 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } thiserror = "2" -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive"] } reqwest = { version = "0.12", features = ["blocking"] } -zip = "2" -tar = "0.4" -zstd = "0.13" -flate2 = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 1aea803b..f6cfa306 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -24,10 +24,6 @@ path = "src/main.rs" clap = { workspace = true } kernal-api = { workspace = true } reqwest = { workspace = true } -zip = { workspace = true } -tar = { workspace = true } -zstd = { workspace = true } -flate2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/crates/fastled-cli/src/archive.rs b/crates/fastled-cli/src/archive.rs index bb9ec0e1..eebbee81 100644 --- a/crates/fastled-cli/src/archive.rs +++ b/crates/fastled-cli/src/archive.rs @@ -7,10 +7,11 @@ //! * Writing the `.emscripten` config file after installation use std::fs::{self, File}; -use std::io::{BufReader, BufWriter, Read, Write}; +use std::io::{BufWriter, Read, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use kernal_api::archive::{ArchiveFormat, DanglingLinks, ExtractionLimits}; // --------------------------------------------------------------------------- // Download @@ -93,25 +94,25 @@ pub fn verify_sha256(path: &Path, expected: &str) -> Result { /// /// If the archive contains a single top-level directory, its contents are /// promoted one level up (mirrors the Python behaviour in `_extract_archive`). +/// The caller owns an absent or empty staging directory; failures may leave +/// partial output for the caller to clean up. /// /// # Errors /// Returns an error if the archive cannot be read or if any entry cannot be /// written to `dest`. pub fn extract_tar_zst(archive: &Path, dest: &Path) -> Result<()> { - fs::create_dir_all(dest) - .with_context(|| format!("cannot create destination {}", dest.display()))?; - - let file = File::open(archive) - .with_context(|| format!("cannot open archive {}", archive.display()))?; - let buf_reader = BufReader::new(file); - - let zstd_decoder = zstd::stream::read::Decoder::new(buf_reader) - .context("failed to initialise zstd decoder")?; - - let mut tar_archive = tar::Archive::new(zstd_decoder); - tar_archive - .unpack(dest) - .with_context(|| format!("failed to unpack tar archive to {}", dest.display()))?; + // The pinned Linux toolchain contains dangling npm links with literal + // backslashes. Preserve their payloads, while requiring confined targets. + kernal_api::archive::extract( + archive, + dest, + ArchiveFormat::TarZstd, + ExtractionLimits { + dangling_links: DanglingLinks::PreserveMissingLeaf, + ..ExtractionLimits::default() + }, + ) + .with_context(|| format!("failed to unpack tar archive to {}", dest.display()))?; // Promote single top-level directory (mirrors Python's behaviour). _promote_single_child(dest)?; @@ -125,50 +126,20 @@ pub fn extract_tar_zst(archive: &Path, dest: &Path) -> Result<()> { /// Extract a `.zip` archive to `dest`. /// -/// Creates `dest` if it does not exist. All entries (files and directories) -/// are extracted, preserving relative paths. +/// Requires an absent or empty, caller-exclusive staging directory. Entries +/// are extracted preserving relative paths; failure may leave partial output. /// /// # Errors /// Returns an error if the archive cannot be read or any entry cannot be /// written. pub fn extract_zip(archive: &Path, dest: &Path) -> Result<()> { - fs::create_dir_all(dest) - .with_context(|| format!("cannot create destination {}", dest.display()))?; - - let file = File::open(archive) - .with_context(|| format!("cannot open zip archive {}", archive.display()))?; - let buf_reader = BufReader::new(file); - let mut zip = zip::ZipArchive::new(buf_reader) - .with_context(|| format!("cannot parse zip archive {}", archive.display()))?; - - for i in 0..zip.len() { - let mut entry = zip - .by_index(i) - .with_context(|| format!("cannot read zip entry {i}"))?; - - let entry_path: PathBuf = entry - .enclosed_name() - .with_context(|| format!("zip entry {i} has an unsafe path"))? - .to_path_buf(); - - let out_path = dest.join(&entry_path); - - if entry.is_dir() { - fs::create_dir_all(&out_path) - .with_context(|| format!("cannot create dir {}", out_path.display()))?; - } else { - if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("cannot create parent {}", parent.display()))?; - } - let mut out_file = File::create(&out_path) - .with_context(|| format!("cannot create {}", out_path.display()))?; - std::io::copy(&mut entry, &mut out_file) - .with_context(|| format!("cannot write {}", out_path.display()))?; - } - } - - Ok(()) + kernal_api::archive::extract( + archive, + dest, + ArchiveFormat::Zip, + ExtractionLimits::default(), + ) + .with_context(|| format!("failed to unpack zip archive to {}", dest.display())) } // --------------------------------------------------------------------------- @@ -179,37 +150,20 @@ pub fn extract_zip(archive: &Path, dest: &Path) -> Result<()> { /// /// Used to pluck the esbuild binary out of an npm-style tarball (`package/...` /// layout) without unpacking the whole archive. +/// The destination file must not exist; the caller owns cleanup on failure. /// /// # Errors /// Returns an error if the archive cannot be read or if `member` is not /// present in the archive. pub fn extract_member_from_tgz(archive: &Path, member: &str, dest: &Path) -> Result<()> { - let file = File::open(archive) - .with_context(|| format!("cannot open archive {}", archive.display()))?; - let gz = flate2::read::GzDecoder::new(BufReader::new(file)); - let mut tar_archive = tar::Archive::new(gz); - - for entry in tar_archive - .entries() - .with_context(|| format!("cannot iterate entries of {}", archive.display()))? - { - let mut entry = entry.with_context(|| format!("bad entry in {}", archive.display()))?; - let path = entry - .path() - .with_context(|| format!("bad entry path in {}", archive.display()))?; - if path.to_string_lossy() == member { - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("cannot create parent {}", parent.display()))?; - } - let mut out = - File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?; - std::io::copy(&mut entry, &mut out) - .with_context(|| format!("cannot write {}", dest.display()))?; - return Ok(()); - } - } - anyhow::bail!("member {member} not found in {}", archive.display()); + kernal_api::archive::extract_member( + archive, + member, + dest, + ArchiveFormat::TarGzip, + ExtractionLimits::default(), + ) + .with_context(|| format!("cannot extract {member} from {}", archive.display())) } // --------------------------------------------------------------------------- @@ -308,7 +262,6 @@ fn _promote_single_child(dir: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use std::io::Write; use tempfile::TempDir; fn temp_dir() -> TempDir { @@ -345,57 +298,6 @@ mod tests { assert!(!ok, "digest should not match different content"); } - // ------------------------------------------------------------------ - // ZIP extraction - // ------------------------------------------------------------------ - - /// Build a minimal in-memory zip containing two files and return the bytes. - fn make_zip(files: &[(&str, &[u8])]) -> Vec { - let buf = std::io::Cursor::new(Vec::new()); - let mut zip = zip::ZipWriter::new(buf); - let options = zip::write::SimpleFileOptions::default(); - for (name, content) in files { - zip.start_file(*name, options).unwrap(); - zip.write_all(content).unwrap(); - } - zip.finish().unwrap().into_inner() - } - - #[test] - fn test_extract_zip_basic() { - let dir = temp_dir(); - let zip_path = dir.path().join("test.zip"); - - let zip_bytes = make_zip(&[ - ("hello.txt", b"hello world"), - ("sub/world.txt", b"sub content"), - ]); - fs::write(&zip_path, &zip_bytes).unwrap(); - - let out = dir.path().join("out"); - extract_zip(&zip_path, &out).expect("extract_zip"); - - assert_eq!(fs::read(out.join("hello.txt")).unwrap(), b"hello world"); - assert_eq!( - fs::read(out.join("sub").join("world.txt")).unwrap(), - b"sub content" - ); - } - - #[test] - fn test_extract_zip_creates_dest() { - let dir = temp_dir(); - let zip_path = dir.path().join("test.zip"); - let zip_bytes = make_zip(&[("file.txt", b"data")]); - fs::write(&zip_path, &zip_bytes).unwrap(); - - // Destination does not exist yet. - let out = dir.path().join("nested").join("dest"); - extract_zip(&zip_path, &out).expect("extract_zip"); - - assert_eq!(fs::read(out.join("file.txt")).unwrap(), b"data"); - } - // ------------------------------------------------------------------ // .emscripten config generation // ------------------------------------------------------------------ diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 4f7d7385..59235291 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -52,12 +52,15 @@ remain part of the final migration audit. To reproduce the default-feature Rust test run on this NixOS host: ```bash -FASTLED_PKG_CONFIG_PATH=$(printf '%s:' /nix/store/*-dev/lib/pkgconfig /nix/store/*/share/pkgconfig) -FASTLED_NATIVE_LIB_PATH=$(PKG_CONFIG_PATH="$FASTLED_PKG_CONFIG_PATH" pkg-config --libs-only-L gtk+-3.0 webkit2gtk-4.1 openssl liblzma | sed 's/-L//g; s/ /:/g') -FASTLED_BZIP_LIB_PATH=$(printf '%s:' /nix/store/*bzip2*/lib) -PKG_CONFIG_PATH="$FASTLED_PKG_CONFIG_PATH" LD_LIBRARY_PATH="$FASTLED_NATIVE_LIB_PATH:$FASTLED_BZIP_LIB_PATH" soldr cargo test --workspace +FASTLED_PKG_CONFIG_PATH=/nix/store/gbzc2cpjmhylnmwljbqa1pwmli5a2n1v-xz-5.8.3-dev/lib/pkgconfig:/nix/store/9vxrnnhc400s1rgq801wqfdxhzpcl1z1-bzip2-1.0.8-dev/lib/pkgconfig:$(printf '%s:' /nix/store/*-dev/lib/pkgconfig /nix/store/*/share/pkgconfig) +FASTLED_NATIVE_LIB_PATH=$(PKG_CONFIG_PATH="$FASTLED_PKG_CONFIG_PATH" pkg-config --libs-only-L gtk+-3.0 webkit2gtk-4.1 openssl liblzma bzip2 | sed 's/-L//g; s/ /:/g') +PKG_CONFIG_PATH="$FASTLED_PKG_CONFIG_PATH" LD_LIBRARY_PATH="$FASTLED_NATIVE_LIB_PATH" soldr cargo test --workspace -j1 ``` +These are host-specific store paths. The explicit native xz/bzip2 prefixes avoid +32-bit libraries also present in this store; an unfiltered glob selected those +and failed the x86-64 link during archive adoption. + ## Completion checks ### Tree fingerprint slice @@ -160,7 +163,7 @@ The full kernel `fs,fs-watch,hash-sha256` suite and strict Clippy in both repositories pass. One-agent pre-push review found no actionable issues. Archive extraction migration is next, tracked in application issue #237. -### Archive slice (in progress; not adopted) +### Archive slice (locally adopted; publication pending) Tracking: application #237 and https://github.com/zackees/kernal-api/issues/165. The sibling `feat/archive-facade` branch contains an unpublished ZIP foundation: @@ -193,10 +196,21 @@ Its npm symlinks include literal backslashes and are dangling on Linux, requirin the explicit policy above to preserve the old extractor's behavior. No archive contents were executed. Fixtures were extracted only into fresh temporary dirs. -No archive dependencies or tests have been removed from FastLED yet. Application -adoption, isolated-feature CI, native Windows coverage, and upstream publication -remain pending. All installer full-extraction call sites use empty staging -directories; esbuild removes an existing selected binary before extraction. +FastLED now delegates all three extraction wrappers to the facade and removes +direct zip/tar/zstd/flate2 dependencies. Two generic ZIP tests and their fixture +builder are removed; their extraction coverage lives upstream. Promotion, +catalog checksums, esbuild member selection, and Emscripten configuration remain +FastLED policy. The lockfile moves the four backends under kernal-api, updates +tar to the facade's exact version, and drops xattr. + +The application boundary regression was RED before adoption and is now GREEN. +All 249 library tests, two binary tests, one CLI integration test and one doctest +pass; strict workspace Clippy and formatting pass. Python has 24 passed and one skipped. The boundary test passes Ruff, +Black, isort and Pyright. One-agent application review found no actionable +issues. Isolated-feature CI, native Windows coverage, upstream publication and +replacement of the temporary path override remain pending. All full-extraction +call sites use empty staging directories; esbuild removes an existing selected +binary before extraction. ### Final migration audit diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 50c43c07..41ba01db 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -20,6 +20,8 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "running-process" not in dependencies assert "sha2" not in dependencies assert "dirs" not in dependencies + for backend in ("zip", "tar", "zstd", "flate2"): + assert backend not in dependencies for target in manifest.get("target", {}).values(): assert "windows-sys" not in target.get("dependencies", {}) for source in (root / "crates/fastled-cli/src").rglob("*.rs"): @@ -31,3 +33,5 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "windows_sys::" not in source.read_text(), source assert "sha2::" not in source.read_text(), source assert "dirs::" not in source.read_text(), source + for backend in ("zip", "tar", "zstd", "flate2"): + assert f"{backend}::" not in source.read_text(), source From 8a4274910f50dfc9f442b335e60bb8fd554e2536 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 20:53:55 -0700 Subject: [PATCH 11/94] docs: track rebased upstream archive PR and CI gates --- docs/kernal-api-migration.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 59235291..759eb9d5 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -166,6 +166,8 @@ Archive extraction migration is next, tracked in application issue #237. ### Archive slice (locally adopted; publication pending) Tracking: application #237 and https://github.com/zackees/kernal-api/issues/165. +Upstream implementation: https://github.com/zackees/kernal-api/pull/166, +stacked on home-resolution PR #164 and SHA-256 PR #162. The sibling `feat/archive-facade` branch contains an unpublished ZIP foundation: owned format/limit types, empty caller-exclusive destination contract, input, metadata, entry, path and output ceilings, fixed-buffer copying, and executable @@ -207,11 +209,20 @@ The application boundary regression was RED before adoption and is now GREEN. All 249 library tests, two binary tests, one CLI integration test and one doctest pass; strict workspace Clippy and formatting pass. Python has 24 passed and one skipped. The boundary test passes Ruff, Black, isort and Pyright. One-agent application review found no actionable -issues. Isolated-feature CI, native Windows coverage, upstream publication and +issues. Archive-only CI and ZIP/tar/zstd dependency-isolation checks are now +included in PR #166; the local isolation checks pass. Native Windows coverage, upstream publication and replacement of the temporary path override remain pending. All full-extraction call sites use empty staging directories; esbuild removes an existing selected binary before extraction. +The SHA/home/archive stack was rebased onto upstream main `1181fdf`, preserving +the newer Linux WebKit changes. `git range-diff` confirms all five migration +patches are identical after rebase. New heads are SHA `40fcb18`, home `4efac1c`, +and archive `6c32591` (including CI checks). The migrated-feature kernel suite +passes after rebase; the first attempt ended in a Soldr relay error, followed +by a successful unchanged retry. PR #162 is now mergeable and its fresh CI +checks are queued. No release has been published. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. From 67059c95727aa5d4f9cdbdbb25f6507d15779790 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:01:56 -0700 Subject: [PATCH 12/94] docs: track full HTTP migration scope and initial facade evidence --- docs/kernal-api-migration.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 759eb9d5..feb68252 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -223,6 +223,35 @@ passes after rebase; the first attempt ended in a Soldr relay error, followed by a successful unchanged retry. PR #162 is now mergeable and its fresh CI checks are queued. No release has been published. +### HTTP client slice (draft; not adopted) + +Tracking: https://github.com/zackees/fastled-wasm/issues/238 and +https://github.com/zackees/kernal-api/issues/167. Investigation found blocking +downloads, GitHub metadata and HEAD probes, async DWARF debug POSTs, and server +HTTP/SSE integration tests. Removing reqwest requires all of these callers, +not just downloads. Existing private kernel ureq callers do not provide a +public capability. Product URLs, schemas, fallback policy and protocol tests +remain FastLED-owned. + +The sibling `feat/http-client-facade` draft adds an optional async GET transport +using private reqwest 0.12.28/native TLS without constructing a runtime. It has +owned client/response/limit types, bounded small-body collection, post-parse +header acceptance limits, finite connection/read/total timeouts, and rejects +embedded URL credentials. Redirects are returned to callers, not followed. +Five loopback tests pass after the initial missing-feature RED test, covering +status/body preservation, declared/streamed overflow, header ceilings, invalid +URLs, unfollowed redirects, and stalled headers/body. Local compilation requires +the host OpenSSL pkg-config and shared-library paths. +Strict HTTP-feature Clippy and the broader HTTP-enabled kernel test suite pass +(166 library tests plus enabled integration tests); no HTTP adoption or +cross-platform execution claim is implied by those checks. + +This is not the complete HTTP capability: runtime-owned blocking access, +streamed artifact sinks, HEAD/POST, owned headers, bounded SSE pulls, redirect +policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation +metadata analysis remain required, along with isolation/platform CI and review. +FastLED still directly depends on reqwest. No HTTP PR or release is published. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. From 84c53bad98321f97259ed62b26b5ecee9b77519e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:07:12 -0700 Subject: [PATCH 13/94] docs: record HTTP request progress and merged upstream capabilities --- docs/kernal-api-migration.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index feb68252..b55d32c3 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -220,8 +220,10 @@ the newer Linux WebKit changes. `git range-diff` confirms all five migration patches are identical after rebase. New heads are SHA `40fcb18`, home `4efac1c`, and archive `6c32591` (including CI checks). The migrated-feature kernel suite passes after rebase; the first attempt ended in a Soldr relay error, followed -by a successful unchanged retry. PR #162 is now mergeable and its fresh CI -checks are queued. No release has been published. +by a successful unchanged retry. SHA-256 PR #162 and home-resolution PR #164 +have now merged after their complete CI checks passed. Archive PR #166 has +been retargeted to main; its remaining release gates still apply. No release +has been published. ### HTTP client slice (draft; not adopted) @@ -246,8 +248,16 @@ Strict HTTP-feature Clippy and the broader HTTP-enabled kernel test suite pass (166 library tests plus enabled integration tests); no HTTP adoption or cross-platform execution claim is implied by those checks. +The draft now adds facade-owned GET/HEAD/POST request types, borrowed application +headers/body, response header access, and request URL/body/header byte ceilings. +Header counts are independently bounded before request construction. Eight +focused tests pass, including HEAD entity-length handling, wire-level POST +payload/header checks, malformed header and framing-header rejection, and an +excessive-header regression observed RED then GREEN. Strict Clippy and the +broader HTTP-enabled kernel suite still pass. + This is not the complete HTTP capability: runtime-owned blocking access, -streamed artifact sinks, HEAD/POST, owned headers, bounded SSE pulls, redirect +streamed artifact sinks, bounded SSE pulls, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation metadata analysis remain required, along with isolation/platform CI and review. FastLED still directly depends on reqwest. No HTTP PR or release is published. From be6175b0fd7e3a9a77a36fb4c94656e7d5480f3f Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:08:00 -0700 Subject: [PATCH 14/94] docs: record archive Windows readiness failure for follow-up --- docs/kernal-api-migration.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index b55d32c3..87efab62 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -225,6 +225,15 @@ have now merged after their complete CI checks passed. Archive PR #166 has been retargeted to main; its remaining release gates still apply. No release has been published. +Archive run `34736578924` ended with a Windows native test failure in +`strict_worker_assigns_before_resume_and_job_close_reaps_tree`: the readiness +marker lacked the expected `ready:` prefix. Inspection found the helper writes +the marker non-atomically while `wait_for_text` accepts any successful read, +including empty/partial content. That race is a concrete candidate requiring +a regression and fix, not grounds to declare Windows validation complete. +Retargeting archive PR #166 also exposed a merge conflict; refresh its stack +after saving the HTTP draft. No failed job has been blindly restarted. + ### HTTP client slice (draft; not adopted) Tracking: https://github.com/zackees/fastled-wasm/issues/238 and From 8a3a6f8f26fa12e432afee44a7d3caff82292c6d Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:16:57 -0700 Subject: [PATCH 15/94] docs: record archive marker fix and fresh validation --- docs/kernal-api-migration.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 87efab62..6a833721 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -231,8 +231,21 @@ marker lacked the expected `ready:` prefix. Inspection found the helper writes the marker non-atomically while `wait_for_text` accepts any successful read, including empty/partial content. That race is a concrete candidate requiring a regression and fix, not grounds to declare Windows validation complete. -Retargeting archive PR #166 also exposed a merge conflict; refresh its stack -after saving the HTTP draft. No failed job has been blindly restarted. +Tracked at https://github.com/zackees/kernal-api/issues/168. A deterministic +test reproduces partial visibility during the original create/write sequence. +The fix stages each test marker with the existing tempfile dev dependency, +then atomically publishes without overwriting an existing marker. Two local +regressions pass, including failed-write cleanup and no-clobber behavior; +production process containment and shutdown assertions are unchanged. + +Archive and HTTP branches have been rebased on merged SHA/home main `63ce8ae`; +the four archive patches compare identically before/after rebase. Positive +ZIP link-chain and tar symlink/hardlink tests now run on Windows as well as +Unix. All 14 archive regressions, both marker regressions, and the combined +archive/fs/fs-watch/hash-sha256 kernel suite pass locally. Strict Windows-target +archive Clippy (all targets) also passes. Updated archive head `8c27706` is +pushed to PR #166; native Windows execution still requires fresh CI. The HTTP +draft is rebased onto this fix. No failed job has been blindly restarted. ### HTTP client slice (draft; not adopted) From 9fdd9fed8f9f95b32935327fcc856dacd6f50827 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:26:43 -0700 Subject: [PATCH 16/94] docs: record HTTP streaming and Windows archive correction --- docs/kernal-api-migration.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 6a833721..f9b883de 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -247,6 +247,21 @@ archive Clippy (all targets) also passes. Updated archive head `8c27706` is pushed to PR #166; native Windows execution still requires fresh CI. The HTTP draft is rebased onto this fix. No failed job has been blindly restarted. +Run `34737499816` passed all 539 native Windows library tests, including the +previously failing containment test, then exposed failures in both newly +enabled archive symlink tests: extraction succeeded, but following the links +returned Windows error 123 (`InvalidFilename`). The raw archive targets retain +forward slashes; Windows relative reparse targets require native separators +(matching https://github.com/dotnet/runtime/issues/79031). A Windows-only +separator conversion is under test/review, preserving Unix literals and the +existing link-graph validation. Native Windows GREEN remains a release gate. +The run is now terminal: every other CI job passed. The scoped separator helper +and all 14 Linux archive regressions pass, and focused source review is clean. +Archive head `9c176b9` is pushed with this fix and portable negative link-graph +tests enabled on Windows. Strict Windows all-target archive Clippy and the +additional Windows test compilation both pass. Fresh native CI is required; +the HTTP draft is rebased onto the fix. + ### HTTP client slice (draft; not adopted) Tracking: https://github.com/zackees/fastled-wasm/issues/238 and @@ -278,8 +293,17 @@ payload/header checks, malformed header and framing-header rejection, and an excessive-header regression observed RED then GREEN. Strict Clippy and the broader HTTP-enabled kernel suite still pass. +Draft commit `c08555e` adds caller-buffered async body reads with a private +zero-copy pending transport chunk. Reads obey the cumulative body ceiling, +return before body completion, and stay failed after an overflow; collection +after partial reads returns only unread bytes. Eleven focused HTTP tests and +the broader HTTP-enabled suite pass, as does strict all-target host Clippy. +An impossible-deadline regression was observed RED then GREEN; client +construction now rejects zero or greater-than-365-day timeouts. These generic +mechanism tests live upstream, not in FastLED. HTTP is still not adopted. + This is not the complete HTTP capability: runtime-owned blocking access, -streamed artifact sinks, bounded SSE pulls, redirect +streamed artifact sinks, SSE integration, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation metadata analysis remain required, along with isolation/platform CI and review. FastLED still directly depends on reqwest. No HTTP PR or release is published. From c9467c2b1fe1c8912f53f311aa5c24bc402843f5 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:31:19 -0700 Subject: [PATCH 17/94] docs: record blocking HTTP adapter and isolation checks --- docs/kernal-api-migration.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index f9b883de..f9cbbee6 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -302,8 +302,16 @@ An impossible-deadline regression was observed RED then GREEN; client construction now rejects zero or greater-than-365-day timeouts. These generic mechanism tests live upstream, not in FastLED. HTTP is still not adopted. -This is not the complete HTTP capability: runtime-owned blocking access, -streamed artifact sinks, SSE integration, redirect +The draft now includes blocking client/response adapters borrowing a caller-owned +kernel runtime. They reuse the async transport, implement streaming `Read`, and +create neither a runtime nor a worker. Calls from an entered runtime fail with +`WouldBlock`; tests verify rejected nested reads leave the stream usable later. +Thirteen HTTP regressions, the broader HTTP-enabled kernel suite, and strict +all-target host Clippy pass. Default-versus-enabled dependency graph checks prove +reqwest isolation, and HTTP is added to the individual-feature CI matrix. + +This is not the complete HTTP capability: streamed artifact sinks, +SSE integration, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation metadata analysis remain required, along with isolation/platform CI and review. FastLED still directly depends on reqwest. No HTTP PR or release is published. From 1814a87913e713530ac5cfbbbaeecaa76c0a1719 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:36:46 -0700 Subject: [PATCH 18/94] docs: record archive merge and bounded HTTP redirects --- docs/kernal-api-migration.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index f9cbbee6..8582db3d 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -262,6 +262,12 @@ tests enabled on Windows. Strict Windows all-target archive Clippy and the additional Windows test compilation both pass. Fresh native CI is required; the HTTP draft is rebased onto the fix. +Run `34737890123` completed successfully on archive head `9c176b9`, including +native Windows positive/negative archive link tests. PR #166 is merged as +`299735e`. The optional native macOS ARM execution job remains skipped by the +repository's runner policy; macOS cross-compilation/archive checks passed. +This is not yet a published release, and the application path patch remains. + ### HTTP client slice (draft; not adopted) Tracking: https://github.com/zackees/fastled-wasm/issues/238 and @@ -310,6 +316,18 @@ Thirteen HTTP regressions, the broader HTTP-enabled kernel suite, and strict all-target host Clippy pass. Default-versus-enabled dependency graph checks prove reqwest isolation, and HTTP is added to the individual-feature CI matrix. +The HTTP draft now implements opt-in redirects (default zero, maximum 32), +validating each intermediate response before following it. Cross-origin hops +drop application headers, HTTPS downgrades fail, and POST redirects use explicit +301/302/303-to-GET versus 307/308 replay semantics. Seventeen focused HTTP tests +and the broader HTTP-enabled kernel suite pass. A buffered-body deadline bypass +was reproduced RED then fixed GREEN by retaining the absolute deadline in the +response, so caller pauses cannot bypass the cumulative request deadline. +Additional redirect method/relative-location and TLS/cancellation coverage, +source review, and client adoption remain pending. +Strict all-target host HTTP Clippy also passes. The complete HTTP draft is +rebased onto merged archive main `299735e`; no HTTP PR or release is published. + This is not the complete HTTP capability: streamed artifact sinks, SSE integration, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation From 75165a5479fc7d5adf641ed9f70b4d4e44eae9f4 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:39:31 -0700 Subject: [PATCH 19/94] docs: record HTTP cancellation and redirect validation --- docs/kernal-api-migration.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 8582db3d..4c61c7d6 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -328,6 +328,19 @@ source review, and client adoption remain pending. Strict all-target host HTTP Clippy also passes. The complete HTTP draft is rebased onto merged archive main `299735e`; no HTTP PR or release is published. +Nineteen focused HTTP tests now pass. New socket-observed cancellation tests +verify that dropping a pending request or a response closes the connection; +the runtime remains running while closure is observed. A relative-redirect +matrix verifies HEAD preservation, same-origin application-header retention, +POST-to-GET for 301/302/303, and POST body replay for 307/308. Strict host Clippy +also passes. These are upstream mechanism tests; FastLED adoption is still pending. + +Initial parser-allocation inspection found the locked private Hyper 1.11.0 +HTTP/1 backend defaults to 100 headers and a 417792-byte read-buffer ceiling. +The facade's configurable header ceiling is still checked after parsing, and +these transitive implementation defaults are not a published facade guarantee. +That distinction must be resolved or explicitly contracted before release. + This is not the complete HTTP capability: streamed artifact sinks, SSE integration, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation From 6531600f5bb22ebc8ed1107eb742f635039b434b Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:45:51 -0700 Subject: [PATCH 20/94] feat: route archive downloads through kernel HTTP (refs #238) --- Cargo.lock | 6 ++- Cargo.toml | 2 +- crates/fastled-cli/src/archive.rs | 59 +++++++++++++++++++++++++----- docs/kernal-api-migration.md | 21 ++++++++++- tests/unit/test_kernal_boundary.py | 7 ++++ 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5b947d7..da8c98df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,9 +338,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -2294,6 +2294,7 @@ name = "kernal-api" version = "0.1.0" dependencies = [ "blake3", + "bytes", "dirs", "flate2", "globset", @@ -2303,6 +2304,7 @@ dependencies = [ "memmap2", "notify", "reflink-copy", + "reqwest 0.12.28", "running-process", "sha2", "sysinfo", diff --git a/Cargo.toml b/Cargo.toml index 2026bf8d..23f98a89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } thiserror = "2" -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client"] } reqwest = { version = "0.12", features = ["blocking"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/fastled-cli/src/archive.rs b/crates/fastled-cli/src/archive.rs index eebbee81..a780db5f 100644 --- a/crates/fastled-cli/src/archive.rs +++ b/crates/fastled-cli/src/archive.rs @@ -19,27 +19,34 @@ use kernal_api::archive::{ArchiveFormat, DanglingLinks, ExtractionLimits}; /// Download a file from `url` and write it to `dest`. /// -/// Uses a blocking reqwest client with a 120-second timeout, streaming the +/// Uses the kernel HTTP client with a 120-second timeout, streaming the /// response body so large archives do not need to be buffered in memory. /// /// # Errors /// Returns an error if the HTTP request fails, the server returns a non-2xx /// status, or writing the destination file fails. pub fn download(url: &str, dest: &Path) -> Result<()> { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .redirect(reqwest::redirect::Policy::limited(10)) + let runtime = kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() .build() - .context("failed to build HTTP client")?; + .context("failed to build download runtime")?; + let client = kernal_api::http::BlockingClient::new( + &runtime, + kernal_api::http::Limits { + max_redirects: 10, + max_body_bytes: 16 * 1024 * 1024 * 1024, + ..kernal_api::http::Limits::default() + }, + ) + .context("failed to build HTTP client")?; let mut response = client .get(url) - .send() .with_context(|| format!("GET {url} failed"))?; - response - .error_for_status_ref() - .with_context(|| format!("server returned error for {url}"))?; + if !(200..300).contains(&response.status()) { + anyhow::bail!("server returned HTTP {} for {url}", response.status()); + } let file = File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?; let mut writer = BufWriter::new(file); @@ -268,6 +275,40 @@ mod tests { tempfile::tempdir().expect("tempdir") } + #[test] + fn download_writes_artifact_and_preserves_destination_on_http_error() { + for status in [200, 404] { + let dir = temp_dir(); + let destination = dir.path().join("artifact.zip"); + fs::write(&destination, b"previous artifact").unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/artifact.zip", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + socket.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + assert!(request.len() < 4096); + } + write!(socket, "HTTP/1.1 {status} Fixture\r\nContent-Length: 8\r\nConnection: close\r\n\r\nartifact").unwrap(); + }); + let result = download(&url, &destination); + worker.join().unwrap(); + if status == 200 { + result.unwrap(); + assert_eq!(fs::read(destination).unwrap(), b"artifact"); + } else { + assert!(result.is_err()); + assert_eq!(fs::read(destination).unwrap(), b"previous artifact"); + } + } + } + // ------------------------------------------------------------------ // SHA-256 verification // ------------------------------------------------------------------ diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 4c61c7d6..8cf2121a 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -268,7 +268,7 @@ native Windows positive/negative archive link tests. PR #166 is merged as repository's runner policy; macOS cross-compilation/archive checks passed. This is not yet a published release, and the application path patch remains. -### HTTP client slice (draft; not adopted) +### HTTP client slice (draft; partial local adoption) Tracking: https://github.com/zackees/fastled-wasm/issues/238 and https://github.com/zackees/kernal-api/issues/167. Investigation found blocking @@ -341,6 +341,25 @@ The facade's configurable header ceiling is still checked after parsing, and these transitive implementation defaults are not a published facade guarantee. That distinction must be resolved or explicitly contracted before release. +The opt-in HTTPS acceptance test passed against FastLED's public GitHub release +metadata endpoint using verified TLS, a user-agent, bounded body collection, +and the facade redirect policy. This proves trusted HTTPS on this host, not +invalid-certificate rejection or all supported platforms. + +Local adoption now routes `archive::download` through the kernel's blocking +streaming client and a caller-owned kernel runtime. FastLED keeps its 120-second +total timeout, ten-redirect policy, destination creation, flush/error reporting, +and a 16-GiB artifact ceiling. A product test proves successful writes and +preservation of an existing destination on HTTP error. The boundary test failed +on the old reqwest import then passed after adoption. The existing 249-library, +2-binary, 1-integration, 1-doctest workspace suite passed, as did the new product +test and seven Python smoke/boundary tests. Enabling the feature required only +updating locked bytes 1.11.1 to the kernel's exact 1.12.1, not a broad refresh. +Other reqwest callers remain, so the direct dependency is not yet removed. +Strict workspace all-target Clippy passes, and the full Python unit suite has +25 passes/1 skip. HTTP remains a locally adopted draft pending upstream review +and release; this patch must not be released with the local path override. + This is not the complete HTTP capability: streamed artifact sinks, SSE integration, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 41ba01db..6655f11d 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -35,3 +35,10 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "dirs::" not in source.read_text(), source for backend in ("zip", "tar", "zstd", "flate2"): assert f"{backend}::" not in source.read_text(), source + + +def test_archive_download_uses_kernel_http(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/archive.rs").read_text() + assert "reqwest::" not in source + assert "kernal_api::http::" in source From b2eb176ec16ea9ff5b2fa63cb550da52e43e30a4 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:50:26 -0700 Subject: [PATCH 21/94] feat: migrate blocking HTTP callers to kernel (refs #238) --- Cargo.lock | 3 -- Cargo.toml | 2 +- crates/fastled-cli/src/install.rs | 75 +++++++++++++++++------------- crates/fastled-cli/src/project.rs | 29 ++++++++---- docs/kernal-api-migration.md | 11 +++++ tests/unit/test_kernal_boundary.py | 11 +++++ 6 files changed, 86 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da8c98df..73104ec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1299,7 +1299,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -3605,9 +3604,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", - "futures-util", "h2", "http", "http-body", diff --git a/Cargo.toml b/Cargo.toml index 23f98a89..87067ca6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ homepage = "https://github.com/zackees/fastled-wasm" clap = { version = "4", features = ["derive"] } thiserror = "2" kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client"] } -reqwest = { version = "0.12", features = ["blocking"] } +reqwest = { version = "0.12" } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index e03bb1cc..d85d5c85 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -1249,22 +1249,33 @@ fn is_commit_sha(ref_str: &str) -> bool { /// Hit the GitHub API for the latest FastLED release tag. /// Returns `None` on any failure so callers can fall back to `master`. fn fetch_latest_release_tag() -> Option { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::limited(10)) + let runtime = kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() .build() .ok()?; + let client = kernal_api::http::BlockingClient::new( + &runtime, + kernal_api::http::Limits { + max_redirects: 10, + total_timeout: std::time::Duration::from_secs(10), + ..kernal_api::http::Limits::default() + }, + ) + .ok()?; let resp = client - .get(FASTLED_LATEST_RELEASE_API) - .header("Accept", "application/vnd.github.v3+json") - .header("User-Agent", "fastled-cli") - .send() + .execute(kernal_api::http::Request { + headers: &[ + ("Accept", "application/vnd.github.v3+json"), + ("User-Agent", "fastled-cli"), + ], + ..kernal_api::http::Request::get(FASTLED_LATEST_RELEASE_API) + }) .ok()?; - if !resp.status().is_success() { + if !(200..300).contains(&resp.status()) { return None; } - let text = resp.text().ok()?; - let value: serde_json::Value = serde_json::from_str(&text).ok()?; + let bytes = resp.into_bytes().ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; value .get("tag_name") .and_then(serde_json::Value::as_str) @@ -1272,16 +1283,28 @@ fn fetch_latest_release_tag() -> Option { } fn head_check(url: &str) -> bool { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::limited(10)) - .build(); + let Ok(runtime) = kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + else { + return false; + }; + let client = kernal_api::http::BlockingClient::new( + &runtime, + kernal_api::http::Limits { + max_redirects: 10, + total_timeout: std::time::Duration::from_secs(10), + ..kernal_api::http::Limits::default() + }, + ); match client { Ok(c) => c - .head(url) - .header("User-Agent", "fastled-cli") - .send() - .map(|r| r.status().is_success()) + .execute(kernal_api::http::Request { + method: kernal_api::http::Method::Head, + headers: &[("User-Agent", "fastled-cli")], + ..kernal_api::http::Request::get(url) + }) + .map(|r| (200..300).contains(&r.status())) .unwrap_or(false), Err(_) => false, } @@ -1990,21 +2013,7 @@ fn update_vscode_settings_for_fastled() -> Result<()> { } fn download_to_path(url: &str, dest: &Path) -> Result<()> { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .redirect(reqwest::redirect::Policy::limited(10)) - .build() - .context("build HTTP client")?; - let bytes = client - .get(url) - .send() - .with_context(|| format!("GET {url} failed"))? - .error_for_status() - .with_context(|| format!("download returned error for {url}"))? - .bytes() - .context("read response bytes")?; - fs::write(dest, &bytes).with_context(|| format!("write {}", dest.display()))?; - Ok(()) + archive::download(url, dest) } fn install_auto_debug_extension(dry_run: bool) -> Result { diff --git a/crates/fastled-cli/src/project.rs b/crates/fastled-cli/src/project.rs index b15618e1..0c659475 100644 --- a/crates/fastled-cli/src/project.rs +++ b/crates/fastled-cli/src/project.rs @@ -395,23 +395,36 @@ const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/FastLED/FastLED/ /// /// Returns `None` when the request fails or the response cannot be parsed. fn fetch_latest_release_tag() -> Option { - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(10)) + let runtime = kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() .build() .ok()?; + let client = kernal_api::http::BlockingClient::new( + &runtime, + kernal_api::http::Limits { + max_redirects: 10, + total_timeout: std::time::Duration::from_secs(10), + ..kernal_api::http::Limits::default() + }, + ) + .ok()?; let resp = client - .get(GITHUB_RELEASES_API) - .header("Accept", "application/vnd.github.v3+json") - .send() + .execute(kernal_api::http::Request { + headers: &[ + ("Accept", "application/vnd.github.v3+json"), + ("User-Agent", "fastled-cli"), + ], + ..kernal_api::http::Request::get(GITHUB_RELEASES_API) + }) .ok()?; - if !resp.status().is_success() { + if !(200..300).contains(&resp.status()) { return None; } - let body = resp.text().ok()?; - let json: serde_json::Value = serde_json::from_str(&body).ok()?; + let body = resp.into_bytes().ok()?; + let json: serde_json::Value = serde_json::from_slice(&body).ok()?; json.get("tag_name") .and_then(|v: &serde_json::Value| v.as_str()) .map(|s: &str| s.to_owned()) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 8cf2121a..19de0b7d 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -360,6 +360,17 @@ Strict workspace all-target Clippy passes, and the full Python unit suite has 25 passes/1 skip. HTTP remains a locally adopted draft pending upstream review and release; this patch must not be released with the local path override. +The remaining blocking HTTP callers in install/project now use kernel clients: +release metadata stays bounded and is parsed as FastLED JSON, HEAD probes keep +their boolean fallback, and extension downloads reuse the streaming artifact +downloader. Project metadata now sends the same user-agent as install metadata. +There are no remaining `reqwest::blocking` imports; the app no longer enables +reqwest's blocking feature. Source and manifest boundary tests were observed +RED then GREEN. Python unit tests pass (26/1 skipped). The direct reqwest +dependency remains for async debug requests and server integration tests. +After feature removal, strict workspace all-target Clippy and the full Rust +suite pass (250 library, 2 binary, 1 integration, 1 doctest). + This is not the complete HTTP capability: streamed artifact sinks, SSE integration, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 6655f11d..8dc0b7ac 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -42,3 +42,14 @@ def test_archive_download_uses_kernel_http(): source = (root / "crates/fastled-cli/src/archive.rs").read_text() assert "reqwest::" not in source assert "kernal_api::http::" in source + + +def test_blocking_http_callers_use_kernel(): + root = Path(__file__).resolve().parents[2] + manifest = tomllib.loads((root / "Cargo.toml").read_text()) + assert "blocking" not in manifest["workspace"]["dependencies"]["reqwest"].get( + "features", [] + ) + for name in ("install.rs", "project.rs"): + source = (root / "crates/fastled-cli/src" / name).read_text() + assert "reqwest::" not in source, name From de8e6c07809a5d892b4587d5fbb617616c061750 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 21:55:30 -0700 Subject: [PATCH 22/94] feat: migrate DWARF HTTP and remove production reqwest (refs #238) --- crates/fastled-cli/Cargo.toml | 2 +- crates/fastled-cli/src/dwarf_smoke.rs | 54 ++++++++++++++++++++++----- docs/kernal-api-migration.md | 10 +++++ tests/unit/test_kernal_boundary.py | 6 ++- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index f6cfa306..cec00ee7 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -23,7 +23,6 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } kernal-api = { workspace = true } -reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } @@ -62,3 +61,4 @@ bin-dir = "fastled{ binary-ext }" pkg-fmt = "zip" [dev-dependencies] +reqwest = { workspace = true } diff --git a/crates/fastled-cli/src/dwarf_smoke.rs b/crates/fastled-cli/src/dwarf_smoke.rs index 5405f1c7..6bdc18be 100644 --- a/crates/fastled-cli/src/dwarf_smoke.rs +++ b/crates/fastled-cli/src/dwarf_smoke.rs @@ -27,22 +27,30 @@ pub(crate) fn run_dwarf_source_smoke(output_dir: &Path) -> anyhow::Result let resolver = debug_symbols::DebugSymbolResolver::new(config); let debug_symbols: server::DebugSymbolHandle = Arc::new(RwLock::new(Some(resolver))); let output_dir = output_dir.to_path_buf(); - let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime"); - rt.block_on(async move { + let rt = kernal_api::async_engine::RuntimeBuilder::multi_thread() + .enable_all() + .build() + .context("failed to create debug smoke runtime")?; + rt.run(async move { let addr = server::start_server(output_dir, 0, None, debug_symbols, None).await?; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let client = reqwest::Client::new(); + kernal_api::async_engine::sleep(std::time::Duration::from_millis(50)).await; + let client = kernal_api::http::Client::new(kernal_api::http::Limits::default())?; for path in &paths { + let url = format!("http://{addr}/dwarfsource"); + let body = serde_json::to_vec(&serde_json::json!({ "path": path }))?; let resp = client - .post(format!("http://{addr}/dwarfsource")) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(serde_json::json!({ "path": path }).to_string()) - .send() + .execute(kernal_api::http::Request { + method: kernal_api::http::Method::Post, + url: &url, + headers: &[("Content-Type", "application/json")], + body: &body, + }) .await .with_context(|| format!("POST /dwarfsource for {path}"))?; - if !resp.status().is_success() { + if !(200..300).contains(&resp.status()) { let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); + let bytes = resp.into_bytes().await.unwrap_or_default(); + let body = String::from_utf8_lossy(&bytes); anyhow::bail!("/dwarfsource rejected {path} with {status}: {body}"); } } @@ -50,6 +58,32 @@ pub(crate) fn run_dwarf_source_smoke(output_dir: &Path) -> anyhow::Result }) } +#[cfg(test)] +#[test] +fn source_smoke_uses_manifest_paths_and_reports_missing_source() { + let dir = tempfile::tempdir().unwrap(); + let sketch = dir.path().join("sketch"); + std::fs::create_dir(&sketch).unwrap(); + let source = sketch.join("demo.ino"); + std::fs::write(&source, "void setup() {}\n").unwrap(); + let output = dir.path().join("output"); + let config = debug_symbols::load_debug_symbol_config(sketch, None, None); + debug_symbols::write_debug_symbol_manifest(&output, &config).unwrap(); + std::fs::write(output.join("fastled.wasm"), b"\0asm\x01\0\0\0").unwrap(); + let source_path = format!("{}/demo.ino", config.prefixes.sketch_prefix); + std::fs::write( + output.join("fastled.wasm.map"), + serde_json::json!({"sources": [source_path]}).to_string(), + ) + .unwrap(); + assert_eq!(run_dwarf_source_smoke(&output).unwrap(), 1); + std::fs::remove_file(source).unwrap(); + assert!(run_dwarf_source_smoke(&output) + .unwrap_err() + .to_string() + .contains("/dwarfsource rejected")); +} + pub(crate) fn collect_debug_source_paths( output_dir: &Path, config: &debug_symbols::DebugSymbolConfig, diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 19de0b7d..168f348d 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -371,6 +371,16 @@ dependency remains for async debug requests and server integration tests. After feature removal, strict workspace all-target Clippy and the full Rust suite pass (250 library, 2 binary, 1 integration, 1 doctest). +DWARF source-smoke POSTs now use kernel HTTP, runtime construction, and sleep; +FastLED retains the JSON payload and source-path/status policy. Reqwest has been +removed from production dependencies and remains temporarily as a dev-dependency +for server integration tests. The boundary regression failed on the old DWARF +import then passed. The full existing workspace suite and strict Clippy passed; +an additional real-local-server source-smoke test also passes for both a mapped +source and its missing-file error. This is HTTP/source-resolution validation, +not a WASM execution or Safari test. Server-test migration is still required +before removing reqwest entirely. + This is not the complete HTTP capability: streamed artifact sinks, SSE integration, redirect policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 8dc0b7ac..7a4916b7 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -44,12 +44,14 @@ def test_archive_download_uses_kernel_http(): assert "kernal_api::http::" in source -def test_blocking_http_callers_use_kernel(): +def test_production_http_callers_use_kernel(): root = Path(__file__).resolve().parents[2] + package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) + assert "reqwest" not in package["dependencies"] manifest = tomllib.loads((root / "Cargo.toml").read_text()) assert "blocking" not in manifest["workspace"]["dependencies"]["reqwest"].get( "features", [] ) - for name in ("install.rs", "project.rs"): + for name in ("install.rs", "project.rs", "dwarf_smoke.rs"): source = (root / "crates/fastled-cli/src" / name).read_text() assert "reqwest::" not in source, name From c5f63760e6706d56441188e6d487118685a31fe5 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:01:39 -0700 Subject: [PATCH 23/94] refactor: remove direct reqwest dependency via kernel HTTP tests Refs #238 --- Cargo.lock | 156 +-------------- Cargo.toml | 1 - crates/fastled-cli/Cargo.toml | 3 - crates/fastled-cli/src/server.rs | 302 +++++++++++++++-------------- docs/kernal-api-migration.md | 19 +- tests/unit/test_kernal_boundary.py | 9 +- 6 files changed, 181 insertions(+), 309 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73104ec0..03eccd06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -582,16 +582,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -615,7 +605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-graphics-types", "foreign-types 0.5.0", "libc", @@ -628,7 +618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "libc", ] @@ -1083,15 +1073,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1135,7 +1116,6 @@ dependencies = [ "notify", "portable-pty", "kernal-api", - "reqwest 0.12.28", "serde", "serde_json", "shell-words", @@ -1701,25 +1681,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -1851,7 +1812,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1863,22 +1823,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -1913,11 +1857,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3603,19 +3545,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", - "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -3669,20 +3607,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "running-process" version = "4.10.10" @@ -3756,19 +3680,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3778,17 +3689,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.103.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -3883,7 +3783,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4442,27 +4342,6 @@ dependencies = [ "windows 0.52.0", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "system-deps" version = "6.2.2" @@ -4484,7 +4363,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation 0.10.1", + "core-foundation", "core-graphics", "crossbeam-channel", "dispatch2", @@ -4907,16 +4786,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.18" @@ -5305,12 +5174,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -5831,17 +5694,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index 87067ca6..0c03905b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ homepage = "https://github.com/zackees/fastled-wasm" clap = { version = "4", features = ["derive"] } thiserror = "2" kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client"] } -reqwest = { version = "0.12" } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index cec00ee7..6a10a6a8 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -59,6 +59,3 @@ webkit2gtk = { version = "2.0", features = ["v2_40"], optional = true } pkg-url = "{ repo }/releases/download/v{ version }/fastled-{ target }{ archive-suffix }" bin-dir = "fastled{ binary-ext }" pkg-fmt = "zip" - -[dev-dependencies] -reqwest = { workspace = true } diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index cdf1824c..4d3f4a56 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -822,8 +822,40 @@ pub async fn start_server( #[cfg(test)] mod tests { use super::*; + use kernal_api::http::{ + Client as HttpClient, Limits as HttpLimits, Method as HttpMethod, Request as HttpRequest, + Response as HttpResponse, + }; use std::fs; + async fn http_request( + method: HttpMethod, + url: &str, + headers: &[(&str, &str)], + body: &[u8], + ) -> std::io::Result { + HttpClient::new(HttpLimits::default())? + .execute(HttpRequest { + method, + url, + headers, + body, + }) + .await + } + + async fn http_get(url: String) -> std::io::Result { + http_request(HttpMethod::Get, &url, &[], &[]).await + } + + async fn response_text(response: HttpResponse) -> String { + String::from_utf8(response.into_bytes().await.unwrap()).unwrap() + } + + fn header_text<'a>(response: &'a HttpResponse, name: &str) -> &'a str { + std::str::from_utf8(response.header(name).unwrap()).unwrap() + } + fn empty_handle() -> DebugSymbolHandle { Arc::new(RwLock::new(None)) } @@ -872,13 +904,14 @@ mod tests { #[tokio::test] async fn test_viewer_log_endpoint_accepts_posts() { let (addr, _dir) = setup_server().await; - let client = reqwest::Client::new(); - let resp = client - .post(format!("http://{addr}/viewer-log")) - .body("error: something broke") - .send() - .await - .unwrap(); + let resp = http_request( + HttpMethod::Post, + &format!("http://{addr}/viewer-log"), + &[], + b"error: something broke", + ) + .await + .unwrap(); assert_eq!(resp.status(), 204); } @@ -912,80 +945,90 @@ mod tests { ) .await .unwrap(); - let client = reqwest::Client::new(); - let unauthorized = client - .get(format!("http://{addr}/test-config")) - .send() - .await - .unwrap(); - assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); - let config_response = client - .get(format!("http://{addr}/test-config")) - .bearer_auth("test-token") - .send() - .await - .unwrap(); + let unauthorized = http_request( + HttpMethod::Get, + &format!("http://{addr}/test-config"), + &[], + &[], + ) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED.as_u16()); + let config_response = http_request( + HttpMethod::Get, + &format!("http://{addr}/test-config"), + &[("Authorization", "Bearer test-token")], + &[], + ) + .await + .unwrap(); let config: serde_json::Value = - serde_json::from_str(&config_response.text().await.unwrap()).unwrap(); + serde_json::from_str(&response_text(config_response).await).unwrap(); assert_eq!(config["waitMs"].as_f64(), Some(25.0)); assert_eq!(config["screenshotNames"][0], "frame-0"); - let response = client - .post(format!("http://{addr}/test-sleep?ms=1")) - .bearer_auth("test-token") - .send() - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - let response = client - .post(format!("http://{addr}/test-sleep?ms=-1")) - .bearer_auth("test-token") - .send() - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let response = client - .post(format!("http://{addr}/test-sleep?ms=26")) - .bearer_auth("test-token") - .send() - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/test-sleep?ms=1"), + &[("Authorization", "Bearer test-token")], + &[], + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT.as_u16()); + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/test-sleep?ms=-1"), + &[("Authorization", "Bearer test-token")], + &[], + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST.as_u16()); + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/test-sleep?ms=26"), + &[("Authorization", "Bearer test-token")], + &[], + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST.as_u16()); - let worker = client - .get(format!("http://{addr}/fastled_background_worker.js")) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); + let worker = response_text( + http_get(format!("http://{addr}/fastled_background_worker.js")) + .await + .unwrap(), + ) + .await; assert!(worker.starts_with("\nconst __fastledOriginalOffscreenGetContext")); assert!(worker.contains("preserveDrawingBuffer: true")); assert!(worker.contains("console.log('worker');")); assert!(worker.contains("gl.readPixels")); assert!(worker.contains("fastled_test_capture_response")); - let response = client - .post(format!("http://{addr}/viewer-screenshot?name=..%2Fescape")) - .bearer_auth("test-token") - .body(vec![137, 80, 78, 71, 13, 10, 26, 10]) - .send() - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/viewer-screenshot?name=..%2Fescape"), + &[("Authorization", "Bearer test-token")], + &[137, 80, 78, 71, 13, 10, 26, 10], + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST.as_u16()); assert!(!dir.path().join("escape").exists()); let png = vec![137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]; - let response = client - .post(format!("http://{addr}/viewer-screenshot?name=frame-0")) - .bearer_auth("test-token") - .body(png.clone()) - .send() - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/viewer-screenshot?name=frame-0"), + &[("Authorization", "Bearer test-token")], + &png, + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT.as_u16()); assert_eq!(fs::read(&screenshot).unwrap(), png); assert!(matches!( rx.recv().await, @@ -996,9 +1039,9 @@ mod tests { #[tokio::test] async fn test_loading_page_when_no_index_html() { let (addr, _dir) = setup_server().await; - let resp = reqwest::get(format!("http://{addr}/")).await.unwrap(); + let resp = http_get(format!("http://{addr}/")).await.unwrap(); assert_eq!(resp.status(), 200); - let body = resp.text().await.unwrap(); + let body = response_text(resp).await; assert!( body.contains("Compiling..."), "expected loading page, got: {body}" @@ -1017,13 +1060,13 @@ mod tests { r#"{"status":"error","message":"Compilation failed"}"#, ) .unwrap(); - let resp = reqwest::get(format!("http://{addr}/")).await.unwrap(); + let resp = http_get(format!("http://{addr}/")).await.unwrap(); assert_eq!( resp.status(), 200, "viewer should land on the loading page, not 404/redirect, when compile failed" ); - let body = resp.text().await.unwrap(); + let body = response_text(resp).await; assert!( body.contains("Compiling..."), "expected loading page, got: {body}" @@ -1039,9 +1082,9 @@ mod tests { async fn test_serves_index_html_when_present() { let (addr, dir) = setup_server().await; fs::write(dir.path().join("index.html"), "OK").unwrap(); - let resp = reqwest::get(format!("http://{addr}/")).await.unwrap(); + let resp = http_get(format!("http://{addr}/")).await.unwrap(); assert_eq!(resp.status(), 200); - let body = resp.text().await.unwrap(); + let body = response_text(resp).await; assert!( body.contains("OK"), "expected index.html content, got: {body}" @@ -1052,14 +1095,9 @@ mod tests { async fn test_serves_js_with_correct_mime() { let (addr, dir) = setup_server().await; fs::write(dir.path().join("app.js"), "console.log('hi')").unwrap(); - let resp = reqwest::get(format!("http://{addr}/app.js")).await.unwrap(); + let resp = http_get(format!("http://{addr}/app.js")).await.unwrap(); assert_eq!(resp.status(), 200); - let ct = resp - .headers() - .get("content-type") - .unwrap() - .to_str() - .unwrap(); + let ct = header_text(&resp, "content-type"); assert!(ct.contains("javascript"), "expected JS mime, got: {ct}"); } @@ -1067,35 +1105,20 @@ mod tests { async fn test_serves_wasm_with_correct_mime() { let (addr, dir) = setup_server().await; fs::write(dir.path().join("fastled.wasm"), [0x00, 0x61, 0x73, 0x6d]).unwrap(); - let resp = reqwest::get(format!("http://{addr}/fastled.wasm")) + let resp = http_get(format!("http://{addr}/fastled.wasm")) .await .unwrap(); assert_eq!(resp.status(), 200); - let ct = resp - .headers() - .get("content-type") - .unwrap() - .to_str() - .unwrap(); + let ct = header_text(&resp, "content-type"); assert!(ct.contains("wasm"), "expected WASM mime, got: {ct}"); } #[tokio::test] async fn test_safari_compatible_coop_coep_headers() { let (addr, _dir) = setup_server().await; - let resp = reqwest::get(format!("http://{addr}/")).await.unwrap(); - let coep = resp - .headers() - .get("cross-origin-embedder-policy") - .unwrap() - .to_str() - .unwrap(); - let coop = resp - .headers() - .get("cross-origin-opener-policy") - .unwrap() - .to_str() - .unwrap(); + let resp = http_get(format!("http://{addr}/")).await.unwrap(); + let coep = header_text(&resp, "cross-origin-embedder-policy"); + let coop = header_text(&resp, "cross-origin-opener-policy"); assert_eq!(coep, "require-corp"); assert_eq!(coop, "same-origin"); } @@ -1103,7 +1126,7 @@ mod tests { #[tokio::test] async fn test_404_for_missing_file() { let (addr, _dir) = setup_server().await; - let resp = reqwest::get(format!("http://{addr}/nonexistent.js")) + let resp = http_get(format!("http://{addr}/nonexistent.js")) .await .unwrap(); assert_eq!(resp.status(), 404); @@ -1113,7 +1136,7 @@ mod tests { async fn test_build_status_json_served() { let (addr, dir) = setup_server().await; // Initially no build-status.json -> 404 - let resp = reqwest::get(format!("http://{addr}/build-status.json")) + let resp = http_get(format!("http://{addr}/build-status.json")) .await .unwrap(); assert_eq!(resp.status(), 404); @@ -1124,11 +1147,11 @@ mod tests { r#"{"status":"compiling","message":"Building..."}"#, ) .unwrap(); - let resp = reqwest::get(format!("http://{addr}/build-status.json")) + let resp = http_get(format!("http://{addr}/build-status.json")) .await .unwrap(); assert_eq!(resp.status(), 200); - let body = resp.text().await.unwrap(); + let body = response_text(resp).await; assert!(body.contains("compiling")); } @@ -1138,7 +1161,7 @@ mod tests { // Create a file outside the serve dir let parent = dir.path().parent().unwrap(); fs::write(parent.join("secret.txt"), "top secret").unwrap(); - let resp = reqwest::get(format!("http://{addr}/../secret.txt")) + let resp = http_get(format!("http://{addr}/../secret.txt")) .await .unwrap(); // Should not serve files outside the serve dir @@ -1153,7 +1176,7 @@ mod tests { async fn test_sse_returns_404_without_broadcast() { // Server started without broadcast channel → /build-stream returns 404. let (addr, _dir) = setup_server().await; - let resp = reqwest::get(format!("http://{addr}/build-stream")) + let resp = http_get(format!("http://{addr}/build-stream")) .await .unwrap(); assert_eq!(resp.status(), 404); @@ -1177,16 +1200,9 @@ mod tests { let url = format!("http://{addr}/build-stream"); // Connect to SSE endpoint. - let client = reqwest::Client::new(); - let mut resp = client.get(&url).send().await.unwrap(); + let mut resp = http_get(url).await.unwrap(); assert_eq!(resp.status(), 200); - let ct = resp - .headers() - .get("content-type") - .unwrap() - .to_str() - .unwrap() - .to_string(); + let ct = header_text(&resp, "content-type").to_string(); assert!( ct.contains("text/event-stream"), "expected event-stream content-type, got: {ct}" @@ -1204,8 +1220,12 @@ mod tests { // Read SSE chunks until we see both events (or timeout). let mut collected = String::new(); let deadline = std::time::Duration::from_secs(3); - while let Ok(Ok(Some(chunk))) = tokio::time::timeout(deadline, resp.chunk()).await { - collected.push_str(&String::from_utf8_lossy(&chunk)); + let mut buffer = [0; 4096]; + while let Ok(Ok(n)) = tokio::time::timeout(deadline, resp.read(&mut buffer)).await { + if n == 0 { + break; + } + collected.push_str(&String::from_utf8_lossy(&buffer[..n])); if collected.contains("Building sketch...") && collected.contains("success") { break; } @@ -1224,8 +1244,8 @@ mod tests { #[tokio::test] async fn test_loading_page_contains_eventsource() { let (addr, _dir) = setup_server().await; - let resp = reqwest::get(format!("http://{addr}/")).await.unwrap(); - let body = resp.text().await.unwrap(); + let resp = http_get(format!("http://{addr}/")).await.unwrap(); + let body = response_text(resp).await; assert!( body.contains("EventSource"), "loading page should use EventSource for SSE" @@ -1275,14 +1295,15 @@ mod tests { value.to_string() } - async fn post_json(addr: SocketAddr, path: &str, body: serde_json::Value) -> reqwest::Response { - reqwest::Client::new() - .post(format!("http://{addr}{path}")) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(json_body(body)) - .send() - .await - .unwrap() + async fn post_json(addr: SocketAddr, path: &str, body: serde_json::Value) -> HttpResponse { + http_request( + HttpMethod::Post, + &format!("http://{addr}{path}"), + &[("Content-Type", "application/json")], + json_body(body).as_bytes(), + ) + .await + .unwrap() } #[tokio::test] @@ -1300,11 +1321,11 @@ mod tests { #[tokio::test] async fn debug_source_roots_empty_without_resolver() { let (addr, _dir) = setup_server().await; - let resp = reqwest::get(format!("http://{addr}/debug/source-roots")) + let resp = http_get(format!("http://{addr}/debug/source-roots")) .await .unwrap(); assert_eq!(resp.status(), 200); - let body: serde_json::Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + let body: serde_json::Value = serde_json::from_str(&response_text(resp).await).unwrap(); assert!(body["roots"].as_array().unwrap().is_empty()); } @@ -1333,13 +1354,13 @@ mod tests { ) .await; assert_eq!(resp.status(), 200); - let body = resp.text().await.unwrap(); + let body = response_text(resp).await; assert!(body.contains("void setup()")); - let resp = reqwest::get(format!("http://{addr}/debug/source-roots")) + let resp = http_get(format!("http://{addr}/debug/source-roots")) .await .unwrap(); - let body: serde_json::Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap(); + let body: serde_json::Value = serde_json::from_str(&response_text(resp).await).unwrap(); let roots = body["roots"].as_array().unwrap(); assert!(!roots.is_empty()); assert!(roots @@ -1366,26 +1387,21 @@ mod tests { .unwrap(); tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let resp = reqwest::get(format!("http://{addr}/sketchsource/src/demo.ino")) + let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) .await .unwrap(); assert_eq!(resp.status(), 200); - let ct = resp - .headers() - .get("content-type") - .unwrap() - .to_str() - .unwrap(); + let ct = header_text(&resp, "content-type"); assert!(ct.contains("text/plain"), "expected text/plain, got {ct}"); - assert!(resp.text().await.unwrap().contains("void loop()")); + assert!(response_text(resp).await.contains("void loop()")); - let resp = reqwest::get(format!( + let resp = http_get(format!( "http://{addr}/.fastled/cache/fl/repo/sketchsource/src/demo.ino" )) .await .unwrap(); assert_eq!(resp.status(), 200); - assert!(resp.text().await.unwrap().contains("void loop()")); + assert!(response_text(resp).await.contains("void loop()")); } #[tokio::test] @@ -1415,11 +1431,11 @@ mod tests { .unwrap(); tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let resp = reqwest::get(format!("http://{addr}/sketchsource/src/demo.ino")) + let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) .await .unwrap(); assert_eq!(resp.status(), 200); - assert!(resp.text().await.unwrap().contains("void setup()")); + assert!(response_text(resp).await.contains("void setup()")); } #[tokio::test] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 168f348d..6bd3ecf4 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -381,11 +381,20 @@ source and its missing-file error. This is HTTP/source-resolution validation, not a WASM execution or Safari test. Server-test migration is still required before removing reqwest entirely. -This is not the complete HTTP capability: streamed artifact sinks, -SSE integration, redirect -policy, explicit cancellation/drop tests, TLS fixtures and pre-allocation -metadata analysis remain required, along with isolation/platform CI and review. -FastLED still directly depends on reqwest. No HTTP PR or release is published. +Server integration tests now use the kernel HTTP client, including POST bodies, +authorization headers, response headers, and caller-buffered SSE reads. Reqwest +is removed from both direct dependency lists; the boundary test was observed RED +then GREEN. All 21 endpoint tests and the full workspace suite pass (251 library, +2 binary, 1 integration, 1 doctest), as do Python unit tests (26/1 skipped) +and strict workspace all-target Clippy. +The lockfile drops now-unused HTTP/2, charset, and secondary TLS dependencies; +reqwest remains a private transitive dependency, not an application import. +No build-speed improvement has yet been measured. + +HTTP remains a locally adopted draft. Upstream review, TLS rejection/downgrade +fixtures, the pre-allocation metadata contract, and supported-platform CI still +need resolution. No HTTP PR or release is published, and the temporary local +path override must be removed before release. ### Final migration audit diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 7a4916b7..37b7bf43 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -44,14 +44,13 @@ def test_archive_download_uses_kernel_http(): assert "kernal_api::http::" in source -def test_production_http_callers_use_kernel(): +def test_http_callers_use_kernel(): root = Path(__file__).resolve().parents[2] package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) assert "reqwest" not in package["dependencies"] manifest = tomllib.loads((root / "Cargo.toml").read_text()) - assert "blocking" not in manifest["workspace"]["dependencies"]["reqwest"].get( - "features", [] - ) - for name in ("install.rs", "project.rs", "dwarf_smoke.rs"): + assert "reqwest" not in manifest["workspace"]["dependencies"] + assert "reqwest" not in package.get("dev-dependencies", {}) + for name in ("install.rs", "project.rs", "dwarf_smoke.rs", "server.rs"): source = (root / "crates/fastled-cli/src" / name).read_text() assert "reqwest::" not in source, name From de59cde4014395ab99c491cca8ede7a426774cc1 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:10:48 -0700 Subject: [PATCH 24/94] docs: record HTTP review fixes and validation (refs #238) --- docs/kernal-api-migration.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 6bd3ecf4..2da2808d 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -396,6 +396,22 @@ fixtures, the pre-allocation metadata contract, and supported-platform CI still need resolution. No HTTP PR or release is published, and the temporary local path override must be removed before release. +Pre-publication HTTP review found that 307/308 could replay a POST body across +origins after stripping its headers. A socket-observed regression reproduced +the second connection, then passed after rejecting cross-origin POST replay; +same-origin replay and 301/302/303 conversion remain covered. Twenty local HTTP +tests pass (one external trusted-HTTPS test is ignored by default). Review also +identified automatic decompression drift under downstream Reqwest feature +unification. A gzip-enabled regression failed when the backend removed the +encoding header, then passed after disabling all four automatic decoders. +Twenty-one HTTP tests and strict all-target Clippy pass with gzip enabled; a +native CI step now exercises this feature-unification regression after locked +checks, resolving its deliberately additional backend feature independently. +No production dependency change is needed. The broader HTTP-enabled kernel +suite and FastLED's full 255-test Rust workspace suite also pass. Local TLS +rejection/hostname/downgrade coverage remains outstanding. These are upstream +mechanism tests, not FastLED protocol tests. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. From e743f21b6c56336d932f5c2fe4c6e024a4c70dfc Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:15:41 -0700 Subject: [PATCH 25/94] docs: record deterministic HTTP TLS validation (refs #238) --- docs/kernal-api-migration.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 2da2808d..5234df21 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -412,6 +412,16 @@ suite and FastLED's full 255-test Rust workspace suite also pass. Local TLS rejection/hostname/downgrade coverage remains outstanding. These are upstream mechanism tests, not FastLED protocol tests. +Four deterministic loopback TLS tests now cover trusted HTTPS, default rejection +of an untrusted certificate, rejection of a trusted certificate with the wrong +hostname, and downgrade rejection before any plaintext connection. A public +test-only PKCS#12 identity and certificate are checked in upstream; trust is +scoped to the test client, never installed in the OS. Disabling verification +and the downgrade guard produced three expected failures; restoring them makes +all four tests pass. Strict HTTP-feature Clippy and the broader kernel suite +also pass. These tests run in the existing native all-features CI lanes, but +supported-platform results and the metadata-allocation contract remain pending. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. From 2a5e80012116272386711fe9fb5b0879a4ff60d4 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:23:20 -0700 Subject: [PATCH 26/94] build: adopt audited kernel HTTP parser (refs #238) --- Cargo.lock | 21 +++++++++++---------- docs/kernal-api-migration.md | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03eccd06..1b692a11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -99,7 +99,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -947,7 +947,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1097,7 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1804,9 +1804,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -2239,6 +2239,7 @@ dependencies = [ "dirs", "flate2", "globset", + "hyper", "jwalk", "libc", "mach2", @@ -3677,7 +3678,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4139,7 +4140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4644,7 +4645,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5548,7 +5549,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 5234df21..db7398dc 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -422,6 +422,23 @@ all four tests pass. Strict HTTP-feature Clippy and the broader kernel suite also pass. These tests run in the existing native all-features CI lanes, but supported-platform results and the metadata-allocation contract remain pending. +The metadata contract is now documented upstream with a distinction between +application acceptance limits, private parser thresholds, and allocator/RSS +usage. Hyper 1.11.0 is an exact optional private dependency so published +consumers use the audited parser. Regressions reject excessive response heads, +field counts, trailers and chunk extensions even with relaxed application +limits; the version-pin guard was observed RED then GREEN. Twenty-two HTTP +integration tests, 170 kernel library tests, the other enabled integration +suites, strict Clippy and dependency-isolation checks pass. Local review is clean. + +Kernel [PR #169](https://github.com/zackees/kernal-api/pull/169) is open; +[CI run 34740069309](https://github.com/zackees/kernal-api/actions/runs/34740069309) +is in progress. FastLED explicitly updates its locked Hyper 1.9.0 to 1.11.0; +Cargo also reselects already-locked Windows dependency edges without adding +package versions. The full FastLED Rust suite (255 tests), strict all-target +Clippy and three boundary tests pass. Publication and removal of the temporary +local path override remain pending, as does the rest of the dependency inventory. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. From 09b1fcff283423c6204c4bae717ebf283431cfd6 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:27:04 -0700 Subject: [PATCH 27/94] refactor: use kernel runtimes and basic timers (refs #239) --- crates/fastled-cli/src/commands.rs | 24 +++++++++++++++++------- crates/fastled-cli/src/server.rs | 19 ++++++++++--------- crates/fastled-cli/src/test_mode.rs | 23 ++++++++++++----------- docs/kernal-api-migration.md | 17 +++++++++++++++++ tests/unit/test_kernal_boundary.py | 12 ++++++++++++ 5 files changed, 68 insertions(+), 27 deletions(-) diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index 775e05ce..53f520d2 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -1,3 +1,4 @@ +use kernal_api::async_engine; use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; use std::io::Write; @@ -68,8 +69,11 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { // Write initial compiling status for polling fallback. write_build_status(&output_dir, "compiling", "Compiling..."); - let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime"); - rt.block_on(async { + let rt = async_engine::RuntimeBuilder::multi_thread() + .enable_all() + .build() + .expect("failed to create kernel runtime"); + rt.run(async { // Start the Rust HTTP server (background tokio task). let addr = match server::start_server( output_dir.clone(), @@ -264,14 +268,17 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { }; write_build_status(&output_dir, "compiling", "Compiling..."); - let rt = match tokio::runtime::Runtime::new() { + let rt = match async_engine::RuntimeBuilder::multi_thread() + .enable_all() + .build() + { Ok(runtime) => runtime, Err(error) => { eprintln!("fastled: could not create test runtime: {error}"); return test_exit(TestOutcome::Failure); } }; - rt.block_on(async { + rt.run(async { let addr = match server::start_server( output_dir.clone(), 0, @@ -761,8 +768,11 @@ pub(crate) fn serve_directory(dir: &str, launch_viewer: bool) -> ExitCode { } }; - let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime"); - rt.block_on(async { + let rt = async_engine::RuntimeBuilder::multi_thread() + .enable_all() + .build() + .expect("failed to create kernel runtime"); + rt.run(async { let debug_symbols: server::DebugSymbolHandle = Arc::new(RwLock::new(resolver)); let addr = match server::start_server(path.clone(), 0, None, debug_symbols, None).await { Ok(a) => a, @@ -799,7 +809,7 @@ pub(crate) fn serve_directory(dir: &str, launch_viewer: bool) -> ExitCode { println!("\nShutting down..."); break; } - _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => { + _ = async_engine::sleep(std::time::Duration::from_secs(1)) => { if !viewer.is_alive() { println!("\nViewer window closed; shutting down."); break; diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 4d3f4a56..57a3bdea 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -18,6 +18,7 @@ use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; +use kernal_api::async_engine; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::broadcast; @@ -574,7 +575,7 @@ async fn test_sleep( let Ok(_permit) = test.sleep_permits.clone().try_acquire_owned() else { return StatusCode::TOO_MANY_REQUESTS.into_response(); }; - tokio::time::sleep(duration).await; + async_engine::sleep(duration).await; StatusCode::NO_CONTENT.into_response() } @@ -867,7 +868,7 @@ mod tests { .await .unwrap(); // Give the server a moment to bind. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + async_engine::sleep(std::time::Duration::from_millis(50)).await; (addr, dir) } @@ -1195,7 +1196,7 @@ mod tests { ) .await .unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + async_engine::sleep(std::time::Duration::from_millis(50)).await; let url = format!("http://{addr}/build-stream"); @@ -1209,7 +1210,7 @@ mod tests { ); // Give the server handler a moment to subscribe to the broadcast. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + async_engine::sleep(std::time::Duration::from_millis(50)).await; // Send test events. tx.send(r#"{"type":"log","line":"Building sketch...","stream":"stdout"}"#.to_string()) @@ -1221,7 +1222,7 @@ mod tests { let mut collected = String::new(); let deadline = std::time::Duration::from_secs(3); let mut buffer = [0; 4096]; - while let Ok(Ok(n)) = tokio::time::timeout(deadline, resp.read(&mut buffer)).await { + while let Ok(Ok(n)) = async_engine::timeout(deadline, resp.read(&mut buffer)).await { if n == 0 { break; } @@ -1345,7 +1346,7 @@ mod tests { let addr = start_server(dir.path().to_path_buf(), 0, None, handle.clone(), None) .await .unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + async_engine::sleep(std::time::Duration::from_millis(50)).await; let resp = post_json( addr, @@ -1385,7 +1386,7 @@ mod tests { let addr = start_server(serve_dir, 0, None, handle, None) .await .unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + async_engine::sleep(std::time::Duration::from_millis(50)).await; let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) .await @@ -1429,7 +1430,7 @@ mod tests { let addr = start_server(serve_dir, 0, None, handle, None) .await .unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + async_engine::sleep(std::time::Duration::from_millis(50)).await; let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) .await @@ -1451,7 +1452,7 @@ mod tests { let addr = start_server(dir.path().to_path_buf(), 0, None, handle, None) .await .unwrap(); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + async_engine::sleep(std::time::Duration::from_millis(50)).await; let resp = post_json( addr, diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index 412e4fee..52aa1639 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -1,3 +1,4 @@ +use kernal_api::async_engine; use std::io::{BufRead, BufReader}; use std::path::Path; use std::process::Command; @@ -154,7 +155,7 @@ async fn run_test_commands_inner( } None => { output_open = false; } }, - _ = tokio::time::sleep(std::time::Duration::from_millis(10)) => { + _ = async_engine::sleep(std::time::Duration::from_millis(10)) => { if let Some(code) = child.try_wait().map_err(|e| format!("test command {index} wait failed: {e}"))? { break code; } @@ -175,7 +176,7 @@ async fn run_test_commands_inner( COMMAND_OUTPUT_DRAIN_TIMEOUT.as_millis(), )); } - match tokio::time::timeout(remaining, output_rx.recv()).await { + match async_engine::timeout(remaining, output_rx.recv()).await { Ok(Some((stream, line))) => { if tx .send(TestCommandEvent::Output { @@ -278,7 +279,7 @@ pub(crate) async fn run_contained_command( drop(child); return Ok(TimedCommandResult::Interrupted); } - _ = tokio::time::sleep(Duration::from_millis(10)) => {} + _ = async_engine::sleep(Duration::from_millis(10)) => {} } } } @@ -461,7 +462,7 @@ pub(crate) async fn wait_for_ready( rx: &mut tokio::sync::mpsc::UnboundedReceiver, timeout: Duration, ) -> TestOutcome { - match tokio::time::timeout(timeout, async { + match async_engine::timeout(timeout, async { while let Some(event) = rx.recv().await { if matches!(event, TestEvent::Ready) { return true; @@ -485,7 +486,7 @@ mod tests { const COMMAND_TEST_TIMEOUT: Duration = Duration::from_secs(10); async fn next_command_event(rx: &mut mpsc::Receiver) -> TestCommandEvent { - tokio::time::timeout(COMMAND_TEST_TIMEOUT, rx.recv()) + async_engine::timeout(COMMAND_TEST_TIMEOUT, rx.recv()) .await .expect("command runner did not emit an event before the test deadline") .expect("command runner closed its event channel before Done") @@ -639,7 +640,7 @@ mod tests { TestCommandEvent::Start { .. } | TestCommandEvent::Exit { .. } => {} } }; - tokio::time::timeout(COMMAND_TEST_TIMEOUT, task) + async_engine::timeout(COMMAND_TEST_TIMEOUT, task) .await .expect("command runner task did not complete before the test deadline") .unwrap(); @@ -683,7 +684,7 @@ mod tests { break value; } }; - tokio::time::timeout(COMMAND_TEST_TIMEOUT, task) + async_engine::timeout(COMMAND_TEST_TIMEOUT, task) .await .expect("command runner task did not complete before the test deadline") .unwrap(); @@ -714,12 +715,12 @@ mod tests { break value; } }; - tokio::time::timeout(COMMAND_TEST_TIMEOUT, task) + async_engine::timeout(COMMAND_TEST_TIMEOUT, task) .await .expect("command runner task did not complete before the test deadline") .unwrap(); assert!(result.is_ok()); - tokio::time::sleep(Duration::from_millis(300)).await; + async_engine::sleep(Duration::from_millis(300)).await; assert!(!temp.path().join("late.txt").exists()); } @@ -736,10 +737,10 @@ mod tests { NormalizedPath::new(temp.path()), tx, )); - tokio::time::sleep(Duration::from_millis(100)).await; + async_engine::sleep(Duration::from_millis(100)).await; task.abort(); let _ = task.await; - tokio::time::sleep(Duration::from_millis(250)).await; + async_engine::sleep(Duration::from_millis(250)).await; assert!(!temp.path().join("late.txt").exists()); } } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index db7398dc..a3562d2b 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -439,6 +439,23 @@ package versions. The full FastLED Rust suite (255 tests), strict all-target Clippy and three boundary tests pass. Publication and removal of the temporary local path override remain pending, as does the rest of the dependency inventory. +### Async runtime and coordination slice + +Issue #239 tracks remaining Tokio ownership. All application runtime construction +now uses kernel `RuntimeBuilder`/`Runtime::run`; basic sleeps and relative +timeouts in commands, the test runner and server use `async_engine` operations. +The source boundary failed before migration and passes afterward. FastLED keeps +its existing timeout values, exit behavior and test-runner policy. The full +255-test Rust workspace suite, strict all-target Clippy and Python suite +(27 passes/1 skip) pass. + +Tokio and tokio-stream remain direct dependencies. Remaining work includes +channel types (notably blocking sends from output-reader threads), broadcast/SSE, +signals, semaphore try-acquisition, absolute timers, tasks and server I/O. +Kernel async_engine explicitly leaves select/attribute macro calls as a separate +macro-boundary decision; this slice does not add a generic race combinator or +claim those dependencies have been removed. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 37b7bf43..98295957 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -54,3 +54,15 @@ def test_http_callers_use_kernel(): for name in ("install.rs", "project.rs", "dwarf_smoke.rs", "server.rs"): source = (root / "crates/fastled-cli/src" / name).read_text() assert "reqwest::" not in source, name + + +def test_runtime_and_basic_timers_use_kernel(): + root = Path(__file__).resolve().parents[2] + for path in (root / "crates/fastled-cli/src").rglob("*.rs"): + source = path.read_text() + for backend in ( + "tokio::runtime::", + "tokio::time::sleep(", + "tokio::time::timeout(", + ): + assert backend not in source, path From ac1edb0c18501b69835f944308f67c6281d2ad07 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:31:15 -0700 Subject: [PATCH 28/94] refactor: use kernel task ownership and event channels (refs #239) --- crates/fastled-cli/src/commands.rs | 16 ++------ crates/fastled-cli/src/server.rs | 13 ++++-- crates/fastled-cli/src/test_mode.rs | 64 ++++++++++++++++++----------- docs/kernal-api-migration.md | 12 ++++++ tests/unit/test_kernal_boundary.py | 6 ++- 5 files changed, 69 insertions(+), 42 deletions(-) diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index 53f520d2..00037c0d 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -241,7 +241,7 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { } }; let (build_tx, _build_rx) = tokio::sync::broadcast::channel::(256); - let (test_tx, mut test_rx) = tokio::sync::mpsc::unbounded_channel(); + let (test_tx, mut test_rx) = async_engine::unbounded_channel(); let mut token_bytes = [0_u8; 32]; if let Err(error) = getrandom::fill(&mut token_bytes) { eprintln!("fastled: could not create test capability: {error}"); @@ -366,7 +366,7 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { let mut commands_done = plan.commands.is_empty(); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(256); let mut command_tx = Some(command_tx); - let mut _command_task: Option = None; + let mut _command_task: Option> = None; loop { tokio::select! { @@ -402,13 +402,13 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { eprintln!("fastled: command runner was already started"); return test_exit(TestOutcome::Failure); }; - _command_task = Some(CommandTaskGuard(tokio::spawn( + _command_task = Some(async_engine::launch( test_mode::run_test_commands( plan.commands.clone(), NormalizedPath::new(&sketch_dir), command_sender, ), - ))); + )); } } } @@ -490,14 +490,6 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { }) } -struct CommandTaskGuard(tokio::task::JoinHandle<()>); - -impl Drop for CommandTaskGuard { - fn drop(&mut self) { - self.0.abort(); - } -} - fn test_exit(outcome: TestOutcome) -> ExitCode { ExitCode::from(outcome.exit_code()) } diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 57a3bdea..5fbea8ee 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -49,7 +49,7 @@ pub(crate) struct TestRuntimeConfig { pub(crate) struct TestServerOptions { pub(crate) runtime: TestRuntimeConfig, pub(crate) screenshot_paths: HashMap, - pub(crate) events: tokio::sync::mpsc::UnboundedSender, + pub(crate) events: async_engine::UnboundedSender, pub(crate) token: String, pub(crate) sleep_permits: Arc, } @@ -809,9 +809,14 @@ pub async fn start_server( .layer(DefaultBodyLimit::max(64 * 1024 * 1024)) .with_state(state); - tokio::spawn(async move { + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))).await?; + let addr = listener.local_addr()?; + + // This server currently lives until its caller-owned runtime shuts down. + async_engine::launch(async move { axum::serve(listener, app).await.ok(); - }); + }) + .detach(); Ok(addr) } @@ -925,7 +930,7 @@ mod tests { ) .unwrap(); let screenshot = dir.path().join("artifacts").join("frame.png"); - let (events, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let (events, mut rx) = async_engine::unbounded_channel(); let options = TestServerOptions { runtime: TestRuntimeConfig { wait_ms: 25.0, diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index 52aa1639..a765b10c 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -167,9 +167,9 @@ async fn run_test_commands_inner( // closes inherited writers held by descendants, then lets every reader // deliver final bytes before Exit and Done are emitted. drop(child); - let drain_deadline = tokio::time::Instant::now() + COMMAND_OUTPUT_DRAIN_TIMEOUT; + let drain_deadline = async_engine::Deadline::after(COMMAND_OUTPUT_DRAIN_TIMEOUT); while output_open { - let remaining = drain_deadline.saturating_duration_since(tokio::time::Instant::now()); + let remaining = drain_deadline.remaining(); if remaining.is_zero() { return Err(format!( "test command {index} exited with code {code}, but {readers} output reader(s) did not close within {} ms", @@ -263,14 +263,14 @@ pub(crate) async fn run_contained_command( kernal_api::platform::process::SpawnStdio::default(), kernal_api::platform::process::SyncEnvironment::Inherit, )?; - let deadline = tokio::time::Instant::now() + timeout; + let deadline = async_engine::Deadline::after(timeout); let ctrl_c = tokio::signal::ctrl_c(); tokio::pin!(ctrl_c); loop { if let Some(code) = child.try_wait()? { return Ok(TimedCommandResult::Exited(code)); } - if tokio::time::Instant::now() >= deadline { + if deadline.is_elapsed() { drop(child); return Ok(TimedCommandResult::TimedOut); } @@ -459,7 +459,7 @@ pub(crate) fn is_viewer_error_line(line: &str) -> bool { #[cfg(test)] pub(crate) async fn wait_for_ready( - rx: &mut tokio::sync::mpsc::UnboundedReceiver, + rx: &mut async_engine::UnboundedReceiver, timeout: Duration, ) -> TestOutcome { match async_engine::timeout(timeout, async { @@ -532,7 +532,7 @@ mod tests { #[tokio::test] async fn waiting_for_ready_has_a_distinct_timeout() { - let (_tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let (_tx, mut rx) = async_engine::unbounded_channel(); let outcome = wait_for_ready(&mut rx, Duration::from_millis(1)).await; assert_eq!(outcome, TestOutcome::ReadyTimeout); } @@ -626,7 +626,7 @@ mod tests { "printf B >> order.txt; printf out; printf err >&2".to_string(), ]; let (tx, mut rx) = mpsc::channel(256); - let task = tokio::spawn(run_test_commands( + let task = async_engine::launch(run_test_commands( commands, NormalizedPath::new(temp.path()), tx, @@ -673,7 +673,7 @@ mod tests { "echo SHOULD_NOT_RUN > later.txt".to_string(), ]; let (tx, mut rx) = mpsc::channel(256); - let task = tokio::spawn(run_test_commands( + let task = async_engine::launch(run_test_commands( commands, NormalizedPath::new(temp.path()), tx, @@ -704,7 +704,7 @@ mod tests { #[cfg(not(windows))] let command = "(sleep 20; echo LATE > late.txt) &"; let (tx, mut rx) = mpsc::channel(256); - let task = tokio::spawn(run_test_commands( + let task = async_engine::launch(run_test_commands( vec![command.to_string()], NormalizedPath::new(temp.path()), tx, @@ -726,21 +726,35 @@ mod tests { #[tokio::test] async fn issue_200_cancelling_runner_kills_the_contained_shell() { - let temp = tempfile::tempdir().unwrap(); - #[cfg(windows)] - let command = "ping -n 20 127.0.0.1 >NUL & echo LATE > late.txt"; - #[cfg(not(windows))] - let command = "sleep 2; echo LATE > late.txt"; - let (tx, _rx) = mpsc::channel(256); - let task = tokio::spawn(run_test_commands( - vec![command.to_string()], - NormalizedPath::new(temp.path()), - tx, - )); - async_engine::sleep(Duration::from_millis(100)).await; - task.abort(); - let _ = task.await; - async_engine::sleep(Duration::from_millis(250)).await; - assert!(!temp.path().join("late.txt").exists()); + for drop_handle in [false, true] { + let temp = tempfile::tempdir().unwrap(); + #[cfg(windows)] + let command = "echo READY & ping -n 3 127.0.0.1 >NUL & echo LATE > late.txt"; + #[cfg(not(windows))] + let command = "echo READY; sleep 1; echo LATE > late.txt"; + let (tx, mut rx) = mpsc::channel(256); + let task = async_engine::launch(run_test_commands( + vec![command.to_string()], + NormalizedPath::new(temp.path()), + tx, + )); + loop { + match next_command_event(&mut rx).await { + TestCommandEvent::Output { line, .. } if line.trim() == "READY" => break, + TestCommandEvent::Start { .. } => {} + event => panic!("unexpected event before cancellation: {event:?}"), + } + } + if drop_handle { + drop(task); + } else { + task.cancel(); + let _ = task.await; + } + // Wait beyond the command's normal completion time, so a live + // escaped shell would actually have written the failure marker. + async_engine::sleep(Duration::from_secs(3)).await; + assert!(!temp.path().join("late.txt").exists()); + } } } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index a3562d2b..b9df6048 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -456,6 +456,18 @@ Kernel async_engine explicitly leaves select/attribute macro calls as a separate macro-boundary decision; this slice does not add a generic race combinator or claim those dependencies have been removed. +Task launch/handles and unbounded test-event channels now use kernel-owned +types. The application-specific command-task Drop guard is removed because +kernel Task already cancels on drop; the HTTP server explicitly detaches its +task to retain its existing runtime-owned lifetime. Test-runner command and +output-drain deadlines use kernel Deadline. The cancellation product regression +now waits for a READY event, covers explicit cancellation and handle drop, and +waits beyond normal command completion before checking for escaped-shell output. +The async source boundary was observed RED then GREEN. Bounded channels still +need a kernel blocking-send operation for the synchronous reader threads. +All 255 Rust tests, strict all-target Clippy and 27 Python tests pass (one +Python test skipped) after this task/channel migration. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 98295957..2518595c 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -56,7 +56,7 @@ def test_http_callers_use_kernel(): assert "reqwest::" not in source, name -def test_runtime_and_basic_timers_use_kernel(): +def test_migrated_async_operations_use_kernel(): root = Path(__file__).resolve().parents[2] for path in (root / "crates/fastled-cli/src").rglob("*.rs"): source = path.read_text() @@ -64,5 +64,9 @@ def test_runtime_and_basic_timers_use_kernel(): "tokio::runtime::", "tokio::time::sleep(", "tokio::time::timeout(", + "tokio::spawn(", + "tokio::task::JoinHandle", + "tokio::sync::mpsc::unbounded_channel", + "tokio::sync::mpsc::Unbounded", ): assert backend not in source, path From fbdc7966784c994611e11f84326737e9a4684222 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:40:35 -0700 Subject: [PATCH 29/94] refactor: route bounded command channels through kernal-api Refs #239. Adopt kernal-api#170 blocking sends for synchronous output readers, retaining queue capacity and receiver-owned cancellation. Ban remaining direct Tokio mpsc imports. --- crates/fastled-cli/src/commands.rs | 2 +- crates/fastled-cli/src/test_mode.rs | 25 +++++++++++++------------ tests/unit/test_kernal_boundary.py | 3 +-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index 00037c0d..b5d33e22 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -364,7 +364,7 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { let mut saved = HashSet::new(); let mut viewer_done: Option = None; let mut commands_done = plan.commands.is_empty(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(256); + let (command_tx, mut command_rx) = async_engine::channel(256); let mut command_tx = Some(command_tx); let mut _command_task: Option> = None; diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index a765b10c..434158eb 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -3,7 +3,6 @@ use std::io::{BufRead, BufReader}; use std::path::Path; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tokio::sync::mpsc; use crate::cli::Cli; use crate::path::NormalizedPath; @@ -93,7 +92,7 @@ pub(crate) fn shell_command(command: &str, sketch_dir: &Path) -> Command { pub(crate) async fn run_test_commands( commands: Vec, sketch_dir: NormalizedPath, - tx: mpsc::Sender, + tx: async_engine::Sender, ) { let result = run_test_commands_inner(commands, sketch_dir, &tx).await; let _ = tx.send(TestCommandEvent::Done(result)).await; @@ -102,7 +101,7 @@ pub(crate) async fn run_test_commands( async fn run_test_commands_inner( commands: Vec, sketch_dir: NormalizedPath, - tx: &mpsc::Sender, + tx: &async_engine::Sender, ) -> Result<(), String> { for (index, command_text) in commands.iter().enumerate() { if tx.send(TestCommandEvent::Start { index }).await.is_err() { @@ -125,7 +124,7 @@ async fn run_test_commands_inner( kernal_api::platform::process::SyncEnvironment::Inherit, ) .map_err(|e| format!("could not spawn test command {index}: {e}"))?; - let (output_tx, mut output_rx) = mpsc::channel::<(CommandStream, String)>(256); + let (output_tx, mut output_rx) = async_engine::channel::<(CommandStream, String)>(256); let mut readers = 0usize; if let Some(stdout) = child.stdout.take() { readers += 1; @@ -216,7 +215,7 @@ async fn run_test_commands_inner( fn spawn_reader( reader: R, stream: CommandStream, - tx: mpsc::Sender<(CommandStream, String)>, + tx: async_engine::Sender<(CommandStream, String)>, ) { std::thread::spawn(move || { let mut reader = BufReader::new(reader); @@ -485,7 +484,9 @@ mod tests { const COMMAND_TEST_TIMEOUT: Duration = Duration::from_secs(10); - async fn next_command_event(rx: &mut mpsc::Receiver) -> TestCommandEvent { + async fn next_command_event( + rx: &mut async_engine::Receiver, + ) -> TestCommandEvent { async_engine::timeout(COMMAND_TEST_TIMEOUT, rx.recv()) .await .expect("command runner did not emit an event before the test deadline") @@ -625,7 +626,7 @@ mod tests { "printf A >> order.txt".to_string(), "printf B >> order.txt; printf out; printf err >&2".to_string(), ]; - let (tx, mut rx) = mpsc::channel(256); + let (tx, mut rx) = async_engine::channel(256); let task = async_engine::launch(run_test_commands( commands, NormalizedPath::new(temp.path()), @@ -647,7 +648,7 @@ mod tests { assert!(done.is_ok()); assert!(matches!( rx.try_recv(), - Err(mpsc::error::TryRecvError::Disconnected) + Err(async_engine::TryRecvError::Disconnected) )); let order = std::fs::read_to_string(temp.path().join("order.txt")).unwrap(); assert_eq!(order.split_whitespace().collect::(), "AB"); @@ -672,7 +673,7 @@ mod tests { "exit 7".to_string(), "echo SHOULD_NOT_RUN > later.txt".to_string(), ]; - let (tx, mut rx) = mpsc::channel(256); + let (tx, mut rx) = async_engine::channel(256); let task = async_engine::launch(run_test_commands( commands, NormalizedPath::new(temp.path()), @@ -691,7 +692,7 @@ mod tests { assert!(result.is_err()); assert!(matches!( rx.try_recv(), - Err(mpsc::error::TryRecvError::Disconnected) + Err(async_engine::TryRecvError::Disconnected) )); assert!(!temp.path().join("later.txt").exists()); } @@ -703,7 +704,7 @@ mod tests { let command = "start /B cmd /C \"ping -n 20 127.0.0.1 >NUL & echo LATE > late.txt\""; #[cfg(not(windows))] let command = "(sleep 20; echo LATE > late.txt) &"; - let (tx, mut rx) = mpsc::channel(256); + let (tx, mut rx) = async_engine::channel(256); let task = async_engine::launch(run_test_commands( vec![command.to_string()], NormalizedPath::new(temp.path()), @@ -732,7 +733,7 @@ mod tests { let command = "echo READY & ping -n 3 127.0.0.1 >NUL & echo LATE > late.txt"; #[cfg(not(windows))] let command = "echo READY; sleep 1; echo LATE > late.txt"; - let (tx, mut rx) = mpsc::channel(256); + let (tx, mut rx) = async_engine::channel(256); let task = async_engine::launch(run_test_commands( vec![command.to_string()], NormalizedPath::new(temp.path()), diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 2518595c..c0c0323b 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -66,7 +66,6 @@ def test_migrated_async_operations_use_kernel(): "tokio::time::timeout(", "tokio::spawn(", "tokio::task::JoinHandle", - "tokio::sync::mpsc::unbounded_channel", - "tokio::sync::mpsc::Unbounded", + "tokio::sync::mpsc", ): assert backend not in source, path From ab0b3ebd1949a55e161c1cdb12efe5329e7c357e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:46:25 -0700 Subject: [PATCH 30/94] refactor: use kernel timers and semaphore admission Refs #239. Adopt kernal-api#172 while preserving viewer cadence, timeout priorities and HTTP429 sleep admission. Ban direct Tokio timer and semaphore imports. --- crates/fastled-cli/src/commands.rs | 21 ++++++++++++++------- crates/fastled-cli/src/server.rs | 24 +++++++++++++++++++++--- tests/unit/test_kernal_boundary.py | 4 ++-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index b5d33e22..427bf39a 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -289,7 +289,7 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { screenshot_paths, events: test_tx, token: test_token.clone(), - sleep_permits: Arc::new(tokio::sync::Semaphore::new(4)), + sleep_permits: async_engine::Semaphore::new(4), }), ) .await @@ -353,12 +353,19 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { eprintln!("fastled: production test timed out while launching the viewer"); return test_exit(TestOutcome::TotalTimeout); }; - let deadline_origin = tokio::time::Instant::now(); - let total_deadline = deadline_origin + remaining; - let ready_deadline = deadline_origin + plan.ready_timeout; + let total_deadline = async_engine::Deadline::after(remaining); + let ready_deadline = async_engine::Deadline::after(plan.ready_timeout); let ctrl_c = tokio::signal::ctrl_c(); tokio::pin!(ctrl_c); - let mut liveness = tokio::time::interval(std::time::Duration::from_millis(100)); + let mut liveness = match async_engine::PeriodicTimer::new( + std::time::Duration::from_millis(100), + ) { + Ok(timer) => timer, + Err(error) => { + eprintln!("fastled: could not start viewer monitoring: {error}"); + return test_exit(TestOutcome::Failure); + } + }; let mut ready = false; let mut page_error = false; let mut saved = HashSet::new(); @@ -371,11 +378,11 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { loop { tokio::select! { biased; - _ = tokio::time::sleep_until(total_deadline) => { + _ = async_engine::sleep_until(total_deadline) => { eprintln!("fastled: production test exceeded --test-timeout-secs"); return test_exit(TestOutcome::TotalTimeout); } - _ = tokio::time::sleep_until(ready_deadline), if !ready => { + _ = async_engine::sleep_until(ready_deadline), if !ready => { eprintln!("fastled: viewer did not render a canvas before --test-ready-timeout-secs"); return test_exit(TestOutcome::ReadyTimeout); } diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 5fbea8ee..2c16d4dc 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -51,7 +51,7 @@ pub(crate) struct TestServerOptions { pub(crate) screenshot_paths: HashMap, pub(crate) events: async_engine::UnboundedSender, pub(crate) token: String, - pub(crate) sleep_permits: Arc, + pub(crate) sleep_permits: async_engine::Semaphore, } #[derive(Debug)] @@ -572,7 +572,7 @@ async fn test_sleep( if query.ms > max_ms { return (StatusCode::BAD_REQUEST, "sleep exceeds test schedule").into_response(); } - let Ok(_permit) = test.sleep_permits.clone().try_acquire_owned() else { + let Some(_permit) = test.sleep_permits.try_acquire() else { return StatusCode::TOO_MANY_REQUESTS.into_response(); }; async_engine::sleep(duration).await; @@ -931,6 +931,7 @@ mod tests { .unwrap(); let screenshot = dir.path().join("artifacts").join("frame.png"); let (events, mut rx) = async_engine::unbounded_channel(); + let sleep_permits = async_engine::Semaphore::new(4); let options = TestServerOptions { runtime: TestRuntimeConfig { wait_ms: 25.0, @@ -940,7 +941,7 @@ mod tests { screenshot_paths: HashMap::from([("frame-0".to_string(), screenshot.clone())]), events, token: "test-token".to_string(), - sleep_permits: Arc::new(tokio::sync::Semaphore::new(4)), + sleep_permits: sleep_permits.clone(), }; let addr = start_server( dir.path().to_path_buf(), @@ -974,6 +975,21 @@ mod tests { assert_eq!(config["waitMs"].as_f64(), Some(25.0)); assert_eq!(config["screenshotNames"][0], "frame-0"); + // Product policy: four occupied slots reject another authorized sleep + // with HTTP 429; releasing a slot admits the next request. + let mut occupied: Vec<_> = (0..4) + .map(|_| sleep_permits.try_acquire().unwrap()) + .collect(); + let saturated = http_request( + HttpMethod::Post, + &format!("http://{addr}/test-sleep?ms=1"), + &[("Authorization", "Bearer test-token")], + &[], + ) + .await + .unwrap(); + assert_eq!(saturated.status(), StatusCode::TOO_MANY_REQUESTS.as_u16()); + drop(occupied.pop()); let response = http_request( HttpMethod::Post, &format!("http://{addr}/test-sleep?ms=1"), @@ -983,6 +999,8 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT.as_u16()); + assert_eq!(sleep_permits.available_permits(), 1); + drop(occupied); let response = http_request( HttpMethod::Post, &format!("http://{addr}/test-sleep?ms=-1"), diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index c0c0323b..031bf063 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -62,8 +62,8 @@ def test_migrated_async_operations_use_kernel(): source = path.read_text() for backend in ( "tokio::runtime::", - "tokio::time::sleep(", - "tokio::time::timeout(", + "tokio::time::", + "tokio::sync::Semaphore", "tokio::spawn(", "tokio::task::JoinHandle", "tokio::sync::mpsc", From c4b783ecc1eefbf6d0b2dcabf814e646abdf9efe Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 22:56:19 -0700 Subject: [PATCH 31/94] refactor: move broadcast and SSE stream delivery behind kernal-api Refs #239; depends on kernal-api#175. Remove direct tokio-stream, retain event payload and SSE filtering policy, and enforce the source boundary. --- Cargo.lock | 25 ++++++++++---------- Cargo.toml | 3 +-- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/commands.rs | 16 +++++++++++-- crates/fastled-cli/src/compile_stream.rs | 30 ++++++++---------------- crates/fastled-cli/src/server.rs | 11 ++++----- docs/kernal-api-migration.md | 28 +++++++++++++++++++--- tests/unit/test_kernal_boundary.py | 3 +++ 8 files changed, 70 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b692a11..be3d80af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -99,7 +99,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1097,7 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1124,7 +1124,6 @@ dependencies = [ "tauri-build", "tempfile", "tokio", - "tokio-stream", "toml 0.8.23", "tower-http", "tree-sitter", @@ -1283,9 +1282,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" @@ -2238,6 +2237,7 @@ dependencies = [ "bytes", "dirs", "flate2", + "futures-core", "globset", "hyper", "jwalk", @@ -2253,6 +2253,7 @@ dependencies = [ "tar", "thiserror 2.0.20", "tokio", + "tokio-stream", "toml 0.8.23", "widestring", "winapi", @@ -3678,7 +3679,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4140,7 +4141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4645,7 +4646,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4789,9 +4790,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -5549,7 +5550,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0c03905b..09dd68d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } thiserror = "2" -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "event-stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" @@ -25,7 +25,6 @@ crossterm = "0.28" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "signal"] } axum = "0.8" tower-http = { version = "0.6", features = ["fs", "cors", "set-header"] } -tokio-stream = { version = "0.1", features = ["sync"] } toml = "0.8" tauri = { version = "2", features = ["devtools"] } tauri-build = { version = "2", features = [] } diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 6a10a6a8..a1044cd9 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -33,7 +33,6 @@ tokio = { workspace = true } axum = { workspace = true, features = ["ws"] } portable-pty = "=0.9.0" tower-http = { workspace = true } -tokio-stream = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } tempfile = "3" diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index 427bf39a..b4051a4e 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -61,7 +61,13 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { } // Broadcast channel for SSE streaming to browser. - let (tx, _rx) = tokio::sync::broadcast::channel::(256); + let (tx, _rx) = match async_engine::broadcast_channel::(256) { + Ok(channel) => channel, + Err(error) => { + eprintln!("fastled: could not create build event channel: {error}"); + return ExitCode::FAILURE; + } + }; // Shared DWARF source resolver populated after the first successful build. let debug_symbols: server::DebugSymbolHandle = Arc::new(RwLock::new(None)); @@ -240,7 +246,13 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { return test_exit(TestOutcome::Failure); } }; - let (build_tx, _build_rx) = tokio::sync::broadcast::channel::(256); + let (build_tx, _build_rx) = match async_engine::broadcast_channel::(256) { + Ok(channel) => channel, + Err(error) => { + eprintln!("fastled: could not create build event channel: {error}"); + return test_exit(TestOutcome::Failure); + } + }; let (test_tx, mut test_rx) = async_engine::unbounded_channel(); let mut token_bytes = [0_u8; 32]; if let Err(error) = getrandom::fill(&mut token_bytes) { diff --git a/crates/fastled-cli/src/compile_stream.rs b/crates/fastled-cli/src/compile_stream.rs index 42f7758d..cca54c9c 100644 --- a/crates/fastled-cli/src/compile_stream.rs +++ b/crates/fastled-cli/src/compile_stream.rs @@ -1,3 +1,4 @@ +use kernal_api::async_engine::BroadcastSender; use std::io::IsTerminal; use std::path::{Path, PathBuf}; @@ -95,11 +96,7 @@ fn migration_warning(cli: &Cli) -> Option<&'static str> { } } -pub(crate) fn announce_link_mode( - cli: &Cli, - tx: Option<&tokio::sync::broadcast::Sender>, - terminal: bool, -) { +pub(crate) fn announce_link_mode(cli: &Cli, tx: Option<&BroadcastSender>, terminal: bool) { let mode = effective_link_mode(cli); if terminal { println!("{}", link_mode_announcement(mode)); @@ -220,15 +217,11 @@ pub(crate) fn json_escape(s: &str) -> String { } /// Send an SSE event through the broadcast channel. -pub(crate) fn send_sse(tx: &tokio::sync::broadcast::Sender, json: &str) { +pub(crate) fn send_sse(tx: &BroadcastSender, json: &str) { let _ = tx.send(json.to_string()); } -pub(crate) fn emit_build_log( - tx: &tokio::sync::broadcast::Sender, - line: &str, - stream: &str, -) { +pub(crate) fn emit_build_log(tx: &BroadcastSender, line: &str, stream: &str) { if stream == "warning" && std::io::stderr().is_terminal() { eprintln!("{}", crossterm::style::Stylize::yellow(line)); } else if stream == "stderr" || stream == "warning" { @@ -246,10 +239,7 @@ pub(crate) fn emit_build_log( ); } -pub(crate) fn emit_build_result_logs( - result: &build::BuildResult, - tx: &tokio::sync::broadcast::Sender, -) { +pub(crate) fn emit_build_result_logs(result: &build::BuildResult, tx: &BroadcastSender) { let stream = if result.success { "stdout" } else { "stderr" }; for line in result.output.lines() { emit_build_log(tx, line, stream); @@ -276,7 +266,7 @@ pub(crate) fn emit_build_result_logs( /// Returns the effective success that was reported. pub(crate) fn report_build_outcome( output_dir: &Path, - tx: &tokio::sync::broadcast::Sender, + tx: &BroadcastSender, build_ok: bool, app_required: bool, ) -> bool { @@ -310,7 +300,7 @@ pub(crate) fn run_native_compile_streaming( cli: &Cli, sketch_dir: &Path, force_clean: bool, - tx: &tokio::sync::broadcast::Sender, + tx: &BroadcastSender, debug_symbols: &server::DebugSymbolHandle, ) -> bool { if force_clean { @@ -384,7 +374,7 @@ mod tests { #[test] fn report_build_outcome_success_requires_index_html() { let dir = tempfile::tempdir().unwrap(); - let (tx, mut rx) = tokio::sync::broadcast::channel::(8); + let (tx, mut rx) = kernal_api::async_engine::broadcast_channel::(8).unwrap(); assert!(!report_build_outcome(dir.path(), &tx, true, true)); assert!(read_status(dir.path()).contains("\"error\"")); @@ -402,7 +392,7 @@ mod tests { fn report_build_outcome_failure_is_error_even_with_stale_index() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("index.html"), "").unwrap(); - let (tx, mut rx) = tokio::sync::broadcast::channel::(8); + let (tx, mut rx) = kernal_api::async_engine::broadcast_channel::(8).unwrap(); assert!(!report_build_outcome(dir.path(), &tx, false, true)); assert!(read_status(dir.path()).contains("\"error\"")); @@ -413,7 +403,7 @@ mod tests { #[test] fn report_build_outcome_allows_success_without_app() { let dir = tempfile::tempdir().unwrap(); - let (tx, mut rx) = tokio::sync::broadcast::channel::(8); + let (tx, mut rx) = kernal_api::async_engine::broadcast_channel::(8).unwrap(); assert!(report_build_outcome(dir.path(), &tx, true, false)); assert!(read_status(dir.path()).contains("\"success\"")); diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 2c16d4dc..747e00ec 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -21,9 +21,6 @@ use axum::{Json, Router}; use kernal_api::async_engine; use serde::{Deserialize, Serialize}; use serde_json::json; -use tokio::sync::broadcast; -use tokio_stream::wrappers::BroadcastStream; -use tokio_stream::StreamExt; use tower_http::cors::{Any, CorsLayer}; use tower_http::set_header::SetResponseHeaderLayer; @@ -365,7 +362,7 @@ struct AppState { terminal_slots: Arc, /// Broadcast channel for SSE build streaming. `None` when serving a /// static directory (no compilation happening). - build_tx: Option>, + build_tx: Option>, /// Shared resolver for DWARF source paths. Empty until a successful /// build populates it. debug_symbols: DebugSymbolHandle, @@ -487,7 +484,7 @@ async fn build_stream(State(state): State) -> Response { }; let rx = tx.subscribe(); - let stream = BroadcastStream::new(rx).filter_map(|result| { + let stream = rx.into_stream_with(|result| { result .ok() .map(|data| Ok::<_, Infallible>(Event::default().data(data))) @@ -750,7 +747,7 @@ fn mime_for_path(path: &str) -> &'static str { pub async fn start_server( serve_dir: PathBuf, port: u16, - build_tx: Option>, + build_tx: Option>, debug_symbols: DebugSymbolHandle, test: Option, ) -> anyhow::Result { @@ -1209,7 +1206,7 @@ mod tests { #[tokio::test] async fn test_sse_endpoint_streams_events() { let dir = tempfile::tempdir().unwrap(); - let (tx, _rx) = broadcast::channel::(16); + let (tx, _rx) = async_engine::broadcast_channel::(16).unwrap(); let addr = start_server( dir.path().to_path_buf(), 0, diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index b9df6048..88d1e0e1 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -449,7 +449,7 @@ its existing timeout values, exit behavior and test-runner policy. The full 255-test Rust workspace suite, strict all-target Clippy and Python suite (27 passes/1 skip) pass. -Tokio and tokio-stream remain direct dependencies. Remaining work includes +At that initial stage, Tokio and tokio-stream remained direct dependencies. Work included channel types (notably blocking sends from output-reader threads), broadcast/SSE, signals, semaphore try-acquisition, absolute timers, tasks and server I/O. Kernel async_engine explicitly leaves select/attribute macro calls as a separate @@ -463,11 +463,33 @@ task to retain its existing runtime-owned lifetime. Test-runner command and output-drain deadlines use kernel Deadline. The cancellation product regression now waits for a READY event, covers explicit cancellation and handle drop, and waits beyond normal command completion before checking for escaped-shell output. -The async source boundary was observed RED then GREEN. Bounded channels still -need a kernel blocking-send operation for the synchronous reader threads. +The async source boundary was observed RED then GREEN. Bounded channels then +needed a kernel blocking-send operation for the synchronous reader threads. All 255 Rust tests, strict all-target Clippy and 27 Python tests pass (one Python test skipped) after this task/channel migration. +Bounded channels now use the kernel blocking-send operation (upstream #170, +PR #171). Shared deadline waits, periodic viewer monitoring and non-waiting +semaphore admission use the kernel operations from #172 / PR #173. The app +retains its 100 ms cadence, timeout priorities and four-slot admission policy; +its endpoint test verifies HTTP 429 on saturation and admission after release. + +Broadcast delivery and SSE stream adaptation now use the kernel API from #174. +The direct `tokio-stream` dependency is removed. The optional kernel +`event-stream` feature owns stream interoperability, without a pump task or +additional queue. Generic tests cover fanout, exact entry capacity, explicit +lag counts, ordered recovery, closure and cancellation. FastLED retains event +JSON and its existing explicit filtering of SSE lag notifications. Entry +capacity is not a payload-byte bound. The source/dependency boundary was +observed RED before adoption and GREEN afterward. + +The lockfile updates only `futures-core` 0.3.32 -> 0.3.34 and `tokio-stream` +0.1.18 -> 0.1.19 to meet exact upstream requirements. Cargo also reselects +already-locked Windows dependency edges; no other package versions change. +Tokio remains direct for signals, server I/O and macro call sites. Upstream +merge/release, exact registry adoption and the other inventory rows remain +outstanding; the local path patch is still migration-only. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 031bf063..27c92d57 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -20,6 +20,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "running-process" not in dependencies assert "sha2" not in dependencies assert "dirs" not in dependencies + assert "tokio-stream" not in dependencies for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies for target in manifest.get("target", {}).values(): @@ -67,5 +68,7 @@ def test_migrated_async_operations_use_kernel(): "tokio::spawn(", "tokio::task::JoinHandle", "tokio::sync::mpsc", + "tokio::sync::broadcast", + "tokio_stream::", ): assert backend not in source, path From f37008bb4b6d920ede6b3de52f65fb499dab44cd Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 23:35:33 -0700 Subject: [PATCH 32/94] refactor(server): persist screenshots through bounded kernel file I/O --- crates/fastled-cli/src/server.rs | 41 ++++++++++++++++++++++-------- docs/kernal-api-migration.md | 21 +++++++++++++++ tests/unit/test_kernal_boundary.py | 2 ++ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 747e00ec..92508717 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -367,6 +367,7 @@ struct AppState { /// build populates it. debug_symbols: DebugSymbolHandle, test: Option>, + screenshot_io: kernal_api::platform::fs::AsyncFileIo, } // --------------------------------------------------------------------------- @@ -601,17 +602,7 @@ async fn viewer_screenshot( let _ = test.events.send(TestEvent::Failure(message.clone())); return (StatusCode::BAD_REQUEST, message).into_response(); } - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - if let Err(error) = tokio::fs::create_dir_all(parent).await { - let message = format!("could not create screenshot directory: {error}"); - let _ = test.events.send(TestEvent::Failure(message.clone())); - return (StatusCode::INTERNAL_SERVER_ERROR, message).into_response(); - } - } - if let Err(error) = tokio::fs::write(path, &body).await { + if let Err(error) = state.screenshot_io.write(path.clone(), body.to_vec()).await { let message = format!("could not write screenshot {}: {error}", path.display()); let _ = test.events.send(TestEvent::Failure(message.clone())); return (StatusCode::INTERNAL_SERVER_ERROR, message).into_response(); @@ -764,6 +755,13 @@ pub async fn start_server( build_tx, debug_symbols, test: test.map(Arc::new), + // One shared budget per server, including native writes still completing + // after timeout. PNG validation and destination policy stay in this app. + screenshot_io: kernal_api::platform::fs::AsyncFileIo::new( + 4, + 64 * 1024 * 1024, + std::time::Duration::from_secs(30), + )?, }; let app = Router::new() @@ -1055,6 +1053,27 @@ mod tests { rx.recv().await, Some(TestEvent::ScreenshotSaved { name, .. }) if name == "frame-0" )); + + // A persistence error must remain a product failure, never a saved + // event. Turn this test's temporary output into a directory to force it. + fs::remove_file(&screenshot).unwrap(); + fs::create_dir(&screenshot).unwrap(); + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/viewer-screenshot?name=frame-0"), + &[("Authorization", "Bearer test-token")], + &png, + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::INTERNAL_SERVER_ERROR.as_u16() + ); + assert!(matches!( + rx.recv().await, + Some(TestEvent::Failure(message)) if message.contains("could not write screenshot") + )); } #[tokio::test] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 88d1e0e1..c3379eb5 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -490,6 +490,27 @@ Tokio remains direct for signals, server I/O and macro call sites. Upstream merge/release, exact registry adoption and the other inventory rows remain outstanding; the local path patch is still migration-only. +### Screenshot persistence adoption + +Screenshot persistence now uses `platform::fs::AsyncFileIo` from the local +kernel HTTP-server development branch (upstream #176). One shared budget per +server admits four native writes, each accepting at most 64 MiB with a 30-second +deadline. Saturation is reported as a screenshot failure; there is no additional +waiting-task queue. Native work still running after cancellation retains its +permit until it ends. Writes remain non-atomic and can leave partial output on +failure or timeout. FastLED retains PNG validation, authorization, preconfigured +destination selection and saved/failure events. Direct Tokio write and directory +creation calls are removed and banned by the boundary test. The HTTP server +itself still uses Axum/Tower/Tokio pending the complete upstream contract and +route-parity work. This remains migration-only path-patch adoption, not a +published release. + +Validation: the screenshot endpoint covers successful persistence and native +write failure reporting. All 255 Rust workspace tests and strict all-target +Clippy pass; Python reports 27 passed and one skipped. The boundary test was +observed RED before removing the Tokio calls and GREEN afterward. One local +review covered the Rust, Python and documentation changes with no findings. + ### Final migration audit - Resolve every inventory row with code and dependency-graph evidence. diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 27c92d57..8318a0ec 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -70,5 +70,7 @@ def test_migrated_async_operations_use_kernel(): "tokio::sync::mpsc", "tokio::sync::broadcast", "tokio_stream::", + "tokio::fs::write", + "tokio::fs::create_dir_all", ): assert backend not in source, path From f332ebdd11903c035ea301a80ae1c5b322a65830 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 12 Sep 2026 23:48:17 -0700 Subject: [PATCH 33/94] test(server): capture HTTP route and browser header parity baseline --- crates/fastled-cli/src/server.rs | 85 ++++++++++++++++++++++---------- docs/kernal-api-migration.md | 5 ++ 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 92508717..17ff6d2a 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -872,34 +872,69 @@ mod tests { (addr, dir) } - // Refs #240: arbitrary web origins must never acquire a shell. + async fn raw_http(addr: SocketAddr, request: String) -> String { + async_engine::launch_blocking(move || { + use std::io::{Read, Write}; + let mut socket = + std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(3)) + .unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + socket + .set_write_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + socket.write_all(request.as_bytes()).unwrap(); + let mut bytes = Vec::new(); + socket.read_to_end(&mut bytes).unwrap(); + String::from_utf8(bytes).unwrap() + }) + .await + .unwrap() + } + #[tokio::test] - async fn terminal_240_rejects_foreign_origin() { - let (addr, _dir) = setup_server().await; - for origin in [Some("https://attacker.example"), Some("null"), None] { - let mut request = reqwest::Client::new() - .get(format!("http://{addr}/terminal/ws")) - .header("connection", "upgrade") - .header("upgrade", "websocket") - .header("sec-websocket-version", "13") - .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="); - if let Some(origin) = origin { - request = request.header("origin", origin); + async fn http_router_migration_preserves_head_errors_and_preflight_policy() { + let (addr, dir) = setup_server().await; + fs::write(dir.path().join("asset.js"), "hello").unwrap(); + for (method, path, status) in [ + ("GET", "/missing", 404), + ("HEAD", "/asset.js", 200), + ("POST", "/asset.js", 405), + ] { + let response = raw_http(addr, format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nOrigin: http://example.test\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")).await; + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + for header in [ + "cross-origin-embedder-policy: require-corp\r\n", + "cross-origin-opener-policy: same-origin\r\n", + "cache-control: no-cache, no-store, must-revalidate\r\n", + "access-control-allow-origin: *\r\n", + ] { + assert!(response.contains(header), "missing {header:?}: {response}"); + } + if method == "HEAD" { + assert!(response.contains("content-length: 5\r\n"), "{response}"); + assert!(response.ends_with("\r\n\r\n"), "{response}"); } - assert_eq!(request.send().await.unwrap().status(), 403); } - let response = reqwest::Client::new() - .get(format!("http://{addr}/terminal/ws")) - .header("origin", format!("http://{addr}")) - .header("host", "attacker.example") - .header("connection", "upgrade") - .header("upgrade", "websocket") - .header("sec-websocket-version", "13") - .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") - .send() - .await - .unwrap(); - assert_eq!(response.status(), 403); + let response = raw_http(addr, "OPTIONS /viewer-screenshot HTTP/1.1\r\nHost: localhost\r\nOrigin: http://example.test\r\nAccess-Control-Request-Method: POST\r\nAccess-Control-Request-Headers: authorization,content-type\r\nConnection: close\r\n\r\n".into()).await; + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!( + response.contains("access-control-allow-origin: *\r\n"), + "{response}" + ); + assert!( + response.contains("access-control-allow-methods: GET,POST,PUT,DELETE,OPTIONS\r\n"), + "{response}" + ); + assert!( + response.contains("access-control-allow-headers: content-type,authorization\r\n"), + "{response}" + ); + assert!(response.ends_with("\r\n\r\n"), "{response}"); } #[tokio::test] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index c3379eb5..e95f1f20 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -513,6 +513,11 @@ review covered the Rust, Python and documentation changes with no findings. ### Final migration audit +The Axum baseline now has a raw-wire parity test for missing-file 404, +unsupported-method 405, HEAD content length with no body, browser-isolation and +cache headers, wildcard CORS, and OPTIONS preflight methods/headers. This test +passes before transport replacement and must remain green after adoption. + - Resolve every inventory row with code and dependency-graph evidence. - Move generic mechanism tests upstream while retaining application integration and product-policy coverage here. From 1541aafaf3f75c7bf38b01a1a6476162283b0ea4 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 00:05:02 -0700 Subject: [PATCH 34/94] refactor(server): adopt kernel HTTP routing and streaming Preserve product routes and browser policy while moving transport, SSE, and bounded native file preparation into kernal-api. Keep deadlines compatible with accepted test schedules. --- Cargo.lock | 181 +------- Cargo.toml | 4 +- crates/fastled-cli/Cargo.toml | 6 - crates/fastled-cli/src/server.rs | 637 ++++++++++++++--------------- docs/kernal-api-migration.md | 31 ++ tests/unit/test_kernal_boundary.py | 6 + 6 files changed, 351 insertions(+), 514 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be3d80af..f55af771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,61 +158,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "base64 0.22.1", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.21.7" @@ -851,12 +796,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - [[package]] name = "deflate64" version = "0.1.12" @@ -1105,16 +1044,12 @@ name = "fastled-cli" version = "2.0.20" dependencies = [ "anyhow", - "axum", "clap", "crossterm", "ctcb-core", "getrandom 0.3.4", "gtk", "indexmap 1.9.3", - "libc", - "notify", - "portable-pty", "kernal-api", "serde", "serde_json", @@ -1125,7 +1060,6 @@ dependencies = [ "tempfile", "tokio", "toml 0.8.23", - "tower-http", "tree-sitter", "tree-sitter-cpp", "webkit2gtk", @@ -1772,9 +1706,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1783,12 +1717,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" @@ -2239,7 +2167,9 @@ dependencies = [ "flate2", "futures-core", "globset", + "http-body-util", "hyper", + "hyper-util", "jwalk", "libc", "mach2", @@ -2474,12 +2404,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.8.0" @@ -2510,16 +2434,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3338,16 +3252,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand_chacha" version = "0.2.2" @@ -3368,16 +3272,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -3396,15 +3290,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_hc" version = "0.2.0" @@ -3915,17 +3800,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -4800,18 +4674,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4961,7 +4823,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -4972,24 +4833,14 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.0", "bytes", - "futures-core", "futures-util", "http", "http-body", - "http-body-util", - "http-range-header", - "httpdate", "iri-string", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -5010,7 +4861,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-core", ] @@ -5082,23 +4932,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "typeid" version = "1.0.3" @@ -5152,12 +4985,6 @@ dependencies = [ "unic-common", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 09dd68d0..b5055b28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } thiserror = "2" -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "event-stream"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" @@ -23,8 +23,6 @@ ctcb-core = "0.4.1" strsim = "0.11" crossterm = "0.28" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "signal"] } -axum = "0.8" -tower-http = { version = "0.6", features = ["fs", "cors", "set-header"] } toml = "0.8" tauri = { version = "2", features = ["devtools"] } tauri-build = { version = "2", features = [] } diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index a1044cd9..2b1b298c 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -30,9 +30,6 @@ ctcb-core = { workspace = true } strsim = { workspace = true } crossterm = { workspace = true } tokio = { workspace = true } -axum = { workspace = true, features = ["ws"] } -portable-pty = "=0.9.0" -tower-http = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } tempfile = "3" @@ -45,9 +42,6 @@ tree-sitter-cpp = { workspace = true } indexmap = { version = "1.9.3", features = ["std"] } tauri-build = { workspace = true, optional = true } -[target.'cfg(unix)'.dependencies] -libc = "0.2" - [target.'cfg(target_os = "linux")'.dependencies] # Same gtk 0.18 that tauri/tao link; used to correct GTK's font DPI before the webview exists. gtk = { version = "0.18", optional = true } diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 17ff6d2a..885c3260 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -6,23 +6,16 @@ //! is returned that polls `/build-status.json` for live updates. use std::collections::HashMap; -use std::convert::Infallible; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::{Arc, RwLock}; -use axum::body::Bytes; -use axum::extract::{DefaultBodyLimit, Query, State}; -use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode}; -use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::response::{Html, IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use kernal_api::async_engine; +use kernal_api::{ + async_engine, + http_server::{Limits, Request, Response, Server}, +}; use serde::{Deserialize, Serialize}; use serde_json::json; -use tower_http::cors::{Any, CorsLayer}; -use tower_http::set_header::SetResponseHeaderLayer; use crate::debug_symbols::{DebugSymbolResolver, ResolveError}; @@ -357,9 +350,6 @@ self.addEventListener('message', (event) => { #[derive(Clone)] struct AppState { serve_dir: Arc, - terminal_cwd: Arc, - terminal_origin: Arc, - terminal_slots: Arc, /// Broadcast channel for SSE build streaming. `None` when serving a /// static directory (no compilation happening). build_tx: Option>, @@ -368,343 +358,319 @@ struct AppState { debug_symbols: DebugSymbolHandle, test: Option>, screenshot_io: kernal_api::platform::fs::AsyncFileIo, + file_responses: kernal_api::http_server::FileResponses, } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- -/// The static router permits CORS; shell access explicitly does not. -async fn terminal_socket( - State(state): State, - headers: HeaderMap, - upgrade: axum::extract::ws::WebSocketUpgrade, -) -> Response { - if headers - .get(header::ORIGIN) - .and_then(|value| value.to_str().ok()) - != Some(state.terminal_origin.as_str()) - || headers - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - != state.terminal_origin.strip_prefix("http://") - { - return StatusCode::FORBIDDEN.into_response(); - } - let Ok(permit) = state.terminal_slots.clone().try_acquire_owned() else { - return StatusCode::TOO_MANY_REQUESTS.into_response(); - }; - upgrade - .max_message_size(64 * 1024) - .max_frame_size(64 * 1024) - .on_upgrade(move |socket| crate::terminal::connect(socket, state.terminal_cwd, permit)) +type Reply = std::io::Result; + +fn empty(status: u16) -> Reply { + Response::new(status, Vec::new()) +} + +fn text(status: u16, body: impl Into) -> Reply { + Response::new(status, body.into().into_bytes())? + .with_header("content-type", "text/plain; charset=utf-8") +} + +fn json_reply(status: u16, value: impl Serialize) -> Reply { + Response::new( + status, + serde_json::to_vec(&value).map_err(std::io::Error::other)?, + )? + .with_header("content-type", "application/json") } -/// Serve index.html or the loading page if it doesn't exist yet. -async fn serve_index(State(state): State) -> Response { +async fn stream_file(state: &AppState, path: &std::path::Path, prefix: &[u8], mime: &str) -> Reply { + state + .file_responses + .open(path.to_path_buf(), prefix.to_vec()) + .await? + .with_header("content-type", mime) +} + +async fn serve_index(state: &AppState) -> Reply { let index = state.serve_dir.join("index.html"); if index.is_file() { - match tokio::fs::read(&index).await { - Ok(data) => ( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], - data, - ) - .into_response(), - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } + stream_file(state, &index, &[], "text/html; charset=utf-8").await } else { - Html(LOADING_PAGE).into_response() + Response::new(200, LOADING_PAGE.as_bytes().to_vec())? + .with_header("content-type", "text/html; charset=utf-8") } } -/// Serve any file from the output directory. -async fn serve_file( - State(state): State, - axum::extract::Path(path): axum::extract::Path, -) -> Response { - let file_path = state.serve_dir.join(&path); - - // Prevent directory traversal. +async fn serve_file(state: &AppState, path: &str) -> Reply { + let file_path = state.serve_dir.join(path); let canonical = match file_path.canonicalize() { - Ok(p) => p, + Ok(path) => path, Err(_) => { - return serve_debug_source_url(&state, &path) + return serve_debug_source_url(state, path) .await - .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()); + .unwrap_or_else(|| empty(404)) } }; - let serve_canonical = match state.serve_dir.canonicalize() { - Ok(p) => p, - Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - }; + let serve_canonical = state.serve_dir.canonicalize()?; if !canonical.starts_with(&serve_canonical) { - return StatusCode::FORBIDDEN.into_response(); + return empty(403); } - - match tokio::fs::read(&file_path).await { - Ok(mut data) => { - if state.test.is_some() && path == "fastled_background_worker.js" { - let mut patched = TEST_WORKER_WEBGL_PREFIX.as_bytes().to_vec(); - patched.extend_from_slice(&data); - data = patched; - } - let mime = mime_for_path(&path); - (StatusCode::OK, [(header::CONTENT_TYPE, mime)], data).into_response() - } - Err(_) => serve_debug_source_url(&state, &path) + let prefix = if state.test.is_some() && path == "fastled_background_worker.js" { + TEST_WORKER_WEBGL_PREFIX.as_bytes() + } else { + &[] + }; + match stream_file(state, &canonical, prefix, mime_for_path(path)).await { + Ok(response) => Ok(response), + Err(_) => serve_debug_source_url(state, path) .await - .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()), + .unwrap_or_else(|| empty(404)), } } -async fn serve_debug_source_url(state: &AppState, request_path: &str) -> Option { +async fn serve_debug_source_url(state: &AppState, path: &str) -> Option { let resolver = state.debug_symbols.read().ok()?.clone()?; - match resolver.resolve(request_path, true) { - Ok(file) => match tokio::fs::read(&file).await { - Ok(bytes) => Some( - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - bytes, - ) - .into_response(), - ), - Err(_) => Some(StatusCode::INTERNAL_SERVER_ERROR.into_response()), - }, - Err(ResolveError::NotFound(_)) => Some(StatusCode::NOT_FOUND.into_response()), + match resolver.resolve(path, true) { + Ok(file) => Some(stream_file(state, &file, &[], "text/plain; charset=utf-8").await), + Err(ResolveError::NotFound(_)) => Some(empty(404)), Err(ResolveError::Invalid(_)) => None, } } -/// SSE endpoint that streams build log lines and status events. -async fn build_stream(State(state): State) -> Response { - let tx = match &state.build_tx { - Some(tx) => tx, - None => return StatusCode::NOT_FOUND.into_response(), +fn build_stream(state: &AppState) -> Reply { + let Some(tx) = &state.build_tx else { + return empty(404); }; - - let rx = tx.subscribe(); - let stream = rx.into_stream_with(|result| { - result - .ok() - .map(|data| Ok::<_, Infallible>(Event::default().data(data))) - }); - - Sse::new(stream) - .keep_alive(KeepAlive::default()) - .into_response() + let stream = tx + .subscribe() + .into_stream_with(|result| result.ok().map(Ok)); + Response::event_stream(stream, std::time::Duration::from_secs(15)) } -/// `POST /viewer-log` — receive console/error lines forwarded from the Tauri -/// viewer's injected logging script and echo them to stderr so viewer-side -/// failures are always diagnosable. -fn test_request_authorized(test: &TestServerOptions, headers: &HeaderMap) -> bool { - headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) +fn test_request_authorized(test: &TestServerOptions, request: &Request) -> bool { + request + .header("authorization") + .and_then(|value| std::str::from_utf8(value).ok()) .and_then(|value| value.strip_prefix("Bearer ")) .is_some_and(|value| value == test.token) } -async fn viewer_log(State(state): State, headers: HeaderMap, body: String) -> Response { - if let Some(test) = &state.test { - if !test_request_authorized(test, &headers) { - return StatusCode::UNAUTHORIZED.into_response(); +fn required_query(request: &Request, name: &str) -> std::io::Result { + let mut value = None; + for pair in request.query_pairs() { + let (key, candidate) = pair?; + if key == name && value.replace(candidate).is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "duplicate query field", + )); } } - for line in body.lines() { - eprintln!("[viewer] {line}"); - if let Some(test) = &state.test { - let _ = test.events.send(TestEvent::ViewerLog(line.to_string())); - } - } - StatusCode::NO_CONTENT.into_response() -} - -async fn test_config(State(state): State, headers: HeaderMap) -> Response { - match &state.test { - Some(test) if test_request_authorized(test, &headers) => { - Json(test.runtime.clone()).into_response() - } - Some(_) => StatusCode::UNAUTHORIZED.into_response(), - None => StatusCode::NOT_FOUND.into_response(), - } -} - -async fn test_ready(State(state): State, headers: HeaderMap) -> Response { - match &state.test { - Some(test) if test_request_authorized(test, &headers) => { - let _ = test.events.send(TestEvent::Ready); - StatusCode::NO_CONTENT.into_response() - } - Some(_) => StatusCode::UNAUTHORIZED.into_response(), - None => StatusCode::NOT_FOUND.into_response(), - } -} - -#[derive(Deserialize)] -struct TestSleepQuery { - ms: f64, + value + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing query field")) } -async fn test_sleep( - State(state): State, - headers: HeaderMap, - Query(query): Query, -) -> Response { +async fn test_sleep(state: &AppState, request: &Request) -> Reply { + let ms = match required_query(request, "ms") + .and_then(|value| value.parse::().map_err(std::io::Error::other)) + { + Ok(ms) => ms, + Err(error) => return text(400, error.to_string()), + }; let Some(test) = &state.test else { - return StatusCode::NOT_FOUND.into_response(); + return empty(404); }; - if !test_request_authorized(test, &headers) { - return StatusCode::UNAUTHORIZED.into_response(); + if !test_request_authorized(test, request) { + return empty(401); } - let Ok(duration) = std::time::Duration::try_from_secs_f64(query.ms / 1_000.0) else { - return (StatusCode::BAD_REQUEST, "invalid sleep duration").into_response(); + let Ok(duration) = std::time::Duration::try_from_secs_f64(ms / 1000.0) else { + return text(400, "invalid sleep duration"); }; let max_ms = test .runtime .interval_ms .unwrap_or(0.0) .max(test.runtime.wait_ms); - if query.ms > max_ms { - return (StatusCode::BAD_REQUEST, "sleep exceeds test schedule").into_response(); + if ms > max_ms { + return text(400, "sleep exceeds test schedule"); } let Some(_permit) = test.sleep_permits.try_acquire() else { - return StatusCode::TOO_MANY_REQUESTS.into_response(); + return empty(429); }; async_engine::sleep(duration).await; - StatusCode::NO_CONTENT.into_response() + empty(204) } -#[derive(Deserialize)] -struct ScreenshotQuery { - name: String, -} - -async fn viewer_screenshot( - State(state): State, - headers: HeaderMap, - Query(query): Query, - body: Bytes, -) -> Response { +async fn viewer_screenshot(state: &AppState, request: &Request) -> Reply { + let name = match required_query(request, "name") { + Ok(name) => name, + Err(error) => return text(400, error.to_string()), + }; let Some(test) = &state.test else { - return StatusCode::NOT_FOUND.into_response(); + return empty(404); }; - if !test_request_authorized(test, &headers) { - return StatusCode::UNAUTHORIZED.into_response(); + if !test_request_authorized(test, request) { + return empty(401); } - let Some(path) = test.screenshot_paths.get(&query.name) else { - return (StatusCode::BAD_REQUEST, "unknown screenshot name").into_response(); + let Some(path) = test.screenshot_paths.get(&name) else { + return text(400, "unknown screenshot name"); }; + let body = request.body(); if body.len() < 8 || body[..8] != [137, 80, 78, 71, 13, 10, 26, 10] { let message = format!("viewer returned invalid PNG data for {}", path.display()); let _ = test.events.send(TestEvent::Failure(message.clone())); - return (StatusCode::BAD_REQUEST, message).into_response(); + return text(400, message); } if let Err(error) = state.screenshot_io.write(path.clone(), body.to_vec()).await { let message = format!("could not write screenshot {}: {error}", path.display()); let _ = test.events.send(TestEvent::Failure(message.clone())); - return (StatusCode::INTERNAL_SERVER_ERROR, message).into_response(); + return text(500, message); } let _ = test.events.send(TestEvent::ScreenshotSaved { - name: query.name, + name, path: path.clone(), }); - StatusCode::NO_CONTENT.into_response() + empty(204) } -async fn test_done(State(state): State, headers: HeaderMap, body: String) -> Response { - let Some(test) = &state.test else { - return StatusCode::NOT_FOUND.into_response(); - }; - if !test_request_authorized(test, &headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - let code = body.trim().parse::().unwrap_or(1); - let _ = test.events.send(TestEvent::Done(code)); - StatusCode::NO_CONTENT.into_response() -} - -// --------------------------------------------------------------------------- -// DWARF debug source handlers -// --------------------------------------------------------------------------- - #[derive(Deserialize)] struct DwarfSourceRequest { path: String, } -/// `POST /dwarfsource` — given `{"path": ""}`, return the source -/// text for the file the path maps to. Empty/missing payload, or a path that -/// escapes the configured roots, returns 400. Unknown files return 404. -async fn dwarf_source( - State(state): State, - Json(payload): Json, -) -> Response { - let resolver = match state.debug_symbols.read() { - Ok(guard) => guard.clone(), - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), +async fn dwarf_source(state: &AppState, request: &Request) -> Reply { + let content_type = request + .header("content-type") + .and_then(|value| std::str::from_utf8(value).ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + if content_type != "application/json" + && !(content_type.starts_with("application/") && content_type.ends_with("+json")) + { + return text(415, "expected application/json"); + } + let payload = match serde_json::from_slice::(request.body()) { + Ok(payload) => payload, + Err(error) => return text(if error.is_data() { 422 } else { 400 }, error.to_string()), }; - + let resolver = state + .debug_symbols + .read() + .map_err(|_| std::io::Error::other("debug source lock poisoned"))? + .clone(); let Some(resolver) = resolver else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "debug source resolver unavailable"})), - ) - .into_response(); + return json_reply(400, json!({"error": "debug source resolver unavailable"})); }; - - let request_path = payload.path.trim(); - if request_path.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "missing path"})), - ) - .into_response(); + let path = payload.path.trim(); + if path.is_empty() { + return json_reply(400, json!({"error": "missing path"})); } - - match resolver.resolve(request_path, true) { - Ok(file) => match tokio::fs::read(&file).await { - Ok(bytes) => ( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - bytes, - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": err.to_string()})), - ) - .into_response(), + match resolver.resolve(path, true) { + Ok(file) => match stream_file(state, &file, &[], "text/plain; charset=utf-8").await { + Ok(response) => Ok(response), + Err(error) => json_reply(500, json!({"error": error.to_string()})), }, - Err(ResolveError::NotFound(msg)) => { - (StatusCode::NOT_FOUND, Json(json!({ "error": msg }))).into_response() - } - Err(ResolveError::Invalid(msg)) => { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() - } + Err(ResolveError::NotFound(message)) => json_reply(404, json!({"error": message})), + Err(ResolveError::Invalid(message)) => json_reply(400, json!({"error": message})), } } -/// `GET /debug/source-roots` — return the named source roots the resolver -/// knows about. Useful for tooling that wants to verify configuration. -async fn debug_source_roots(State(state): State) -> Response { - let resolver = match state.debug_symbols.read() { - Ok(guard) => guard.clone(), - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR).into_response(), - }; +fn debug_source_roots(state: &AppState) -> Reply { + let resolver = state + .debug_symbols + .read() + .map_err(|_| std::io::Error::other("debug source lock poisoned"))? + .clone(); + let roots = + resolver + .map(|resolver| { + resolver.config().source_roots().into_iter() + .map(|(prefix, path)| json!({"prefix": prefix, "path": path.display().to_string()})) + .collect::>() + }) + .unwrap_or_default(); + json_reply(200, json!({"roots": roots})) +} - let roots = match resolver { - Some(resolver) => resolver - .config() - .source_roots() - .into_iter() - .map(|(prefix, path)| json!({"prefix": prefix, "path": path.display().to_string()})) - .collect::>(), - None => Vec::new(), +async fn route(state: &AppState, request: Request) -> Reply { + if request.method() == "OPTIONS" { + return empty(200)? + .with_header( + "access-control-allow-methods", + "GET,POST,PUT,DELETE,OPTIONS", + )? + .with_header("access-control-allow-headers", "content-type,authorization"); + } + let path = match request.decoded_path() { + Ok(path) => path, + Err(error) => return text(400, error.to_string()), }; - Json(json!({ "roots": roots })).into_response() + let post = matches!( + path.as_str(), + "/dwarfsource" + | "/viewer-log" + | "/test-ready" + | "/test-sleep" + | "/viewer-screenshot" + | "/test-done" + ); + if (post && request.method() != "POST") + || (!post && !matches!(request.method(), "GET" | "HEAD")) + { + return empty(405)?.with_header("allow", if post { "POST" } else { "GET,HEAD" }); + } + match path.as_str() { + "/" => serve_index(state).await, + "/build-stream" => build_stream(state), + "/dwarfsource" => dwarf_source(state, &request).await, + "/debug/source-roots" => debug_source_roots(state), + "/test-sleep" => test_sleep(state, &request).await, + "/viewer-screenshot" => viewer_screenshot(state, &request).await, + "/test-config" | "/test-ready" | "/test-done" | "/viewer-log" => { + let body = if matches!(path.as_str(), "/test-done" | "/viewer-log") { + match std::str::from_utf8(request.body()) { + Ok(body) => body, + Err(_) => return text(400, "invalid UTF-8 body"), + } + } else { + "" + }; + if let Some(test) = &state.test { + if !test_request_authorized(test, &request) { + return empty(401); + } + match path.as_str() { + "/test-config" => return json_reply(200, &test.runtime), + "/test-ready" => { + let _ = test.events.send(TestEvent::Ready); + } + "/test-done" => { + let _ = test + .events + .send(TestEvent::Done(body.trim().parse::().unwrap_or(1))); + } + _ => {} + } + } else if path != "/viewer-log" { + return empty(404); + } + if path == "/viewer-log" { + for line in body.lines() { + eprintln!("[viewer] {line}"); + if let Some(test) = &state.test { + let _ = test.events.send(TestEvent::ViewerLog(line.to_string())); + } + } + } + empty(204) + } + _ => serve_file(state, path.strip_prefix('/').unwrap_or(&path)).await, + } } fn mime_for_path(path: &str) -> &'static str { @@ -730,7 +696,25 @@ fn mime_for_path(path: &str) -> &'static str { // Public API // --------------------------------------------------------------------------- -/// Start the HTTP server in a background tokio task. +fn server_limits(runtime: Option<&TestRuntimeConfig>) -> std::io::Result { + let schedule = runtime + .map(|runtime| runtime.wait_ms.max(runtime.interval_ms.unwrap_or(0.0))) + .unwrap_or(0.0); + let wait = + std::time::Duration::try_from_secs_f64(schedule / 1000.0).map_err(std::io::Error::other)?; + let deadline = wait + .checked_add(std::time::Duration::from_secs(60)) + .ok_or_else(|| std::io::Error::other("test schedule deadline overflow"))? + .max(std::time::Duration::from_secs(3600)); + Ok(Limits { + max_request_body_bytes: 64 * 1024 * 1024, + handler_timeout: deadline, + connection_timeout: deadline, + ..Limits::default() + }) +} + +/// Start the kernel HTTP server in a caller-runtime-owned background task. /// /// Returns the actual address the server bound to (useful when port 0 is /// requested for automatic assignment). `debug_symbols` is shared with the @@ -742,21 +726,17 @@ pub async fn start_server( debug_symbols: DebugSymbolHandle, test: Option, ) -> anyhow::Result { - // Serving never changes cwd. Capture it separately from output/sketch paths - // so the terminal follows the app launch, including --serve and --test. - let terminal_cwd = Arc::new(crate::path::NormalizedPath::new(std::env::current_dir()?)); - let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))).await?; - let addr = listener.local_addr()?; let state = AppState { - terminal_cwd, - terminal_origin: Arc::new(format!("http://{addr}")), - terminal_slots: Arc::new(tokio::sync::Semaphore::new(4)), serve_dir: Arc::new(serve_dir), build_tx, debug_symbols, test: test.map(Arc::new), // One shared budget per server, including native writes still completing // after timeout. PNG validation and destination policy stay in this app. + file_responses: kernal_api::http_server::FileResponses::new( + 4, + std::time::Duration::from_secs(30), + )?, screenshot_io: kernal_api::platform::fs::AsyncFileIo::new( 4, 64 * 1024 * 1024, @@ -764,52 +744,40 @@ pub async fn start_server( )?, }; - let app = Router::new() - .route("/", get(serve_index)) - .route("/build-stream", get(build_stream)) - .route("/terminal/ws", get(terminal_socket)) - .route("/dwarfsource", post(dwarf_source)) - .route("/viewer-log", post(viewer_log)) - .route("/test-config", get(test_config)) - .route("/test-ready", post(test_ready)) - .route("/test-sleep", post(test_sleep)) - .route("/viewer-screenshot", post(viewer_screenshot)) - .route("/test-done", post(test_done)) - .route("/debug/source-roots", get(debug_source_roots)) - .route("/{*path}", get(serve_file)) - .layer(SetResponseHeaderLayer::overriding( - axum::http::HeaderName::from_static("cross-origin-embedder-policy"), - HeaderValue::from_static("require-corp"), - )) - .layer(SetResponseHeaderLayer::overriding( - axum::http::HeaderName::from_static("cross-origin-opener-policy"), - HeaderValue::from_static("same-origin"), - )) - .layer(SetResponseHeaderLayer::overriding( - header::CACHE_CONTROL, - HeaderValue::from_static("no-cache, no-store, must-revalidate"), - )) - .layer( - CorsLayer::new() - .allow_origin(Any) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]), - ) - .layer(DefaultBodyLimit::max(64 * 1024 * 1024)) - .with_state(state); - - let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))).await?; - let addr = listener.local_addr()?; - - // This server currently lives until its caller-owned runtime shuts down. + let server = Server::bind( + SocketAddr::from(([127, 0, 0, 1], port)), + server_limits(state.test.as_ref().map(|test| &test.runtime))?, + ) + .await? + .with_response_header("cross-origin-embedder-policy", "require-corp")? + .with_response_header("cross-origin-opener-policy", "same-origin")? + .with_response_header("cache-control", "no-cache, no-store, must-revalidate")? + .with_response_header("access-control-allow-origin", "*")? + .with_response_header( + "vary", + "origin, access-control-request-method, access-control-request-headers", + )?; + let addr = server.local_addr()?; + let diagnostics = server.diagnostics(); async_engine::launch(async move { - axum::serve(listener, app).await.ok(); + let result = server + .serve(move |request| { + let state = state.clone(); + async move { + match route(&state, request).await { + Ok(response) => response, + Err(error) => { + eprintln!("[server] response preparation failed: {error}"); + Response::default() + } + } + } + }) + .await; + if let Err(error) = result { + eprintln!("[server] listener failed: {error}"); + } + eprintln!("[server] stopped: {:?}", diagnostics.snapshot()); }) .detach(); @@ -861,6 +829,22 @@ mod tests { Arc::new(RwLock::new(None)) } + #[test] + fn server_deadlines_cover_every_accepted_test_sleep() { + for ms in [25.0, 3_600_001.0, f64::from(i32::MAX)] { + let runtime = TestRuntimeConfig { + wait_ms: ms, + interval_ms: Some(ms), + screenshot_names: Vec::new(), + }; + let limits = server_limits(Some(&runtime)).unwrap(); + let wait = std::time::Duration::from_secs_f64(ms / 1000.0); + assert!(limits.handler_timeout > wait); + assert!(limits.connection_timeout > wait); + assert_eq!(limits.handler_timeout, limits.connection_timeout); + } + } + /// Helper: create a temp dir, start the server, return (addr, dir). async fn setup_server() -> (SocketAddr, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); @@ -991,7 +975,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED.as_u16()); + assert_eq!(unauthorized.status(), 401); let config_response = http_request( HttpMethod::Get, &format!("http://{addr}/test-config"), @@ -1018,7 +1002,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(saturated.status(), StatusCode::TOO_MANY_REQUESTS.as_u16()); + assert_eq!(saturated.status(), 429); drop(occupied.pop()); let response = http_request( HttpMethod::Post, @@ -1028,7 +1012,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT.as_u16()); + assert_eq!(response.status(), 204); assert_eq!(sleep_permits.available_permits(), 1); drop(occupied); let response = http_request( @@ -1039,7 +1023,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST.as_u16()); + assert_eq!(response.status(), 400); let response = http_request( HttpMethod::Post, &format!("http://{addr}/test-sleep?ms=26"), @@ -1048,7 +1032,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST.as_u16()); + assert_eq!(response.status(), 400); let worker = response_text( http_get(format!("http://{addr}/fastled_background_worker.js")) @@ -1070,7 +1054,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST.as_u16()); + assert_eq!(response.status(), 400); assert!(!dir.path().join("escape").exists()); let png = vec![137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]; @@ -1082,7 +1066,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT.as_u16()); + assert_eq!(response.status(), 204); assert_eq!(fs::read(&screenshot).unwrap(), png); assert!(matches!( rx.recv().await, @@ -1101,10 +1085,7 @@ mod tests { ) .await .unwrap(); - assert_eq!( - response.status(), - StatusCode::INTERNAL_SERVER_ERROR.as_u16() - ); + assert_eq!(response.status(), 500); assert!(matches!( rx.recv().await, Some(TestEvent::Failure(message)) if message.contains("could not write screenshot") diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index e95f1f20..ae64172b 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -511,6 +511,37 @@ Clippy pass; Python reports 27 passed and one skipped. The boundary test was observed RED before removing the Tokio calls and GREEN afterward. One local review covered the Rust, Python and documentation changes with no findings. +### HTTP transport adoption draft + +The current local draft replaces Axum/Tower routing with kernel HTTP serving. +FastLED retains the route table, JSON payloads, authorization, screenshot names, +sleep scheduling, MIME mapping, source-root policy and browser headers. Files +and worker-prefix injection now stream through kernel response bodies; build +events use kernel SSE encoding. The source boundary bans direct Axum, Tower HTTP, +Tokio filesystem reads and Tokio listeners. Axum and its private routing helpers +leave the lockfile; Tower HTTP remains transitive through other facilities. +The only updated package version is `http-body-util` 0.1.3 -> 0.1.5, matching the +kernel's exact requirement. + +This is not release-ready adoption: native path selection/canonicalization still +run synchronously as in the baseline; native CI and exact published kernel +adoption remain required. Opening and response metadata preparation use one +shared kernel `FileResponses` pool with four slots and a 30-second deadline. +Workers retain admission after cancellation until native work really ends. +Kernel transport limits +now bound requests, responses, streams, connections and native reads. The app +retains a 64 MiB request limit. Handler and absolute connection deadlines both +cover the largest accepted test wait/interval plus 60 seconds, with a one-hour +minimum; they no longer cut off an accepted long sleep after one hour. +Parser-generated errors remain outside application header policy. + +Validation: all 257 Rust workspace tests and strict all-target Clippy pass; +Python reports 27 passed and one skipped. The raw-wire Axum baseline remains +green with kernel serving. Review identified blocking file preparation and +deadlines shorter than accepted test schedules; both are fixed and re-reviewed. +Generic file-preparation and cancellation-admission regressions live upstream; +the application retains a regression covering its maximum accepted sleep. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 8318a0ec..3b82a868 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -21,6 +21,8 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "sha2" not in dependencies assert "dirs" not in dependencies assert "tokio-stream" not in dependencies + assert "axum" not in dependencies + assert "tower-http" not in dependencies for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies for target in manifest.get("target", {}).values(): @@ -72,5 +74,9 @@ def test_migrated_async_operations_use_kernel(): "tokio_stream::", "tokio::fs::write", "tokio::fs::create_dir_all", + "tokio::fs::read", + "tokio::net::TcpListener", + "axum::", + "tower_http::", ): assert backend not in source, path From 7506db47baec92bf70a838ebc7d5363307a37c03 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 00:07:59 -0700 Subject: [PATCH 35/94] test(server): cover malformed-request browser header boundaries --- crates/fastled-cli/src/server.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 885c3260..f45c4e60 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -921,6 +921,32 @@ mod tests { assert!(response.ends_with("\r\n\r\n"), "{response}"); } + #[tokio::test] + async fn malformed_requests_respect_the_browser_header_dispatch_boundary() { + let (addr, _dir) = setup_server().await; + let parser_error = raw_http( + addr, + "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: invalid\r\nConnection: close\r\n\r\n".into(), + ) + .await; + assert!(parser_error.starts_with("HTTP/1.1 400"), "{parser_error}"); + assert!(!parser_error.contains("cross-origin-opener-policy:")); + assert!(!parser_error.contains("access-control-allow-origin:")); + + for target in ["/%zz", "/test-sleep?ms=%zz"] { + let response = raw_http( + addr, + format!("POST {target} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"), + ) + .await; + assert!(response.starts_with("HTTP/1.1 400"), "{response}"); + assert!(response.contains("cross-origin-opener-policy: same-origin\r\n")); + assert!(response.contains("cross-origin-embedder-policy: require-corp\r\n")); + assert!(response.contains("access-control-allow-origin: *\r\n")); + assert!(response.contains("cache-control: no-cache, no-store, must-revalidate\r\n")); + } + } + #[tokio::test] async fn test_viewer_log_endpoint_accepts_posts() { let (addr, _dir) = setup_server().await; From 0c6bfa0f674362f0e5c132e00c2d0eea2ca89c3a Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 00:11:33 -0700 Subject: [PATCH 36/94] build: remove obsolete direct manifest dependencies Refs #229. Verify viewer builds without the direct indexmap std feature request and ban reintroduction of the unused entries. --- Cargo.lock | 1 - Cargo.toml | 1 - crates/fastled-cli/Cargo.toml | 1 - docs/kernal-api-migration.md | 13 ++++++++++++- tests/unit/test_kernal_boundary.py | 8 ++++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f55af771..079b0b37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1049,7 +1049,6 @@ dependencies = [ "ctcb-core", "getrandom 0.3.4", "gtk", - "indexmap 1.9.3", "kernal-api", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b5055b28..2d85823e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -thiserror = "2" kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 2b1b298c..658fe7aa 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -39,7 +39,6 @@ tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } [build-dependencies] -indexmap = { version = "1.9.3", features = ["std"] } tauri-build = { workspace = true, optional = true } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index ae64172b..2b48f41f 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -24,7 +24,7 @@ capability migration, with the same toolchain, features, and cache conditions. | Native viewer and terminal | tauri, tauri-build, gtk, webkit2gtk, crossterm | Extend semantic hosting as needed for test capture, microphone, graphics fixes, keyboard handling | | Compiler provisioning and C++ parsing | ctcb-core, tree-sitter, tree-sitter-cpp | Move reusable tool/parse mechanisms; retain FastLED build and preprocessing policy | | CLI, configuration, and utility support | clap, serde, serde_json, toml, anyhow, strsim, shell-words, tempfile, getrandom | Establish facade operations appropriate to actual consumers; remove obsolete dependencies | -| Build-only support | indexmap | Determine whether still required by build graph | +| Build-only support | indexmap | Direct unused entry removed; transitive Tauri copies remain until viewer migration | | Python launcher/tool provisioning | meson, ninja, uv, typeguard, zcmds_win32 | Audit runtime necessity and move shared provisioning into the base without breaking wheel installation | ## Development state @@ -63,6 +63,17 @@ and failed the x86-64 link during archive adoption. ## Completion checks +### Obsolete manifest entries + +The unused workspace `thiserror` declaration and direct build dependency on +`indexmap` are removed. `build.rs` only invokes the optional Tauri build helper; +there are no application references to either crate. The default viewer-enabled +build and all 258 Rust tests pass without the direct `indexmap/std` feature +request. The lockfile removes only the application-to-indexmap edge; neither +transitive package removal nor a build-speed gain is claimed. The new manifest +boundary was observed RED before removal and GREEN afterward; Python reports +28 passed and one skipped. Tauri build support remains to be migrated. + ### Tree fingerprint slice The sibling kernel branch `feat/tree-fingerprint` (commit `87e937d`, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 3b82a868..2d08cf9b 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -47,6 +47,14 @@ def test_archive_download_uses_kernel_http(): assert "kernal_api::http::" in source +def test_obsolete_manifest_dependencies_are_removed(): + root = Path(__file__).resolve().parents[2] + package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) + workspace = tomllib.loads((root / "Cargo.toml").read_text()) + assert "indexmap" not in package.get("build-dependencies", {}) + assert "thiserror" not in workspace["workspace"]["dependencies"] + + def test_http_callers_use_kernel(): root = Path(__file__).resolve().parents[2] package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) From 4afcaf7a93cde52123d60d0ff7a6de4cc07bab25 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 00:44:14 -0700 Subject: [PATCH 37/94] docs: record registry release gate and remaining capability gaps --- docs/kernal-api-migration.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 2b48f41f..8b30a5a0 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -553,6 +553,25 @@ deadlines shorter than accepted test schedules; both are fixed and re-reviewed. Generic file-preparation and cancellation-admission regressions live upstream; the application retains a regression covering its maximum accepted sleep. +### Registry release gate + +HTTP server PR https://github.com/zackees/kernal-api/pull/177 is merged, but +that is not a published release. Extracted-package verification exposed a +viewer feature build failure hidden by the upstream checkout's Git patches. +The registry-only correction and all-features packaging gate are tracked in +https://github.com/zackees/kernal-api/pull/179. The corrected graph passes six +native Linux browser scenarios, targeted Rust tests, strict Clippy and +formatting. Full extracted-package verification and cross-platform CI remain +pending at this checkpoint; do not remove the migration patch until a usable +release actually exists. Publishing credentials also need configuration. + +Next capability gaps include bounded terminal key polling and styling +(https://github.com/zackees/kernal-api/issues/178) and secure entropy +(https://github.com/zackees/kernal-api/issues/180). FastLED keeps rebuild-key +selection, warning text, token length/encoding and authorization policy. The +kernel owns the underlying terminal and OS-random mechanisms and generic tests. +Neither issue is implemented or adopted yet. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, From d068f93132c107959a4859814d136b8ba71c9c30 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 00:59:05 -0700 Subject: [PATCH 38/94] refactor: generate test capability entropy through kernal-api Refs #229 and zackees/kernal-api#180. Preserve token wire format and fail-closed startup; retain migration-only exact-version sibling patch pending publication. --- Cargo.lock | 182 +---------------------------- Cargo.toml | 4 +- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/commands.rs | 36 ++++-- docs/kernal-api-migration.md | 27 ++++- tests/unit/test_kernal_boundary.py | 2 + 6 files changed, 63 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 079b0b37..ad60e20e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -943,7 +943,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser 0.36.0", - "foldhash 0.2.0", + "foldhash", "html5ever 0.38.0", "precomputed-hash", "selectors 0.36.1", @@ -1047,7 +1047,6 @@ dependencies = [ "clap", "crossterm", "ctcb-core", - "getrandom 0.3.4", "gtk", "kernal-api", "serde", @@ -1122,12 +1121,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1441,15 +1434,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] @@ -1619,15 +1610,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.17.0" @@ -1904,12 +1886,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2165,6 +2141,7 @@ dependencies = [ "dirs", "flate2", "futures-core", + "getrandom 0.4.3", "globset", "http-body-util", "hyper", @@ -2234,12 +2211,6 @@ dependencies = [ "selectors 0.24.0", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -3119,16 +3090,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4516,7 +4477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.59.0", @@ -4996,12 +4957,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "url" version = "2.5.8" @@ -5051,7 +5006,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -5135,15 +5090,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -5199,28 +5145,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -5234,18 +5158,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" version = "0.3.95" @@ -5849,88 +5761,6 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index 2d85823e..c02f22b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" @@ -36,7 +36,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-entropy" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 658fe7aa..00645436 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -34,7 +34,6 @@ toml = { workspace = true } tauri = { workspace = true, optional = true } tempfile = "3" shell-words = { workspace = true } -getrandom = "0.3" tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index b4051a4e..d7a972a1 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -254,15 +254,6 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { } }; let (test_tx, mut test_rx) = async_engine::unbounded_channel(); - let mut token_bytes = [0_u8; 32]; - if let Err(error) = getrandom::fill(&mut token_bytes) { - eprintln!("fastled: could not create test capability: {error}"); - return test_exit(TestOutcome::Failure); - } - let test_token = token_bytes - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); let debug_symbols: server::DebugSymbolHandle = Arc::new(RwLock::new(None)); let screenshot_paths = plan .screenshots @@ -291,6 +282,13 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { } }; rt.run(async { + let test_token = match create_test_capability().await { + Ok(token) => token, + Err(error) => { + eprintln!("fastled: could not create test capability: {error}"); + return test_exit(TestOutcome::Failure); + } + }; let addr = match server::start_server( output_dir.clone(), 0, @@ -509,6 +507,14 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { }) } +async fn create_test_capability() -> Result { + // One startup request; the shared kernel budget outlives cancellation of + // any native OS entropy call. Length, encoding and wait policy stay here. + let entropy = kernal_api::random::SecureRandom::new(1, std::time::Duration::from_secs(5))?; + let bytes = entropy.bytes(32).await?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + fn test_exit(outcome: TestOutcome) -> ExitCode { ExitCode::from(outcome.exit_code()) } @@ -892,3 +898,15 @@ pub(crate) fn run_internal_dwarf_smoke(cli: &Cli) -> ExitCode { } } } + +#[cfg(test)] +mod capability_tests { + #[tokio::test] + async fn test_capability_preserves_32_byte_lowercase_hex_wire_format() { + let token = super::create_test_capability().await.unwrap(); + assert_eq!(token.len(), 64); + assert!(token + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + } +} diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 8b30a5a0..00a5c194 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -570,7 +570,32 @@ Next capability gaps include bounded terminal key polling and styling (https://github.com/zackees/kernal-api/issues/180). FastLED keeps rebuild-key selection, warning text, token length/encoding and authorization policy. The kernel owns the underlying terminal and OS-random mechanisms and generic tests. -Neither issue is implemented or adopted yet. +Terminal work remains outstanding. The entropy capability is now implemented +in https://github.com/zackees/kernal-api/pull/181, with five focused upstream +tests, broader feature tests, strict Clippy, dependency-isolation checks and +entropy-enabled extracted-package verification passing locally. + +### Secure entropy adoption + +FastLED now requests its 32 entropy bytes through the kernel's bounded native +capability and keeps lowercase-hex encoding, authorization and startup-failure +handling here. The one startup request has a five-second caller deadline; an +OS call still running afterward retains the kernel admission slot until it ends. +The deadline does not promise bounded runtime shutdown if that native call is +still blocked when the CLI exits. +No predictable fallback is permitted. Direct `getrandom` is removed and banned +by the app boundary test (observed RED then GREEN). The transitive 0.4.2 pin +updates to the kernel-selected 0.4.3; its obsolete WASI 0.3 binding packages +leave the lockfile, but other transitive random dependencies remain. + +During this slice the migration-only patch points at +`../fastled-wasm-extern/kernal-api-entropy` on `feat/secure-entropy`, not the +original sibling checkout. Restore the canonical checkout after upstream merge, +then remove the patch entirely when adopting the exact published release. +All 259 Rust workspace tests passed. Strict all-target Clippy and the focused +token-format test pass after moving its test module to the end of the file. +The single reviewer confirmed the corrected async integration; Python tests +pass (28 passed, one skipped). This is not published adoption. ### Final migration audit diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 2d08cf9b..29605644 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -23,6 +23,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "tokio-stream" not in dependencies assert "axum" not in dependencies assert "tower-http" not in dependencies + assert "getrandom" not in dependencies for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies for target in manifest.get("target", {}).values(): @@ -36,6 +37,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "windows_sys::" not in source.read_text(), source assert "sha2::" not in source.read_text(), source assert "dirs::" not in source.read_text(), source + assert "getrandom::" not in source.read_text(), source for backend in ("zip", "tar", "zstd", "flate2"): assert f"{backend}::" not in source.read_text(), source From a27f9a40d7530fb59b13ae4fd926b3b1ea3618fe Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 01:14:52 -0700 Subject: [PATCH 39/94] refactor: own temporary directories through kernal-api --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/archive.rs | 8 ++-- crates/fastled-cli/src/cache_upgrade.rs | 14 +++---- crates/fastled-cli/src/clangd_config.rs | 8 ++-- crates/fastled-cli/src/compile_stream.rs | 6 +-- crates/fastled-cli/src/debug_symbols.rs | 6 +-- crates/fastled-cli/src/dwarf_smoke.rs | 2 +- crates/fastled-cli/src/dynamic_cache.rs | 38 ++++++++++--------- .../src/dynamic_cache_matrix_tests.rs | 4 +- crates/fastled-cli/src/frontend.rs | 18 ++++----- crates/fastled-cli/src/install.rs | 28 +++++++------- crates/fastled-cli/src/install_unlock.rs | 10 ++--- crates/fastled-cli/src/lib.rs | 10 ++--- crates/fastled-cli/src/project.rs | 9 +++-- crates/fastled-cli/src/runtime.rs | 14 +++---- crates/fastled-cli/src/server.rs | 16 ++++---- crates/fastled-cli/src/sketch_preprocessor.rs | 10 ++--- crates/fastled-cli/src/source.rs | 16 ++++---- crates/fastled-cli/src/test_mode.rs | 8 ++-- crates/fastled-cli/src/viewer.rs | 10 ++--- crates/fastled-cli/src/wasm_build.rs | 30 +++++++-------- crates/fastled-cli/src/watcher.rs | 6 +-- crates/fastled-cli/tests/cache_upgrade_cli.rs | 2 +- docs/kernal-api-migration.md | 35 +++++++++++++++-- tests/unit/test_kernal_boundary.py | 3 ++ 27 files changed, 176 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad60e20e..6914445f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1055,7 +1055,6 @@ dependencies = [ "strsim", "tauri", "tauri-build", - "tempfile", "tokio", "toml 0.8.23", "tree-sitter", @@ -2157,6 +2156,7 @@ dependencies = [ "sha2", "sysinfo", "tar", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-stream", diff --git a/Cargo.toml b/Cargo.toml index c02f22b5..7cfd784d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-entropy" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-temporary" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 00645436..40b2cfc0 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -32,7 +32,6 @@ crossterm = { workspace = true } tokio = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } -tempfile = "3" shell-words = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } diff --git a/crates/fastled-cli/src/archive.rs b/crates/fastled-cli/src/archive.rs index a780db5f..22ce95f2 100644 --- a/crates/fastled-cli/src/archive.rs +++ b/crates/fastled-cli/src/archive.rs @@ -269,10 +269,10 @@ fn _promote_single_child(dir: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; + use kernal_api::platform::fs::TemporaryDirectory; - fn temp_dir() -> TempDir { - tempfile::tempdir().expect("tempdir") + fn temp_dir() -> TemporaryDirectory { + kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir") } #[test] @@ -422,7 +422,7 @@ mod tests { #[test] fn test_write_emscripten_config_does_not_touch_unchanged_file() { - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let install_dir = dir.path().join("emscripten").join("4.0.19"); fs::create_dir_all(&install_dir).unwrap(); let node = install_dir.join("managed-node"); diff --git a/crates/fastled-cli/src/cache_upgrade.rs b/crates/fastled-cli/src/cache_upgrade.rs index 920bdac8..3070b985 100644 --- a/crates/fastled-cli/src/cache_upgrade.rs +++ b/crates/fastled-cli/src/cache_upgrade.rs @@ -183,7 +183,7 @@ mod tests { #[test] fn first_version_clears_only_shared_cache_and_records_breadcrumb() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path(); write(&root.join("cache/legacy.txt"), "stale"); write(&root.join("toolchains/keep.txt"), "toolchain"); @@ -210,7 +210,7 @@ mod tests { #[test] fn same_version_preserves_new_cache_and_emits_no_warning() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path(); write(&root.join("cache/legacy.txt"), "stale"); ensure_cache_version(root, "2.0.16").unwrap(); @@ -228,7 +228,7 @@ mod tests { #[test] fn changed_version_clears_cache_once_again() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path(); ensure_cache_version(root, "2.0.16").unwrap(); write(&root.join("cache/from-old-version.txt"), "stale"); @@ -246,7 +246,7 @@ mod tests { #[test] fn missing_cache_records_version_silently() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let outcome = ensure_cache_version(temp.path(), "2.0.16").unwrap(); @@ -260,7 +260,7 @@ mod tests { #[test] fn deletion_failure_does_not_publish_breadcrumb() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path(); write(&root.join("cache/legacy.txt"), "stale"); @@ -275,7 +275,7 @@ mod tests { #[test] fn breadcrumb_failure_does_not_publish_success() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path(); write(&root.join("cache/legacy.txt"), "stale"); fs::create_dir_all(breadcrumb_temp_path(root, "2.0.16")).unwrap(); @@ -288,7 +288,7 @@ mod tests { #[test] fn concurrent_first_runs_clear_once_after_rechecking_under_lock() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path().to_path_buf(); write(&root.join("cache/legacy.txt"), "stale"); let barrier = Arc::new(Barrier::new(3)); diff --git a/crates/fastled-cli/src/clangd_config.rs b/crates/fastled-cli/src/clangd_config.rs index 7258bb04..587bae65 100644 --- a/crates/fastled-cli/src/clangd_config.rs +++ b/crates/fastled-cli/src/clangd_config.rs @@ -545,7 +545,7 @@ mod tests { #[test] fn writes_all_three_files_with_expected_contents() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let inputs = stub_inputs(tmp.path()); write_clangd_config(&inputs).unwrap(); @@ -664,7 +664,7 @@ mod tests { #[test] fn merges_existing_vscode_settings() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let inputs = stub_inputs(tmp.path()); let settings_path = inputs.sketch_dir.join(".vscode").join("settings.json"); fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); @@ -686,7 +686,7 @@ mod tests { #[test] fn regenerating_is_idempotent() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let inputs = stub_inputs(tmp.path()); write_clangd_config(&inputs).unwrap(); let first = fs::read_to_string(inputs.sketch_dir.join("compile_commands.json")).unwrap(); @@ -697,7 +697,7 @@ mod tests { #[test] fn emits_a_forced_prelude_entry_for_every_ino_tab() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let mut inputs = stub_inputs(tmp.path()); let utility = inputs.sketch_dir.join("Utility.ino"); fs::write(&utility, "void utility() {}\n").unwrap(); diff --git a/crates/fastled-cli/src/compile_stream.rs b/crates/fastled-cli/src/compile_stream.rs index cca54c9c..8d41001f 100644 --- a/crates/fastled-cli/src/compile_stream.rs +++ b/crates/fastled-cli/src/compile_stream.rs @@ -373,7 +373,7 @@ mod tests { #[test] fn report_build_outcome_success_requires_index_html() { - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let (tx, mut rx) = kernal_api::async_engine::broadcast_channel::(8).unwrap(); assert!(!report_build_outcome(dir.path(), &tx, true, true)); @@ -390,7 +390,7 @@ mod tests { #[test] fn report_build_outcome_failure_is_error_even_with_stale_index() { - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); std::fs::write(dir.path().join("index.html"), "").unwrap(); let (tx, mut rx) = kernal_api::async_engine::broadcast_channel::(8).unwrap(); @@ -402,7 +402,7 @@ mod tests { #[test] fn report_build_outcome_allows_success_without_app() { - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let (tx, mut rx) = kernal_api::async_engine::broadcast_channel::(8).unwrap(); assert!(report_build_outcome(dir.path(), &tx, true, false)); diff --git a/crates/fastled-cli/src/debug_symbols.rs b/crates/fastled-cli/src/debug_symbols.rs index c23b8c22..7f294f6e 100644 --- a/crates/fastled-cli/src/debug_symbols.rs +++ b/crates/fastled-cli/src/debug_symbols.rs @@ -368,11 +368,11 @@ pub fn guess_emsdk_path() -> Option { #[cfg(test)] mod tests { use super::*; + use kernal_api::platform::fs::TemporaryDirectory; use std::fs; - use tempfile::TempDir; - fn setup_dirs() -> (TempDir, PathBuf, PathBuf, PathBuf) { - let tmp = TempDir::new().unwrap(); + fn setup_dirs() -> (TemporaryDirectory, PathBuf, PathBuf, PathBuf) { + let tmp = TemporaryDirectory::new().unwrap(); let sketch_dir = tmp.path().join("sketch"); let fastled_dir = tmp.path().join("FastLED"); let emsdk_dir = tmp.path().join("emsdk"); diff --git a/crates/fastled-cli/src/dwarf_smoke.rs b/crates/fastled-cli/src/dwarf_smoke.rs index 6bdc18be..a0d430c7 100644 --- a/crates/fastled-cli/src/dwarf_smoke.rs +++ b/crates/fastled-cli/src/dwarf_smoke.rs @@ -61,7 +61,7 @@ pub(crate) fn run_dwarf_source_smoke(output_dir: &Path) -> anyhow::Result #[cfg(test)] #[test] fn source_smoke_uses_manifest_paths_and_reports_missing_source() { - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch = dir.path().join("sketch"); std::fs::create_dir(&sketch).unwrap(); let source = sketch.join("demo.ino"); diff --git a/crates/fastled-cli/src/dynamic_cache.rs b/crates/fastled-cli/src/dynamic_cache.rs index 8eca0674..2782ab34 100644 --- a/crates/fastled-cli/src/dynamic_cache.rs +++ b/crates/fastled-cli/src/dynamic_cache.rs @@ -335,8 +335,11 @@ pub(crate) fn write_metadata(staging: &Path, fingerprint: &str, artifacts: &[&st /// Publish a fully validated staging directory by one same-filesystem rename. /// A key is never observable as successful until every artifact and its /// metadata are complete. -pub(crate) fn publish_staging(staging: tempfile::TempDir, target: &Path) -> Result<()> { - let staging_path = staging.keep(); +pub(crate) fn publish_staging( + staging: kernal_api::platform::fs::TemporaryDirectory, + target: &Path, +) -> Result<()> { + let staging_path = staging.persist(); if target.exists() { fs::remove_dir_all(target) .with_context(|| format!("remove invalid cache entry {}", target.display()))?; @@ -354,12 +357,13 @@ pub(crate) fn publish_staging(staging: tempfile::TempDir, target: &Path) -> Resu Ok(()) } -pub(crate) fn staging_dir(cache_root: &Path, prefix: &str) -> Result { +pub(crate) fn staging_dir( + cache_root: &Path, + prefix: &str, +) -> Result { fs::create_dir_all(cache_root) .with_context(|| format!("create cache root {}", cache_root.display()))?; - tempfile::Builder::new() - .prefix(prefix) - .tempdir_in(cache_root) + kernal_api::platform::fs::TemporaryDirectory::in_directory(cache_root, prefix) .with_context(|| format!("create staging directory in {}", cache_root.display())) } @@ -473,7 +477,7 @@ mod tests { // the persistent fingerprint before the next lookup. #[test] fn explicit_invalidation_detects_immediate_same_size_edit_with_restored_mtime() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let source = temp.path().join("sketch.ino"); fs::write(&source, "aaaa").unwrap(); let mut fingerprint = fingerprint_tree_persistent(temp.path(), &["**/*.ino"], &[]).unwrap(); @@ -499,8 +503,8 @@ mod tests { #[test] fn path_invalidation_dirties_only_matching_spec() { - let first = tempfile::tempdir().unwrap(); - let second = tempfile::tempdir().unwrap(); + let first = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let second = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); fs::write(first.path().join("one.cpp"), "one").unwrap(); fs::write(second.path().join("two.cpp"), "two").unwrap(); fingerprint_tree_persistent(first.path(), &["**/*.cpp"], &[]).unwrap(); @@ -516,7 +520,7 @@ mod tests { #[test] fn lost_fingerprint_watch_discards_cached_value_and_registers_again() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); fs::write(temp.path().join("sketch.ino"), "aaaa").unwrap(); let expected = fingerprint_tree_persistent(temp.path(), &["**/*.ino"], &[]).unwrap(); let spec = FingerprintSpec { @@ -542,8 +546,8 @@ mod tests { #[test] fn invalidate_all_dirties_every_spec() { - let first = tempfile::tempdir().unwrap(); - let second = tempfile::tempdir().unwrap(); + let first = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let second = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); fs::write(first.path().join("one.cpp"), "one").unwrap(); fs::write(second.path().join("two.cpp"), "two").unwrap(); fingerprint_tree_persistent(first.path(), &["**/*.cpp"], &[]).unwrap(); @@ -554,7 +558,7 @@ mod tests { #[test] fn excluded_output_path_does_not_dirty_spec() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); fs::create_dir_all(temp.path().join("fastled_js")).unwrap(); fs::write(temp.path().join("source.cpp"), "source").unwrap(); fingerprint_tree_persistent(temp.path(), &["**/*.cpp"], &["fastled_js/**"]).unwrap(); @@ -588,7 +592,7 @@ mod tests { #[test] fn validation_rejects_missing_empty_truncated_and_corrupt_entries() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let entry = temp.path().join("entry"); fs::create_dir(&entry).unwrap(); fs::write(entry.join("fastled.js"), "js").unwrap(); @@ -612,7 +616,7 @@ mod tests { #[test] fn cache_lock_serializes_same_key() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path().to_path_buf(); let barrier = Arc::new(Barrier::new(2)); let other_barrier = Arc::clone(&barrier); @@ -630,7 +634,7 @@ mod tests { #[test] fn atomic_publish_replaces_invalid_entry() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path().join("cache"); fs::create_dir(&root).unwrap(); let target = root.join("key"); @@ -648,7 +652,7 @@ mod tests { #[test] fn attempt_state_records_pending_failure_and_clears_after_success() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); mark_pending(temp.path(), "key", "main-link").unwrap(); assert_eq!( previous_attempt(temp.path(), "key").as_deref(), diff --git a/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs b/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs index 201f91cd..b2ef4e8e 100644 --- a/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs +++ b/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs @@ -27,7 +27,7 @@ struct OutputHashes { } struct FakeRepository { - _temp: tempfile::TempDir, + _temp: kernal_api::platform::fs::TemporaryDirectory, root: NormalizedPath, fastled: NormalizedPath, app: NormalizedPath, @@ -39,7 +39,7 @@ struct FakeRepository { impl FakeRepository { fn new() -> Self { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = NormalizedPath::new(temp.path()); let fastled = root.join("FastLED"); write(&fastled.join("src/core.cpp"), "core-v1"); diff --git a/crates/fastled-cli/src/frontend.rs b/crates/fastled-cli/src/frontend.rs index f1c5d7b7..aac766b5 100644 --- a/crates/fastled-cli/src/frontend.rs +++ b/crates/fastled-cli/src/frontend.rs @@ -461,10 +461,10 @@ pub fn remove_app_from_output(output_dir: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use kernal_api::platform::fs::TemporaryDirectory; use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::Duration; - use tempfile::TempDir; fn write(path: &Path, content: &[u8]) { if let Some(parent) = path.parent() { @@ -503,7 +503,7 @@ mod tests { #[test] fn compute_dir_hash_is_deterministic() { - let dir = TempDir::new().unwrap(); + let dir = TemporaryDirectory::new().unwrap(); let root = dir.path(); // Create files in mixed order; the function must sort internally. write(&root.join("b.txt"), b"bravo"); @@ -520,7 +520,7 @@ mod tests { #[test] fn compute_dir_hash_changes_on_content_change() { - let dir = TempDir::new().unwrap(); + let dir = TemporaryDirectory::new().unwrap(); let root = dir.path(); write(&root.join("a.txt"), b"alpha"); @@ -532,7 +532,7 @@ mod tests { #[test] fn walk_files_skips_dist_subtree() { - let dir = TempDir::new().unwrap(); + let dir = TemporaryDirectory::new().unwrap(); let root = dir.path(); write(&root.join("app.ts"), b"// app"); write(&root.join("dist").join("index.js"), b"// built"); @@ -567,7 +567,7 @@ mod tests { // that *already* has a populated dist + matching marker, so build_dist // returns immediately. Then run copy twice and confirm the second // invocation short-circuits on the hash marker. - let workspace = TempDir::new().unwrap(); + let workspace = TemporaryDirectory::new().unwrap(); let source = workspace.path().join("frontend"); let dist = source.join("dist"); fs::create_dir_all(&dist).unwrap(); @@ -578,7 +578,7 @@ mod tests { // is <= it. write(&dist.join(".esbuild_marker"), b"9999999999.0"); - let output = TempDir::new().unwrap(); + let output = TemporaryDirectory::new().unwrap(); copy_frontend_to_output(output.path(), Some(&source)).expect("first copy"); assert!(output.path().join("index.js").exists()); let hash_after_first = fs::read_to_string(output.path().join(".frontend_hash")).unwrap(); @@ -612,7 +612,7 @@ mod tests { // usable path on the host. The installed Python launcher must provide // the packaged frontend through FASTLED_FRONTEND_DIR instead. let _guard = frontend_env_lock().lock().unwrap(); - let package = TempDir::new().unwrap(); + let package = TemporaryDirectory::new().unwrap(); let frontend = package.path().join("site-packages/fastled/frontend"); write_frontend_source(&frontend); @@ -632,7 +632,7 @@ mod tests { #[test] fn installed_wheel_frontend_must_be_complete() { let _guard = frontend_env_lock().lock().unwrap(); - let package = TempDir::new().unwrap(); + let package = TemporaryDirectory::new().unwrap(); let frontend = package.path().join("site-packages/fastled/frontend"); fs::create_dir_all(&frontend).unwrap(); @@ -651,7 +651,7 @@ mod tests { #[test] fn remove_app_from_output_preserves_runtime_and_assets_manifest() { - let output = TempDir::new().unwrap(); + let output = TemporaryDirectory::new().unwrap(); write(&output.path().join("index.js"), b"app"); write(&output.path().join("index.html"), b"html"); write(&output.path().join("index.css"), b"css"); diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index d85d5c85..8548832f 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -1389,10 +1389,11 @@ pub(crate) fn refresh_fastled_repo(reference: &str) -> Result { let cache_base = fastled_root()?.join("cache"); let archive_cache = cache_base.join("archives"); fs::create_dir_all(&archive_cache)?; - let download_dir = tempfile::Builder::new() - .prefix("fastled-source-download-") - .tempdir_in(&archive_cache) - .context("create temporary FastLED download directory")?; + let download_dir = kernal_api::platform::fs::TemporaryDirectory::in_directory( + &archive_cache, + "fastled-source-download-", + ) + .context("create temporary FastLED download directory")?; let archive_path = download_dir.path().join("FastLED.zip"); archive::download(&url, &archive_path) .with_context(|| format!("download FastLED archive from {url}"))?; @@ -2027,7 +2028,8 @@ fn install_auto_debug_extension(dry_run: bool) -> Result { return Ok(false); }; - let temp_dir = tempfile::tempdir().context("create temp dir for extension")?; + let temp_dir = kernal_api::platform::fs::TemporaryDirectory::new() + .context("create temp dir for extension")?; let vsix_path = temp_dir.path().join("auto-debug.vsix"); println!("Downloading Auto Debug extension..."); download_to_path(AUTO_DEBUG_VSIX_URL, &vsix_path)?; @@ -2212,7 +2214,7 @@ mod tests { fn fastled_python_keeps_virtual_environment_symlink_path() { use std::os::unix::fs::symlink; - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let base_python = temp.path().join("base-python"); fs::write(&base_python, "python").unwrap(); let venv_bin = temp.path().join("FastLED/.venv/bin"); @@ -2231,7 +2233,7 @@ mod tests { fn selected_virtualenv_python_keeps_sibling_uv_path() { use std::os::unix::fs::symlink; - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let base_python = temp.path().join("base/bin/python"); fs::create_dir_all(base_python.parent().unwrap()).unwrap(); fs::write(&base_python, "python").unwrap(); @@ -2273,7 +2275,7 @@ mod tests { // Regression coverage for issue #194: a valid active install must not // fall through to manifest discovery or an implicit replacement. fn active_install_is_selected_without_network_or_manifest_state() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let spec = test_spec(); let install = create_valid_emscripten_install(temp.path()); write_state( @@ -2294,7 +2296,7 @@ mod tests { #[test] fn legacy_marker_migrates_to_receipt_and_active_state() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let spec = test_spec(); let install = temp.path().join(spec.package_id); for path in required_emscripten_payload_files(&install) { @@ -2317,14 +2319,14 @@ mod tests { #[test] fn existing_history_never_bootstraps_implicitly() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); fs::create_dir(temp.path().join("4.0.18")).unwrap(); assert!(has_install_history(temp.path())); } #[test] fn invalid_active_uses_previous_known_good_without_rewriting_state() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let spec = test_spec(); let install = create_valid_emscripten_install(temp.path()); let broken = temp.path().join("broken"); @@ -2345,7 +2347,7 @@ mod tests { #[test] fn missing_required_tool_rejects_managed_install() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let spec = test_spec(); let install = create_valid_emscripten_install(temp.path()); fs::remove_file(install.join(if cfg!(windows) { @@ -2374,7 +2376,7 @@ mod tests { fn ensure_toolchain_executables_restores_unix_execute_bits() { use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir().expect("tempdir"); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let bin_dir = temp.path().join("bin"); fs::create_dir_all(&bin_dir).expect("create bin"); let tool = bin_dir.join("llvm-ar"); diff --git a/crates/fastled-cli/src/install_unlock.rs b/crates/fastled-cli/src/install_unlock.rs index dd0e9a24..7913ac82 100644 --- a/crates/fastled-cli/src/install_unlock.rs +++ b/crates/fastled-cli/src/install_unlock.rs @@ -91,7 +91,7 @@ mod tests { #[test] fn unlock_creates_old_sibling_and_fresh_canonical_copy() { let _guard = ENV_LOCK.lock().unwrap(); - let dir = tempfile::tempdir().expect("tempdir"); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let exe = dir.path().join("myapp.exe"); fs::write(&exe, b"binary-bytes").expect("write exe"); @@ -122,7 +122,7 @@ mod tests { #[test] fn sweep_removes_stale_old_siblings() { let _guard = ENV_LOCK.lock().unwrap(); - let dir = tempfile::tempdir().expect("tempdir"); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let exe = dir.path().join("myapp.exe"); fs::write(&exe, b"bytes").expect("write exe"); let stale = dir.path().join("myapp.exe.old.deadbeef"); @@ -137,7 +137,7 @@ mod tests { #[test] fn no_unlock_env_var_disables_trampoline() { let _guard = ENV_LOCK.lock().unwrap(); - let dir = tempfile::tempdir().expect("tempdir"); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let exe = dir.path().join("myapp.exe"); fs::write(&exe, b"bytes").expect("write exe"); @@ -156,7 +156,7 @@ mod tests { #[test] fn already_renamed_image_only_sweeps() { let _guard = ENV_LOCK.lock().unwrap(); - let dir = tempfile::tempdir().expect("tempdir"); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let renamed = dir.path().join("myapp.exe.old.123abc"); fs::write(&renamed, b"bytes").expect("write renamed"); let stale = dir.path().join("myapp.exe.old.456def"); @@ -178,7 +178,7 @@ mod tests { #[test] fn missing_exe_is_tolerated() { let _guard = ENV_LOCK.lock().unwrap(); - let dir = tempfile::tempdir().expect("tempdir"); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let exe = dir.path().join("ghost.exe"); unlock_install_exe(&exe); assert!(!exe.exists()); diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index d099c36d..15d61579 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -305,7 +305,7 @@ mod tests { #[test] fn collect_debug_source_paths_finds_wasm_and_source_map_entries() { - let temp = tempfile::tempdir().expect("tempdir"); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let output_dir = temp.path().join("fastled_js"); fs::create_dir_all(&output_dir).unwrap(); fs::write( @@ -338,7 +338,7 @@ mod tests { let _lock = cwd_lock() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let temp = tempfile::tempdir().expect("tempdir"); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let sketch_dir = temp.path().join("Blink"); fs::create_dir_all(&sketch_dir).unwrap(); fs::write(sketch_dir.join("Blink.ino"), b"void setup() {}").unwrap(); @@ -355,7 +355,7 @@ mod tests { #[test] fn resolve_compile_directory_accepts_file_inside_sketch() { - let temp = tempfile::tempdir().expect("tempdir"); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let sketch_dir = temp.path().join("Blink"); let source_file = sketch_dir.join("Blink.ino"); fs::create_dir_all(&sketch_dir).unwrap(); @@ -373,7 +373,7 @@ mod tests { let _lock = cwd_lock() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let temp = tempfile::tempdir().expect("tempdir"); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let examples_dir = temp.path().join("examples").join("FxWave2d"); fs::create_dir_all(&examples_dir).unwrap(); fs::write(examples_dir.join("FxWave2d.ino"), b"void setup() {}").unwrap(); @@ -399,7 +399,7 @@ mod tests { let _lock = cwd_lock() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let temp = tempfile::tempdir().expect("tempdir"); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir"); let repo_root = temp.path().join("FastLED"); let nested = repo_root.join("examples").join("Blink"); fs::create_dir_all(&nested).unwrap(); diff --git a/crates/fastled-cli/src/project.rs b/crates/fastled-cli/src/project.rs index 0c659475..2ed1a05a 100644 --- a/crates/fastled-cli/src/project.rs +++ b/crates/fastled-cli/src/project.rs @@ -494,7 +494,8 @@ pub fn init_example(example_name: &str, dest: &Path, branch: Option<&str>) -> Re let (_ref_name, url) = resolve_ref(branch); // Download the zip to a temp file. - let tmp_dir = tempfile::tempdir().context("failed to create temp directory")?; + let tmp_dir = kernal_api::platform::fs::TemporaryDirectory::new() + .context("failed to create temp directory")?; let zip_path = tmp_dir.path().join("fastled.zip"); crate::archive::download(&url, &zip_path) @@ -782,11 +783,11 @@ pub fn find_sketch_by_partial_name(partial_name: &str, search_dir: &Path) -> Res #[cfg(test)] mod tests { use super::*; + use kernal_api::platform::fs::TemporaryDirectory; use std::fs; - use tempfile::TempDir; - fn temp_dir() -> TempDir { - tempfile::tempdir().expect("tempdir") + fn temp_dir() -> TemporaryDirectory { + kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir") } // ------------------------------------------------------------------ diff --git a/crates/fastled-cli/src/runtime.rs b/crates/fastled-cli/src/runtime.rs index bcf540bb..2214f356 100644 --- a/crates/fastled-cli/src/runtime.rs +++ b/crates/fastled-cli/src/runtime.rs @@ -670,9 +670,9 @@ fn version_from_name(name: &str) -> Option<(String, ParsedVersion)> { #[cfg(test)] mod tests { use super::*; + use kernal_api::platform::fs::TemporaryDirectory; use std::fs::File; use std::io::Write; - use tempfile::TempDir; fn write_file(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { @@ -716,7 +716,7 @@ mod tests { #[test] fn pending_update_prefers_newest_version() { - let temp = TempDir::new().expect("tempdir"); + let temp = TemporaryDirectory::new().expect("tempdir"); let update_root = temp.path().join(UPDATE_DIR); write_file(&update_root.join(candidate_name("2.0.8")), b"old"); write_file(&update_root.join(candidate_name("2.1.0")), b"new"); @@ -732,7 +732,7 @@ mod tests { #[test] fn pending_update_can_live_inside_versioned_directory() { - let temp = TempDir::new().expect("tempdir"); + let temp = TemporaryDirectory::new().expect("tempdir"); let update_root = temp.path().join(UPDATE_DIR); let nested = update_root.join("fastled-v9.0.0"); write_file(&nested.join(FASTLED_EXE_NAMES[0]), b"new"); @@ -748,7 +748,7 @@ mod tests { #[test] fn runtime_candidate_prefers_newer_run_copy() { - let temp = TempDir::new().expect("tempdir"); + let temp = TemporaryDirectory::new().expect("tempdir"); let run_root = temp.path().join(RUN_DIR); write_file( &run_root @@ -773,7 +773,7 @@ mod tests { #[test] fn ensure_runtime_copy_copies_to_hash_keyed_run_dir() { - let temp = TempDir::new().expect("tempdir"); + let temp = TemporaryDirectory::new().expect("tempdir"); let paths = RuntimePaths::with_root(temp.path().join("home")); let source = temp.path().join(FASTLED_EXE_NAMES[0]); write_file(&source, b"binary"); @@ -799,7 +799,7 @@ mod tests { #[test] fn install_update_moves_candidate_into_run_dir() { - let temp = TempDir::new().expect("tempdir"); + let temp = TemporaryDirectory::new().expect("tempdir"); let paths = RuntimePaths::with_root(temp.path().join("home")); let current = temp.path().join(FASTLED_EXE_NAMES[0]); let update = paths.update_root.join(candidate_name("3.0.0")); @@ -825,7 +825,7 @@ mod tests { #[test] fn periodic_gc_removes_stale_run_dirs_and_skips_current() { - let temp = TempDir::new().expect("tempdir"); + let temp = TemporaryDirectory::new().expect("tempdir"); let paths = RuntimePaths::with_root(temp.path().join("home")); fs::create_dir_all(&paths.run_root).expect("create run root"); fs::create_dir_all(&paths.update_root).expect("create update root"); diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index f45c4e60..130cef25 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -846,8 +846,8 @@ mod tests { } /// Helper: create a temp dir, start the server, return (addr, dir). - async fn setup_server() -> (SocketAddr, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); + async fn setup_server() -> (SocketAddr, kernal_api::platform::fs::TemporaryDirectory) { + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let addr = start_server(dir.path().to_path_buf(), 0, None, empty_handle(), None) .await .unwrap(); @@ -963,7 +963,7 @@ mod tests { #[tokio::test] async fn test_runtime_endpoints_use_preconfigured_screenshot_paths() { - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); fs::write( dir.path().join("fastled_background_worker.js"), "console.log('worker');", @@ -1266,7 +1266,7 @@ mod tests { #[tokio::test] async fn test_sse_endpoint_streams_events() { - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let (tx, _rx) = async_engine::broadcast_channel::(16).unwrap(); let addr = start_server( dir.path().to_path_buf(), @@ -1415,7 +1415,7 @@ mod tests { async fn dwarfsource_returns_resolved_file() { use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch_dir = dir.path().join("sketch"); fs::create_dir_all(sketch_dir.join("src")).unwrap(); let sketch_file = sketch_dir.join("src").join("demo.ino"); @@ -1454,7 +1454,7 @@ mod tests { async fn source_map_style_get_returns_resolved_file() { use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let serve_dir = dir.path().join("fastled_js"); let sketch_dir = dir.path().join("sketch"); fs::create_dir_all(sketch_dir.join("src")).unwrap(); @@ -1493,7 +1493,7 @@ mod tests { DebugSymbolResolver, }; - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let serve_dir = dir.path().join("fastled_js"); let sketch_dir = dir.path().join("sketch"); fs::create_dir_all(sketch_dir.join("src")).unwrap(); @@ -1524,7 +1524,7 @@ mod tests { async fn dwarfsource_rejects_traversal() { use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; - let dir = tempfile::tempdir().unwrap(); + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch_dir = dir.path().join("sketch"); fs::create_dir_all(&sketch_dir).unwrap(); let resolver = DebugSymbolResolver::new(load_debug_symbol_config(sketch_dir, None, None)); diff --git a/crates/fastled-cli/src/sketch_preprocessor.rs b/crates/fastled-cli/src/sketch_preprocessor.rs index accbaba0..a08e4560 100644 --- a/crates/fastled-cli/src/sketch_preprocessor.rs +++ b/crates/fastled-cli/src/sketch_preprocessor.rs @@ -550,7 +550,7 @@ mod tests { #[test] fn orders_primary_then_other_top_level_tabs_and_maps_each_tab() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch = temp.path().join("Blink"); fs::create_dir_all(sketch.join("nested")).unwrap(); fs::write( @@ -592,7 +592,7 @@ mod tests { #[test] fn live_snapshot_uses_unsaved_text_and_never_overwrites_sketch() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch = temp.path().join("Sketch"); fs::create_dir_all(&sketch).unwrap(); let source = sketch.join("Sketch.ino"); @@ -626,7 +626,7 @@ mod tests { #[test] fn invalid_new_buffer_preserves_last_known_good_snapshot() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch = temp.path().join("Sketch"); fs::create_dir_all(&sketch).unwrap(); let source = sketch.join("Sketch.ino"); @@ -661,7 +661,7 @@ mod tests { #[test] fn older_generation_cannot_replace_newer_snapshot() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch = temp.path().join("Sketch"); fs::create_dir_all(&sketch).unwrap(); let source = sketch.join("Sketch.ino"); @@ -696,7 +696,7 @@ mod tests { #[test] fn disk_topology_refresh_replaces_a_previous_live_generation() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch = temp.path().join("Sketch"); fs::create_dir_all(&sketch).unwrap(); let source = sketch.join("Sketch.ino"); diff --git a/crates/fastled-cli/src/source.rs b/crates/fastled-cli/src/source.rs index c0ea155c..397f3c48 100644 --- a/crates/fastled-cli/src/source.rs +++ b/crates/fastled-cli/src/source.rs @@ -318,7 +318,7 @@ fn unique_sibling(cache_base: &Path, requested_ref: &str, purpose: &str) -> Resu #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; + use kernal_api::platform::fs::TemporaryDirectory; fn receipt(ref_name: &str, fetched_at: SystemTime) -> SourceReceipt { SourceReceipt::new( @@ -332,7 +332,7 @@ mod tests { #[test] fn master_warning_uses_exact_command_and_dynamic_days() { - let cache = TempDir::new().unwrap(); + let cache = TemporaryDirectory::new().unwrap(); let now = UNIX_EPOCH + Duration::from_secs(5 * 24 * 60 * 60); let old = receipt("master", now - Duration::from_secs(2 * 24 * 60 * 60)); update_checkout(cache.path(), &old, |staging| { @@ -351,7 +351,7 @@ mod tests { #[test] fn master_freshness_uses_a_strict_24_hour_boundary() { - let cache = TempDir::new().unwrap(); + let cache = TemporaryDirectory::new().unwrap(); let now = UNIX_EPOCH + Duration::from_secs(10 * 24 * 60 * 60); for (ref_name, age, warns) in [ ("master", Duration::from_secs(23 * 60 * 60), false), @@ -370,7 +370,7 @@ mod tests { #[test] fn master_without_receipt_is_stale() { - let cache = TempDir::new().unwrap(); + let cache = TemporaryDirectory::new().unwrap(); let repo = repo_dir(cache.path(), "master").unwrap(); fs::create_dir_all(&repo).unwrap(); fs::write(repo.join("library.json"), "{}").unwrap(); @@ -385,7 +385,7 @@ mod tests { #[test] fn tags_shas_and_non_master_branches_never_warn() { - let cache = TempDir::new().unwrap(); + let cache = TemporaryDirectory::new().unwrap(); let now = SystemTime::now(); for ref_name in ["3.10.0", "abcdef0", "main"] { let old = receipt(ref_name, now - Duration::from_secs(7 * 24 * 60 * 60)); @@ -400,7 +400,7 @@ mod tests { #[test] fn failed_staged_update_preserves_existing_checkout() { - let cache = TempDir::new().unwrap(); + let cache = TemporaryDirectory::new().unwrap(); let old = receipt("master", SystemTime::now()); let live = update_checkout(cache.path(), &old, |staging| { fs::write(staging.join("library.json"), "{}").context("write library")?; @@ -417,7 +417,7 @@ mod tests { #[test] fn update_replaces_checkout_and_receipt_together() { - let cache = TempDir::new().unwrap(); + let cache = TemporaryDirectory::new().unwrap(); let old = receipt("master", UNIX_EPOCH + Duration::from_secs(1)); update_checkout(cache.path(), &old, |staging| { fs::write(staging.join("library.json"), "{}").context("write library")?; @@ -437,7 +437,7 @@ mod tests { #[test] fn purge_is_scoped_to_one_safe_checkout() { - let cache = TempDir::new().unwrap(); + let cache = TemporaryDirectory::new().unwrap(); let master = repo_dir(cache.path(), "master").unwrap(); let tag = repo_dir(cache.path(), "3.10.0").unwrap(); fs::create_dir_all(&master).unwrap(); diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index 434158eb..b6adde5b 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -615,7 +615,7 @@ mod tests { #[tokio::test] async fn issue_200_commands_run_sequentially_and_capture_both_streams() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); #[cfg(windows)] let commands = vec![ "echo A>order.txt".to_string(), @@ -662,7 +662,7 @@ mod tests { #[tokio::test] async fn issue_200_nonzero_command_stops_the_sequence() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); #[cfg(windows)] let commands = vec![ "exit /b 7".to_string(), @@ -699,7 +699,7 @@ mod tests { #[tokio::test] async fn issue_208_runner_finishes_after_closing_descendant_pipe_writers() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); #[cfg(windows)] let command = "start /B cmd /C \"ping -n 20 127.0.0.1 >NUL & echo LATE > late.txt\""; #[cfg(not(windows))] @@ -728,7 +728,7 @@ mod tests { #[tokio::test] async fn issue_200_cancelling_runner_kills_the_contained_shell() { for drop_handle in [false, true] { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); #[cfg(windows)] let command = "echo READY & ping -n 3 127.0.0.1 >NUL & echo LATE > late.txt"; #[cfg(not(windows))] diff --git a/crates/fastled-cli/src/viewer.rs b/crates/fastled-cli/src/viewer.rs index 4d02ea1e..933f46ee 100644 --- a/crates/fastled-cli/src/viewer.rs +++ b/crates/fastled-cli/src/viewer.rs @@ -267,8 +267,8 @@ fn is_fastled_binary(path: &Path) -> bool { #[cfg(test)] mod tests { use super::*; + use kernal_api::platform::fs::TemporaryDirectory; use std::fs; - use tempfile::TempDir; #[test] fn test_viewer_available_does_not_panic() { @@ -295,7 +295,7 @@ mod tests { fn test_find_viewer_in_arch_dirs_finds_binary() { // Set up a fake target tree: // /target/x86_64-pc-windows-msvc/release/fastled[.exe] - let tmp = TempDir::new().expect("tempdir"); + let tmp = TemporaryDirectory::new().expect("tempdir"); let target = tmp.path().join("target"); let arch_dir = target.join("x86_64-pc-windows-msvc").join("release"); fs::create_dir_all(&arch_dir).expect("mkdir arch_dir"); @@ -309,7 +309,7 @@ mod tests { #[test] fn test_find_viewer_in_arch_dirs_skips_dotfiles() { // A hidden `.cache` dir should not be scanned. - let tmp = TempDir::new().expect("tempdir"); + let tmp = TemporaryDirectory::new().expect("tempdir"); let target = tmp.path().join("target"); let hidden = target.join(".cache").join("debug"); fs::create_dir_all(&hidden).expect("mkdir hidden"); @@ -320,7 +320,7 @@ mod tests { #[test] fn test_find_viewer_in_arch_dirs_returns_none_for_empty_tree() { - let tmp = TempDir::new().expect("tempdir"); + let tmp = TemporaryDirectory::new().expect("tempdir"); let target = tmp.path().join("target"); fs::create_dir_all(&target).expect("mkdir target"); assert!(find_viewer_in_arch_dirs(&target).is_none()); @@ -328,7 +328,7 @@ mod tests { #[test] fn test_find_viewer_in_arch_dirs_missing_target() { - let tmp = TempDir::new().expect("tempdir"); + let tmp = TemporaryDirectory::new().expect("tempdir"); let missing = tmp.path().join("does-not-exist"); assert!(find_viewer_in_arch_dirs(&missing).is_none()); } diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index 060185d9..2a988853 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -2620,7 +2620,7 @@ mod tests { #[test] fn managed_runtime_bin_exposes_python_spellings_and_node() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let install = temp.path().join("toolchain"); let tools = temp.path().join("tools"); fs::create_dir_all(&tools).unwrap(); @@ -2651,7 +2651,7 @@ mod tests { #[test] fn managed_runtime_path_prepends_without_dropping_caller_path() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let runtime_bin = temp.path().join("runtime-bin"); let managed_bin = temp.path().join("managed-bin"); let caller_bin = temp.path().join("caller-bin"); @@ -2793,7 +2793,7 @@ mod tests { #[test] fn direct_side_link_is_strictly_version_mode_and_abi_gated() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let tools = fake_tools_for_direct_link(temp.path(), "4.0.19"); assert!(direct_side_link_supported( &tools, @@ -2820,7 +2820,7 @@ mod tests { #[test] fn direct_side_link_plan_preserves_side_module_one_contract() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let tools = fake_tools_for_direct_link(temp.path(), "4.0.19"); let args = direct_side_link_args(&tools, Path::new("sketch.o"), Path::new("sketch.wasm")); assert!(args.contains(&"--whole-archive".to_string())); @@ -2831,7 +2831,7 @@ mod tests { #[test] fn dynamic_output_does_not_recopy_unchanged_runtime() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let runtime = temp.path().join("runtime"); let sketch = temp.path().join("sketch"); let output = temp.path().join("output"); @@ -2909,7 +2909,7 @@ mod tests { #[test] fn runtime_source_fingerprint_includes_js_library_and_meson_helpers() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path(); for (relative, contents) in [ ("src/core.cpp", "core"), @@ -2941,7 +2941,7 @@ mod tests { #[test] fn library_archive_validation_rejects_missing_empty_and_truncated_files() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let archive = temp.path().join("libfastled.a"); assert!(!library_archive_is_valid(&archive)); fs::write(&archive, []).unwrap(); @@ -2956,7 +2956,7 @@ mod tests { #[test] fn debug_compile_flags_include_dynamic_dwarf_prefix_maps() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let fastled_dir = tmp.path().join("FastLED"); let sketch_dir = tmp.path().join("Blink"); let emsdk_root = tmp.path().join("emsdk"); @@ -3007,7 +3007,7 @@ dwarf_prefix = "dwarfsource" #[test] fn quick_compile_flags_do_not_include_dwarf_prefix_maps() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let fastled_dir = tmp.path().join("FastLED"); write_build_flags( &fastled_dir, @@ -3039,7 +3039,7 @@ flags = ["-g0"] #[test] fn debug_link_flags_emit_wasm_source_map() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let fastled_dir = tmp.path().join("FastLED"); write_build_flags( &fastled_dir, @@ -3062,7 +3062,7 @@ link_flags = [] #[test] fn copy_linked_output_copies_and_removes_source_maps() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let cache = tmp.path().join("cache"); let output = tmp.path().join("out").join("fastled.js"); fs::create_dir_all(&cache).unwrap(); @@ -3084,7 +3084,7 @@ link_flags = [] #[test] fn copy_linked_output_copies_dynamic_side_module_and_removes_it_when_stale() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let cache = tmp.path().join("cache"); let output = tmp.path().join("out").join("fastled.js"); fs::create_dir_all(&cache).unwrap(); @@ -3106,7 +3106,7 @@ link_flags = [] #[test] fn generate_manifest_uses_new_name_and_removes_legacy_name() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let example = tmp.path().join("Sketch"); let output = example.join("fastled_js"); fs::create_dir_all(example.join("data")).unwrap(); @@ -3130,7 +3130,7 @@ link_flags = [] #[test] fn generate_manifest_resolves_lnk_and_assets_json_for_the_wasm_vfs() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let example = tmp.path().join("Sketch"); let data = example.join("data"); let output = example.join("fastled_js"); @@ -3211,7 +3211,7 @@ link_flags = [] #[test] fn wrapper_compiles_canonical_multi_tab_ino_translation_unit() { - let tmp = tempfile::tempdir().unwrap(); + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let sketch = tmp.path().join("Blink"); fs::create_dir_all(&sketch).unwrap(); fs::write( diff --git a/crates/fastled-cli/src/watcher.rs b/crates/fastled-cli/src/watcher.rs index a6e48900..0c01e683 100644 --- a/crates/fastled-cli/src/watcher.rs +++ b/crates/fastled-cli/src/watcher.rs @@ -334,12 +334,12 @@ fn path_contains_ignored(path: &Path, ignored: &[String]) -> bool { #[cfg(test)] mod tests { use super::*; + use kernal_api::platform::fs::TemporaryDirectory; use std::fs; use std::time::{Duration, Instant}; - use tempfile::TempDir; - fn temp_dir() -> TempDir { - tempfile::tempdir().expect("tempdir") + fn temp_dir() -> TemporaryDirectory { + kernal_api::platform::fs::TemporaryDirectory::new().expect("tempdir") } // ------------------------------------------------------------------ diff --git a/crates/fastled-cli/tests/cache_upgrade_cli.rs b/crates/fastled-cli/tests/cache_upgrade_cli.rs index 490fbf65..2b5b3e5d 100644 --- a/crates/fastled-cli/tests/cache_upgrade_cli.rs +++ b/crates/fastled-cli/tests/cache_upgrade_cli.rs @@ -12,7 +12,7 @@ fn run_fastled(root: &std::path::Path) -> Output { #[test] fn cli_upgrade_clears_the_shared_cache_exactly_once_per_version() { - let temp = tempfile::tempdir().unwrap(); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); let root = temp.path(); fs::create_dir_all(root.join("cache")).unwrap(); fs::write(root.join("cache/stale.txt"), "stale").unwrap(); diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 00a5c194..dec6e677 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -561,9 +561,12 @@ viewer feature build failure hidden by the upstream checkout's Git patches. The registry-only correction and all-features packaging gate are tracked in https://github.com/zackees/kernal-api/pull/179. The corrected graph passes six native Linux browser scenarios, targeted Rust tests, strict Clippy and -formatting. Full extracted-package verification and cross-platform CI remain -pending at this checkpoint; do not remove the migration patch until a usable -release actually exists. Publishing credentials also need configuration. +formatting. Full all-features extracted-package verification now passes, and +PR #179 is merged after green cross-platform CI, including the native Windows +external-page isolation proof. Native macOS execution remains disabled by +repository policy; cross-compilation is not a substitute for that proof. +Do not remove the migration patch until a usable release actually exists. +Publishing credentials still need configuration. Next capability gaps include bounded terminal key polling and styling (https://github.com/zackees/kernal-api/issues/178) and secure entropy @@ -588,7 +591,7 @@ by the app boundary test (observed RED then GREEN). The transitive 0.4.2 pin updates to the kernel-selected 0.4.3; its obsolete WASI 0.3 binding packages leave the lockfile, but other transitive random dependencies remain. -During this slice the migration-only patch points at +During the entropy slice the migration-only patch pointed at `../fastled-wasm-extern/kernal-api-entropy` on `feat/secure-entropy`, not the original sibling checkout. Restore the canonical checkout after upstream merge, then remove the patch entirely when adopting the exact published release. @@ -597,6 +600,30 @@ token-format test pass after moving its test module to the end of the file. The single reviewer confirmed the corrected async integration; Python tests pass (28 passed, one skipped). This is not published adoption. +### Temporary-directory adoption + +FastLED now uses the kernel-owned `TemporaryDirectory` for production staging, +downloads and test fixtures. Direct `tempfile` is removed from the manifest and +its direct lockfile edge moves to the kernel; the backend remains transitive. +The boundary test bans direct manifest and source use (observed RED then GREEN). +Cache naming, publication by rename, invalid-entry handling and download policy +stay in FastLED. This does not change compiler flags or native build logic. + +The upstream guard and generic lifecycle tests are in +https://github.com/zackees/kernal-api/pull/183 (issue #182), stacked on entropy +PR #181. Five Linux tests cover lifecycle, prefix validation, absolute-path +cleanup across cwd changes, restrictive creation permissions and symlink +cleanup. Full fs tests pass serially, strict Clippy and default-feature checks +pass, and the upstream review is clean. The Windows exclusive-handle cleanup +test awaits native CI. Native recursive cleanup has no wall-clock bound and +drop cleanup remains best-effort; persistence explicitly transfers ownership. + +The current migration-only patch points at +`../fastled-wasm-extern/kernal-api-temporary` on `feat/temporary-directories`. +FastLED's full Rust workspace tests, strict all-target Clippy and formatting +pass; Python tests pass (28 passed, one skipped). This remains local adoption, +not an exact published dependency, and no build-speed improvement is claimed. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 29605644..505ee09f 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -24,6 +24,8 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "axum" not in dependencies assert "tower-http" not in dependencies assert "getrandom" not in dependencies + assert "tempfile" not in dependencies + assert "tempfile" not in manifest.get("dev-dependencies", {}) for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies for target in manifest.get("target", {}).values(): @@ -38,6 +40,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "sha2::" not in source.read_text(), source assert "dirs::" not in source.read_text(), source assert "getrandom::" not in source.read_text(), source + assert "tempfile::" not in source.read_text(), source for backend in ("zip", "tar", "zstd", "flate2"): assert f"{backend}::" not in source.read_text(), source From 61993b4e10abe32c5ff91804a3d321a5b13f01ae Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 01:31:11 -0700 Subject: [PATCH 40/94] refactor: score sketch suggestions through kernal-api --- Cargo.lock | 2 +- Cargo.toml | 5 ++- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/lib.rs | 16 ++++++--- crates/fastled-cli/src/project.rs | 50 ++++++++++++++++++++++------- crates/fastled-cli/src/selection.rs | 29 +++++++++-------- docs/kernal-api-migration.md | 24 ++++++++++++++ tests/unit/test_kernal_boundary.py | 2 ++ 8 files changed, 94 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6914445f..0d708f67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,7 +1052,6 @@ dependencies = [ "serde", "serde_json", "shell-words", - "strsim", "tauri", "tauri-build", "tokio", @@ -2154,6 +2153,7 @@ dependencies = [ "reqwest 0.12.28", "running-process", "sha2", + "strsim", "sysinfo", "tar", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 7cfd784d..cf10f5d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,12 +14,11 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" ctcb-core = "0.4.1" -strsim = "0.11" crossterm = "0.28" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "signal"] } toml = "0.8" @@ -36,7 +35,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-temporary" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-text" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 40b2cfc0..adfa0d9a 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -27,7 +27,6 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } ctcb-core = { workspace = true } -strsim = { workspace = true } crossterm = { workspace = true } tokio = { workspace = true } toml = { workspace = true } diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index 15d61579..a82f7cf2 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -413,16 +413,24 @@ mod tests { #[test] fn resolve_prompt_choice_accepts_default_selection() { let options = vec!["Blink".to_string(), "Noise".to_string()]; - match resolve_prompt_choice("", &options, 1) { + match resolve_prompt_choice("", &options, 1).unwrap() { PromptChoice::Selected(choice) => assert_eq!(choice, "Noise"), _ => panic!("expected default selection"), } } + #[test] + fn resolve_prompt_choice_propagates_similarity_limit_errors() { + let options = vec!["examples/Blink".to_string(), "examples/Fire".to_string()]; + let error = resolve_prompt_choice(&"x".repeat(4097), &options, 0).unwrap_err(); + assert!(error.contains("score sketch-name suggestion")); + assert!(error.contains("byte limit")); + } + #[test] fn resolve_prompt_choice_accepts_numeric_selection() { let options = vec!["Blink".to_string(), "Noise".to_string()]; - match resolve_prompt_choice("2", &options, 0) { + match resolve_prompt_choice("2", &options, 0).unwrap() { PromptChoice::Selected(choice) => assert_eq!(choice, "Noise"), _ => panic!("expected numeric selection"), } @@ -435,7 +443,7 @@ mod tests { "Fire2012WithPalette".to_string(), "Blink".to_string(), ]; - match resolve_prompt_choice("Fire", &options, 0) { + match resolve_prompt_choice("Fire", &options, 0).unwrap() { PromptChoice::Narrowed(matches) => { assert_eq!( matches, @@ -453,7 +461,7 @@ mod tests { "examples/BeatDetection".to_string(), "examples/Blink".to_string(), ]; - match resolve_prompt_choice("beats", &options, 0) { + match resolve_prompt_choice("beats", &options, 0).unwrap() { PromptChoice::Selected(choice) => { assert_eq!(choice, "examples/BeatDetection"); } diff --git a/crates/fastled-cli/src/project.rs b/crates/fastled-cli/src/project.rs index 2ed1a05a..a79c938f 100644 --- a/crates/fastled-cli/src/project.rs +++ b/crates/fastled-cli/src/project.rs @@ -639,7 +639,7 @@ fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { /// Find the best-matching sketch name from `candidates` for a given `query`. /// -/// Uses the Jaro-Winkler distance (via `strsim`) to score each candidate. +/// Uses kernel-owned Jaro-Winkler similarity to score each candidate. /// Returns the candidate(s) with the highest score. /// /// When `query` is an exact substring of a candidate that candidate is @@ -647,9 +647,10 @@ fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { /// fast-path in `sketch.py::find_sketch_by_partial_name`). /// /// Returns an empty `Vec` when `candidates` is empty. -pub fn best_sketch_match(query: &str, candidates: &[&str]) -> Vec { +/// Returns an error if fuzzy scoring exceeds the kernel's resource limits. +pub fn best_sketch_match(query: &str, candidates: &[&str]) -> Result> { if candidates.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let q_lower = query.to_lowercase(); @@ -661,25 +662,29 @@ pub fn best_sketch_match(query: &str, candidates: &[&str]) -> Vec { .filter(|c| c.to_lowercase().contains(&q_lower)) .collect(); if !substring_hits.is_empty() { - return substring_hits.iter().map(|s| s.to_string()).collect(); + return Ok(substring_hits.iter().map(|s| s.to_string()).collect()); } // Fuzzy: Jaro-Winkler distance. let mut scored: Vec<(f64, &str)> = candidates .iter() - .map(|c| (strsim::jaro_winkler(&q_lower, &c.to_lowercase()), *c)) - .collect(); + .map(|c| { + let score = kernal_api::text::name_similarity(&q_lower, &c.to_lowercase()) + .context("score sketch-name suggestion")?; + Ok((score, *c)) + }) + .collect::>()?; // Sort descending by score. scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); let best_score = scored[0].0; // Return all candidates tied at the best score (within floating-point epsilon). - scored + Ok(scored .iter() .take_while(|(s, _)| (s - best_score).abs() < 1e-9) .map(|(_, name)| name.to_string()) - .collect() + .collect()) } /// Find a sketch directory by partial name match using the Python-side rules. @@ -1063,10 +1068,31 @@ mod tests { // best_sketch_match (fuzzy matching) // ------------------------------------------------------------------ + #[test] + fn sketch_matching_preserves_substring_precedence_and_stable_ties() { + assert_eq!( + best_sketch_match("Blink", &["Blin", "BlinkFast", "Blink"]).unwrap(), + ["BlinkFast", "Blink"] + ); + assert_eq!( + best_sketch_match("BLNK", &["Blink", "BLINK", "Fire"]).unwrap(), + ["Blink", "BLINK"] + ); + } + + #[test] + fn sketch_matching_reports_resource_errors_without_partial_ranking() { + let error = best_sketch_match(&"x".repeat(4097), &["Blink"]).unwrap_err(); + assert!(format!("{error:#}").contains("byte limit")); + let candidate = "y".repeat(1024); + let error = best_sketch_match(&"x".repeat(1025), &["Blink", &candidate]).unwrap_err(); + assert!(format!("{error:#}").contains("work limit")); + } + #[test] fn test_best_sketch_match_exact_substring() { let candidates = ["Blink", "BlinkFast", "Fire2012"]; - let matches = best_sketch_match("Blink", &candidates); + let matches = best_sketch_match("Blink", &candidates).unwrap(); assert!( matches.contains(&"Blink".to_owned()), "Blink should match: {matches:?}" @@ -1084,7 +1110,7 @@ mod tests { #[test] fn test_best_sketch_match_fuzzy_fallback() { let candidates = ["Blink", "Fire2012", "Noise"]; - let matches = best_sketch_match("blnk", &candidates); + let matches = best_sketch_match("blnk", &candidates).unwrap(); // Jaro-Winkler should rank "Blink" highest. assert!(!matches.is_empty(), "fuzzy match should return results"); assert_eq!(matches[0], "Blink", "best fuzzy match should be Blink"); @@ -1092,14 +1118,14 @@ mod tests { #[test] fn test_best_sketch_match_empty_candidates() { - let matches = best_sketch_match("anything", &[]); + let matches = best_sketch_match("anything", &[]).unwrap(); assert!(matches.is_empty()); } #[test] fn test_best_sketch_match_case_insensitive() { let candidates = ["Blink", "Fire2012"]; - let matches = best_sketch_match("BLINK", &candidates); + let matches = best_sketch_match("BLINK", &candidates).unwrap(); assert!( matches.contains(&"Blink".to_owned()), "case-insensitive match: {matches:?}" diff --git a/crates/fastled-cli/src/selection.rs b/crates/fastled-cli/src/selection.rs index 26cee2b1..018c7456 100644 --- a/crates/fastled-cli/src/selection.rs +++ b/crates/fastled-cli/src/selection.rs @@ -25,15 +25,16 @@ fn option_basename(option: &str) -> &str { .unwrap_or(option) } -fn fuzzy_match_options(input: &str, options: &[String]) -> Vec { +fn fuzzy_match_options(input: &str, options: &[String]) -> Result, String> { let basenames: Vec = options .iter() .map(|option| option_basename(option).to_string()) .collect(); let basename_refs: Vec<&str> = basenames.iter().map(String::as_str).collect(); - let fuzzy_matches = project::best_sketch_match(input, &basename_refs); + let fuzzy_matches = + project::best_sketch_match(input, &basename_refs).map_err(|error| format!("{error:#}"))?; if fuzzy_matches.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let mut results = Vec::new(); @@ -42,22 +43,22 @@ fn fuzzy_match_options(input: &str, options: &[String]) -> Vec { results.push(options[index].clone()); } } - results + Ok(results) } pub fn resolve_prompt_choice( input: &str, options: &[String], default_index: usize, -) -> PromptChoice { +) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { - return PromptChoice::Selected(options[default_index].clone()); + return Ok(PromptChoice::Selected(options[default_index].clone())); } if let Ok(index) = trimmed.parse::() { if (1..=options.len()).contains(&index) { - return PromptChoice::Selected(options[index - 1].clone()); + return Ok(PromptChoice::Selected(options[index - 1].clone())); } } @@ -65,7 +66,7 @@ pub fn resolve_prompt_choice( .iter() .find(|option| option.eq_ignore_ascii_case(trimmed)) { - return PromptChoice::Selected(exact.clone()); + return Ok(PromptChoice::Selected(exact.clone())); } let input_lower = trimmed.to_lowercase(); @@ -75,17 +76,17 @@ pub fn resolve_prompt_choice( .cloned() .collect(); match partial_matches.len() { - 1 => return PromptChoice::Selected(partial_matches[0].clone()), - n if n > 1 => return PromptChoice::Narrowed(partial_matches), + 1 => return Ok(PromptChoice::Selected(partial_matches[0].clone())), + n if n > 1 => return Ok(PromptChoice::Narrowed(partial_matches)), _ => {} } - let fuzzy_matches = fuzzy_match_options(trimmed, options); - match fuzzy_matches.len() { + let fuzzy_matches = fuzzy_match_options(trimmed, options)?; + Ok(match fuzzy_matches.len() { 1 => PromptChoice::Selected(fuzzy_matches[0].clone()), n if n > 1 => PromptChoice::Narrowed(fuzzy_matches), _ => PromptChoice::Retry, - } + }) } pub fn prepare_sketch_selection( @@ -150,7 +151,7 @@ pub(crate) fn prompt_for_choice( .read_line(&mut input) .map_err(|err| format!("failed to read selection: {err}"))?; - match resolve_prompt_choice(&input, ¤t_options, current_default) { + match resolve_prompt_choice(&input, ¤t_options, current_default)? { PromptChoice::Selected(choice) => return Ok(choice), PromptChoice::Narrowed(matches) => { let query = input.trim(); diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index dec6e677..3c2cd1c1 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -624,6 +624,30 @@ FastLED's full Rust workspace tests, strict all-target Clippy and formatting pass; Python tests pass (28 passed, one skipped). This remains local adoption, not an exact published dependency, and no build-speed improvement is claimed. +### Name-similarity adoption checkpoint + +Issue https://github.com/zackees/kernal-api/issues/184 moves Jaro-Winkler scoring +behind the opt-in kernel text capability. FastLED retains lowercase conversion, +substring precedence, candidate selection, stable best-score ties and prompts. +Direct `strsim` is removed and banned by the boundary test (observed RED then +GREEN); the package remains transitive through the kernel and other consumers. + +`best_sketch_match` and `resolve_prompt_choice` are now fallible public helpers. +Scoring rejects inputs over 4096 UTF-8 bytes or scalar-count products over +1,048,576. Errors propagate through the interactive prompt; no partial ranking, +truncation or replacement score is used. Existing exact/substring fast paths +remain application policy and do not invoke fuzzy scoring. Generic Unicode, +score-vector and resource-limit tests live upstream; application tests cover +substring precedence, stable ties and error propagation. + +The current migration-only patch points at +`../fastled-wasm-extern/kernal-api-text` on `feat/text-similarity`. +Upstream focused and broad feature tests, default-feature check, strict Clippy +and dependency-isolation checks pass locally. Python tests pass (28 passed, +one skipped). Full application Rust tests and strict all-target Clippy pass; +the cross-repository source review is clean. Publication and exact adoption remain required; +no speed improvement is claimed. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 505ee09f..dfb15425 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -25,6 +25,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "tower-http" not in dependencies assert "getrandom" not in dependencies assert "tempfile" not in dependencies + assert "strsim" not in dependencies assert "tempfile" not in manifest.get("dev-dependencies", {}) for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies @@ -41,6 +42,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "dirs::" not in source.read_text(), source assert "getrandom::" not in source.read_text(), source assert "tempfile::" not in source.read_text(), source + assert "strsim::" not in source.read_text(), source for backend in ("zip", "tar", "zstd", "flate2"): assert f"{backend}::" not in source.read_text(), source From 85c8b8d7de53017c15abb61f70f47a941524ba10 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 02:47:20 -0700 Subject: [PATCH 41/94] refactor: adopt kernel terminal input and styling (refs #229) --- Cargo.lock | 76 +--------- Cargo.toml | 5 +- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/commands.rs | 45 +++++- crates/fastled-cli/src/compile_stream.rs | 4 +- crates/fastled-cli/src/diagnostics.rs | 47 ++++++ crates/fastled-cli/src/keyboard.rs | 174 ++++++++--------------- crates/fastled-cli/src/lib.rs | 1 + crates/fastled-cli/src/source.rs | 2 +- crates/fastled-cli/src/wasm_build.rs | 2 +- docs/kernal-api-migration.md | 31 ++++ tests/unit/test_kernal_boundary.py | 2 + 12 files changed, 191 insertions(+), 199 deletions(-) create mode 100644 crates/fastled-cli/src/diagnostics.rs diff --git a/Cargo.lock b/Cargo.lock index 0d708f67..75490d32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -665,31 +665,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.11.0", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -1045,7 +1020,6 @@ version = "2.0.20" dependencies = [ "anyhow", "clap", - "crossterm", "ctcb-core", "gtk", "kernal-api", @@ -1390,7 +1364,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix", "windows-link 0.2.1", ] @@ -2263,12 +2237,6 @@ dependencies = [ "redox_syscall 0.7.4", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3351,7 +3319,7 @@ checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" dependencies = [ "cfg-if", "libc", - "rustix 1.1.4", + "rustix", "windows 0.61.3", ] @@ -3501,19 +3469,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -3523,7 +3478,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.59.0", ] @@ -3907,27 +3862,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4479,7 +4413,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.59.0", ] @@ -5840,7 +5774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.4", + "rustix", "x11rb-protocol", ] diff --git a/Cargo.toml b/Cargo.toml index cf10f5d5..ebd26140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,12 +14,11 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" ctcb-core = "0.4.1" -crossterm = "0.28" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "signal"] } toml = "0.8" tauri = { version = "2", features = ["devtools"] } @@ -35,7 +34,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-text" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-terminal" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index adfa0d9a..b5fd71be 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -27,7 +27,6 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } ctcb-core = { workspace = true } -crossterm = { workspace = true } tokio = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index d7a972a1..583bce8d 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -138,7 +138,37 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { }; let rx = file_watcher.start(); + let ctrl_c = tokio::signal::ctrl_c(); + tokio::pin!(ctrl_c); + let mut rebuild_keys = keyboard::RebuildKeys::default(); + let mut watch_exit = ExitCode::SUCCESS; + let mut input_tick = match async_engine::PeriodicTimer::new(std::time::Duration::from_millis(100)) { + Ok(timer) => timer, + Err(error) => { + eprintln!("fastled: could not start watch polling: {error}"); + file_watcher.stop(); + return ExitCode::FAILURE; + } + }; + loop { + // Poll Ctrl+C first so its handler is registered before lazy key + // capture changes terminal modes. In-flight builds still finish + // before the next loop tick handles a pending interruption. + tokio::select! { + biased; + interrupted = &mut ctrl_c => { + watch_exit = match interrupted { + Ok(()) => ExitCode::from(130), + Err(error) => { + eprintln!("fastled: Ctrl+C monitoring failed: {error}"); + ExitCode::FAILURE + } + }; + break; + } + _ = input_tick.tick() => {} + } // The viewer window is the app from the user's perspective: once // it is gone (closed or crashed), shut the CLI down cleanly. An // in-flight compile always finishes first because this check only @@ -148,7 +178,7 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { break; } - let should_rebuild = match rx.recv_timeout(std::time::Duration::from_secs(1)) { + let should_rebuild = match rx.try_recv() { Ok(batch) => { println!( "\nChanges detected in {:?}{}", @@ -175,8 +205,14 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { } true } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - let manual = keyboard::check_for_space(); + Err(std::sync::mpsc::TryRecvError::Empty) => { + let manual = match rebuild_keys.poll() { + Ok(manual) => manual, + Err(error) => { + eprintln!("fastled: manual rebuild input disabled: {error}"); + false + } + }; if manual { if let Err(error) = dynamic_cache::invalidate_all_persistent_fingerprints() { eprintln!("fastled: persistent fingerprint invalidation failed; falling back to full scans: {error:#}"); @@ -206,8 +242,9 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { } } + drop(rebuild_keys); file_watcher.stop(); - ExitCode::SUCCESS + watch_exit }) } diff --git a/crates/fastled-cli/src/compile_stream.rs b/crates/fastled-cli/src/compile_stream.rs index 8d41001f..94ac0e1e 100644 --- a/crates/fastled-cli/src/compile_stream.rs +++ b/crates/fastled-cli/src/compile_stream.rs @@ -102,7 +102,7 @@ pub(crate) fn announce_link_mode(cli: &Cli, tx: Option<&BroadcastSender> println!("{}", link_mode_announcement(mode)); if let Some(warning) = migration_warning(cli) { if std::io::stderr().is_terminal() { - eprintln!("{}", crossterm::style::Stylize::yellow(warning)); + eprintln!("{}", crate::diagnostics::yellow_warning(warning)); } else { eprintln!("{warning}"); } @@ -223,7 +223,7 @@ pub(crate) fn send_sse(tx: &BroadcastSender, json: &str) { pub(crate) fn emit_build_log(tx: &BroadcastSender, line: &str, stream: &str) { if stream == "warning" && std::io::stderr().is_terminal() { - eprintln!("{}", crossterm::style::Stylize::yellow(line)); + eprintln!("{}", crate::diagnostics::yellow_warning(line)); } else if stream == "stderr" || stream == "warning" { eprintln!("{line}"); } else { diff --git a/crates/fastled-cli/src/diagnostics.rs b/crates/fastled-cli/src/diagnostics.rs new file mode 100644 index 00000000..f6dc71dd --- /dev/null +++ b/crates/fastled-cli/src/diagnostics.rs @@ -0,0 +1,47 @@ +//! FastLED warning-color policy; encoding and native setup belong to the kernel. + +use kernal_api::terminal_style::{self, Foreground, StyledText}; +use std::sync::OnceLock; + +pub(crate) fn yellow_warning(text: &str) -> StyledText<'_> { + static ENABLED: OnceLock = OnceLock::new(); + let enabled = *ENABLED.get_or_init(|| { + if no_color_requested(std::env::var("NO_COLOR").ok().as_deref()) { + return false; + } + match terminal_style::prepare_stderr_ansi() { + Ok(prepared) => prepared || std::env::var("TERM").is_ok_and(|term| term != "dumb"), + Err(error) => { + eprintln!("fastled: terminal color unavailable; using plain warnings: {error}"); + false + } + } + }); + StyledText::new(text, Foreground::Yellow, enabled) +} + +fn no_color_requested(value: Option<&str>) -> bool { + value.is_some_and(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_color_policy_and_warning_content_are_preserved() { + assert!(!no_color_requested(None)); + assert!(!no_color_requested(Some(""))); + assert!(no_color_requested(Some("1"))); + assert!(no_color_requested(Some("0"))); + let warning = "Warning: stale source\nUse source update"; + assert_eq!( + StyledText::new(warning, Foreground::Yellow, false).to_string(), + warning + ); + assert_eq!( + StyledText::new(warning, Foreground::Yellow, true).to_string(), + format!("\x1b[38;5;11m{warning}\x1b[39m") + ); + } +} diff --git a/crates/fastled-cli/src/keyboard.rs b/crates/fastled-cli/src/keyboard.rs index d0dfa7eb..7b94059f 100644 --- a/crates/fastled-cli/src/keyboard.rs +++ b/crates/fastled-cli/src/keyboard.rs @@ -1,136 +1,78 @@ -//! Non-blocking keyboard input detection. -//! -//! Ports the core behaviour of `keyboard.py` (`SpaceBarWatcher`) to Rust -//! using the [`crossterm`] crate for cross-platform terminal raw-mode support. -//! -//! The public surface is intentionally minimal for Phase 2e: -//! * [`check_for_space`] — poll once and return immediately. -//! * [`start_listener`] — spawn a background thread and return a channel. -//! -//! The module is included as a library component; the CLI main loop will -//! integrate it in a later phase. +//! FastLED manual-rebuild policy over kernel-owned terminal input. -use std::sync::mpsc; -use std::thread; -use std::time::Duration; +use kernal_api::keys::{Key, KeyEvent, TerminalKeys}; +use std::{io, time::Duration}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use crossterm::terminal; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Check once (non-blocking) whether a space bar key press is pending. -/// -/// Returns `true` if a space (or Enter) is available in the terminal event -/// queue right now; `false` otherwise. -/// -/// This function enables raw mode momentarily to read the event. If the -/// terminal is not interactive (e.g. piped stdin in CI), it returns `false` -/// rather than panicking. -pub fn check_for_space() -> bool { - // poll(Duration::ZERO) returns Ok(true) if an event is ready immediately. - match terminal::enable_raw_mode() { - Ok(()) => {} - Err(_) => return false, // Not an interactive terminal — non-fatal. - } - let result = event::poll(Duration::ZERO) - .map(|ready| { - if !ready { - return false; - } - matches!( - event::read(), - Ok(Event::Key(KeyEvent { - code: KeyCode::Char(' ') | KeyCode::Enter, - .. - })) - ) - }) - .unwrap_or(false); - let _ = terminal::disable_raw_mode(); - result +/// Lazy ownership lets the watch loop register graceful Ctrl+C handling before +/// changing terminal modes. Drop restores modes through the kernel owner. +#[derive(Default)] +pub(crate) struct RebuildKeys { + input: Option, + attempted: bool, } -/// Spawn a background thread that continuously reads keyboard events and -/// sends them through the returned channel. -#[allow(dead_code)] -/// -/// The thread exits when the channel receiver is dropped or when a -/// `Ctrl+C` / `Esc` event is received. -/// -/// # Platform notes -/// Raw mode is enabled for the duration of the listener's life. The caller -/// is responsible for disabling it if needed after the receiver is dropped. -pub fn start_listener() -> mpsc::Receiver { - let (tx, rx) = mpsc::channel::(); - - thread::spawn(move || { - if terminal::enable_raw_mode().is_err() { - return; // Not interactive — just exit the thread. +impl RebuildKeys { + /// Poll once. Noninteractive stdin is optional; other errors disable this + /// input source permanently and are reported once to the caller. + pub(crate) fn poll(&mut self) -> io::Result { + if !self.attempted { + self.attempted = true; + self.input = match TerminalKeys::new() { + Ok(input) => input, + Err(error) if error.kind() == io::ErrorKind::Unsupported => None, + Err(error) => return Err(error), + }; } - - loop { - // Block up to 100 ms so we can yield the thread periodically. - match event::poll(Duration::from_millis(100)) { - Ok(true) => { - if let Ok(Event::Key(key)) = event::read() { - // Quit the listener on Ctrl+C or Esc. - let is_ctrl_c = key.code == KeyCode::Char('c') - && key.modifiers.contains(KeyModifiers::CONTROL); - let is_esc = key.code == KeyCode::Esc; - - if tx.send(key).is_err() || is_ctrl_c || is_esc { - break; - } - } - } - Ok(false) => { - // Timeout — nothing to do; loop and poll again. - // The `tx.send(key).is_err()` path above handles receiver - // drop detection on the next real key event. - } - Err(_) => break, + let Some(input) = self.input.as_mut() else { + return Ok(false); + }; + match input.poll(Duration::ZERO) { + Ok(event) => Ok(event.is_some_and(is_rebuild_key)), + Err(error) => { + self.input.take(); + Err(error) } } - - let _ = terminal::disable_raw_mode(); - }); - - rx + } } -// --------------------------------------------------------------------------- -// Unit tests -// --------------------------------------------------------------------------- +fn is_rebuild_key(event: KeyEvent) -> bool { + matches!(event.key, Key::Character(' ') | Key::Enter) +} #[cfg(test)] mod tests { use super::*; + use kernal_api::keys::KeyModifiers; - /// In a non-interactive test environment (no TTY), `check_for_space` - /// must return `false` without panicking. #[test] - fn test_check_for_space_returns_false_when_no_key_pressed() { - // CI / test runners pipe stdin, so raw mode will either fail or no - // key will be available. Either way we must get `false`. - let result = check_for_space(); - assert!( - !result, - "expected false when no key pressed in non-interactive mode" - ); + fn only_space_and_enter_request_rebuilds() { + for (key, expected) in [ + (Key::Character(' '), true), + (Key::Enter, true), + (Key::Character('x'), false), + (Key::Escape, false), + (Key::Other, false), + ] { + for modifiers in [ + KeyModifiers::default(), + KeyModifiers { + control: true, + alt: true, + }, + ] { + assert_eq!(is_rebuild_key(KeyEvent { key, modifiers }), expected); + } + } } - /// `start_listener` must return a live receiver without panicking, even - /// if the terminal is not interactive. #[test] - fn test_start_listener_returns_receiver() { - let rx = start_listener(); - // Drop the receiver immediately; the background thread must shut down - // gracefully rather than panicking. - drop(rx); - // Give the thread a moment to exit cleanly. - std::thread::sleep(Duration::from_millis(150)); + fn disabled_input_does_not_retry_capture() { + let mut keys = RebuildKeys { + input: None, + attempted: true, + }; + assert!(!keys.poll().unwrap()); + assert!(!keys.poll().unwrap()); } } diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index a82f7cf2..58785583 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -12,6 +12,7 @@ mod cli; mod commands; mod compile_stream; pub mod debug_symbols; +mod diagnostics; mod dwarf_smoke; mod dynamic_cache; #[cfg(test)] diff --git a/crates/fastled-cli/src/source.rs b/crates/fastled-cli/src/source.rs index 397f3c48..bdbead7c 100644 --- a/crates/fastled-cli/src/source.rs +++ b/crates/fastled-cli/src/source.rs @@ -258,7 +258,7 @@ pub(crate) fn run_source_action(action: SourceAction) -> Result<()> { if let Some(warning) = master_stale_warning(&status) { use std::io::IsTerminal; if std::io::stderr().is_terminal() { - eprintln!("{}", crossterm::style::Stylize::yellow(warning)); + eprintln!("{}", crate::diagnostics::yellow_warning(&warning)); } else { eprintln!("{warning}"); } diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index 2a988853..1717cc7c 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -26,7 +26,7 @@ fn stdio_log(line: &str, stream: &str) { use std::io::IsTerminal; if stream == "warning" && std::io::stderr().is_terminal() { - eprintln!("{}", crossterm::style::Stylize::yellow(line)); + eprintln!("{}", crate::diagnostics::yellow_warning(line)); } else if stream == "stderr" || stream == "warning" { eprintln!("{line}"); } else { diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 3c2cd1c1..313e7139 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -648,6 +648,37 @@ one skipped). Full application Rust tests and strict all-target Clippy pass; the cross-repository source review is clean. Publication and exact adoption remain required; no speed improvement is claimed. +### Terminal-input adoption checkpoint + +FastLED now uses kernel-owned decoded terminal input and diagnostic styling +from https://github.com/zackees/kernal-api/pull/186 (issue #178). Direct +`crossterm` is removed and banned by the boundary test (observed RED then GREEN). +The lightweight `terminal-input` and `terminal-style` features add no backend +dependencies; enabling key input does not enable PTY process spawning. + +FastLED retains Space/Enter rebuild policy, cache invalidation, warning text, +stderr terminal detection and cached nonempty `NO_COLOR` handling. The unused +background keyboard listener is removed. Native capture, bounded decoding, +ownership/restoration and generic tests live upstream. The decoder deliberately +does not promise complete Crossterm modifier equivalence: ambiguous Alt+Space +and control sequences are not interpreted as rebuild keys. + +Watch mode polls input and file events every 100 ms. Graceful Ctrl+C handling +is registered before lazy terminal capture; interruption drops the capture +owner and exits with status 130. An in-flight build finishes before interruption +is handled. Input failures disable manual rebuild input with one diagnostic; +file watching continues. Compiler flags and backend behavior are unchanged. + +The current migration-only patch points at +`../fastled-wasm-extern/kernal-api-terminal` on `feat/terminal-input`. +Dependency-isolation checks and Python tests pass (28 passed, one skipped). +Full Rust workspace tests pass: 259 library tests, two binary tests, one +integration test and one doc test. The cross-repository review is clean. +Strict all-target application Clippy and formatting pass. Native-platform CI +for the latest upstream feature split remains pending. +Publication and exact registry adoption remain required; no build-speed +improvement is claimed. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index dfb15425..3d14c5d2 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -26,6 +26,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "getrandom" not in dependencies assert "tempfile" not in dependencies assert "strsim" not in dependencies + assert "crossterm" not in dependencies assert "tempfile" not in manifest.get("dev-dependencies", {}) for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies @@ -43,6 +44,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "getrandom::" not in source.read_text(), source assert "tempfile::" not in source.read_text(), source assert "strsim::" not in source.read_text(), source + assert "crossterm::" not in source.read_text(), source for backend in ("zip", "tar", "zstd", "flate2"): assert f"{backend}::" not in source.read_text(), source From b091c489f6543fd9d9271a141e87af45974ec5ff Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 02:57:09 -0700 Subject: [PATCH 42/94] refactor: use kernel-owned interrupt waits (refs #229) --- Cargo.toml | 2 +- crates/fastled-cli/src/commands.rs | 18 +++++------ crates/fastled-cli/src/test_mode.rs | 49 ++++++++++++++++------------- docs/kernal-api-migration.md | 26 +++++++++++++++ tests/unit/test_kernal_boundary.py | 2 ++ 5 files changed, 66 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ebd26140..1681792f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-terminal" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-interrupt" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index 583bce8d..c693937b 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -132,14 +132,14 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { Ok(w) => w, Err(e) => { eprintln!("fastled: file watcher failed: {e}"); - tokio::signal::ctrl_c().await.ok(); + rt.wait_for_interrupt().await.ok(); return ExitCode::SUCCESS; } }; let rx = file_watcher.start(); - let ctrl_c = tokio::signal::ctrl_c(); - tokio::pin!(ctrl_c); + let ctrl_c = rt.wait_for_interrupt(); + let mut ctrl_c = std::pin::pin!(ctrl_c); let mut rebuild_keys = keyboard::RebuildKeys::default(); let mut watch_exit = ExitCode::SUCCESS; let mut input_tick = match async_engine::PeriodicTimer::new(std::time::Duration::from_millis(100)) { @@ -364,7 +364,7 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { return test_exit(TestOutcome::Failure); } }; - let compile_result = match test_mode::run_contained_command(&mut compile, compile_budget).await + let compile_result = match test_mode::run_contained_command(&rt, &mut compile, compile_budget).await { Ok(result) => result, Err(error) => { @@ -402,8 +402,8 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { }; let total_deadline = async_engine::Deadline::after(remaining); let ready_deadline = async_engine::Deadline::after(plan.ready_timeout); - let ctrl_c = tokio::signal::ctrl_c(); - tokio::pin!(ctrl_c); + let ctrl_c = rt.wait_for_interrupt(); + let mut ctrl_c = std::pin::pin!(ctrl_c); let mut liveness = match async_engine::PeriodicTimer::new( std::time::Duration::from_millis(100), ) { @@ -855,8 +855,8 @@ pub(crate) fn serve_directory(dir: &str, launch_viewer: bool) -> ExitCode { match viewer { Some(mut viewer) => { // Exit on Ctrl+C or when the viewer window is closed. - let ctrl_c = tokio::signal::ctrl_c(); - tokio::pin!(ctrl_c); + let ctrl_c = rt.wait_for_interrupt(); + let mut ctrl_c = std::pin::pin!(ctrl_c); loop { tokio::select! { _ = &mut ctrl_c => { @@ -874,7 +874,7 @@ pub(crate) fn serve_directory(dir: &str, launch_viewer: bool) -> ExitCode { } None => { // Headless serve has no viewer to watch; run until Ctrl+C. - tokio::signal::ctrl_c().await.ok(); + rt.wait_for_interrupt().await.ok(); println!("\nShutting down..."); } } diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index b6adde5b..ecec4b10 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -250,6 +250,7 @@ pub(crate) enum TimedCommandResult { } pub(crate) async fn run_contained_command( + rt: &async_engine::Runtime, command: &mut Command, timeout: Duration, ) -> std::io::Result { @@ -263,8 +264,8 @@ pub(crate) async fn run_contained_command( kernal_api::platform::process::SyncEnvironment::Inherit, )?; let deadline = async_engine::Deadline::after(timeout); - let ctrl_c = tokio::signal::ctrl_c(); - tokio::pin!(ctrl_c); + let ctrl_c = rt.wait_for_interrupt(); + let mut ctrl_c = std::pin::pin!(ctrl_c); loop { if let Some(code) = child.try_wait()? { return Ok(TimedCommandResult::Exited(code)); @@ -591,26 +592,32 @@ mod tests { } } - #[tokio::test] - async fn contained_command_is_stopped_at_the_hard_deadline() { - #[cfg(windows)] - let mut command = { - let mut command = Command::new("cmd"); - command.args(["/C", "ping -n 3 127.0.0.1 >NUL"]); - command - }; - #[cfg(not(windows))] - let mut command = { - let mut command = Command::new("sh"); - command.args(["-c", "sleep 2"]); - command - }; - let started = std::time::Instant::now(); - let result = run_contained_command(&mut command, Duration::from_millis(30)) - .await + #[test] + fn contained_command_is_stopped_at_the_hard_deadline() { + let rt = async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() .unwrap(); - assert_eq!(result, TimedCommandResult::TimedOut); - assert!(started.elapsed() < Duration::from_secs(1)); + rt.run(async { + #[cfg(windows)] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "ping -n 3 127.0.0.1 >NUL"]); + command + }; + #[cfg(not(windows))] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 2"]); + command + }; + let started = std::time::Instant::now(); + let result = run_contained_command(&rt, &mut command, Duration::from_millis(30)) + .await + .unwrap(); + assert_eq!(result, TimedCommandResult::TimedOut); + assert!(started.elapsed() < Duration::from_secs(1)); + }); } #[tokio::test] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 313e7139..73348cb9 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -679,6 +679,32 @@ for the latest upstream feature split remains pending. Publication and exact registry adoption remain required; no build-speed improvement is claimed. +### Interrupt-notification adoption checkpoint + +Issue https://github.com/zackees/kernal-api/issues/187 adds kernel-owned Ctrl+C +notifications using the existing private signal driver, without a new runtime, +dependency or signal-handler stack. Eager listeners borrow an owned runtime; +registration without enabled drivers fails explicitly. Notifications can +coalesce, pending waits are cancellation-safe, and process-wide handler effects +persist after a listener is dropped. Generic native-delivery and ownership +tests live upstream in isolated child processes. + +FastLED now uses `Runtime::wait_for_interrupt`, whose registration happens on +first poll just as the previous wait did. It retains existing signal timing, +watch-mode cleanup, contained-command deadlines and command-specific exit +policy. Standard-library pinning replaces backend pinning. Direct signal and +pin calls are banned by the boundary check (observed RED then GREEN). One +contained-command policy test now runs on a kernel runtime rather than a +backend test attribute. Event selection and remaining test attributes still +require a separate migration; this checkpoint does not remove Tokio entirely. + +The migration-only patch points at +`../fastled-wasm-extern/kernal-api-interrupt` on `feat/interrupt-notification`. +Source review, the focused boundary check, full Rust workspace tests, strict +all-target Clippy and formatting pass. Python tests pass (28 passed, one skipped). +Native upstream CI remains pending. Exact published adoption and build measurements +remain outstanding; no build-speed improvement is claimed. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 3d14c5d2..d3d9ac3f 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -83,6 +83,8 @@ def test_migrated_async_operations_use_kernel(): for backend in ( "tokio::runtime::", "tokio::time::", + "tokio::signal::", + "tokio::pin!", "tokio::sync::Semaphore", "tokio::spawn(", "tokio::task::JoinHandle", From fc7954937e117a7229cf108bab815de833ed0570 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 03:09:14 -0700 Subject: [PATCH 43/94] refactor: remove direct Tokio dependency (refs #243) --- Cargo.lock | 1 - Cargo.toml | 1 - crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/commands.rs | 96 +-- crates/fastled-cli/src/lib.rs | 1 + crates/fastled-cli/src/server.rs | 822 +++++++++++++++----------- crates/fastled-cli/src/test_events.rs | 196 ++++++ crates/fastled-cli/src/test_mode.rs | 398 ++++++++----- docs/kernal-api-migration.md | 31 + tests/unit/test_kernal_boundary.py | 4 + 10 files changed, 1014 insertions(+), 537 deletions(-) create mode 100644 crates/fastled-cli/src/test_events.rs diff --git a/Cargo.lock b/Cargo.lock index 75490d32..6e2c4035 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1028,7 +1028,6 @@ dependencies = [ "shell-words", "tauri", "tauri-build", - "tokio", "toml 0.8.23", "tree-sitter", "tree-sitter-cpp", diff --git a/Cargo.toml b/Cargo.toml index 1681792f..2c04be32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" ctcb-core = "0.4.1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "signal"] } toml = "0.8" tauri = { version = "2", features = ["devtools"] } tauri-build = { version = "2", features = [] } diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index b5fd71be..9b5dc5ea 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -27,7 +27,6 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } ctcb-core = { workspace = true } -tokio = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } shell-words = { workspace = true } diff --git a/crates/fastled-cli/src/commands.rs b/crates/fastled-cli/src/commands.rs index c693937b..a4ab2200 100644 --- a/crates/fastled-cli/src/commands.rs +++ b/crates/fastled-cli/src/commands.rs @@ -1,10 +1,13 @@ +use crate::test_events::{TestEventSources, TestWake}; use kernal_api::async_engine; use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; +use std::future::{poll_fn, Future}; use std::io::Write; use std::path::PathBuf; use std::process::ExitCode; use std::sync::{Arc, RwLock}; +use std::task::Poll; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use crate::build; @@ -155,19 +158,24 @@ pub(crate) fn compile_and_serve(dir: &str, cli: &Cli) -> ExitCode { // Poll Ctrl+C first so its handler is registered before lazy key // capture changes terminal modes. In-flight builds still finish // before the next loop tick handles a pending interruption. - tokio::select! { - biased; - interrupted = &mut ctrl_c => { - watch_exit = match interrupted { - Ok(()) => ExitCode::from(130), - Err(error) => { - eprintln!("fastled: Ctrl+C monitoring failed: {error}"); - ExitCode::FAILURE - } - }; - break; - } - _ = input_tick.tick() => {} + let interrupted = { + let mut tick = std::pin::pin!(input_tick.tick()); + poll_fn(|context| { + if let Poll::Ready(result) = ctrl_c.as_mut().poll(context) { + return Poll::Ready(Some(result)); + } + tick.as_mut().poll(context).map(|()| None) + }).await + }; + if let Some(interrupted) = interrupted { + watch_exit = match interrupted { + Ok(()) => ExitCode::from(130), + Err(error) => { + eprintln!("fastled: Ctrl+C monitoring failed: {error}"); + ExitCode::FAILURE + } + }; + break; } // The viewer window is the app from the user's perspective: once // it is gone (closed or crashed), shut the CLI down cleanly. An @@ -423,27 +431,32 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { let mut _command_task: Option> = None; loop { - tokio::select! { - biased; - _ = async_engine::sleep_until(total_deadline) => { + let event = TestEventSources { + interrupt: ctrl_c.as_mut(), + liveness: &mut liveness, + viewer: &mut test_rx, + commands: &mut command_rx, + }.next(total_deadline, (!ready).then_some(ready_deadline), !commands_done).await; + match event { + TestWake::TotalTimeout => { eprintln!("fastled: production test exceeded --test-timeout-secs"); return test_exit(TestOutcome::TotalTimeout); } - _ = async_engine::sleep_until(ready_deadline), if !ready => { + TestWake::ReadyTimeout => { eprintln!("fastled: viewer did not render a canvas before --test-ready-timeout-secs"); return test_exit(TestOutcome::ReadyTimeout); } - _ = &mut ctrl_c => { + TestWake::Interrupted => { eprintln!("fastled: production test interrupted"); return test_exit(TestOutcome::Interrupted); } - _ = liveness.tick() => { + TestWake::Liveness => { if !viewer.is_alive() { eprintln!("fastled: test viewer exited before completing"); return test_exit(TestOutcome::Failure); } } - event = test_rx.recv() => { + TestWake::Viewer(event) => { match event { Some(server::TestEvent::Ready) => { if !ready { @@ -505,7 +518,7 @@ pub(crate) fn compile_and_test(dir: &str, cli: &Cli) -> ExitCode { } } } - command_event = command_rx.recv(), if !commands_done => { + TestWake::Command(command_event) => { match command_event { Some(test_mode::TestCommandEvent::Start { index }) => { let marker = format!("[fastled-test-cmd {index}] start"); @@ -858,17 +871,16 @@ pub(crate) fn serve_directory(dir: &str, launch_viewer: bool) -> ExitCode { let ctrl_c = rt.wait_for_interrupt(); let mut ctrl_c = std::pin::pin!(ctrl_c); loop { - tokio::select! { - _ = &mut ctrl_c => { - println!("\nShutting down..."); - break; - } - _ = async_engine::sleep(std::time::Duration::from_secs(1)) => { - if !viewer.is_alive() { - println!("\nViewer window closed; shutting down."); - break; - } - } + if async_engine::timeout(std::time::Duration::from_secs(1), &mut ctrl_c) + .await + .is_ok() + { + println!("\nShutting down..."); + break; + } + if !viewer.is_alive() { + println!("\nViewer window closed; shutting down."); + break; } } } @@ -938,12 +950,18 @@ pub(crate) fn run_internal_dwarf_smoke(cli: &Cli) -> ExitCode { #[cfg(test)] mod capability_tests { - #[tokio::test] - async fn test_capability_preserves_32_byte_lowercase_hex_wire_format() { - let token = super::create_test_capability().await.unwrap(); - assert_eq!(token.len(), 64); - assert!(token - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + #[test] + fn test_capability_preserves_32_byte_lowercase_hex_wire_format() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let token = super::create_test_capability().await.unwrap(); + assert_eq!(token.len(), 64); + assert!(token + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))); + }); } } diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index 58785583..f4839f4d 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -32,6 +32,7 @@ mod server; mod sketch_preprocessor; mod source; mod terminal; +mod test_events; mod test_mode; pub mod viewer; pub mod wasm_build; diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 130cef25..4f0d4c65 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -877,8 +877,13 @@ mod tests { .unwrap() } - #[tokio::test] - async fn http_router_migration_preserves_head_errors_and_preflight_policy() { + #[test] + fn http_router_migration_preserves_head_errors_and_preflight_policy() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { let (addr, dir) = setup_server().await; fs::write(dir.path().join("asset.js"), "hello").unwrap(); for (method, path, status) in [ @@ -919,10 +924,16 @@ mod tests { "{response}" ); assert!(response.ends_with("\r\n\r\n"), "{response}"); + }); } - #[tokio::test] - async fn malformed_requests_respect_the_browser_header_dispatch_boundary() { + #[test] + fn malformed_requests_respect_the_browser_header_dispatch_boundary() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { let (addr, _dir) = setup_server().await; let parser_error = raw_http( addr, @@ -945,24 +956,36 @@ mod tests { assert!(response.contains("access-control-allow-origin: *\r\n")); assert!(response.contains("cache-control: no-cache, no-store, must-revalidate\r\n")); } + }); } - #[tokio::test] - async fn test_viewer_log_endpoint_accepts_posts() { - let (addr, _dir) = setup_server().await; - let resp = http_request( - HttpMethod::Post, - &format!("http://{addr}/viewer-log"), - &[], - b"error: something broke", - ) - .await - .unwrap(); - assert_eq!(resp.status(), 204); + #[test] + fn test_viewer_log_endpoint_accepts_posts() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let resp = http_request( + HttpMethod::Post, + &format!("http://{addr}/viewer-log"), + &[], + b"error: something broke", + ) + .await + .unwrap(); + assert_eq!(resp.status(), 204); + }); } - #[tokio::test] - async fn test_runtime_endpoints_use_preconfigured_screenshot_paths() { + #[test] + fn test_runtime_endpoints_use_preconfigured_screenshot_paths() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); fs::write( dir.path().join("fastled_background_worker.js"), @@ -1116,226 +1139,302 @@ mod tests { rx.recv().await, Some(TestEvent::Failure(message)) if message.contains("could not write screenshot") )); + }); } - #[tokio::test] - async fn test_loading_page_when_no_index_html() { - let (addr, _dir) = setup_server().await; - let resp = http_get(format!("http://{addr}/")).await.unwrap(); - assert_eq!(resp.status(), 200); - let body = response_text(resp).await; - assert!( - body.contains("Compiling..."), - "expected loading page, got: {body}" - ); + #[test] + fn test_loading_page_when_no_index_html() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let resp = http_get(format!("http://{addr}/")).await.unwrap(); + assert_eq!(resp.status(), 200); + let body = response_text(resp).await; + assert!( + body.contains("Compiling..."), + "expected loading page, got: {body}" + ); + }); } /// Instant-launch failure UX (#148 acceptance criterion 5): /// when a compile has failed and `index.html` is absent, `/` must still /// return the in-browser loading page (which surfaces the error log) — /// it must NOT 404 or navigate to a stale page. - #[tokio::test] - async fn test_loading_page_stays_when_build_failed_and_no_index_html() { - let (addr, dir) = setup_server().await; - fs::write( - dir.path().join("build-status.json"), - r#"{"status":"error","message":"Compilation failed"}"#, - ) - .unwrap(); - let resp = http_get(format!("http://{addr}/")).await.unwrap(); - assert_eq!( - resp.status(), - 200, - "viewer should land on the loading page, not 404/redirect, when compile failed" - ); - let body = response_text(resp).await; - assert!( - body.contains("Compiling..."), - "expected loading page, got: {body}" - ); - // Sanity check: the embedded JS knows how to render the error state. - assert!( - body.contains("setError"), - "loading page must include error-handling branch" - ); + #[test] + fn test_loading_page_stays_when_build_failed_and_no_index_html() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, dir) = setup_server().await; + fs::write( + dir.path().join("build-status.json"), + r#"{"status":"error","message":"Compilation failed"}"#, + ) + .unwrap(); + let resp = http_get(format!("http://{addr}/")).await.unwrap(); + assert_eq!( + resp.status(), + 200, + "viewer should land on the loading page, not 404/redirect, when compile failed" + ); + let body = response_text(resp).await; + assert!( + body.contains("Compiling..."), + "expected loading page, got: {body}" + ); + // Sanity check: the embedded JS knows how to render the error state. + assert!( + body.contains("setError"), + "loading page must include error-handling branch" + ); + }); } - #[tokio::test] - async fn test_serves_index_html_when_present() { - let (addr, dir) = setup_server().await; - fs::write(dir.path().join("index.html"), "OK").unwrap(); - let resp = http_get(format!("http://{addr}/")).await.unwrap(); - assert_eq!(resp.status(), 200); - let body = response_text(resp).await; - assert!( - body.contains("OK"), - "expected index.html content, got: {body}" - ); + #[test] + fn test_serves_index_html_when_present() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, dir) = setup_server().await; + fs::write(dir.path().join("index.html"), "OK").unwrap(); + let resp = http_get(format!("http://{addr}/")).await.unwrap(); + assert_eq!(resp.status(), 200); + let body = response_text(resp).await; + assert!( + body.contains("OK"), + "expected index.html content, got: {body}" + ); + }); } - #[tokio::test] - async fn test_serves_js_with_correct_mime() { - let (addr, dir) = setup_server().await; - fs::write(dir.path().join("app.js"), "console.log('hi')").unwrap(); - let resp = http_get(format!("http://{addr}/app.js")).await.unwrap(); - assert_eq!(resp.status(), 200); - let ct = header_text(&resp, "content-type"); - assert!(ct.contains("javascript"), "expected JS mime, got: {ct}"); + #[test] + fn test_serves_js_with_correct_mime() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, dir) = setup_server().await; + fs::write(dir.path().join("app.js"), "console.log('hi')").unwrap(); + let resp = http_get(format!("http://{addr}/app.js")).await.unwrap(); + assert_eq!(resp.status(), 200); + let ct = header_text(&resp, "content-type"); + assert!(ct.contains("javascript"), "expected JS mime, got: {ct}"); + }); } - #[tokio::test] - async fn test_serves_wasm_with_correct_mime() { - let (addr, dir) = setup_server().await; - fs::write(dir.path().join("fastled.wasm"), [0x00, 0x61, 0x73, 0x6d]).unwrap(); - let resp = http_get(format!("http://{addr}/fastled.wasm")) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let ct = header_text(&resp, "content-type"); - assert!(ct.contains("wasm"), "expected WASM mime, got: {ct}"); + #[test] + fn test_serves_wasm_with_correct_mime() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, dir) = setup_server().await; + fs::write(dir.path().join("fastled.wasm"), [0x00, 0x61, 0x73, 0x6d]).unwrap(); + let resp = http_get(format!("http://{addr}/fastled.wasm")) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let ct = header_text(&resp, "content-type"); + assert!(ct.contains("wasm"), "expected WASM mime, got: {ct}"); + }); } - #[tokio::test] - async fn test_safari_compatible_coop_coep_headers() { - let (addr, _dir) = setup_server().await; - let resp = http_get(format!("http://{addr}/")).await.unwrap(); - let coep = header_text(&resp, "cross-origin-embedder-policy"); - let coop = header_text(&resp, "cross-origin-opener-policy"); - assert_eq!(coep, "require-corp"); - assert_eq!(coop, "same-origin"); + #[test] + fn test_safari_compatible_coop_coep_headers() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let resp = http_get(format!("http://{addr}/")).await.unwrap(); + let coep = header_text(&resp, "cross-origin-embedder-policy"); + let coop = header_text(&resp, "cross-origin-opener-policy"); + assert_eq!(coep, "require-corp"); + assert_eq!(coop, "same-origin"); + }); } - #[tokio::test] - async fn test_404_for_missing_file() { - let (addr, _dir) = setup_server().await; - let resp = http_get(format!("http://{addr}/nonexistent.js")) - .await - .unwrap(); - assert_eq!(resp.status(), 404); + #[test] + fn test_404_for_missing_file() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let resp = http_get(format!("http://{addr}/nonexistent.js")) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + }); } - #[tokio::test] - async fn test_build_status_json_served() { - let (addr, dir) = setup_server().await; - // Initially no build-status.json -> 404 - let resp = http_get(format!("http://{addr}/build-status.json")) - .await - .unwrap(); - assert_eq!(resp.status(), 404); + #[test] + fn test_build_status_json_served() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, dir) = setup_server().await; + // Initially no build-status.json -> 404 + let resp = http_get(format!("http://{addr}/build-status.json")) + .await + .unwrap(); + assert_eq!(resp.status(), 404); - // Write status file -> 200 - fs::write( - dir.path().join("build-status.json"), - r#"{"status":"compiling","message":"Building..."}"#, - ) - .unwrap(); - let resp = http_get(format!("http://{addr}/build-status.json")) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = response_text(resp).await; - assert!(body.contains("compiling")); + // Write status file -> 200 + fs::write( + dir.path().join("build-status.json"), + r#"{"status":"compiling","message":"Building..."}"#, + ) + .unwrap(); + let resp = http_get(format!("http://{addr}/build-status.json")) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body = response_text(resp).await; + assert!(body.contains("compiling")); + }); } - #[tokio::test] - async fn test_directory_traversal_blocked() { - let (addr, dir) = setup_server().await; - // Create a file outside the serve dir - let parent = dir.path().parent().unwrap(); - fs::write(parent.join("secret.txt"), "top secret").unwrap(); - let resp = http_get(format!("http://{addr}/../secret.txt")) - .await - .unwrap(); - // Should not serve files outside the serve dir - assert_ne!(resp.status(), 200); + #[test] + fn test_directory_traversal_blocked() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, dir) = setup_server().await; + // Create a file outside the serve dir + let parent = dir.path().parent().unwrap(); + fs::write(parent.join("secret.txt"), "top secret").unwrap(); + let resp = http_get(format!("http://{addr}/../secret.txt")) + .await + .unwrap(); + // Should not serve files outside the serve dir + assert_ne!(resp.status(), 200); + }); } // ------------------------------------------------------------------ // SSE build-stream tests // ------------------------------------------------------------------ - #[tokio::test] - async fn test_sse_returns_404_without_broadcast() { - // Server started without broadcast channel → /build-stream returns 404. - let (addr, _dir) = setup_server().await; - let resp = http_get(format!("http://{addr}/build-stream")) - .await - .unwrap(); - assert_eq!(resp.status(), 404); + #[test] + fn test_sse_returns_404_without_broadcast() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + // Server started without broadcast channel → /build-stream returns 404. + let (addr, _dir) = setup_server().await; + let resp = http_get(format!("http://{addr}/build-stream")) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + }); } - #[tokio::test] - async fn test_sse_endpoint_streams_events() { - let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - let (tx, _rx) = async_engine::broadcast_channel::(16).unwrap(); - let addr = start_server( - dir.path().to_path_buf(), - 0, - Some(tx.clone()), - empty_handle(), - None, - ) - .await - .unwrap(); - async_engine::sleep(std::time::Duration::from_millis(50)).await; - - let url = format!("http://{addr}/build-stream"); - - // Connect to SSE endpoint. - let mut resp = http_get(url).await.unwrap(); - assert_eq!(resp.status(), 200); - let ct = header_text(&resp, "content-type").to_string(); - assert!( - ct.contains("text/event-stream"), - "expected event-stream content-type, got: {ct}" - ); - - // Give the server handler a moment to subscribe to the broadcast. - async_engine::sleep(std::time::Duration::from_millis(50)).await; - - // Send test events. - tx.send(r#"{"type":"log","line":"Building sketch...","stream":"stdout"}"#.to_string()) - .unwrap(); - tx.send(r#"{"type":"status","status":"success","message":"Done"}"#.to_string()) - .unwrap(); + #[test] + fn test_sse_endpoint_streams_events() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let (tx, _rx) = async_engine::broadcast_channel::(16).unwrap(); + let addr = start_server( + dir.path().to_path_buf(), + 0, + Some(tx.clone()), + empty_handle(), + None, + ) + .await + .unwrap(); + async_engine::sleep(std::time::Duration::from_millis(50)).await; + + let url = format!("http://{addr}/build-stream"); + + // Connect to SSE endpoint. + let mut resp = http_get(url).await.unwrap(); + assert_eq!(resp.status(), 200); + let ct = header_text(&resp, "content-type").to_string(); + assert!( + ct.contains("text/event-stream"), + "expected event-stream content-type, got: {ct}" + ); + + // Give the server handler a moment to subscribe to the broadcast. + async_engine::sleep(std::time::Duration::from_millis(50)).await; + + // Send test events. + tx.send( + r#"{"type":"log","line":"Building sketch...","stream":"stdout"}"#.to_string(), + ) + .unwrap(); + tx.send(r#"{"type":"status","status":"success","message":"Done"}"#.to_string()) + .unwrap(); - // Read SSE chunks until we see both events (or timeout). - let mut collected = String::new(); - let deadline = std::time::Duration::from_secs(3); - let mut buffer = [0; 4096]; - while let Ok(Ok(n)) = async_engine::timeout(deadline, resp.read(&mut buffer)).await { - if n == 0 { - break; - } - collected.push_str(&String::from_utf8_lossy(&buffer[..n])); - if collected.contains("Building sketch...") && collected.contains("success") { - break; - } - } + // Read SSE chunks until we see both events (or timeout). + let mut collected = String::new(); + let deadline = std::time::Duration::from_secs(3); + let mut buffer = [0; 4096]; + while let Ok(Ok(n)) = async_engine::timeout(deadline, resp.read(&mut buffer)).await + { + if n == 0 { + break; + } + collected.push_str(&String::from_utf8_lossy(&buffer[..n])); + if collected.contains("Building sketch...") && collected.contains("success") { + break; + } + } - assert!( - collected.contains("Building sketch..."), - "expected log line in SSE body, got: {collected}" - ); - assert!( - collected.contains("success"), - "expected status event in SSE body, got: {collected}" - ); + assert!( + collected.contains("Building sketch..."), + "expected log line in SSE body, got: {collected}" + ); + assert!( + collected.contains("success"), + "expected status event in SSE body, got: {collected}" + ); + }); } - #[tokio::test] - async fn test_loading_page_contains_eventsource() { - let (addr, _dir) = setup_server().await; - let resp = http_get(format!("http://{addr}/")).await.unwrap(); - let body = response_text(resp).await; - assert!( - body.contains("EventSource"), - "loading page should use EventSource for SSE" - ); - assert!( - body.contains("/build-stream"), - "loading page should connect to /build-stream" - ); + #[test] + fn test_loading_page_contains_eventsource() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let resp = http_get(format!("http://{addr}/")).await.unwrap(); + let body = response_text(resp).await; + assert!( + body.contains("EventSource"), + "loading page should use EventSource for SSE" + ); + assert!( + body.contains("/build-stream"), + "loading page should connect to /build-stream" + ); + }); } /// Live compile log UX (#153): the loading page must classify and color @@ -1388,159 +1487,200 @@ mod tests { .unwrap() } - #[tokio::test] - async fn dwarfsource_without_resolver_returns_400() { - let (addr, _dir) = setup_server().await; - let resp = post_json( - addr, - "/dwarfsource", - serde_json::json!({"path": "sketchsource/foo.ino"}), - ) - .await; - assert_eq!(resp.status(), 400); + #[test] + fn dwarfsource_without_resolver_returns_400() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let resp = post_json( + addr, + "/dwarfsource", + serde_json::json!({"path": "sketchsource/foo.ino"}), + ) + .await; + assert_eq!(resp.status(), 400); + }); } - #[tokio::test] - async fn debug_source_roots_empty_without_resolver() { - let (addr, _dir) = setup_server().await; - let resp = http_get(format!("http://{addr}/debug/source-roots")) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body: serde_json::Value = serde_json::from_str(&response_text(resp).await).unwrap(); - assert!(body["roots"].as_array().unwrap().is_empty()); + #[test] + fn debug_source_roots_empty_without_resolver() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let resp = http_get(format!("http://{addr}/debug/source-roots")) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = + serde_json::from_str(&response_text(resp).await).unwrap(); + assert!(body["roots"].as_array().unwrap().is_empty()); + }); } - #[tokio::test] - async fn dwarfsource_returns_resolved_file() { - use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; - - let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - let sketch_dir = dir.path().join("sketch"); - fs::create_dir_all(sketch_dir.join("src")).unwrap(); - let sketch_file = sketch_dir.join("src").join("demo.ino"); - fs::write(&sketch_file, "void setup() {}").unwrap(); - - let resolver = DebugSymbolResolver::new(load_debug_symbol_config(sketch_dir, None, None)); - let handle: DebugSymbolHandle = Arc::new(RwLock::new(Some(resolver))); + #[test] + fn dwarfsource_returns_resolved_file() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; - let addr = start_server(dir.path().to_path_buf(), 0, None, handle.clone(), None) - .await - .unwrap(); - async_engine::sleep(std::time::Duration::from_millis(50)).await; + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let sketch_dir = dir.path().join("sketch"); + fs::create_dir_all(sketch_dir.join("src")).unwrap(); + let sketch_file = sketch_dir.join("src").join("demo.ino"); + fs::write(&sketch_file, "void setup() {}").unwrap(); - let resp = post_json( - addr, - "/dwarfsource", - serde_json::json!({"path": "sketchsource/src/demo.ino"}), - ) - .await; - assert_eq!(resp.status(), 200); - let body = response_text(resp).await; - assert!(body.contains("void setup()")); + let resolver = + DebugSymbolResolver::new(load_debug_symbol_config(sketch_dir, None, None)); + let handle: DebugSymbolHandle = Arc::new(RwLock::new(Some(resolver))); - let resp = http_get(format!("http://{addr}/debug/source-roots")) - .await - .unwrap(); - let body: serde_json::Value = serde_json::from_str(&response_text(resp).await).unwrap(); - let roots = body["roots"].as_array().unwrap(); - assert!(!roots.is_empty()); - assert!(roots - .iter() - .any(|r| r["prefix"].as_str() == Some("sketchsource"))); + let addr = start_server(dir.path().to_path_buf(), 0, None, handle.clone(), None) + .await + .unwrap(); + async_engine::sleep(std::time::Duration::from_millis(50)).await; + + let resp = post_json( + addr, + "/dwarfsource", + serde_json::json!({"path": "sketchsource/src/demo.ino"}), + ) + .await; + assert_eq!(resp.status(), 200); + let body = response_text(resp).await; + assert!(body.contains("void setup()")); + + let resp = http_get(format!("http://{addr}/debug/source-roots")) + .await + .unwrap(); + let body: serde_json::Value = + serde_json::from_str(&response_text(resp).await).unwrap(); + let roots = body["roots"].as_array().unwrap(); + assert!(!roots.is_empty()); + assert!(roots + .iter() + .any(|r| r["prefix"].as_str() == Some("sketchsource"))); + }); } - #[tokio::test] - async fn source_map_style_get_returns_resolved_file() { - use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; - - let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - let serve_dir = dir.path().join("fastled_js"); - let sketch_dir = dir.path().join("sketch"); - fs::create_dir_all(sketch_dir.join("src")).unwrap(); - fs::create_dir_all(&serve_dir).unwrap(); - fs::write(sketch_dir.join("src").join("demo.ino"), "void loop() {}").unwrap(); - - let resolver = DebugSymbolResolver::new(load_debug_symbol_config(sketch_dir, None, None)); - let handle: DebugSymbolHandle = Arc::new(RwLock::new(Some(resolver))); - - let addr = start_server(serve_dir, 0, None, handle, None) - .await - .unwrap(); - async_engine::sleep(std::time::Duration::from_millis(50)).await; + #[test] + fn source_map_style_get_returns_resolved_file() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; + + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let serve_dir = dir.path().join("fastled_js"); + let sketch_dir = dir.path().join("sketch"); + fs::create_dir_all(sketch_dir.join("src")).unwrap(); + fs::create_dir_all(&serve_dir).unwrap(); + fs::write(sketch_dir.join("src").join("demo.ino"), "void loop() {}").unwrap(); + + let resolver = + DebugSymbolResolver::new(load_debug_symbol_config(sketch_dir, None, None)); + let handle: DebugSymbolHandle = Arc::new(RwLock::new(Some(resolver))); + + let addr = start_server(serve_dir, 0, None, handle, None) + .await + .unwrap(); + async_engine::sleep(std::time::Duration::from_millis(50)).await; - let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let ct = header_text(&resp, "content-type"); - assert!(ct.contains("text/plain"), "expected text/plain, got {ct}"); - assert!(response_text(resp).await.contains("void loop()")); - - let resp = http_get(format!( - "http://{addr}/.fastled/cache/fl/repo/sketchsource/src/demo.ino" - )) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - assert!(response_text(resp).await.contains("void loop()")); + let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let ct = header_text(&resp, "content-type"); + assert!(ct.contains("text/plain"), "expected text/plain, got {ct}"); + assert!(response_text(resp).await.contains("void loop()")); + + let resp = http_get(format!( + "http://{addr}/.fastled/cache/fl/repo/sketchsource/src/demo.ino" + )) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert!(response_text(resp).await.contains("void loop()")); + }); } - #[tokio::test] - async fn source_map_get_works_from_debug_symbol_manifest() { - use crate::debug_symbols::{ - load_debug_symbol_config, read_debug_symbol_manifest, write_debug_symbol_manifest, - DebugSymbolResolver, - }; - - let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - let serve_dir = dir.path().join("fastled_js"); - let sketch_dir = dir.path().join("sketch"); - fs::create_dir_all(sketch_dir.join("src")).unwrap(); - fs::create_dir_all(&serve_dir).unwrap(); - fs::write(sketch_dir.join("src").join("demo.ino"), "void setup() {}").unwrap(); - - let config = load_debug_symbol_config(sketch_dir, None, None); - write_debug_symbol_manifest(&serve_dir, &config).unwrap(); - let loaded = read_debug_symbol_manifest(&serve_dir) + #[test] + fn source_map_get_works_from_debug_symbol_manifest() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() .unwrap() - .expect("manifest should exist"); - let handle: DebugSymbolHandle = - Arc::new(RwLock::new(Some(DebugSymbolResolver::new(loaded)))); - - let addr = start_server(serve_dir, 0, None, handle, None) - .await - .unwrap(); - async_engine::sleep(std::time::Duration::from_millis(50)).await; + .run(async { + use crate::debug_symbols::{ + load_debug_symbol_config, read_debug_symbol_manifest, + write_debug_symbol_manifest, DebugSymbolResolver, + }; + + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let serve_dir = dir.path().join("fastled_js"); + let sketch_dir = dir.path().join("sketch"); + fs::create_dir_all(sketch_dir.join("src")).unwrap(); + fs::create_dir_all(&serve_dir).unwrap(); + fs::write(sketch_dir.join("src").join("demo.ino"), "void setup() {}").unwrap(); + + let config = load_debug_symbol_config(sketch_dir, None, None); + write_debug_symbol_manifest(&serve_dir, &config).unwrap(); + let loaded = read_debug_symbol_manifest(&serve_dir) + .unwrap() + .expect("manifest should exist"); + let handle: DebugSymbolHandle = + Arc::new(RwLock::new(Some(DebugSymbolResolver::new(loaded)))); + + let addr = start_server(serve_dir, 0, None, handle, None) + .await + .unwrap(); + async_engine::sleep(std::time::Duration::from_millis(50)).await; - let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) - .await - .unwrap(); - assert_eq!(resp.status(), 200); - assert!(response_text(resp).await.contains("void setup()")); + let resp = http_get(format!("http://{addr}/sketchsource/src/demo.ino")) + .await + .unwrap(); + assert_eq!(resp.status(), 200); + assert!(response_text(resp).await.contains("void setup()")); + }); } - #[tokio::test] - async fn dwarfsource_rejects_traversal() { - use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; - - let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - let sketch_dir = dir.path().join("sketch"); - fs::create_dir_all(&sketch_dir).unwrap(); - let resolver = DebugSymbolResolver::new(load_debug_symbol_config(sketch_dir, None, None)); - let handle: DebugSymbolHandle = Arc::new(RwLock::new(Some(resolver))); - - let addr = start_server(dir.path().to_path_buf(), 0, None, handle, None) - .await - .unwrap(); - async_engine::sleep(std::time::Duration::from_millis(50)).await; - - let resp = post_json( - addr, - "/dwarfsource", - serde_json::json!({"path": "sketchsource/../escape.txt"}), - ) - .await; - assert_eq!(resp.status(), 400); + #[test] + fn dwarfsource_rejects_traversal() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + use crate::debug_symbols::{load_debug_symbol_config, DebugSymbolResolver}; + + let dir = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let sketch_dir = dir.path().join("sketch"); + fs::create_dir_all(&sketch_dir).unwrap(); + let resolver = + DebugSymbolResolver::new(load_debug_symbol_config(sketch_dir, None, None)); + let handle: DebugSymbolHandle = Arc::new(RwLock::new(Some(resolver))); + + let addr = start_server(dir.path().to_path_buf(), 0, None, handle, None) + .await + .unwrap(); + async_engine::sleep(std::time::Duration::from_millis(50)).await; + + let resp = post_json( + addr, + "/dwarfsource", + serde_json::json!({"path": "sketchsource/../escape.txt"}), + ) + .await; + assert_eq!(resp.status(), 400); + }); } } diff --git a/crates/fastled-cli/src/test_events.rs b/crates/fastled-cli/src/test_events.rs new file mode 100644 index 00000000..18caa726 --- /dev/null +++ b/crates/fastled-cli/src/test_events.rs @@ -0,0 +1,196 @@ +//! FastLED production-test event priority and enabled-source policy. + +use kernal_api::async_engine::{self, Deadline, PeriodicTimer, Receiver, UnboundedReceiver}; +use std::future::{poll_fn, Future}; +use std::pin::Pin; +use std::task::Poll; + +use crate::server::TestEvent; +use crate::test_mode::TestCommandEvent; + +#[derive(Debug)] +pub(crate) enum TestWake { + TotalTimeout, + ReadyTimeout, + Interrupted, + Liveness, + Viewer(Option), + Command(Option), +} + +pub(crate) struct TestEventSources<'a, F> { + pub interrupt: Pin<&'a mut F>, + pub liveness: &'a mut PeriodicTimer, + pub viewer: &'a mut UnboundedReceiver, + pub commands: &'a mut Receiver, +} + +impl>> TestEventSources<'_, F> { + /// Preserve the production test's priority: total deadline, readiness + /// deadline while unready, interrupt, liveness, viewer, then command events. + /// Disabled sources are not polled and cannot consume a queued event. + pub async fn next( + &mut self, + total_deadline: Deadline, + readiness_deadline: Option, + commands_open: bool, + ) -> TestWake { + let mut total = std::pin::pin!(async_engine::sleep_until(total_deadline)); + let mut readiness = std::pin::pin!(async_engine::sleep_until( + readiness_deadline.unwrap_or(total_deadline) + )); + let mut tick = std::pin::pin!(self.liveness.tick()); + let mut viewer = std::pin::pin!(self.viewer.recv()); + let mut command = std::pin::pin!(self.commands.recv()); + poll_fn(|context| { + if total.as_mut().poll(context).is_ready() { + return Poll::Ready(TestWake::TotalTimeout); + } + if readiness_deadline.is_some() && readiness.as_mut().poll(context).is_ready() { + return Poll::Ready(TestWake::ReadyTimeout); + } + if self.interrupt.as_mut().poll(context).is_ready() { + return Poll::Ready(TestWake::Interrupted); + } + if tick.as_mut().poll(context).is_ready() { + return Poll::Ready(TestWake::Liveness); + } + if let Poll::Ready(event) = viewer.as_mut().poll(context) { + return Poll::Ready(TestWake::Viewer(event)); + } + if commands_open { + if let Poll::Ready(event) = command.as_mut().poll(context) { + return Poll::Ready(TestWake::Command(event)); + } + } + Poll::Pending + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn terminal_conditions_precede_other_ready_events() { + async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + for expected in ["total", "ready", "interrupt"] { + let (viewer_tx, mut viewer) = async_engine::unbounded_channel(); + viewer_tx.send(TestEvent::Ready).unwrap(); + let (command_tx, mut commands) = async_engine::channel(1); + command_tx + .send(TestCommandEvent::Start { index: 0 }) + .await + .unwrap(); + let mut liveness = PeriodicTimer::new(Duration::from_secs(1)).unwrap(); + let mut interrupt = std::pin::pin!(std::future::ready(Ok(()))); + let total = Deadline::after(if expected == "total" { + Duration::ZERO + } else { + Duration::from_secs(1) + }); + let readiness = + (expected != "interrupt").then(|| Deadline::after(Duration::ZERO)); + // Let the timer driver advance past its scheduling + // granularity so these timers really are ready on poll. + async_engine::sleep(Duration::from_millis(5)).await; + let event = TestEventSources { + interrupt: interrupt.as_mut(), + liveness: &mut liveness, + viewer: &mut viewer, + commands: &mut commands, + } + .next(total, readiness, true) + .await; + assert!(matches!( + (expected, event), + ("total", TestWake::TotalTimeout) + | ("ready", TestWake::ReadyTimeout) + | ("interrupt", TestWake::Interrupted) + )); + } + }); + } + + #[test] + fn liveness_then_viewer_then_command_preserves_priority() { + async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (viewer_tx, mut viewer) = async_engine::unbounded_channel(); + viewer_tx.send(TestEvent::Ready).unwrap(); + let (command_tx, mut commands) = async_engine::channel(1); + command_tx + .send(TestCommandEvent::Start { index: 7 }) + .await + .unwrap(); + let mut liveness = PeriodicTimer::new(Duration::from_secs(1)).unwrap(); + async_engine::sleep(Duration::from_millis(5)).await; + let mut interrupt = std::pin::pin!(std::future::pending()); + let mut sources = TestEventSources { + interrupt: interrupt.as_mut(), + liveness: &mut liveness, + viewer: &mut viewer, + commands: &mut commands, + }; + let total = Deadline::after(Duration::from_secs(1)); + assert!(matches!( + sources.next(total, None, true).await, + TestWake::Liveness + )); + assert!(matches!( + sources.next(total, None, true).await, + TestWake::Viewer(Some(TestEvent::Ready)) + )); + assert!(matches!( + sources.next(total, None, true).await, + TestWake::Command(Some(TestCommandEvent::Start { index: 7 })) + )); + }); + } + + #[test] + fn disabled_command_source_does_not_consume_queued_events() { + async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (_viewer_tx, mut viewer) = async_engine::unbounded_channel(); + let (command_tx, mut commands) = async_engine::channel(1); + command_tx + .send(TestCommandEvent::Start { index: 3 }) + .await + .unwrap(); + let mut liveness = PeriodicTimer::new(Duration::from_secs(1)).unwrap(); + liveness.tick().await; + let mut interrupt = std::pin::pin!(std::future::pending()); + let mut sources = TestEventSources { + interrupt: interrupt.as_mut(), + liveness: &mut liveness, + viewer: &mut viewer, + commands: &mut commands, + }; + let total = Deadline::after(Duration::from_secs(1)); + assert!(async_engine::timeout( + Duration::from_millis(5), + sources.next(total, None, false) + ) + .await + .is_err()); + assert!(matches!( + sources.next(total, None, true).await, + TestWake::Command(Some(TestCommandEvent::Start { index: 3 })) + )); + }); + } +} diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index ecec4b10..e595a80a 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -136,9 +136,25 @@ async fn run_test_commands_inner( } drop(output_tx); let mut output_open = readers != 0; + let mut next_exit_check = async_engine::Deadline::after(Duration::from_millis(10)); let code = loop { - tokio::select! { - item = output_rx.recv(), if output_open => match item { + // A fixed checkpoint survives output arrivals. Recreating a sleep + // for each line can starve exit observation under continuous output. + if next_exit_check.is_elapsed() { + if let Some(code) = child + .try_wait() + .map_err(|e| format!("test command {index} wait failed: {e}"))? + { + break code; + } + next_exit_check = async_engine::Deadline::after(Duration::from_millis(10)); + } + if !output_open { + async_engine::sleep_until(next_exit_check).await; + continue; + } + if let Ok(item) = async_engine::timeout_at(next_exit_check, output_rx.recv()).await { + match item { Some((stream, line)) => { if tx .send(TestCommandEvent::Output { @@ -152,11 +168,8 @@ async fn run_test_commands_inner( return Ok(()); } } - None => { output_open = false; } - }, - _ = async_engine::sleep(std::time::Duration::from_millis(10)) => { - if let Some(code) = child.try_wait().map_err(|e| format!("test command {index} wait failed: {e}"))? { - break code; + None => { + output_open = false; } } } @@ -274,12 +287,12 @@ pub(crate) async fn run_contained_command( drop(child); return Ok(TimedCommandResult::TimedOut); } - tokio::select! { - _ = &mut ctrl_c => { - drop(child); - return Ok(TimedCommandResult::Interrupted); - } - _ = async_engine::sleep(Duration::from_millis(10)) => {} + if async_engine::timeout(Duration::from_millis(10), &mut ctrl_c) + .await + .is_ok() + { + drop(child); + return Ok(TimedCommandResult::Interrupted); } } } @@ -532,11 +545,17 @@ mod tests { } } - #[tokio::test] - async fn waiting_for_ready_has_a_distinct_timeout() { - let (_tx, mut rx) = async_engine::unbounded_channel(); - let outcome = wait_for_ready(&mut rx, Duration::from_millis(1)).await; - assert_eq!(outcome, TestOutcome::ReadyTimeout); + #[test] + fn waiting_for_ready_has_a_distinct_timeout() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (_tx, mut rx) = async_engine::unbounded_channel(); + let outcome = wait_for_ready(&mut rx, Duration::from_millis(1)).await; + assert_eq!(outcome, TestOutcome::ReadyTimeout); + }); } #[test] @@ -592,6 +611,42 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn exited_command_is_observed_despite_continuous_descendant_output() { + async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let (tx, mut rx) = async_engine::channel(256); + let task = async_engine::launch(run_test_commands( + vec!["(while :; do printf 'output\\n'; done) & sleep 0.05".to_string()], + NormalizedPath::new(temp.path()), + tx, + )); + let mut saw_output = false; + let result = async_engine::timeout(Duration::from_secs(3), async { + while let Some(event) = rx.recv().await { + saw_output |= matches!(&event, TestCommandEvent::Output { .. }); + if let TestCommandEvent::Done(result) = event { + return result; + } + } + Err("command channel closed without completion".to_string()) + }) + .await + .expect("continuous descendant output starved shell exit observation"); + assert!(result.is_ok(), "{result:?}"); + assert!(saw_output, "the fixture must exercise the output path"); + async_engine::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(); + }); + } + #[test] fn contained_command_is_stopped_at_the_hard_deadline() { let rt = async_engine::RuntimeBuilder::current_thread() @@ -620,149 +675,184 @@ mod tests { }); } - #[tokio::test] - async fn issue_200_commands_run_sequentially_and_capture_both_streams() { - let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - #[cfg(windows)] - let commands = vec![ - "echo A>order.txt".to_string(), - "echo B>>order.txt & echo out & echo err 1>&2".to_string(), - ]; - #[cfg(not(windows))] - let commands = vec![ - "printf A >> order.txt".to_string(), - "printf B >> order.txt; printf out; printf err >&2".to_string(), - ]; - let (tx, mut rx) = async_engine::channel(256); - let task = async_engine::launch(run_test_commands( - commands, - NormalizedPath::new(temp.path()), - tx, - )); - let mut outputs = Vec::new(); - let done = loop { - let event = next_command_event(&mut rx).await; - match event { - TestCommandEvent::Output { stream, line, .. } => outputs.push((stream, line)), - TestCommandEvent::Done(result) => break result, - TestCommandEvent::Start { .. } | TestCommandEvent::Exit { .. } => {} - } - }; - async_engine::timeout(COMMAND_TEST_TIMEOUT, task) - .await - .expect("command runner task did not complete before the test deadline") - .unwrap(); - assert!(done.is_ok()); - assert!(matches!( - rx.try_recv(), - Err(async_engine::TryRecvError::Disconnected) - )); - let order = std::fs::read_to_string(temp.path().join("order.txt")).unwrap(); - assert_eq!(order.split_whitespace().collect::(), "AB"); - assert!(outputs - .iter() - .any(|(stream, line)| *stream == CommandStream::Stdout && line.contains("out"))); - assert!(outputs - .iter() - .any(|(stream, line)| *stream == CommandStream::Stderr && line.contains("err"))); + #[test] + fn issue_200_commands_run_sequentially_and_capture_both_streams() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + #[cfg(windows)] + let commands = vec![ + "echo A>order.txt".to_string(), + "echo B>>order.txt & echo out & echo err 1>&2".to_string(), + ]; + #[cfg(not(windows))] + let commands = vec![ + "printf A >> order.txt".to_string(), + "printf B >> order.txt; printf out; printf err >&2".to_string(), + ]; + let (tx, mut rx) = async_engine::channel(256); + let task = async_engine::launch(run_test_commands( + commands, + NormalizedPath::new(temp.path()), + tx, + )); + let mut outputs = Vec::new(); + let done = loop { + let event = next_command_event(&mut rx).await; + match event { + TestCommandEvent::Output { stream, line, .. } => { + outputs.push((stream, line)) + } + TestCommandEvent::Done(result) => break result, + TestCommandEvent::Start { .. } | TestCommandEvent::Exit { .. } => {} + } + }; + async_engine::timeout(COMMAND_TEST_TIMEOUT, task) + .await + .expect("command runner task did not complete before the test deadline") + .unwrap(); + assert!(done.is_ok()); + assert!(matches!( + rx.try_recv(), + Err(async_engine::TryRecvError::Disconnected) + )); + let order = std::fs::read_to_string(temp.path().join("order.txt")).unwrap(); + assert_eq!(order.split_whitespace().collect::(), "AB"); + assert!( + outputs + .iter() + .any(|(stream, line)| *stream == CommandStream::Stdout + && line.contains("out")) + ); + assert!( + outputs + .iter() + .any(|(stream, line)| *stream == CommandStream::Stderr + && line.contains("err")) + ); + }); } - #[tokio::test] - async fn issue_200_nonzero_command_stops_the_sequence() { - let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - #[cfg(windows)] - let commands = vec![ - "exit /b 7".to_string(), - "echo SHOULD_NOT_RUN > later.txt".to_string(), - ]; - #[cfg(not(windows))] - let commands = vec![ - "exit 7".to_string(), - "echo SHOULD_NOT_RUN > later.txt".to_string(), - ]; - let (tx, mut rx) = async_engine::channel(256); - let task = async_engine::launch(run_test_commands( - commands, - NormalizedPath::new(temp.path()), - tx, - )); - let result = loop { - let event = next_command_event(&mut rx).await; - if let TestCommandEvent::Done(value) = event { - break value; - } - }; - async_engine::timeout(COMMAND_TEST_TIMEOUT, task) - .await - .expect("command runner task did not complete before the test deadline") - .unwrap(); - assert!(result.is_err()); - assert!(matches!( - rx.try_recv(), - Err(async_engine::TryRecvError::Disconnected) - )); - assert!(!temp.path().join("later.txt").exists()); + #[test] + fn issue_200_nonzero_command_stops_the_sequence() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + #[cfg(windows)] + let commands = vec![ + "exit /b 7".to_string(), + "echo SHOULD_NOT_RUN > later.txt".to_string(), + ]; + #[cfg(not(windows))] + let commands = vec![ + "exit 7".to_string(), + "echo SHOULD_NOT_RUN > later.txt".to_string(), + ]; + let (tx, mut rx) = async_engine::channel(256); + let task = async_engine::launch(run_test_commands( + commands, + NormalizedPath::new(temp.path()), + tx, + )); + let result = loop { + let event = next_command_event(&mut rx).await; + if let TestCommandEvent::Done(value) = event { + break value; + } + }; + async_engine::timeout(COMMAND_TEST_TIMEOUT, task) + .await + .expect("command runner task did not complete before the test deadline") + .unwrap(); + assert!(result.is_err()); + assert!(matches!( + rx.try_recv(), + Err(async_engine::TryRecvError::Disconnected) + )); + assert!(!temp.path().join("later.txt").exists()); + }); } - #[tokio::test] - async fn issue_208_runner_finishes_after_closing_descendant_pipe_writers() { - let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - #[cfg(windows)] - let command = "start /B cmd /C \"ping -n 20 127.0.0.1 >NUL & echo LATE > late.txt\""; - #[cfg(not(windows))] - let command = "(sleep 20; echo LATE > late.txt) &"; - let (tx, mut rx) = async_engine::channel(256); - let task = async_engine::launch(run_test_commands( - vec![command.to_string()], - NormalizedPath::new(temp.path()), - tx, - )); + #[test] + fn issue_208_runner_finishes_after_closing_descendant_pipe_writers() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + #[cfg(windows)] + let command = + "start /B cmd /C \"ping -n 20 127.0.0.1 >NUL & echo LATE > late.txt\""; + #[cfg(not(windows))] + let command = "(sleep 20; echo LATE > late.txt) &"; + let (tx, mut rx) = async_engine::channel(256); + let task = async_engine::launch(run_test_commands( + vec![command.to_string()], + NormalizedPath::new(temp.path()), + tx, + )); - let result = loop { - if let TestCommandEvent::Done(value) = next_command_event(&mut rx).await { - break value; - } - }; - async_engine::timeout(COMMAND_TEST_TIMEOUT, task) - .await - .expect("command runner task did not complete before the test deadline") - .unwrap(); - assert!(result.is_ok()); - async_engine::sleep(Duration::from_millis(300)).await; - assert!(!temp.path().join("late.txt").exists()); + let result = loop { + if let TestCommandEvent::Done(value) = next_command_event(&mut rx).await { + break value; + } + }; + async_engine::timeout(COMMAND_TEST_TIMEOUT, task) + .await + .expect("command runner task did not complete before the test deadline") + .unwrap(); + assert!(result.is_ok()); + async_engine::sleep(Duration::from_millis(300)).await; + assert!(!temp.path().join("late.txt").exists()); + }); } - #[tokio::test] - async fn issue_200_cancelling_runner_kills_the_contained_shell() { - for drop_handle in [false, true] { - let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); - #[cfg(windows)] - let command = "echo READY & ping -n 3 127.0.0.1 >NUL & echo LATE > late.txt"; - #[cfg(not(windows))] - let command = "echo READY; sleep 1; echo LATE > late.txt"; - let (tx, mut rx) = async_engine::channel(256); - let task = async_engine::launch(run_test_commands( - vec![command.to_string()], - NormalizedPath::new(temp.path()), - tx, - )); - loop { - match next_command_event(&mut rx).await { - TestCommandEvent::Output { line, .. } if line.trim() == "READY" => break, - TestCommandEvent::Start { .. } => {} - event => panic!("unexpected event before cancellation: {event:?}"), + #[test] + fn issue_200_cancelling_runner_kills_the_contained_shell() { + kernal_api::async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + for drop_handle in [false, true] { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + #[cfg(windows)] + let command = "echo READY & ping -n 3 127.0.0.1 >NUL & echo LATE > late.txt"; + #[cfg(not(windows))] + let command = "echo READY; sleep 1; echo LATE > late.txt"; + let (tx, mut rx) = async_engine::channel(256); + let task = async_engine::launch(run_test_commands( + vec![command.to_string()], + NormalizedPath::new(temp.path()), + tx, + )); + loop { + match next_command_event(&mut rx).await { + TestCommandEvent::Output { line, .. } if line.trim() == "READY" => { + break + } + TestCommandEvent::Start { .. } => {} + event => panic!("unexpected event before cancellation: {event:?}"), + } + } + if drop_handle { + drop(task); + } else { + task.cancel(); + let _ = task.await; + } + // Wait beyond the command's normal completion time, so a live + // escaped shell would actually have written the failure marker. + async_engine::sleep(Duration::from_secs(3)).await; + assert!(!temp.path().join("late.txt").exists()); } - } - if drop_handle { - drop(task); - } else { - task.cancel(); - let _ = task.await; - } - // Wait beyond the command's normal completion time, so a live - // escaped shell would actually have written the failure marker. - async_engine::sleep(Duration::from_secs(3)).await; - assert!(!temp.path().join("late.txt").exists()); - } + }); } } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 73348cb9..c61fbfcc 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -705,6 +705,37 @@ all-target Clippy and formatting pass. Python tests pass (28 passed, one skipped Native upstream CI remains pending. Exact published adoption and build measurements remain outstanding; no build-speed improvement is claimed. +### Direct Tokio removal checkpoint + +Issue https://github.com/zackees/fastled-wasm/issues/243 removes the final direct +Tokio manifest edge and all source references. The boundary check covers both +workspace and crate manifests plus production and test Rust sources (observed +RED then GREEN). The package still exists transitively behind the kernel and +other private implementations; no package-count or build-speed gain is claimed. + +All 28 remaining backend async test attributes now use ordinary Rust tests and +kernel-owned current-thread runtimes, preserving their assertions. FastLED's +production-test event selector uses standard future polling over kernel-owned +inputs. It preserves total timeout, readiness timeout while unready, interrupt, +liveness, viewer and command priority, without introducing a generic select +macro, runtime or abstraction crate. Focused policy tests cover simultaneous +ready sources and disabled-command nonconsumption. + +Watch polling still checks interruption before the tick that enables terminal +capture. Serve and contained-command polling use kernel timeouts around the +same pinned interrupt future. Their simultaneous-ready ties now favor the +interrupt rather than randomized selection. Command-output arrivals no longer +reset the 10 ms child-exit checkpoint; the checkpoint is checked between output +deliveries, so continuous output cannot indefinitely postpone exit observation. +Existing output backpressure and post-exit drain limits are retained. A Unix +regression test exercises an exited shell with a continuously writing descendant. + +Focused tests and source review pass. Full Rust checks pass (263 library tests, +two binary tests, one integration test and one doc test), as do strict all-target +Clippy, formatting and Python checks (28 passed, one skipped). Compiler +flags and native backend logic are unchanged. The path patch still points at +the interrupt sibling pending upstream publication and exact registry adoption. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index d3d9ac3f..f9ddb661 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -27,6 +27,9 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "tempfile" not in dependencies assert "strsim" not in dependencies assert "crossterm" not in dependencies + assert "tokio" not in dependencies + workspace = tomllib.loads((root / "Cargo.toml").read_text()) + assert "tokio" not in workspace["workspace"]["dependencies"] assert "tempfile" not in manifest.get("dev-dependencies", {}) for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies @@ -80,6 +83,7 @@ def test_migrated_async_operations_use_kernel(): root = Path(__file__).resolve().parents[2] for path in (root / "crates/fastled-cli/src").rglob("*.rs"): source = path.read_text() + assert "tokio::" not in source, path for backend in ( "tokio::runtime::", "tokio::time::", From a8c172ff5331cb4bf00207b61a0d8b9ec1932a8d Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 03:19:02 -0700 Subject: [PATCH 44/94] refactor: use kernel compile-target facts instead of ctcb-core (refs #229) --- Cargo.lock | 13 --------- Cargo.toml | 3 +- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/install.rs | 47 ++++++++++++++++++++++++++++-- docs/kernal-api-migration.md | 17 +++++++++++ tests/unit/test_kernal_boundary.py | 3 ++ 6 files changed, 65 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e2c4035..8cbd1713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -715,18 +715,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ctcb-core" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d666d682b69add1b1bea317cb3184018f21a0ff95f3a9d1b44887bd5b01961" -dependencies = [ - "anyhow", - "serde", - "serde_json", - "thiserror 2.0.20", -] - [[package]] name = "ctor" version = "0.2.9" @@ -1020,7 +1008,6 @@ version = "2.0.20" dependencies = [ "anyhow", "clap", - "ctcb-core", "gtk", "kernal-api", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2c04be32..66a4b879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" -ctcb-core = "0.4.1" toml = "0.8" tauri = { version = "2", features = ["devtools"] } tauri-build = { version = "2", features = [] } @@ -33,7 +32,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-interrupt" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-target" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 9b5dc5ea..2bd98586 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -26,7 +26,6 @@ kernal-api = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -ctcb-core = { workspace = true } toml = { workspace = true } tauri = { workspace = true, optional = true } shell-words = { workspace = true } diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index 8548832f..dc8d2ea8 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -15,7 +15,6 @@ use std::sync::{Mutex, OnceLock}; use std::collections::BTreeMap; use anyhow::{bail, Context, Result}; -use ctcb_core::Target; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -67,8 +66,26 @@ fn fastled_root() -> Result { } fn detect_platform_arch() -> Result<(String, String)> { - let target = Target::current().context("detect host clang-tool-chain target")?; - Ok((target.platform.to_string(), target.arch.to_string())) + let target = kernal_api::platform::host::process_target(); + toolchain_platform_arch(target.os, target.architecture) + .context("detect host clang-tool-chain target") + .map(|(platform, arch)| (platform.to_owned(), arch.to_owned())) +} + +/// Clang-tool-chain catalog names and supported targets are product policy. +fn toolchain_platform_arch(os: &str, architecture: &str) -> Result<(&'static str, &'static str)> { + let platform = match os { + "windows" => "win", + "linux" => "linux", + "macos" => "darwin", + _ => bail!("unsupported operating system"), + }; + let arch = match architecture { + "x86_64" => "x86_64", + "aarch64" => "arm64", + other => bail!("unsupported architecture: {other}"), + }; + Ok((platform, arch)) } /// Parse a platform manifest, accepting both the current schema (`versions` @@ -2147,6 +2164,30 @@ pub fn run_install(options: InstallOptions) -> Result { #[cfg(test)] mod tests { + #[test] + fn toolchain_target_aliases_preserve_supported_binary_targets() { + for (os, platform) in [("windows", "win"), ("linux", "linux"), ("macos", "darwin")] { + for (architecture, arch) in [("x86_64", "x86_64"), ("aarch64", "arm64")] { + assert_eq!( + super::toolchain_platform_arch(os, architecture).unwrap(), + (platform, arch) + ); + } + } + assert_eq!( + super::toolchain_platform_arch("freebsd", "x86_64") + .unwrap_err() + .to_string(), + "unsupported operating system" + ); + assert_eq!( + super::toolchain_platform_arch("linux", "x86") + .unwrap_err() + .to_string(), + "unsupported architecture: x86" + ); + } + use super::*; const DARWIN_ARM64_MANIFEST: &str = r#"{ diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index c61fbfcc..7601d3cc 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -736,6 +736,23 @@ Clippy, formatting and Python checks (28 passed, one skipped). Compiler flags and native backend logic are unchanged. The path patch still points at the interrupt sibling pending upstream publication and exact registry adoption. +### Compile-target identity checkpoint + +Kernel issue https://github.com/zackees/kernal-api/issues/190 adds +`platform::host::process_target()`: compile-time OS and architecture facts, +not physical hardware or emulation detection. FastLED now consumes that API +instead of `ctcb-core`. Catalog aliases (`win`, `darwin`, `arm64`) and supported +toolchain choices remain product policy. All six supported OS/architecture +mappings and unsupported-target errors are covered locally; the target-fact +contract is tested upstream. Toolchain versions, URLs, compiler flags, and +compiler invocation logic are unchanged. + +The upstream missing-API test and local forbidden-dependency check were both +observed RED then GREEN. The lockfile removes the `ctcb-core` package without +adding a replacement package; no build-speed improvement is claimed. The +migration-only path patch now points to the process-target sibling, stacked +on interrupt notifications, pending publication and exact registry adoption. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index f9ddb661..000a020b 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -28,8 +28,10 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "strsim" not in dependencies assert "crossterm" not in dependencies assert "tokio" not in dependencies + assert "ctcb-core" not in dependencies workspace = tomllib.loads((root / "Cargo.toml").read_text()) assert "tokio" not in workspace["workspace"]["dependencies"] + assert "ctcb-core" not in workspace["workspace"]["dependencies"] assert "tempfile" not in manifest.get("dev-dependencies", {}) for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies @@ -48,6 +50,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "tempfile::" not in source.read_text(), source assert "strsim::" not in source.read_text(), source assert "crossterm::" not in source.read_text(), source + assert "ctcb_core::" not in source.read_text(), source for backend in ("zip", "tar", "zstd", "flate2"): assert f"{backend}::" not in source.read_text(), source From 49ee1383f99467e68ccb8036eae16a4f74eea4b5 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 03:30:09 -0700 Subject: [PATCH 45/94] docs: record remaining native viewer migration gaps (refs #229) --- docs/kernal-api-migration.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 7601d3cc..2e1f9cc1 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -11,7 +11,7 @@ capability migration, with the same toolchain, features, and cache conditions. ## Capability inventory -| Capability | Current direct dependencies | Migration work | +| Capability | Original direct dependencies | Migration work | | --- | --- | --- | | Advisory locks | fs2 | #230: all six call sites migrated to kernal-api guards; headless Rust suite passes | | Glob matching and watchers | globset, notify | #231/#232: migrated to filesystem patterns and fs-watch; lost watches trigger recovery and rescan | @@ -753,6 +753,32 @@ adding a replacement package; no build-speed improvement is claimed. The migration-only path patch now points to the process-target sibling, stacked on interrupt notifications, pending publication and exact registry adoption. +### Native viewer adoption gaps + +The dependency inventory above records the original migration inputs, not the +current manifests. After `904b8fb`, remaining direct Rust dependencies are Clap, +Serde/JSON/TOML, Anyhow, Tauri/build/GTK/WebKitGTK, shell-words, and the two +Tree-sitter crates. The application has no direct Tokio or ctcb-core edge. + +Source comparison of `tauri_viewer.rs` and the kernel's isolated webview host +identifies these remaining viewer requirements: + +- Product title and logical window dimensions: kernel issue + https://github.com/zackees/kernal-api/issues/193 adds bounded presentation + options through the existing native host and resource lifecycle. +- Pre-page script delivery: preserve test-token extraction, console forwarding, + WebGL capture setup and FastLED's screenshot schedule, including reloads. + These scripts and HTTP endpoints remain product policy. Do not weaken the + isolated webview's no-native-IPC contract to obtain script delivery. +- Windows DPI/zoom policy: preserve the existing Windows-only adjustment and + avoid applying it on Linux/macOS. Validate on native hosts. +- Media and graphics parity: the kernel already contains the Linux renderer + environment, font-DPI and opt-in user-media mechanisms, but adoption still + needs real viewer tests for microphone, rendering, logs, captures and teardown. + +The native viewer dependencies remain until these behaviors are integrated and +verified. Window-configuration groundwork alone does not complete GUI migration. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, From 6680188a1ef311abc68805bcbd0d07981fdbbb53 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 03:43:19 -0700 Subject: [PATCH 46/94] docs: track bootstrap and interactive viewer lifetime gaps (refs #229) --- docs/kernal-api-migration.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 2e1f9cc1..f2738cd8 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -770,8 +770,17 @@ identifies these remaining viewer requirements: WebGL capture setup and FastLED's screenshot schedule, including reloads. These scripts and HTTP endpoints remain product policy. Do not weaken the isolated webview's no-native-IPC contract to obtain script delivery. + Kernel issue https://github.com/zackees/kernal-api/issues/195 implements an + explicit bounded bootstrap route, restricted to the original HTTP(S) origin. + Its Linux native proof checks ordering before page code, same-origin reload, + subframe exclusion, absent IPC and rejected cross-origin navigation. The + existing isolated route remains script-free; Windows execution and app + integration remain required. - Windows DPI/zoom policy: preserve the existing Windows-only adjustment and avoid applying it on Linux/macOS. Validate on native hosts. +- Interactive lifetime: preserve waiting for the user to close the window, + without introducing an arbitrary lifetime timeout. The current kernel + terminal-wait API requires a timeout that revokes the window on expiry. - Media and graphics parity: the kernel already contains the Linux renderer environment, font-DPI and opt-in user-media mechanisms, but adoption still needs real viewer tests for microphone, rendering, logs, captures and teardown. From be5103188087b3b8bd5e4b401b5386d52d859310 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 04:04:49 -0700 Subject: [PATCH 47/94] docs: track interactive viewer lifetime migration --- docs/kernal-api-migration.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index f2738cd8..9b2a0e72 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -779,8 +779,20 @@ identifies these remaining viewer requirements: - Windows DPI/zoom policy: preserve the existing Windows-only adjustment and avoid applying it on Linux/macOS. Validate on native hosts. - Interactive lifetime: preserve waiting for the user to close the window, - without introducing an arbitrary lifetime timeout. The current kernel - terminal-wait API requires a timeout that revokes the window on expiry. + without introducing an arbitrary lifetime timeout. Kernel issue + https://github.com/zackees/kernal-api/issues/198 and draft upstream PR + https://github.com/zackees/kernal-api/pull/199 add `wait_for_terminal()` + alongside the existing timed API. Linux native tests verify that cancelling + the wait preserves the window, a resumed wait observes closure, and timed + expiry/handle cancellation retain their existing behavior. No timer, polling + loop or new runtime is introduced. A review found that overlapping waits + could strand a waiter; cancellation-released admission now rejects a second + timed or untimed wait with `TerminalWaitInProgress`. Linux native regression + tests pass for both initial waiter types; full GUI-support tests, strict + Clippy, dependency isolation and Windows MSVC cross-compilation pass. Two + macOS ARM64 cross-check attempts failed in the build-cache relay while + compiling a dependency, before checking the facade. Native Windows + execution, macOS validation and app adoption remain. - Media and graphics parity: the kernel already contains the Linux renderer environment, font-DPI and opt-in user-media mechanisms, but adoption still needs real viewer tests for microphone, rendering, logs, captures and teardown. From 482381e7393544f3e98d4c6dc384cc582b5294dd Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 04:20:42 -0700 Subject: [PATCH 48/94] refactor(viewer): adopt kernal-api native hosting (refs #229) --- Cargo.lock | 1033 ++++++++-------------- Cargo.toml | 4 +- ci/native_viewer_smoke.py | 127 +++ crates/fastled-cli/Cargo.toml | 12 +- crates/fastled-cli/build.rs | 4 - crates/fastled-cli/src/lib.rs | 2 - crates/fastled-cli/src/linux_graphics.rs | 272 ------ crates/fastled-cli/src/tauri_viewer.rs | 158 ++-- crates/fastled-cli/tauri.conf.json | 21 - docs/kernal-api-migration.md | 52 +- tests/unit/test_kernal_boundary.py | 18 + 11 files changed, 666 insertions(+), 1037 deletions(-) create mode 100644 ci/native_viewer_smoke.py delete mode 100644 crates/fastled-cli/build.rs delete mode 100644 crates/fastled-cli/src/linux_graphics.rs delete mode 100644 crates/fastled-cli/tauri.conf.json diff --git a/Cargo.lock b/Cargo.lock index 8cbd1713..bca80459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,21 +28,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -232,27 +217,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "bstr" version = "1.12.3" @@ -511,12 +475,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" version = "0.18.1" @@ -677,31 +635,27 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.29.6" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.6.1", "dtoa-short", "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", + "phf", "smallvec", - "syn 1.0.109", ] [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.7.1", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -716,15 +670,31 @@ dependencies = [ ] [[package]] -name = "ctor" -version = "0.2.9" +name = "cssparser-macros" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.117", + "syn 3.0.5", ] +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "darling" version = "0.23.0" @@ -786,19 +756,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -840,6 +797,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -910,7 +876,22 @@ dependencies = [ "html5ever 0.38.0", "precomputed-hash", "selectors 0.36.1", - "tendril 0.5.0", + "tendril", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser 0.37.0", + "foldhash", + "html5ever 0.39.0", + "precomputed-hash", + "selectors 0.38.0", + "tendril", ] [[package]] @@ -937,6 +918,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -1008,17 +1004,13 @@ version = "2.0.20" dependencies = [ "anyhow", "clap", - "gtk", "kernal-api", "serde", "serde_json", "shell-words", - "tauri", - "tauri-build", "toml 0.8.23", "tree-sitter", "tree-sitter-cpp", - "webkit2gtk", ] [[package]] @@ -1145,16 +1137,6 @@ dependencies = [ "libc", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1226,15 +1208,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1307,33 +1280,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1354,17 +1300,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1373,7 +1308,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1603,24 +1538,22 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.29.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "mac", - "markup5ever 0.14.1", - "match_token", + "markup5ever 0.38.0", ] [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever 0.39.0", ] [[package]] @@ -1759,7 +1692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2096,11 +2029,12 @@ version = "0.1.0" dependencies = [ "blake3", "bytes", - "dirs", + "dirs 6.0.0", "flate2", "futures-core", "getrandom 0.4.3", "globset", + "gtk", "http-body-util", "hyper", "hyper-util", @@ -2116,14 +2050,21 @@ dependencies = [ "strsim", "sysinfo", "tar", + "tauri", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", "tempfile", "thiserror 2.0.20", "tokio", "tokio-stream", "toml 0.8.23", + "url", + "webkit2gtk", "widestring", "winapi", "windows-sys 0.61.2", + "wry 0.57.0", "zip", "zstd", ] @@ -2159,58 +2100,12 @@ dependencies = [ "libc", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", -] - -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libredox" version = "0.1.16" @@ -2271,12 +2166,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mach2" version = "0.4.3" @@ -2286,20 +2175,6 @@ dependencies = [ "libc", ] -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -2307,27 +2182,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] [[package]] -name = "match_token" -version = "0.1.0" +name = "markup5ever" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "log", + "tendril", + "web_atoms", ] -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "memchr" version = "2.8.0" @@ -2376,15 +2245,15 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] [[package]] name = "muda" -version = "0.17.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -2395,7 +2264,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.20", "windows-sys 0.60.2", @@ -2433,12 +2302,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2454,12 +2317,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "notify" version = "7.0.0" @@ -2557,6 +2414,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2581,6 +2459,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2638,8 +2548,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -2783,105 +2712,25 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "phf_generator", + "phf_shared", ] [[package]] @@ -2891,34 +2740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_shared", ] [[package]] @@ -2927,47 +2749,20 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -3003,11 +2798,24 @@ dependencies = [ [[package]] name = "png" -version = "0.17.16" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -3029,15 +2837,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3096,12 +2895,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3141,87 +2934,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3430,7 +3142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dee9be8d7fb159393e54586b070fec01912fab04444f98f4db54fd3ceebdae59" dependencies = [ "libc", - "png", + "png 0.17.16", "sysinfo", "thiserror 2.0.20", "tokio", @@ -3589,38 +3301,39 @@ dependencies = [ [[package]] name = "selectors" -version = "0.24.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more", "log", - "phf 0.8.0", - "phf_codegen 0.8.0", + "new_debug_unreachable", + "phf", + "phf_codegen", "precomputed-hash", - "servo_arc 0.2.0", + "rustc-hash", + "servo_arc", "smallvec", ] [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags 2.11.0", - "cssparser 0.36.0", - "derive_more 2.1.1", + "cssparser 0.37.0", + "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] @@ -3795,16 +3508,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -3864,12 +3567,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - [[package]] name = "siphasher" version = "1.0.2" @@ -3958,19 +3655,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -3979,30 +3663,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -4037,7 +3709,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -4113,9 +3784,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.8" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.11.0", "block2", @@ -4126,19 +3797,19 @@ dependencies = [ "dlopen2", "dpi", "gdkwayland-sys", - "gdkx11-sys", "gtk", "jni", "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", @@ -4146,14 +3817,13 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-version", - "x11-dl", ] [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", @@ -4178,14 +3848,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4215,27 +3885,23 @@ dependencies = [ "tauri-build", "tauri-macros", "tauri-runtime", - "tauri-runtime-wry", "tauri-utils", "thiserror 2.0.20", "tokio", - "tray-icon", "url", - "webkit2gtk", - "webview2-com", "window-vibrancy", "windows 0.61.3", ] [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4245,22 +3911,20 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", - "brotli", "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4278,9 +3942,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4292,9 +3956,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -4311,15 +3975,15 @@ dependencies = [ "thiserror 2.0.20", "url", "webkit2gtk", - "webview2-com", + "webview2-com 0.38.2", "windows 0.61.3", ] [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -4336,31 +4000,30 @@ dependencies = [ "tauri-utils", "url", "webkit2gtk", - "webview2-com", + "webview2-com 0.38.2", "windows 0.61.3", - "wry", + "wry 0.55.1", ] [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", - "brotli", "cargo_metadata", "ctor", + "dom_query 0.27.0", "dunce", "glob", - "html5ever 0.29.1", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf", + "plist", "proc-macro2", "quote", "regex", @@ -4403,17 +4066,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "tendril" version = "0.5.0" @@ -4754,28 +4406,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tray-icon" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png", - "serde", - "thiserror 2.0.20", - "windows-sys 0.60.2", -] - [[package]] name = "tree-sitter" version = "0.26.11" @@ -4989,12 +4619,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5094,10 +4718,10 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -5151,13 +4775,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", - "webview2-com-sys", + "webview2-com-sys 0.38.2", "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", ] +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys 0.39.1", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "webview2-com-macros" version = "0.8.1" @@ -5180,6 +4816,17 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.20", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "widestring" version = "1.2.1" @@ -5248,11 +4895,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5264,6 +4923,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -5307,7 +4975,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5354,6 +5033,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5492,6 +5181,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5690,19 +5388,18 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.54.4" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", "cookie", "crossbeam-channel", - "dirs", - "dom_query", + "dirs 6.0.0", + "dom_query 0.27.0", "dpi", "dunce", - "gdkx11", "gtk", "http", "javascriptcore-rs", @@ -5725,32 +5422,52 @@ dependencies = [ "url", "webkit2gtk", "webkit2gtk-sys", - "webview2-com", + "webview2-com 0.38.2", "windows 0.61.3", "windows-core 0.61.2", "windows-version", - "x11-dl", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", ] [[package]] -name = "x11-dl" -version = "2.21.0" +name = "wry" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 7.0.0", + "dom_query 0.28.0", + "dpi", + "dunce", + "gtk", + "http", + "javascriptcore-rs", + "jni", "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", "once_cell", - "pkg-config", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com 0.39.1", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-version", ] [[package]] @@ -5802,26 +5519,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zerofrom" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 66a4b879..d63eb8d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,6 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" toml = "0.8" -tauri = { version = "2", features = ["devtools"] } -tauri-build = { version = "2", features = [] } shell-words = "1" tree-sitter = "0.26.9" tree-sitter-cpp = "0.23.4" @@ -32,7 +30,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-target" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-webview-scale" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/ci/native_viewer_smoke.py b/ci/native_viewer_smoke.py new file mode 100644 index 00000000..246399c1 --- /dev/null +++ b/ci/native_viewer_smoke.py @@ -0,0 +1,127 @@ +"""Exercise shipped viewer bootstrap, HTTP logs/capture, and security teardown. + +Run with a built fastled binary inside an isolated native desktop (e.g. Xvfb). +The fixture uses a real 2D canvas and a minimal ready-event source, not a WASM +sketch or render worker. This does not replace real sketch/media/DPI validation. +""" + +import argparse +import json +import struct +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary", type=Path) + args = parser.parse_args() + token = "native-viewer-fixture" + done = threading.Event() + screenshots: list[bytes] = [] + logs: list[str] = [] + failures: list[str] = [] + + class Handler(BaseHTTPRequestHandler): + def setup(self) -> None: + super().setup() + self.connection.settimeout(5) + + def log_message(self, format: str, *args: object) -> None: + pass + + def reply(self, body: bytes = b"", status: int = 200) -> None: + self.send_response(status) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path == "/": + page = f""" +""" + self.reply(page.encode()) + elif self.path == "/test-config": + self.reply(json.dumps({"waitMs": 0, "intervalMs": 0, "screenshotNames": ["probe.png"]}).encode()) + elif self.path == "/probe-done": + self.reply(json.dumps(done.is_set()).encode()) + elif self.path == "/favicon.ico": + self.reply(status=204) + else: + failures.append(f"unexpected GET {self.path}") + self.reply(status=404) + + def do_POST(self) -> None: + size = int(self.headers.get("Content-Length", "0")) + if not 0 <= size <= 1024 * 1024: + failures.append("oversize fixture request") + self.reply(status=413) + return + body = self.rfile.read(size) + if self.headers.get("Authorization") != f"Bearer {token}": + failures.append(f"missing authorization: {self.path}") + self.reply(status=403) + return + if self.path == "/viewer-log" and len(logs) < 128: + logs.append(body.decode()) + elif self.path == "/viewer-screenshot?name=probe.png" and not screenshots: + screenshots.append(body) + elif self.path == "/test-done": + if body != b"0": + failures.append(f"capture failed: {body!r}") + done.set() + elif self.path != "/test-ready" and not self.path.startswith("/test-sleep?ms="): + failures.append(f"unexpected POST {self.path}") + self.reply() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + result = subprocess.run( + [str(args.binary.resolve()), "--internal-viewer", f"http://127.0.0.1:{server.server_port}/#fastled-test-token={token}", "--viewer-inject-test-runtime"], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + assert not failures, failures + assert done.is_set(), (logs, result.stderr) + assert "log: native-viewer-bootstrap-ok" in logs, logs + assert len(screenshots) == 1, logs + png = screenshots[0] + assert png.startswith(b"\x89PNG\r\n\x1a\n") and len(png) > 32 + assert struct.unpack(">II", png[16:24]) == (32, 32) + assert result.returncode == 1, result + assert "navigation was rejected" in result.stderr, result.stderr + print("native viewer bootstrap, authenticated logs, 32x32 PNG capture and rejection teardown passed") + + +if __name__ == "__main__": + main() diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 2bd98586..92f88341 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -10,7 +10,7 @@ description = "FastLED WASM compilation CLI" [features] default = ["viewer"] -viewer = ["dep:tauri", "dep:tauri-build", "dep:gtk", "dep:webkit2gtk"] +viewer = ["kernal-api/tauri-webview"] [lib] name = "fastled_cli" @@ -27,20 +27,10 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } toml = { workspace = true } -tauri = { workspace = true, optional = true } shell-words = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } -[build-dependencies] -tauri-build = { workspace = true, optional = true } - -[target.'cfg(target_os = "linux")'.dependencies] -# Same gtk 0.18 that tauri/tao link; used to correct GTK's font DPI before the webview exists. -gtk = { version = "0.18", optional = true } -# Same webkit2gtk that wry links; used to grant microphone access to the page. -webkit2gtk = { version = "2.0", features = ["v2_40"], optional = true } - [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/fastled-{ target }{ archive-suffix }" bin-dir = "fastled{ binary-ext }" diff --git a/crates/fastled-cli/build.rs b/crates/fastled-cli/build.rs deleted file mode 100644 index 1895a7c2..00000000 --- a/crates/fastled-cli/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - #[cfg(feature = "viewer")] - tauri_build::build(); -} diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index f4839f4d..62f2ad81 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -21,8 +21,6 @@ pub mod frontend; pub mod install; mod install_unlock; mod keyboard; -#[cfg(target_os = "linux")] -pub mod linux_graphics; pub mod path; mod paths_util; pub mod project; diff --git a/crates/fastled-cli/src/linux_graphics.rs b/crates/fastled-cli/src/linux_graphics.rs deleted file mode 100644 index 514c80c9..00000000 --- a/crates/fastled-cli/src/linux_graphics.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Linux graphics workarounds for the WebKitGTK-backed Tauri viewer. -//! -//! WebKitGTK renders the page in a separate web process and hands finished -//! frames to the viewer over DMA-BUF. On NVIDIA that path has two known -//! failure modes, both observed on driver 595.71.05 / WebKitGTK 2.52.6: -//! -//! * Wayland: the window dies at once with "Error 71 (Protocol error) -//! dispatching to Wayland display" because of the driver's explicit sync. -//! `__NV_DISABLE_EXPLICIT_SYNC=1` keeps the fast DMA-BUF path working -//! (Tauri's documented fix; UI-process CPU drops from ~50% to ~15%). -//! * X11: the viewer process cannot import the NVIDIA DMA-BUF and paints a -//! solid grey window. Only `WEBKIT_DISABLE_DMABUF_RENDERER=1` paints, at -//! the cost of a CPU copy of the window every frame. -//! -//! Separately, JavaScriptCore hides the `SharedArrayBuffer` global even when -//! the page is cross-origin isolated, which breaks Emscripten's pthread -//! bootstrap ("The object can not be cloned"). `JSC_useSharedArrayBuffer=1` -//! exposes it. -//! -//! Every override is applied only when the variable is unset, so a user who -//! sets one explicitly keeps their value. The decision is a pure function of -//! the host facts so it can be unit tested; `apply` gathers the facts and -//! writes the environment. - -use std::collections::BTreeMap; - -/// What the decision needs to know about the host. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HostFacts { - /// The proprietary NVIDIA kernel driver is loaded. - pub nvidia: bool, - /// GDK will use the X11 backend (explicit `GDK_BACKEND=x11`, or no - /// Wayland display is available). - pub x11_backend: bool, - /// Variables the environment already carries; existing values win. - pub already_set: Vec, -} - -/// Environment variables the viewer should set before creating its webview. -pub fn overrides(facts: &HostFacts) -> BTreeMap<&'static str, &'static str> { - let mut result = BTreeMap::new(); - let mut set = |key: &'static str, value: &'static str| { - if !facts.already_set.iter().any(|k| k == key) { - result.insert(key, value); - } - }; - - // WebKitGTK's JavaScriptCore keeps SharedArrayBuffer hidden; the - // pthread WASM build needs it. - set("JSC_useSharedArrayBuffer", "1"); - - if facts.nvidia { - set("__NV_DISABLE_EXPLICIT_SYNC", "1"); - if facts.x11_backend { - set("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } - } - - result -} - -/// Reads the host facts from `/proc` and the environment. -pub fn detect() -> HostFacts { - let nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists() - || std::path::Path::new("/sys/module/nvidia").exists(); - let gdk_backend = std::env::var("GDK_BACKEND").unwrap_or_default(); - let x11_backend = if gdk_backend.is_empty() { - std::env::var_os("WAYLAND_DISPLAY").is_none() - } else { - // GDK_BACKEND is a comma-separated preference list; the first wins. - gdk_backend - .split(',') - .next() - .is_some_and(|b| b.trim().eq_ignore_ascii_case("x11")) - }; - let already_set = [ - "JSC_useSharedArrayBuffer", - "__NV_DISABLE_EXPLICIT_SYNC", - "WEBKIT_DISABLE_DMABUF_RENDERER", - ] - .into_iter() - .filter(|key| std::env::var_os(key).is_some()) - .map(str::to_string) - .collect(); - HostFacts { - nvidia, - x11_backend, - already_set, - } -} - -/// Applies the overrides to this process's environment and reports them on -/// stderr, so a user debugging a blank viewer can see what was changed. -pub fn apply() { - let facts = detect(); - let overrides = overrides(&facts); - for (key, value) in &overrides { - std::env::set_var(key, value); - } - if !overrides.is_empty() { - let list = overrides - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(" "); - eprintln!( - "fastled: viewer graphics workarounds applied (nvidia={}, x11={}): {list}", - facts.nvidia, facts.x11_backend - ); - } -} - -/// CSS reference DPI; also what GTK reports once the GSettings schema is -/// visible and the text scaling factor is 1.0. -pub const FALLBACK_FONT_DPI: i32 = 96; - -/// True when a `gtk-xft-dpi` value (DPI * 1024) is unusable. GDK reports the -/// DPI as -1 when unknown; WebKitGTK (2.52 and main) multiplies that by 1024, -/// checks only for exactly -1, and turns the result into a page zoom of -/// -1/96, which collapses layout and reports a negative devicePixelRatio. -pub fn font_dpi_is_unknown(gtk_xft_dpi: i32) -> bool { - gtk_xft_dpi <= 0 -} - -/// The `gtk-xft-dpi` value (DPI * 1024) the viewer should run with. -/// -/// WebKitGTK renders at GDK's integer scale factor and multiplies its page -/// zoom by font DPI / 96. Desktops with fractional scaling (KDE at 1.5 or -/// 1.75) tell GTK3 apps their intended scale through that font DPI, so the -/// effective scale should be `desktop_dpi / 96`, not the integer scale. That -/// means reporting `desktop_dpi / integer_scale`: at 168 dpi on a 2x display -/// the page zooms to 0.875 and lands at 1.75x. On X11 the integer scale is 1 -/// and the value passes through unchanged. When the desktop gives no DPI at -/// all, report 96 so plain integer scaling applies and WebKit never sees -/// GDK's -1. -pub fn effective_font_dpi(desktop_gtk_xft_dpi: i32, integer_scale: i32) -> i32 { - let scale = integer_scale.max(1); - if font_dpi_is_unknown(desktop_gtk_xft_dpi) { - return FALLBACK_FONT_DPI * 1024; - } - (desktop_gtk_xft_dpi / scale).max(1) -} - -/// Applies [`effective_font_dpi`] to GTK before the webview exists. Setting -/// the `gtk-xft-dpi` setting also updates the GdkScreen resolution that -/// WebKitGTK's GTK3 build reads, and WebKit re-reads it on change. On Wayland -/// GDK only learns the desktop DPI from the GSettings -/// `org.gnome.desktop.interface` schema, which unwrapped binaries on some -/// distributions (NixOS with Plasma) cannot see; the GtkSettings property may -/// still carry the desktop's value from settings.ini, so it is read from -/// there and the screen resolution is corrected explicitly as well. -#[cfg(feature = "viewer")] -pub fn ensure_font_dpi() { - use gtk::gdk::prelude::MonitorExt; - use gtk::prelude::GtkSettingsExt; - let Some(settings) = gtk::Settings::default() else { - return; - }; - let desktop = settings.gtk_xft_dpi(); - let integer_scale = gtk::gdk::Display::default() - .and_then(|display| display.primary_monitor().or_else(|| display.monitor(0))) - .map(|monitor| monitor.scale_factor()) - .unwrap_or(1); - let wanted = effective_font_dpi(desktop, integer_scale); - let screen_dpi = gtk::gdk::Screen::default() - .map(|screen| screen.resolution()) - .unwrap_or(-1.0); - if wanted != desktop || screen_dpi.is_nan() || screen_dpi <= 0.0 { - settings.set_gtk_xft_dpi(wanted); - if let Some(screen) = gtk::gdk::Screen::default() { - screen.set_resolution(f64::from(wanted) / 1024.0); - } - eprintln!( - "fastled: font dpi {desktop} (desktop) / scale {integer_scale} -> {wanted}; screen resolution was {screen_dpi}" - ); - } -} - -/// Lets the page use the microphone. WebKitGTK ships with media streams -/// disabled and, unlike a browser, has no built-in permission prompt: every -/// `getUserMedia()` call is refused unless the embedding application enables -/// the setting and answers the `permission-request` signal. The viewer only -/// ever shows the user's own sketch, so user-media requests are allowed; -/// anything else (geolocation, notifications) is left to WebKit's default, -/// which is to deny. -#[cfg(feature = "viewer")] -pub fn allow_user_media(webview: &webkit2gtk::WebView) { - use gtk::glib::Cast; - use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt}; - if let Some(settings) = webview.settings() { - settings.set_enable_media_stream(true); - settings.set_enable_webaudio(true); - } - webview.connect_permission_request(|_, request| { - if request - .downcast_ref::() - .is_some() - { - request.allow(); - true - } else { - false - } - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn facts(nvidia: bool, x11: bool, set: &[&str]) -> HostFacts { - HostFacts { - nvidia, - x11_backend: x11, - already_set: set.iter().map(|s| s.to_string()).collect(), - } - } - - #[test] - fn effective_font_dpi_follows_the_desktop_scale() { - // Desktop at 1.75 (168 dpi) on a 2x integer display: zoom to 0.875 - assert_eq!(effective_font_dpi(172032, 2), 86016); - // X11: integer scale 1, value passes through - assert_eq!(effective_font_dpi(172032, 1), 172032); - // 96 dpi desktop on a 2x display stays plain integer scaling - assert_eq!(effective_font_dpi(98304, 2), 49152); - // Unknown desktop DPI: 96 so WebKit never sees GDK's -1 - assert_eq!(effective_font_dpi(-1, 2), 98304); - assert_eq!(effective_font_dpi(0, 1), 98304); - } - - #[test] - fn unknown_font_dpi_is_detected() { - assert!(font_dpi_is_unknown(-1)); - assert!(font_dpi_is_unknown(-1024)); - assert!(font_dpi_is_unknown(0)); - assert!(!font_dpi_is_unknown(96 * 1024)); - } - - #[test] - fn shared_array_buffer_is_always_exposed() { - let o = overrides(&facts(false, false, &[])); - assert_eq!(o.get("JSC_useSharedArrayBuffer"), Some(&"1")); - assert_eq!(o.len(), 1, "no NVIDIA-specific overrides without NVIDIA"); - } - - #[test] - fn nvidia_wayland_keeps_dmabuf_and_disables_explicit_sync() { - let o = overrides(&facts(true, false, &[])); - assert_eq!(o.get("__NV_DISABLE_EXPLICIT_SYNC"), Some(&"1")); - assert!(!o.contains_key("WEBKIT_DISABLE_DMABUF_RENDERER")); - } - - #[test] - fn nvidia_x11_disables_dmabuf_renderer() { - let o = overrides(&facts(true, true, &[])); - assert_eq!(o.get("__NV_DISABLE_EXPLICIT_SYNC"), Some(&"1")); - assert_eq!(o.get("WEBKIT_DISABLE_DMABUF_RENDERER"), Some(&"1")); - } - - #[test] - fn explicit_user_values_are_kept() { - let o = overrides(&facts( - true, - true, - &["WEBKIT_DISABLE_DMABUF_RENDERER", "JSC_useSharedArrayBuffer"], - )); - assert!(!o.contains_key("WEBKIT_DISABLE_DMABUF_RENDERER")); - assert!(!o.contains_key("JSC_useSharedArrayBuffer")); - assert_eq!(o.get("__NV_DISABLE_EXPLICIT_SYNC"), Some(&"1")); - } -} diff --git a/crates/fastled-cli/src/tauri_viewer.rs b/crates/fastled-cli/src/tauri_viewer.rs index 9ef39418..c4c1a4dc 100644 --- a/crates/fastled-cli/src/tauri_viewer.rs +++ b/crates/fastled-cli/src/tauri_viewer.rs @@ -1,5 +1,11 @@ use std::process::ExitCode; +use kernal_api::async_engine::RuntimeBuilder; +use kernal_api::webview::{ + ExternalWebviewClient, ExternalWebviewHost, WebviewError, WebviewPageBootstrap, + WebviewPermissions, WebviewWindowOptions, +}; + pub struct ViewerOptions { pub url: String, pub title: String, @@ -316,69 +322,96 @@ const TEST_RUNTIME_SCRIPT: &str = r#" })(); "#; -pub fn run(options: ViewerOptions) -> ExitCode { - let ViewerOptions { - url, - title, - width, - height, - inject_test_runtime, - } = options; - - // Must run before the webview (and its WebKit processes) exist. - #[cfg(target_os = "linux")] - fastled_cli::linux_graphics::apply(); - - let result = tauri::Builder::default() - .setup(move |app| { - // GTK is initialised here but the webview does not exist yet. - #[cfg(target_os = "linux")] - fastled_cli::linux_graphics::ensure_font_dpi(); +// Product policy, not a kernel default. WebKitGTK/WKWebView must not receive +// this Windows-specific compensation. Native scale is not browser zoom/DPR. +const WINDOWS_ZOOM_SCRIPT: &str = r#" +(() => { + const scale = kernalWindow.initialScaleFactor; + if (scale > 1) { + document.addEventListener('DOMContentLoaded', () => { + document.body.style.zoom = String(0.92 / scale); + }, { once: true }); + } +})(); +"#; - let url = tauri::WebviewUrl::External(url.parse()?); +fn bootstrap_source(test_runtime: bool, windows: bool) -> String { + let mut source = String::new(); + if test_runtime { + source.push_str(TEST_CAPABILITY_SCRIPT); + } + source.push_str(LOG_FORWARD_SCRIPT); + if test_runtime { + source.push_str(TEST_RUNTIME_SCRIPT); + } + if windows { + source.push_str(WINDOWS_ZOOM_SCRIPT); + } + source +} - let mut builder = tauri::WebviewWindowBuilder::new(app, "main", url) - .title(&title) - .inner_size(width as f64, height as f64); - if inject_test_runtime { - builder = builder.initialization_script(TEST_CAPABILITY_SCRIPT); - } - builder = builder.initialization_script(LOG_FORWARD_SCRIPT); - if inject_test_runtime { - builder = builder.initialization_script(TEST_RUNTIME_SCRIPT); - } - #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] - let window = builder.build()?; +struct ViewerExitRequest(ExternalWebviewClient); - #[cfg(target_os = "linux")] - window.with_webview(|webview| { - fastled_cli::linux_graphics::allow_user_media(&webview.inner()); - })?; +impl Drop for ViewerExitRequest { + fn drop(&mut self) { + if let Err(error) = self.0.request_exit() { + eprintln!("fastled: viewer shutdown request failed: {error}"); + } + } +} - // Counteract WebView2 DPI auto-scaling while keeping the UI readable. - // Windows only: WebKitGTK and WKWebView already render at the - // window's scale factor, so this zoom would halve the page on a - // 2x Linux or macOS display. - #[cfg(target_os = "windows")] - { - let scale = window.scale_factor().unwrap_or(1.0); - if scale > 1.0 { - let zoom = 0.92 / scale; - let js = format!( - "document.addEventListener('DOMContentLoaded', function() {{ document.body.style.zoom = '{}'; }});", - zoom - ); - window.eval(&js).ok(); +fn run_viewer(options: ViewerOptions) -> Result<(), String> { + let window = WebviewWindowOptions::new(&options.title, options.width, options.height) + .map_err(|error| error.to_string())?; + let bootstrap = WebviewPageBootstrap::new(&bootstrap_source( + options.inject_test_runtime, + cfg!(target_os = "windows"), + )) + .map_err(|error| error.to_string())?; + // No runtime worker threads exist while the kernel prepares Linux graphics + // environment and creates the main-thread host. Drive this one runtime on + // the lifecycle thread only after host initialization has completed. + let runtime = RuntimeBuilder::current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + let host = ExternalWebviewHost::new(runtime.handle()).map_err(|error| error.to_string())?; + let client = host.client(); + let worker = std::thread::Builder::new() + .name("fastled-viewer-lifecycle".into()) + .spawn(move || { + let _exit = ViewerExitRequest(client.clone()); + runtime.run(async move { + let permissions = WebviewPermissions::deny_all().allow_user_media(); + let webview = client + .open_webview_with_bootstrap(&options.url, window, permissions, bootstrap) + .await?; + // Interactive windows have no arbitrary lifetime expiry. Do not + // wait for exact load-URL equality: the test script strips its + // capability fragment before the load callback can arrive. + match webview.wait_for_terminal().await { + Err(WebviewError::WindowClosed) | Ok(()) => Ok(()), + Err(error) => Err(error), } - } - Ok(()) + }) }) - .run(tauri::generate_context!()); + .map_err(|error| error.to_string())?; + let host_code = host.run(); + let outcome = worker + .join() + .map_err(|_| "viewer lifecycle thread panicked".to_owned())?; + outcome.map_err(|error| error.to_string())?; + if host_code != 0 { + return Err(format!("viewer event loop exited with {host_code}")); + } + Ok(()) +} - match result { +pub fn run(options: ViewerOptions) -> ExitCode { + match run_viewer(options) { Ok(()) => ExitCode::SUCCESS, Err(err) => { - eprintln!("fastled: Tauri viewer failed: {err}"); + eprintln!("fastled: viewer failed: {err}"); ExitCode::FAILURE } } @@ -388,6 +421,23 @@ pub fn run(options: ViewerOptions) -> ExitCode { mod tests { use super::*; + #[test] + fn bootstrap_preserves_product_order_and_platform_policy() { + let test = bootstrap_source(true, false); + assert!(test.starts_with(TEST_CAPABILITY_SCRIPT)); + assert_eq!( + test, + format!("{TEST_CAPABILITY_SCRIPT}{LOG_FORWARD_SCRIPT}{TEST_RUNTIME_SCRIPT}") + ); + assert_eq!(bootstrap_source(false, false), LOG_FORWARD_SCRIPT); + let windows = bootstrap_source(true, true); + assert_eq!(windows, format!("{test}{WINDOWS_ZOOM_SCRIPT}")); + assert!(WINDOWS_ZOOM_SCRIPT.contains("kernalWindow.initialScaleFactor")); + assert!(WINDOWS_ZOOM_SCRIPT.contains("0.92 / scale")); + assert!(!WINDOWS_ZOOM_SCRIPT.contains("devicePixelRatio")); + assert!(WebviewPageBootstrap::new(&windows).is_ok()); + } + #[test] fn log_forward_script_targets_server_endpoint() { assert!(LOG_FORWARD_SCRIPT.contains("/viewer-log")); diff --git a/crates/fastled-cli/tauri.conf.json b/crates/fastled-cli/tauri.conf.json deleted file mode 100644 index 5325a31d..00000000 --- a/crates/fastled-cli/tauri.conf.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/nicovince/tauri-docs/refs/heads/dev/src/content/docs/_schema/config.schema.json", - "productName": "FastLED Viewer", - "version": "2.0.7", - "identifier": "com.fastled.viewer", - "build": { - "frontendDist": "./frontend" - }, - "app": { - "withGlobalTauri": true, - "windows": [], - "security": { - "csp": null - } - }, - "bundle": { - "active": false, - "targets": "all", - "icon": [] - } -} diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 9b2a0e72..6f53b0b9 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -797,8 +797,56 @@ identifies these remaining viewer requirements: environment, font-DPI and opt-in user-media mechanisms, but adoption still needs real viewer tests for microphone, rendering, logs, captures and teardown. -The native viewer dependencies remain until these behaviors are integrated and -verified. Window-configuration groundwork alone does not complete GUI migration. +The following adoption checkpoint supersedes the dependency-retention statements +in this historical inventory; it does not claim complete viewer parity. + +## Native viewer adoption checkpoint + +The application now uses `kernal_api::webview` for window creation, Linux +graphics/font/media mechanisms, bootstrap installation and interactive lifetime. +Direct `tauri`, `tauri-build`, `gtk` and `webkit2gtk` dependencies, the Tauri +build hook/config and the duplicated Linux graphics module are removed. Their +implementation dependencies remain transitive through the opt-in kernel viewer +feature. Existing generic Linux policy tests already live upstream (#154). + +FastLED retains its capability-token, console forwarding and test/capture +scripts unchanged, in the same order. The Windows-only `0.92 / nativeScale` +policy remains local and consumes the creation-time snapshot from kernel issue +https://github.com/zackees/kernal-api/issues/200 / draft PR +https://github.com/zackees/kernal-api/pull/201. The kernel query happens outside +the UI callback; the snapshot is not browser DPR or a live monitor-change feed. +The app creates a current-thread kernel runtime and the main-thread UI host +before starting its lifecycle thread; it adds no second runtime. Normal window +closure is success; other terminal events are errors. Dropping the lifecycle +exit guard requests event-loop shutdown, including during unwinding. + +The migration-only path patch now points to the `kernal-api-webview-scale` +sibling at upstream `8f23200`. Upstream full GUI tests, strict Clippy, dependency +isolation, Linux native proofs and Windows MSVC/macOS ARM crosschecks pass. +Bootstrap PR #197 previously failed native Windows CI because the test server +mistook an automatic favicon request for its iframe request. Fix `46344dc` +answers only exact favicon GETs within existing limits; a socket regression +observed RED then GREEN. The fix is included in the lifetime/scale stack; native +Windows reruns remain required. + +App validation: the new GUI boundary test observed RED then GREEN; full Rust +tests, strict all-target Clippy, formatting, Ruff and Python tests pass (29 +passed, one skipped). `ci/native_viewer_smoke.py`, run against the built binary +under Linux Xvfb, verifies before-page token stripping and IPC absence through +authenticated logs, a real 32x32 canvas PNG upload, successful test completion, +and process teardown after prohibited cross-origin navigation. This fixture +explicitly exercises the canvas fallback with a minimal ready-event source, +not a WASM sketch/render worker, and does not prove pixel correctness. + +Remaining viewer acceptance: real sketch/worker rendering and capture, media, +normal user-close behavior in the app, Windows executable-resource/DPI parity, +and native macOS/Safari coverage. Linux graphics decisions now come from the +kernel, so the old app-specific workaround diagnostic lines are no longer +emitted. Default isolated-host security (including no native IPC and +same-origin bootstrap navigation) is intentional and must remain documented. +Remaining direct CLI/configuration/parser dependencies and a usable exact +published release without any path patch still block overall completion. No +build-speed improvement is claimed. ### Final migration audit diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 000a020b..ec7e498d 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -62,6 +62,24 @@ def test_archive_download_uses_kernel_http(): assert "kernal_api::http::" in source +def test_viewer_backend_is_owned_by_kernel(): + root = Path(__file__).resolve().parents[2] + manifests = [root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"] + for path in manifests: + data = tomllib.loads(path.read_text()) + sections = [data, data.get("workspace", {})] + sections.extend(data.get("target", {}).values()) + for section in sections: + for kind in ("dependencies", "build-dependencies", "dev-dependencies"): + for backend in ("tauri", "tauri-build", "gtk", "webkit2gtk"): + assert backend not in section.get(kind, {}), (path, backend) + for source in (root / "crates/fastled-cli").rglob("*.rs"): + if "target" in source.parts or "gen" in source.parts: + continue + for backend in ("tauri::", "tauri_build::", "gtk::", "webkit2gtk::"): + assert backend not in source.read_text(), (source, backend) + + def test_obsolete_manifest_dependencies_are_removed(): root = Path(__file__).resolve().parents[2] package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) From f3fe05ded68a1fa4dba0d478ce8e295c086c811c Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 04:24:50 -0700 Subject: [PATCH 49/94] chore: remove unused Python runtime requirements (refs #245) --- docs/kernal-api-migration.md | 14 ++++++++++++++ pyproject.toml | 4 +--- tests/unit/test_kernal_boundary.py | 10 ++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 6f53b0b9..dd01a9cd 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -848,6 +848,20 @@ Remaining direct CLI/configuration/parser dependencies and a usable exact published release without any path patch still block overall completion. No build-speed improvement is claimed. +## Python shim dependency cleanup + +Issue https://github.com/zackees/fastled-wasm/issues/245 removes unused +`typeguard` and Windows-only `zcmds_win32` runtime requirements. Searches across +the remaining Python shim, Rust sources, packaging and CI found no callers. +Meson, Ninja and uv remain required by native compilation/runtime provisioning; +this cleanup does not replace those active build tools. + +The metadata regression observed RED then GREEN; Python tests pass (30 passed, +one skipped), Ruff and local lock consistency checks pass. The regenerated +local uv graph dropped 23 packages, but `uv.lock` is intentionally ignored by +the repository and is not a committed release artifact. No Rust implementation +or native compiler behavior changed, and no build-time improvement is claimed. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/pyproject.toml b/pyproject.toml index d42abfc2..e16f0aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ # Build-system dependencies for the FastLED C++ -> WASM compile. # `meson` configures the build; `ninja` is meson's backend. Both ship # standalone executables when pip-installed. Rust owns Emscripten - # resolution via linked ctcb crates and invokes the checked-in + # resolution using kernel host/process capabilities and invokes the checked-in # emcc.py/em++.py/emar.py entry points directly. "meson>=1.4", "ninja>=1.11", @@ -39,8 +39,6 @@ dependencies = [ # Node runtimes. Keeping uv in the wheel environment makes first-run WASM # builds independent of Homebrew or a user-managed shell installation. "uv>=0.5", - "typeguard>=4.4.4", - "zcmds_win32; sys_platform == 'win32'", ] [tool.setuptools] diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index ec7e498d..b93b0a90 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -88,6 +88,16 @@ def test_obsolete_manifest_dependencies_are_removed(): assert "thiserror" not in workspace["workspace"]["dependencies"] +def test_python_shim_has_no_obsolete_runtime_requirements(): + root = Path(__file__).resolve().parents[2] + project = tomllib.loads((root / "pyproject.toml").read_text())["project"] + requirements = project["dependencies"] + for obsolete in ("typeguard", "zcmds_win32", "zcmds-win32"): + assert not any(requirement.startswith(obsolete) for requirement in requirements) + for required in ("meson", "ninja", "uv"): + assert any(requirement.startswith(required + ">=") for requirement in requirements) + + def test_http_callers_use_kernel(): root = Path(__file__).resolve().parents[2] package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) From 811142cd9f1962cfbadc5bed0b6dd1ad3ecae93c Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 04:59:25 -0700 Subject: [PATCH 50/94] fix: relocate Emscripten config before toolchain publication (refs #246) --- crates/fastled-cli/src/archive.rs | 11 +++++++- crates/fastled-cli/src/install.rs | 45 ++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/fastled-cli/src/archive.rs b/crates/fastled-cli/src/archive.rs index 22ce95f2..65931871 100644 --- a/crates/fastled-cli/src/archive.rs +++ b/crates/fastled-cli/src/archive.rs @@ -190,6 +190,15 @@ pub fn extract_member_from_tgz(archive: &Path, member: &str, dest: &Path) -> Res /// # Errors /// Returns an error if the file cannot be written. pub fn write_emscripten_config(install_dir: &Path, node_path: &Path) -> Result<()> { + write_emscripten_config_at(install_dir, install_dir, node_path) +} + +/// Write configuration for its final location while it is still staged. +pub(crate) fn write_emscripten_config_at( + config_dir: &Path, + install_dir: &Path, + node_path: &Path, +) -> Result<()> { if !node_path.is_absolute() { anyhow::bail!( "NODE_JS must be an absolute path, got {}", @@ -212,7 +221,7 @@ pub fn write_emscripten_config(install_dir: &Path, node_path: &Path) -> Result<( path_to_forward_slash(node_path), ); - let config_path = install_dir.join(".emscripten"); + let config_path = config_dir.join(".emscripten"); if fs::read_to_string(&config_path).ok().as_deref() == Some(config.as_str()) { return Ok(()); } diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index dc8d2ea8..10bb2902 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -811,6 +811,9 @@ fn run_health_checks(install: &Path) -> Result<()> { let empp = install.join("emscripten").join("em++.py"); let python = resolve_managed_python()?; let node = resolve_managed_node(None)?; + // Older managed installs may retain their pre-publication staging path. + // Restore our generated configuration before probing the actual install. + archive::write_emscripten_config(install, &node)?; let run = |args: &[&str]| -> Result<()> { let mut command = Command::new(&python); command @@ -925,6 +928,7 @@ fn install_spec( bail!("checksum mismatch for catalog package {}", spec.package_id); } + let node = resolve_managed_node(None)?; let staging = base.join(format!( ".{}.staging-{}", package_key(spec), @@ -938,7 +942,6 @@ fn install_spec( archive::extract_tar_zst(&archive_path, &staging)?; ensure_toolchain_executables(&staging)?; validate_emscripten_payload(&staging)?; - let node = resolve_managed_node(None)?; archive::write_emscripten_config(&staging, &node)?; fs::write(staging.join("done.txt"), "ok\n")?; write_receipt(&staging, spec, false)?; @@ -969,11 +972,19 @@ fn install_spec( fs::rename(&destination, &quarantine) .with_context(|| format!("quarantine invalid toolchain {}", destination.display()))?; } - fs::rename(&staging, &destination) - .with_context(|| format!("publish toolchain {}", destination.display()))?; + publish_toolchain(&staging, &destination, &node)?; Ok(destination) } +fn publish_toolchain(staging: &Path, destination: &Path, node: &Path) -> Result<()> { + // Health checks ran against staging. Rewrite only our generated config for + // the destination before the directory rename makes the package visible. + archive::write_emscripten_config_at(staging, destination, node)?; + fs::rename(staging, destination) + .with_context(|| format!("publish toolchain {}", destination.display()))?; + Ok(()) +} + fn activate_install(base: &Path, install: &Path, spec: ToolchainSpec) -> Result<()> { validate_managed_install(install, spec)?; let mut state = read_state(base)?; @@ -2164,6 +2175,34 @@ pub fn run_install(options: InstallOptions) -> Result { #[cfg(test)] mod tests { + #[test] + fn published_toolchain_config_does_not_retain_staging_paths() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let staging = temp.path().join(".toolchain.staging"); + let destination = temp.path().join("toolchain"); + let node = temp.path().join("node"); + std::fs::create_dir_all(&staging).unwrap(); + crate::archive::write_emscripten_config(&staging, &node).unwrap(); + super::publish_toolchain(&staging, &destination, &node).unwrap(); + let config = std::fs::read_to_string(destination.join(".emscripten")).unwrap(); + assert!(!config.contains(".toolchain.staging"), "{config}"); + assert!(config.contains(&destination.to_string_lossy().replace('\\', "/"))); + assert!(!staging.exists()); + } + + #[test] + fn invalid_config_does_not_publish_toolchain() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let staging = temp.path().join("staging"); + let destination = temp.path().join("final"); + std::fs::create_dir_all(&staging).unwrap(); + assert!( + super::publish_toolchain(&staging, &destination, std::path::Path::new("node")).is_err() + ); + assert!(staging.exists()); + assert!(!destination.exists()); + } + #[test] fn toolchain_target_aliases_preserve_supported_binary_targets() { for (os, platform) in [("windows", "win"), ("linux", "linux"), ("macos", "darwin")] { From 0a9f06c81e6975005785482cac62394c0f1733cc Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 04:59:25 -0700 Subject: [PATCH 51/94] refactor: use kernel-owned POSIX argument parsing --- Cargo.lock | 2 +- Cargo.toml | 5 ++--- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/wasm_build.rs | 3 ++- docs/kernal-api-migration.md | 32 ++++++++++++++++++++++++++++ tests/unit/test_kernal_boundary.py | 3 +++ 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bca80459..b98f46c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,7 +1007,6 @@ dependencies = [ "kernal-api", "serde", "serde_json", - "shell-words", "toml 0.8.23", "tree-sitter", "tree-sitter-cpp", @@ -2047,6 +2046,7 @@ dependencies = [ "reqwest 0.12.28", "running-process", "sha2", + "shell-words", "strsim", "sysinfo", "tar", diff --git a/Cargo.toml b/Cargo.toml index d63eb8d2..4a556904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,12 +14,11 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" toml = "0.8" -shell-words = "1" tree-sitter = "0.26.9" tree-sitter-cpp = "0.23.4" @@ -30,7 +29,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-webview-scale" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-arguments" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 92f88341..8c2e6f93 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -27,7 +27,6 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } toml = { workspace = true } -shell-words = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index 1717cc7c..f4b7f5e6 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -491,7 +491,8 @@ fn direct_clang_cflags( ); } let stdout = String::from_utf8(output.stdout).context("em++ --cflags returned non-UTF-8")?; - let flags = shell_words::split(stdout.trim()).context("parse em++ --cflags output")?; + let flags = + kernal_api::arguments::parse_posix(stdout.trim()).context("parse em++ --cflags output")?; if flags.is_empty() { bail!("em++ --cflags returned no backend flags"); } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index dd01a9cd..147dfa20 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -862,6 +862,38 @@ local uv graph dropped 23 packages, but `uv.lock` is intentionally ignored by the repository and is not a committed release artifact. No Rust implementation or native compiler behavior changed, and no build-time improvement is claimed. +## Bounded tool argument decoding + +Upstream https://github.com/zackees/kernal-api/pull/205 owns POSIX quoting, +escaping, Unicode handling, semantic parse errors and resource bounds behind +`command-arguments`. The private `shell-words` implementation is absent from +the kernel's default feature graph. FastLED removes its direct dependency and +retains `em++ --cflags` discovery, caching and the empty-output product error. +The app boundary regression observed RED then GREEN; the generic parser's +tests, strict Clippy, dependency isolation and Windows/macOS cross-checks pass. + +Real Linux validation used an isolated Blink sketch and the existing catalog +Emscripten 4.0.21 release default with `/home/niteris/dev/fastled` source. A fresh +direct-cflags cache confirmed the parser ran; sketch compilation and final +static WASM linking succeeded. The output has a valid WASM header, and a scan +of emitted artifacts found none of `WebAssembly.Suspending`, +`WebAssembly.promising`, `-sJSPI` or `JSPI_EXPORTS`. This is compiler/artifact +evidence, not Safari browser validation or a build-speed comparison. + +That real compile exposed issue #246: catalog publication retained absolute +staging paths in its generated `.emscripten` file. Publication now writes final +paths before the directory rename, and managed health checks refresh older +generated configurations. The relocation test observed RED then GREEN; static +and dynamic compiler health checks then passed. No compiler version or linking +defaults changed. This Emscripten-specific installation policy stays local. + +Post-fix Rust workspace tests, strict all-target Clippy, formatting, Python +tests (30 passed, one skipped), and Ruff pass. The real native-viewer run did +not pass: worker capabilities reported `webgl2: false`, the frontend rejected +OffscreenCanvas WebGL2, and no canvas appeared before the readiness deadline. +Issue #247 tracks this real WASM worker/render gap; the earlier synthetic +canvas fixture is not a substitute. No browser check was weakened. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index b93b0a90..2653a401 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -29,9 +29,11 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "crossterm" not in dependencies assert "tokio" not in dependencies assert "ctcb-core" not in dependencies + assert "shell-words" not in dependencies workspace = tomllib.loads((root / "Cargo.toml").read_text()) assert "tokio" not in workspace["workspace"]["dependencies"] assert "ctcb-core" not in workspace["workspace"]["dependencies"] + assert "shell-words" not in workspace["workspace"]["dependencies"] assert "tempfile" not in manifest.get("dev-dependencies", {}) for backend in ("zip", "tar", "zstd", "flate2"): assert backend not in dependencies @@ -51,6 +53,7 @@ def test_migrated_capabilities_are_owned_by_kernal_api(): assert "strsim::" not in source.read_text(), source assert "crossterm::" not in source.read_text(), source assert "ctcb_core::" not in source.read_text(), source + assert "shell_words::" not in source.read_text(), source for backend in ("zip", "tar", "zstd", "flate2"): assert f"{backend}::" not in source.read_text(), source From fb3808bfa941d44c7f43928c6d177454f2d16d92 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 05:22:40 -0700 Subject: [PATCH 52/94] refactor(config): consume kernal-api TOML documents --- Cargo.lock | 1 - Cargo.toml | 5 +- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/debug_symbols.rs | 61 +++++++++-- crates/fastled-cli/src/wasm_build.rs | 135 ++++++++++++++++++++++-- docs/kernal-api-migration.md | 31 ++++++ tests/unit/test_kernal_boundary.py | 10 ++ 7 files changed, 226 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b98f46c1..c9fa8124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,7 +1007,6 @@ dependencies = [ "kernal-api", "serde", "serde_json", - "toml 0.8.23", "tree-sitter", "tree-sitter-cpp", ] diff --git a/Cargo.toml b/Cargo.toml index 4a556904..b17aa6a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,10 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "config-toml"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" -toml = "0.8" tree-sitter = "0.26.9" tree-sitter-cpp = "0.23.4" @@ -29,7 +28,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-arguments" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-config" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 8c2e6f93..2f43fae6 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -26,7 +26,6 @@ kernal-api = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -toml = { workspace = true } tree-sitter = { workspace = true } tree-sitter-cpp = { workspace = true } diff --git a/crates/fastled-cli/src/debug_symbols.rs b/crates/fastled-cli/src/debug_symbols.rs index 7f294f6e..a291fd95 100644 --- a/crates/fastled-cli/src/debug_symbols.rs +++ b/crates/fastled-cli/src/debug_symbols.rs @@ -60,6 +60,29 @@ impl Default for DwarfPrefixConfig { } } +impl DwarfPrefixConfig { + pub(crate) fn from_config(value: &kernal_api::config::Value) -> Result { + use kernal_api::config::Value; + let Value::Table(fields) = value else { + anyhow::bail!("dwarf must be a table"); + }; + let string = |name: &str| -> Result> { + match fields.get(name) { + None => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => anyhow::bail!("dwarf.{name} must be a string"), + } + }; + Ok(Self { + fastled_prefix: string("fastled_prefix")?.unwrap_or_else(default_fastled_prefix), + sketch_prefix: string("sketch_prefix")?.unwrap_or_else(default_sketch_prefix), + dwarf_prefix: string("dwarf_prefix")?.unwrap_or_else(default_dwarf_prefix), + file_prefix_map_from: string("file_prefix_map_from")?, + file_prefix_map_to: string("file_prefix_map_to")?, + }) + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct DebugSymbolConfig { pub sketch_dir: PathBuf, @@ -121,11 +144,6 @@ pub fn load_debug_symbol_config( } fn read_dwarf_prefixes(fastled_dir: &Path) -> Option { - #[derive(Deserialize)] - struct BuildFlagsToml { - dwarf: Option, - } - let candidate = fastled_dir .join("src") .join("platforms") @@ -133,8 +151,11 @@ fn read_dwarf_prefixes(fastled_dir: &Path) -> Option { .join("compiler") .join("build_flags.toml"); let text = std::fs::read_to_string(&candidate).ok()?; - let parsed: BuildFlagsToml = toml::from_str(&text).ok()?; - parsed.dwarf + let parsed = kernal_api::config::Document::parse_toml(&text).ok()?; + let kernal_api::config::Value::Table(fields) = parsed.root() else { + return None; + }; + DwarfPrefixConfig::from_config(fields.get("dwarf")?).ok() } #[derive(Debug, Deserialize, Serialize)] @@ -371,6 +392,32 @@ mod tests { use kernal_api::platform::fs::TemporaryDirectory; use std::fs; + #[test] + fn dwarf_schema_ignores_unrelated_sections_and_defaults_invalid_overrides() { + let tmp = TemporaryDirectory::new().unwrap(); + let compiler = tmp.path().join("src/platforms/wasm/compiler"); + fs::create_dir_all(&compiler).unwrap(); + let path = compiler.join("build_flags.toml"); + // This reader validates only DWARF policy, not compiler flag schemas. + fs::write(&path, "all = 1\n[dwarf]\nsketch_prefix = 'custom'\n").unwrap(); + let parsed = read_dwarf_prefixes(tmp.path()).unwrap(); + assert_eq!(parsed.sketch_prefix, "custom"); + assert_eq!(parsed.fastled_prefix, DEFAULT_FASTLED_PREFIX); + for source in ["[dwarf]\nsketch_prefix = 1", "x =", "all = 1"] { + fs::write(&path, source).unwrap(); + assert!( + read_dwarf_prefixes(tmp.path()).is_none(), + "accepted {source}" + ); + let config = load_debug_symbol_config( + tmp.path().join("sketch"), + Some(tmp.path().to_path_buf()), + None, + ); + assert_eq!(config.prefixes.sketch_prefix, DEFAULT_SKETCH_PREFIX); + } + } + fn setup_dirs() -> (TemporaryDirectory, PathBuf, PathBuf, PathBuf) { let tmp = TemporaryDirectory::new().unwrap(); let sketch_dir = tmp.path().join("sketch"); diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index f4b7f5e6..00943197 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -156,7 +156,7 @@ impl BuildFingerprints { } } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default)] struct BuildFlagsToml { all: Option, sketch: Option, @@ -165,30 +165,120 @@ struct BuildFlagsToml { dwarf: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default)] struct FlagSection { defines: Option>, compiler_flags: Option>, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default)] struct LinkingSection { base: Option, sketch: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default)] struct ModeSection { flags: Option>, sketch_flags: Option>, link_flags: Option>, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default)] struct FlagList { flags: Option>, } +// The kernel owns TOML syntax and resource bounds; these fields and defaults +// are FastLED's compiler policy. Unknown fields deliberately remain ignored. +impl BuildFlagsToml { + fn parse(source: &str) -> Result { + use kernal_api::config::{Document, Value}; + fn table(value: &Value) -> Result<&BTreeMap> { + match value { + Value::Table(fields) => Ok(fields), + _ => anyhow::bail!("build flag section must be a table"), + } + } + fn strings(fields: &BTreeMap, name: &str) -> Result>> { + fields + .get(name) + .map(|value| { + let Value::Array(values) = value else { + anyhow::bail!("{name} must be an array of strings"); + }; + values + .iter() + .map(|value| match value { + Value::String(value) => Ok(value.clone()), + _ => anyhow::bail!("{name} must contain only strings"), + }) + .collect() + }) + .transpose() + } + fn flags(value: &Value) -> Result { + let fields = table(value)?; + Ok(FlagSection { + defines: strings(fields, "defines")?, + compiler_flags: strings(fields, "compiler_flags")?, + }) + } + fn flag_list(value: &Value) -> Result { + Ok(FlagList { + flags: strings(table(value)?, "flags")?, + }) + } + fn linking(value: &Value) -> Result { + let fields = table(value)?; + Ok(LinkingSection { + base: fields.get("base").map(flag_list).transpose()?, + sketch: fields.get("sketch").map(flag_list).transpose()?, + }) + } + fn modes(value: &Value) -> Result> { + table(value)? + .iter() + .map(|(name, value)| { + let fields = table(value).with_context(|| format!("build_modes.{name}"))?; + Ok(( + name.clone(), + ModeSection { + flags: strings(fields, "flags")?, + sketch_flags: strings(fields, "sketch_flags")?, + link_flags: strings(fields, "link_flags")?, + }, + )) + }) + .collect() + } + let document = Document::parse_toml(source)?; + let fields = table(document.root())?; + Ok(Self { + all: fields.get("all").map(flags).transpose().context("all")?, + sketch: fields + .get("sketch") + .map(flags) + .transpose() + .context("sketch")?, + linking: fields + .get("linking") + .map(linking) + .transpose() + .context("linking")?, + build_modes: fields + .get("build_modes") + .map(modes) + .transpose() + .context("build_modes")?, + dwarf: fields + .get("dwarf") + .map(debug_symbols::DwarfPrefixConfig::from_config) + .transpose()?, + }) + } +} + #[derive(Debug, Clone)] pub(crate) struct DwarfPathRoots { sketch_dir: Option, @@ -1013,7 +1103,7 @@ fn load_build_flags(fastled_dir: &Path) -> Result { .join("build_flags.toml"); let source = fs::read_to_string(&path) .with_context(|| format!("read build flags {}", path.display()))?; - toml::from_str(&source).with_context(|| format!("parse {}", path.display())) + BuildFlagsToml::parse(&source).with_context(|| format!("parse {}", path.display())) } pub(crate) fn get_sketch_compile_flags( @@ -2595,6 +2685,39 @@ mod tests { fs::write(compiler_dir.join("build_flags.toml"), source).unwrap(); } + #[test] + fn build_flags_schema_preserves_defaults_and_ignores_unknown_fields() { + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + write_build_flags(tmp.path(), "unknown = 42\n[all]\ndefines = ['A', 'B']\nunknown = false\n[dwarf]\nsketch_prefix = 'custom'\n"); + let parsed = load_build_flags(tmp.path()).unwrap(); + let all = parsed.all.unwrap(); + assert_eq!(all.defines.unwrap(), ["A", "B"]); + assert!(all.compiler_flags.is_none()); + assert!(parsed.build_modes.is_none()); + let dwarf = parsed.dwarf.unwrap(); + assert_eq!(dwarf.sketch_prefix, "custom"); + assert_eq!(dwarf.fastled_prefix, debug_symbols::DEFAULT_FASTLED_PREFIX); + assert!(dwarf.file_prefix_map_from.is_none()); + } + + #[test] + fn build_flags_schema_rejects_wrong_known_field_types() { + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + for source in [ + "all = 1", + "[all]\ndefines = 'A'", + "[sketch]\ncompiler_flags = [1]", + "[linking]\nbase = false", + "[linking.base]\nflags = [false]", + "[build_modes]\nquick = 1", + "[build_modes.debug]\nsketch_flags = 1", + "[dwarf]\nfastled_prefix = 1", + ] { + write_build_flags(tmp.path(), source); + assert!(load_build_flags(tmp.path()).is_err(), "accepted {source}"); + } + } + #[test] fn resolve_example_preserves_nested_names() { let root = PathBuf::from("/tmp/FastLED"); diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 147dfa20..632adc95 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -894,6 +894,37 @@ OffscreenCanvas WebGL2, and no canvas appeared before the readiness deadline. Issue #247 tracks this real WASM worker/render gap; the earlier synthetic canvas fixture is not a substitute. No browser check was weakened. +### TOML migration in progress + +Upstream issue zackees/kernal-api#206 is implemented in draft +zackees/kernal-api#208, stacked on #205. The optional `config-toml` capability +owns parsing and bounded semantic values; FastLED retains field validation and +defaults. Its focused/full-feature tests, strict Clippy, runtime dependency +isolation, and Windows/macOS cross-checks passed locally. CI remains pending. + +Three app regression tests passed against the existing parser: build flag +defaults/unknown fields, rejection of wrong known-field types, and independent +DWARF validation/default fallback. These protect product policy during adoption. +The app now consumes `kernal_api::config::Document` at both TOML call sites, +and neither app manifest nor its lockfile dependency list contains direct TOML. +The schema adapter keeps compiler field validation local, including ordered flag +lists, optional modes, and DWARF defaults. Standalone DWARF loading still ignores +unrelated compiler fields. Kernel syntax errors are bounded and do not echo file +contents; the app adds the configuration path. Kernel source/node/depth limits +now apply to configuration input. + +The new dependency-boundary test failed before adoption and passed afterward. +All three schema regressions also passed after adoption, followed by 263 library, +3 binary, 1 integration, and 1 doc test, plus 31 Python tests (1 skipped). +Strict all-target Clippy and the updated executable build passed. A fresh Blink +sketch in `/tmp/fastled-toml-wasm.LFSPI7` compiled and linked successfully using +the unchanged Emscripten 4.0.21 toolchain and real FastLED source configuration. +The generated WASM has the expected magic/version bytes; generated JavaScript +contains none of the prohibited JSPI entry points. This compile is not browser +or Safari rendering proof; the existing viewer acceptance gap remains open. +The temporary path patch now points at the config capability checkout; a usable +published release and patch removal remain required. No build speedup is claimed. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 2653a401..63a46e42 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -65,6 +65,16 @@ def test_archive_download_uses_kernel_http(): assert "kernal_api::http::" in source +def test_toml_configuration_uses_kernel(): + root = Path(__file__).resolve().parents[2] + for path in (root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"): + data = tomllib.loads(path.read_text()) + assert "toml" not in data.get("dependencies", {}) + assert "toml" not in data.get("workspace", {}).get("dependencies", {}) + for source in (root / "crates/fastled-cli/src").rglob("*.rs"): + assert "toml::" not in source.read_text(), source + + def test_viewer_backend_is_owned_by_kernel(): root = Path(__file__).resolve().parents[2] manifests = [root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"] From 8f61d5ee9e3954fad3090057cc0a45308faa53c4 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 05:46:40 -0700 Subject: [PATCH 53/94] refactor(source): consume kernal-api C++ analysis --- Cargo.lock | 4 +- Cargo.toml | 6 +- crates/fastled-cli/Cargo.toml | 2 - crates/fastled-cli/src/sketch_preprocessor.rs | 266 ++++-------------- docs/kernal-api-migration.md | 30 ++ tests/unit/test_kernal_boundary.py | 12 + 6 files changed, 102 insertions(+), 218 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9fa8124..3d4e5bcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,8 +1007,6 @@ dependencies = [ "kernal-api", "serde", "serde_json", - "tree-sitter", - "tree-sitter-cpp", ] [[package]] @@ -2058,6 +2056,8 @@ dependencies = [ "tokio", "tokio-stream", "toml 0.8.23", + "tree-sitter", + "tree-sitter-cpp", "url", "webkit2gtk", "widestring", diff --git a/Cargo.toml b/Cargo.toml index b17aa6a3..012e405a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,12 +14,10 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "config-toml"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "config-toml", "source-cpp"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" -tree-sitter = "0.26.9" -tree-sitter-cpp = "0.23.4" [profile.release] debug = "line-tables-only" @@ -28,7 +26,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-config" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-source-cpp" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 2f43fae6..e0f67a06 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -26,8 +26,6 @@ kernal-api = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -tree-sitter = { workspace = true } -tree-sitter-cpp = { workspace = true } [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/fastled-{ target }{ archive-suffix }" diff --git a/crates/fastled-cli/src/sketch_preprocessor.rs b/crates/fastled-cli/src/sketch_preprocessor.rs index a08e4560..270cbe0d 100644 --- a/crates/fastled-cli/src/sketch_preprocessor.rs +++ b/crates/fastled-cli/src/sketch_preprocessor.rs @@ -1,9 +1,8 @@ //! Arduino `.ino` preprocessing shared by the WASM build and editor support. //! -//! This is deliberately adapted from FastLED/fbuild's source scanner at -//! `1e75ccf5a4ca922b4d922a6da286b965fac8832d`. Keep generic parser fixes in -//! fbuild: this local adapter is transitional until fbuild publishes a stable -//! preprocessor crate/API that fastled-wasm can depend on directly (see #206). +//! Product integration retains FastLED/fbuild source-scanner provenance at +//! `1e75ccf5a4ca922b4d922a6da286b965fac8832d` (see #206). Generic C++ analysis +//! now lives in kernal-api; Arduino selection and editor publication stay here. use std::collections::{BTreeMap, HashSet}; use std::fs; @@ -13,7 +12,6 @@ use std::path::Path; use anyhow::{bail, Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; use serde::{Deserialize, Serialize}; -use tree_sitter::{Node, Parser}; use crate::path::NormalizedPath; @@ -330,218 +328,45 @@ fn render_prototype_declarations(prototypes: &[String]) -> String { declarations } -/// Tree-sitter based prototype collection copied/adapted from fbuild's -/// `source_scanner.rs`; regex-only prototype generation is intentionally not -/// used because Arduino sketches routinely use attributes/default arguments. +/// Select Arduino prototypes from kernel-owned C++ syntax analysis. fn extract_function_prototypes(source: &str) -> Result> { - let mut parser = Parser::new(); - let language = tree_sitter_cpp::LANGUAGE.into(); - parser.set_language(&language).context("load C++ parser")?; - let tree = parser.parse(source, None).context("parse Arduino sketch")?; - if tree.root_node().has_error() { - bail!("sketch has incomplete C++ syntax; retaining the last good IntelliSense prelude"); - } - let mut candidates = Vec::new(); - collect_function_prototypes(tree.root_node(), source, &mut candidates); - let mut seen = HashSet::new(); - Ok(candidates - .into_iter() - .filter(|prototype| seen.insert(prototype.clone())) - .collect()) -} - -fn collect_function_prototypes(node: Node<'_>, source: &str, output: &mut Vec) { - if node.kind() == "function_definition" { - if let Some(prototype) = prototype_from_definition(node, source) { - output.push(prototype); - } - return; - } - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - collect_function_prototypes(child, source, output); - } -} - -fn prototype_from_definition(node: Node<'_>, source: &str) -> Option { - if has_skipped_context(node) { - return None; - } - let signature_node = node - .parent() - .filter(|parent| parent.kind() == "template_declaration") - .unwrap_or(node); - let body = node.child_by_field_name("body")?; - let start = signature_node.start_byte(); - let signature = source.get(start..body.start_byte())?; - let parameters = find_descendant(node, "parameter_list")?; - let parameters_start = parameters.start_byte().checked_sub(start)?; - let parameters_end = parameters.end_byte().checked_sub(start)?; - let signature = normalize_signature(&strip_default_arguments( - signature, - parameters_start, - parameters_end, - ))?; - if signature.contains("::") - || signature.starts_with('#') - || matches!( - signature.trim(), - "void setup()" | "void setup(void)" | "void loop()" | "void loop(void)" - ) - { - return None; - } - Some(signature) -} - -fn has_skipped_context(node: Node<'_>) -> bool { - let mut current = node.parent(); - while let Some(parent) = current { - match parent.kind() { - "namespace_definition" - | "class_specifier" - | "struct_specifier" - | "union_specifier" - | "field_declaration_list" - // Arduino sketches occasionally expose WASM/browser hooks with - // `extern \"C\"`. A generated C++ declaration would change the - // function's language linkage and make the later definition fail. - | "linkage_specification" => return true, - _ => current = parent.parent(), + use kernal_api::source::{analyze_cpp, AnalysisError}; + let candidates = match analyze_cpp(source) { + Err(AnalysisError::InvalidSyntax) => { + bail!("sketch has incomplete C++ syntax; retaining the last good IntelliSense prelude"); } - } - false -} - -fn find_descendant<'a>(node: Node<'a>, kind: &str) -> Option> { - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - if child.kind() == kind { - return Some(child); - } - if let Some(found) = find_descendant(child, kind) { - return Some(found); - } - } - None -} - -fn normalize_signature(signature: &str) -> Option { - let lines = signature - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>(); - (!lines.is_empty()).then(|| lines.join(" ")) -} - -fn strip_default_arguments(signature: &str, start: usize, end: usize) -> String { - let Some(parameters) = signature.get(start..end) else { - return signature.to_string(); + result => result.context("analyze Arduino sketch")?, }; - let Some(inner) = parameters - .strip_prefix('(') - .and_then(|value| value.strip_suffix(')')) - else { - return signature.to_string(); - }; - format!( - "{}({}){}", - &signature[..start], - strip_defaults(inner), - &signature[end..] - ) -} - -fn strip_defaults(parameters: &str) -> String { - let mut output = String::new(); - let mut skip_default = false; - let mut depths = [0usize; 4]; // paren, bracket, brace, angle - let mut quote = None; - let mut escaped = false; - for character in parameters.chars() { - if let Some(delimiter) = quote { - if !skip_default { - output.push(character); - } - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == delimiter { - quote = None; - } + let mut seen = HashSet::new(); + let mut prototypes = Vec::new(); + for candidate in candidates { + let context = candidate.context; + if context.namespace || context.aggregate || context.explicit_linkage { continue; } - match character { - '\'' | '"' => { - if !skip_default { - output.push(character); - } - quote = Some(character); - } - '(' => { - depths[0] += 1; - if !skip_default { - output.push(character); - } - } - ')' => { - depths[0] = depths[0].saturating_sub(1); - if !skip_default { - output.push(character); - } - } - '[' => { - depths[1] += 1; - if !skip_default { - output.push(character); - } - } - ']' => { - depths[1] = depths[1].saturating_sub(1); - if !skip_default { - output.push(character); - } - } - '{' => { - depths[2] += 1; - if !skip_default { - output.push(character); - } - } - '}' => { - depths[2] = depths[2].saturating_sub(1); - if !skip_default { - output.push(character); - } - } - '<' => { - depths[3] += 1; - if !skip_default { - output.push(character); - } - } - '>' => { - depths[3] = depths[3].saturating_sub(1); - if !skip_default { - output.push(character); - } - } - '=' if depths.iter().all(|depth| *depth == 0) => { - skip_default = true; - output = output.trim_end().to_string(); - } - ',' if depths.iter().all(|depth| *depth == 0) => { - skip_default = false; - output = output.trim_end().to_string(); - output.push(','); - } - _ if !skip_default => output.push(character), - _ => {} + // Normalize only the selection/deduplication key. Emitted source must + // preserve newlines that terminate C++ line comments. + let key = candidate + .signature + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + if key.is_empty() + || key.contains("::") + || key.starts_with('#') + || matches!( + key.as_str(), + "void setup()" | "void setup(void)" | "void loop()" | "void loop(void)" + ) + || !seen.insert(key) + { + continue; } + prototypes.push(candidate.signature); } - output + Ok(prototypes) } #[cfg(test)] @@ -741,4 +566,25 @@ mod tests { .iter() .any(|prototype| prototype.contains("browser_hook"))); } + + #[test] + fn prototype_output_retains_comment_terminating_newlines() { + let prototypes = extract_function_prototypes( + "int helper(int x // parameter\n = 1) // header\n { return x; }", + ) + .unwrap(); + assert_eq!( + render_prototype_declarations(&prototypes), + "int helper(int x // parameter\n) // header\n;\n" + ); + } + + #[test] + fn arduino_selection_keeps_global_helpers_in_order_without_duplicates() { + let source = "void setup() {}\nvoid loop(void) {}\nnamespace n { void hidden() {} }\nstruct S { void member() {} };\nvoid helper() {}\nvoid helper() {}\nvoid second() {}"; + assert_eq!( + extract_function_prototypes(source).unwrap(), + ["void helper()", "void second()"] + ); + } } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 632adc95..009642e7 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -925,6 +925,36 @@ or Safari rendering proof; the existing viewer acceptance gap remains open. The temporary path patch now points at the config capability checkout; a usable published release and patch removal remain required. No build speedup is claimed. +### C++ source analysis migration + +Upstream issue zackees/kernal-api#209 is implemented in draft PR #210. Generic +C++ traversal, syntax contexts, source ranges, parameter-default removal, and +parser contract tests now belong to its optional `source-cpp` capability. The +app removes both direct tree-sitter dependencies and their imports, consuming +only owned kernel records. The temporary path patch points to the source-cpp +checkout; a usable exact published release remains required. + +Arduino tab order, `setup`/`loop` and scope/linkage filtering, deduplication, +generated preambles, line maps, snapshot generations, and atomic publication +remain here. Selection uses normalized keys, but emitted signatures retain the +newlines needed to terminate C++ line comments. Provenance from the original +fbuild scanner remains documented. Bounded kernel parsing failures retain the +last good snapshot through the existing publish-after-success path. + +The dependency ban and line-comment output regression both failed before +adoption. All 8 preprocessor tests now pass, followed by 265 library tests, +3 binary tests, 1 integration test, and 1 doc test. All 32 Python tests pass +(1 skipped), including the dependency ban. Strict all-target Clippy and the CLI +build passed. A fresh `/tmp/fastled-cpp-wasm.ncl9j6` sketch with forward calls, +a default argument, and line-commented headers compiled and linked successfully +using the unchanged Emscripten 4.0.21 toolchain. Its actual generated wrapper +retains comment-ending newlines and removes the default in the prototype; the +WASM header is valid and generated JavaScript contains no prohibited JSPI entry +points. This is compiler-path evidence, not browser or Safari rendering proof. +This change does not upgrade Emscripten or claim a measured build +speedup. Remaining direct Rust dependencies are Clap, Serde, Serde JSON, Anyhow, +and kernal-api; their migration remains required. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 63a46e42..13d39648 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -75,6 +75,18 @@ def test_toml_configuration_uses_kernel(): assert "toml::" not in source.read_text(), source +def test_cpp_source_analysis_uses_kernel(): + root = Path(__file__).resolve().parents[2] + for path in (root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"): + data = tomllib.loads(path.read_text()) + for name in ("tree-sitter", "tree-sitter-cpp"): + assert name not in data.get("dependencies", {}) + assert name not in data.get("workspace", {}).get("dependencies", {}) + for source in (root / "crates/fastled-cli/src").rglob("*.rs"): + for backend in ("tree_sitter::", "tree_sitter_cpp::"): + assert backend not in source.read_text(), source + + def test_viewer_backend_is_owned_by_kernel(): root = Path(__file__).resolve().parents[2] manifests = [root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"] From 1f8113ec0290630b2208ad7364bc424c82f93450 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:09:40 -0700 Subject: [PATCH 54/94] refactor(json): adopt kernel project and DWARF smoke documents --- Cargo.lock | 20 +++--- Cargo.toml | 4 +- crates/fastled-cli/src/dwarf_smoke.rs | 30 +++++++-- crates/fastled-cli/src/project.rs | 97 +++++++++++++++++++-------- docs/kernal-api-migration.md | 25 +++++++ tests/unit/test_kernal_boundary.py | 14 ++++ 6 files changed, 146 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d4e5bcf..0aa48873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2042,6 +2042,8 @@ dependencies = [ "reflink-copy", "reqwest 0.12.28", "running-process", + "serde", + "serde_json", "sha2", "shell-words", "strsim", @@ -3348,9 +3350,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3370,22 +3372,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -3401,9 +3403,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap 2.14.0", "itoa", diff --git a/Cargo.toml b/Cargo.toml index 012e405a..dcaceaae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "config-toml", "source-cpp"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "config-toml", "source-cpp", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" @@ -26,7 +26,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-source-cpp" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-json" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/src/dwarf_smoke.rs b/crates/fastled-cli/src/dwarf_smoke.rs index a0d430c7..d2b64a26 100644 --- a/crates/fastled-cli/src/dwarf_smoke.rs +++ b/crates/fastled-cli/src/dwarf_smoke.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::sync::{Arc, RwLock}; use anyhow::Context; +use kernal_api::json::{self, Layout, Value}; use crate::debug_symbols; use crate::server; @@ -37,7 +38,10 @@ pub(crate) fn run_dwarf_source_smoke(output_dir: &Path) -> anyhow::Result let client = kernal_api::http::Client::new(kernal_api::http::Limits::default())?; for path in &paths { let url = format!("http://{addr}/dwarfsource"); - let body = serde_json::to_vec(&serde_json::json!({ "path": path }))?; + let body = json::encode( + &Value::Object([("path".into(), Value::String(path.clone()))].into()), + Layout::Compact, + )?; let resp = client .execute(kernal_api::http::Request { method: kernal_api::http::Method::Post, @@ -73,7 +77,17 @@ fn source_smoke_uses_manifest_paths_and_reports_missing_source() { let source_path = format!("{}/demo.ino", config.prefixes.sketch_prefix); std::fs::write( output.join("fastled.wasm.map"), - serde_json::json!({"sources": [source_path]}).to_string(), + json::encode( + &Value::Object( + [( + "sources".into(), + Value::Array(vec![Value::String(source_path)]), + )] + .into(), + ), + Layout::Compact, + ) + .unwrap(), ) .unwrap(); assert_eq!(run_dwarf_source_smoke(&output).unwrap(), 1); @@ -102,11 +116,15 @@ pub(crate) fn collect_debug_source_paths( if source_map_path.is_file() { let json = std::fs::read_to_string(&source_map_path) .with_context(|| format!("read {}", source_map_path.display()))?; - let parsed: serde_json::Value = serde_json::from_str(&json) + let parsed = json::parse(json.as_bytes()) .with_context(|| format!("parse {}", source_map_path.display()))?; - if let Some(sources) = parsed.get("sources").and_then(serde_json::Value::as_array) { - for source in sources.iter().filter_map(serde_json::Value::as_str) { - insert_debug_source_candidate(&mut paths, &prefixes, source); + if let Value::Object(parsed) = parsed { + if let Some(Value::Array(sources)) = parsed.get("sources") { + for source in sources { + if let Value::String(source) = source { + insert_debug_source_candidate(&mut paths, &prefixes, source); + } + } } } } diff --git a/crates/fastled-cli/src/project.rs b/crates/fastled-cli/src/project.rs index a79c938f..6fc078ba 100644 --- a/crates/fastled-cli/src/project.rs +++ b/crates/fastled-cli/src/project.rs @@ -14,6 +14,7 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; +use kernal_api::json::{self, Layout, Value}; // --------------------------------------------------------------------------- // Sketch detection @@ -242,8 +243,8 @@ pub fn is_fastled_repo(path: &Path) -> bool { let lib_json = path.join("library.json"); if lib_json.is_file() { if let Ok(txt) = fs::read_to_string(&lib_json) { - if let Ok(val) = serde_json::from_str::(&txt) { - if val.get("name").and_then(|v| v.as_str()) == Some("FastLED") { + if let Ok(Value::Object(val)) = json::parse(txt.as_bytes()) { + if matches!(val.get("name"), Some(Value::String(name)) if name == "FastLED") { return true; } } @@ -321,8 +322,13 @@ pub fn collect_examples(examples_dir: &Path) -> Vec { pub fn read_fastled_json_ref(directory: &Path) -> Option { let fpath = directory.join("fastled.json"); let txt = fs::read_to_string(fpath).ok()?; - let value: serde_json::Value = serde_json::from_str(&txt).ok()?; - value.get("ref")?.as_str().map(str::to_owned) + let Value::Object(value) = json::parse(txt.as_bytes()).ok()? else { + return None; + }; + match value.get("ref")? { + Value::String(value) => Some(value.clone()), + _ => None, + } } /// Derive the resolved FastLED ref name from a cached repo directory. @@ -344,25 +350,22 @@ pub fn cached_repo_ref_name(repo_root: &Path) -> String { /// keys when the file already contains valid JSON. pub fn write_fastled_json_ref(directory: &Path, ref_name: &str) -> Result<()> { let fpath = directory.join("fastled.json"); - let mut data = if fpath.is_file() { - fs::read_to_string(&fpath) - .ok() - .and_then(|txt| { - serde_json::from_str::>(&txt).ok() - }) - .unwrap_or_default() - } else { - serde_json::Map::new() + let mut data = match fs::read_to_string(&fpath).ok() { + Some(txt) => match json::parse(txt.as_bytes()) { + Ok(Value::Object(value)) => value, + Ok(_) | Err(json::Error::InvalidSyntax) => std::collections::BTreeMap::new(), + // Do not erase valid user settings merely because the bounded + // parser cannot retain their complete contents. + Err(error) => return Err(error).context("read existing fastled.json"), + }, + None => std::collections::BTreeMap::new(), }; - data.insert( - "ref".to_owned(), - serde_json::Value::String(ref_name.to_owned()), - ); + data.insert("ref".to_owned(), Value::String(ref_name.to_owned())); - let mut json = serde_json::to_string_pretty(&serde_json::Value::Object(data)) - .context("serialize fastled.json")?; - json.push('\n'); + let mut json = + json::encode(&Value::Object(data), Layout::Pretty).context("serialize fastled.json")?; + json.push(b'\n'); fs::write(&fpath, json).with_context(|| format!("write {}", fpath.display()))?; Ok(()) } @@ -424,10 +427,13 @@ fn fetch_latest_release_tag() -> Option { } let body = resp.into_bytes().ok()?; - let json: serde_json::Value = serde_json::from_slice(&body).ok()?; - json.get("tag_name") - .and_then(|v: &serde_json::Value| v.as_str()) - .map(|s: &str| s.to_owned()) + let Value::Object(json) = json::parse(&body).ok()? else { + return None; + }; + match json.get("tag_name")? { + Value::String(value) => Some(value.clone()), + _ => None, + } } /// Return `true` when `ref_str` looks like a git commit SHA (7–40 hex chars). @@ -1020,6 +1026,41 @@ mod tests { assert_eq!(read_fastled_json_ref(dir.path()).as_deref(), Some("master")); } + #[test] + fn project_json_preserves_nested_unknown_fields_and_layout() { + let dir = temp_dir(); + let path = dir.path().join("fastled.json"); + fs::write(&path, r#"{"extra":[null,true,18446744073709551615,{"日本":"line\ntext"}],"ref":"old","ref":"last"}"#).unwrap(); + assert_eq!(read_fastled_json_ref(dir.path()).as_deref(), Some("last")); + write_fastled_json_ref(dir.path(), "new").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "{\n \"extra\": [\n null,\n true,\n 18446744073709551615,\n {\n \"日本\": \"line\\ntext\"\n }\n ],\n \"ref\": \"new\"\n}\n"); + } + + #[test] + fn project_json_keeps_invalid_and_nonobject_fallback_policy() { + let dir = temp_dir(); + let path = dir.path().join("fastled.json"); + for input in ["broken", "[]", "null", "42", r#"{"ref":false}"#] { + fs::write(&path, input).unwrap(); + assert_eq!(read_fastled_json_ref(dir.path()), None); + write_fastled_json_ref(dir.path(), "master").unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "{\n \"ref\": \"master\"\n}\n" + ); + } + } + + #[test] + fn project_json_resource_limit_does_not_replace_existing_settings() { + let dir = temp_dir(); + let path = dir.path().join("fastled.json"); + let original = format!("{{\"extra\":\"{}\"}}", "x".repeat(json::MAX_INPUT_BYTES)); + fs::write(&path, &original).unwrap(); + assert!(write_fastled_json_ref(dir.path(), "new").is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), original); + } + #[test] fn test_cached_repo_ref_name_strips_fastled_prefix() { let path = Path::new("/tmp/fastled-3.9.12"); @@ -1044,9 +1085,11 @@ mod tests { write_fastled_json_ref(dir.path(), "3.9.12").unwrap(); let txt = fs::read_to_string(dir.path().join("fastled.json")).unwrap(); - let value: serde_json::Value = serde_json::from_str(&txt).unwrap(); - assert_eq!(value.get("name").and_then(|v| v.as_str()), Some("demo")); - assert_eq!(value.get("ref").and_then(|v| v.as_str()), Some("3.9.12")); + let Value::Object(value) = json::parse(txt.as_bytes()).unwrap() else { + panic!("project settings must be an object"); + }; + assert_eq!(value.get("name"), Some(&Value::String("demo".into()))); + assert_eq!(value.get("ref"), Some(&Value::String("3.9.12".into()))); } #[test] diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 009642e7..2dab4eab 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -955,6 +955,31 @@ This change does not upgrade Emscripten or claim a measured build speedup. Remaining direct Rust dependencies are Clap, Serde, Serde JSON, Anyhow, and kernal-api; their migration remains required. +### JSON adoption in progress + +Upstream issue zackees/kernal-api#211 and draft PR #212 provide bounded owned +JSON values and parsing/encoding without public Serde contracts. The app's +migration-only patch now points to `kernal-api-json` at upstream `b3896d2` and +enables `json`. The lockfile aligns Serde/Serde Core/Serde Derive to 1.0.229 and +Serde JSON to 1.0.151 to satisfy the kernel's exact private pins. + +Project discovery, ref settings, release-tag extraction, and the DWARF smoke +client/source-map reader now use this API. Their field selection, non-string +filtering, HTTP status policy, unknown-setting preservation and trailing newline +remain local. Project updates propagate resource-limit failures before writing; +they must not discard an oversized valid settings file. Malformed/non-object +project settings retain the existing reset behavior. Kernel object encoding is +key-sorted, and its documented source/node/depth/output limits now apply. + +Both module-specific dependency bans observed RED then GREEN. All 268 library, +3 binary, 1 integration and 1 doc tests pass, including the DWARF HTTP smoke +test. All 34 Python tests pass (1 skipped); formatting, Ruff, strict all-target +Clippy and single-reviewer review pass. Final native/runtime verification remains +pending. Direct Serde +dependencies remain for the other eight JSON modules; this is not complete JSON +adoption or a release-ready dependency graph. Published adoption, browser/Safari +acceptance and measured build performance remain outstanding. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 13d39648..057f9e33 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -75,6 +75,20 @@ def test_toml_configuration_uses_kernel(): assert "toml::" not in source.read_text(), source +def test_project_json_uses_kernel(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/project.rs").read_text() + assert "serde_json::" not in source + assert "kernal_api::json" in source + + +def test_dwarf_smoke_json_uses_kernel(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/dwarf_smoke.rs").read_text() + assert "serde_json::" not in source + assert "kernal_api::json" in source + + def test_cpp_source_analysis_uses_kernel(): root = Path(__file__).resolve().parents[2] for path in (root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"): From a808d124252359d68ee1612bf6b83f6c06b311cf Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:15:55 -0700 Subject: [PATCH 55/94] refactor(editor): use kernel JSON for configuration and tests --- crates/fastled-cli/src/clangd_config.rs | 361 ++++++++++++++++-------- docs/kernal-api-migration.md | 16 ++ tests/unit/test_kernal_boundary.py | 11 + 3 files changed, 278 insertions(+), 110 deletions(-) diff --git a/crates/fastled-cli/src/clangd_config.rs b/crates/fastled-cli/src/clangd_config.rs index 587bae65..ed00c147 100644 --- a/crates/fastled-cli/src/clangd_config.rs +++ b/crates/fastled-cli/src/clangd_config.rs @@ -17,7 +17,8 @@ use std::path::Path; use anyhow::{Context, Result}; -use serde_json::json; +use kernal_api::json::{self, Layout, Value as DocumentValue}; +use std::collections::BTreeMap; use crate::install; use crate::path::NormalizedPath; @@ -234,21 +235,49 @@ fn write_compile_commands(inputs: &ClangdConfigInputs, paths: &ResolvedPaths) -> .iter() .map(|ino_file| { let ino = display_path(ino_file); - json!({ - "directory": paths.fastled_dir, - "file": ino, - "arguments": compile_arguments(inputs, paths, &ino, true, Some(&prelude)), - }) + DocumentValue::Object( + [ + ( + "directory".into(), + DocumentValue::String(paths.fastled_dir.clone()), + ), + ("file".into(), DocumentValue::String(ino.clone())), + ( + "arguments".into(), + DocumentValue::Array( + compile_arguments(inputs, paths, &ino, true, Some(&prelude)) + .into_iter() + .map(DocumentValue::String) + .collect(), + ), + ), + ] + .into(), + ) }) .collect::>(); - entries.push(json!({ - "directory": paths.fastled_dir, - "file": wrapper, - "arguments": compile_arguments(inputs, paths, &wrapper, false, None), - })); - install::write_json_file( + entries.push(DocumentValue::Object( + [ + ( + "directory".into(), + DocumentValue::String(paths.fastled_dir.clone()), + ), + ("file".into(), DocumentValue::String(wrapper.clone())), + ( + "arguments".into(), + DocumentValue::Array( + compile_arguments(inputs, paths, &wrapper, false, None) + .into_iter() + .map(DocumentValue::String) + .collect(), + ), + ), + ] + .into(), + )); + write_editor_document( &inputs.sketch_dir.join("compile_commands.json"), - &serde_json::Value::Array(entries), + &DocumentValue::Array(entries), ) } @@ -286,47 +315,76 @@ fn write_clangd_file(inputs: &ClangdConfigInputs, paths: &ResolvedPaths) -> Resu fn write_vscode_settings(paths: &ResolvedPaths, sketch_dir: &Path) -> Result<()> { let settings_path = sketch_dir.join(".vscode").join("settings.json"); - let mut settings = install::read_json_file(&settings_path, json!({})); - if !settings.is_object() { - settings = json!({}); - } - let object = settings.as_object_mut().expect("settings.json root object"); + let mut object = read_editor_settings(&settings_path)?; object.insert( "clangd.arguments".to_string(), - json!([ - "--compile-commands-dir=${workspaceFolder}", - format!("--query-driver={}", paths.clangxx), - "--background-index", - "--header-insertion=never", - "--completion-style=detailed", - "--pch-storage=memory", - ]), + DocumentValue::Array( + [ + "--compile-commands-dir=${workspaceFolder}".into(), + format!("--query-driver={}", paths.clangxx), + "--background-index".into(), + "--header-insertion=never".into(), + "--completion-style=detailed".into(), + "--pch-storage=memory".into(), + ] + .into_iter() + .map(DocumentValue::String) + .collect(), + ), ); object.insert( "clangd.fallbackFlags".to_string(), - json!([ - "-std=c++20", - "--target=wasm32-unknown-emscripten", - format!("-I{}", paths.fastled_src), - format!("-I{}", paths.fastled_wasm_compiler), - format!("-isystem{}", paths.libcxx_include), - format!("-isystem{}", paths.sysroot_compat_include), - "-D__EMSCRIPTEN__=1", - ]), + DocumentValue::Array( + [ + "-std=c++20".into(), + "--target=wasm32-unknown-emscripten".into(), + format!("-I{}", paths.fastled_src), + format!("-I{}", paths.fastled_wasm_compiler), + format!("-isystem{}", paths.libcxx_include), + format!("-isystem{}", paths.sysroot_compat_include), + "-D__EMSCRIPTEN__=1".into(), + ] + .into_iter() + .map(DocumentValue::String) + .collect(), + ), ); - let associations = object - .entry("files.associations") - .or_insert_with(|| json!({})); - if !associations.is_object() { - *associations = json!({}); + let mut associations = match object.remove("files.associations") { + Some(DocumentValue::Object(value)) => value, + _ => BTreeMap::new(), + }; + associations.insert("*.ino".into(), DocumentValue::String("cpp".into())); + object.insert( + "files.associations".into(), + DocumentValue::Object(associations), + ); + + write_editor_document(&settings_path, &DocumentValue::Object(object)) +} + +// Editor policy: retain unknown object fields; missing, malformed and nonobject +// settings start empty. Resource failures must never erase a valid settings file. +fn read_editor_settings(path: &Path) -> Result> { + let Ok(text) = std::fs::read_to_string(path) else { + return Ok(BTreeMap::new()); + }; + match json::parse(text.as_bytes()) { + Ok(DocumentValue::Object(value)) => Ok(value), + Ok(_) | Err(json::Error::InvalidSyntax) => Ok(BTreeMap::new()), + Err(error) => { + Err(error).with_context(|| format!("read editor settings {}", path.display())) + } } - associations - .as_object_mut() - .expect("files.associations object") - .insert("*.ino".to_string(), json!("cpp")); +} - install::write_json_file(&settings_path, &settings) +fn write_editor_document(path: &Path, value: &DocumentValue) -> Result<()> { + let mut bytes = json::encode(value, Layout::Pretty).context("serialize editor JSON")?; + bytes.push(b'\n'); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + std::fs::write(path, bytes).with_context(|| format!("write {}", path.display())) } /// Emit the same compile database and forced declaration prelude for the @@ -337,23 +395,45 @@ fn write_cpptools_config(inputs: &ClangdConfigInputs, paths: &ResolvedPaths) -> .sketch_dir .join(".vscode") .join("c_cpp_properties.json"); - let mut properties = install::read_json_file(&path, json!({})); - if !properties.is_object() { - properties = json!({}); - } - let root = properties.as_object_mut().expect("properties root object"); + let mut root = read_editor_settings(&path)?; root.insert( "configurations".to_string(), - json!([{ - "name": "FastLED WASM", - "compilerPath": paths.clangxx, - "compileCommands": "${workspaceFolder}/compile_commands.json", - "cppStandard": "c++20", - "includePath": [paths.sketch_dir, paths.fastled_src, paths.fastled_wasm_compiler], - "forcedInclude": [display_path(&inputs.prototype_header)], - }]), + DocumentValue::Array(vec![DocumentValue::Object( + [ + ("name".into(), DocumentValue::String("FastLED WASM".into())), + ( + "compilerPath".into(), + DocumentValue::String(paths.clangxx.clone()), + ), + ( + "compileCommands".into(), + DocumentValue::String("${workspaceFolder}/compile_commands.json".into()), + ), + ("cppStandard".into(), DocumentValue::String("c++20".into())), + ( + "includePath".into(), + DocumentValue::Array( + [ + paths.sketch_dir.clone(), + paths.fastled_src.clone(), + paths.fastled_wasm_compiler.clone(), + ] + .into_iter() + .map(DocumentValue::String) + .collect(), + ), + ), + ( + "forcedInclude".into(), + DocumentValue::Array(vec![DocumentValue::String(display_path( + &inputs.prototype_header, + ))]), + ), + ] + .into(), + )]), ); - install::write_json_file(&path, &properties) + write_editor_document(&path, &DocumentValue::Object(root)) } /// Write `compile_commands.json`, `.clangd`, and `.vscode/settings.json` @@ -432,9 +512,33 @@ pub fn run_write_clangd(dir_arg: &str) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use serde_json::Value; use std::fs; + fn field<'a>(value: &'a DocumentValue, key: &str) -> &'a DocumentValue { + let DocumentValue::Object(fields) = value else { + panic!("expected editor object") + }; + fields.get(key).expect("expected editor field") + } + + fn array(value: &DocumentValue) -> &[DocumentValue] { + let DocumentValue::Array(values) = value else { + panic!("expected editor array") + }; + values + } + + fn string(value: &DocumentValue) -> &str { + let DocumentValue::String(value) = value else { + panic!("expected editor string") + }; + value + } + + fn read_document(path: &Path) -> DocumentValue { + json::parse(&fs::read(path).unwrap()).unwrap() + } + fn stub_inputs(root: &Path) -> ClangdConfigInputs { let sketch_dir = root.join("Blink"); let cache = sketch_dir.join(".build").join("wasm"); @@ -554,8 +658,8 @@ mod tests { let cc_path = inputs.sketch_dir.join("compile_commands.json"); let cc_text = fs::read_to_string(&cc_path).unwrap(); assert!(!cc_text.contains("\\\\?\\"), "no \\\\?\\ prefixes allowed"); - let cc: Value = serde_json::from_str(&cc_text).unwrap(); - let entries = cc.as_array().expect("array"); + let cc = json::parse(cc_text.as_bytes()).unwrap(); + let entries = array(&cc); assert_eq!(entries.len(), 2); let clangxx = bundled_clangxx(&crate::path::canonicalize_normalized( @@ -563,11 +667,9 @@ mod tests { )); assert!(clangxx.is_file(), "bundled clang++ must exist on disk"); for entry in entries { - let args: Vec<&str> = entry["arguments"] - .as_array() - .unwrap() + let args: Vec<&str> = array(field(entry, "arguments")) .iter() - .map(|v| v.as_str().unwrap()) + .map(string) .collect(); assert_eq!(args[0], clangxx.to_string_lossy().replace('\\', "/")); assert!(args.contains(&"--target=wasm32-unknown-emscripten")); @@ -598,18 +700,16 @@ mod tests { && pair[1].ends_with("emscripten/cache/sysroot/include/compat") })); // directory is the FastLED checkout (build cwd). - let directory = entry["directory"].as_str().unwrap(); + let directory = string(field(entry, "directory")); assert!(directory.ends_with("fastled")); assert!(!directory.contains("\\\\?\\")); } // The .ino entry forces C++. - let ino_args: Vec<&str> = entries[0]["arguments"] - .as_array() - .unwrap() + let ino_args: Vec<&str> = array(field(&entries[0], "arguments")) .iter() - .map(|v| v.as_str().unwrap()) + .map(string) .collect(); - assert!(entries[0]["file"].as_str().unwrap().ends_with("Blink.ino")); + assert!(string(field(&entries[0], "file")).ends_with("Blink.ino")); assert_eq!(&ino_args[1..3], ["-x", "c++"]); assert!(ino_args.windows(2).any(|pair| pair[0] == "-include" && pair[1].ends_with(".fastled/intellisense/prototypes.hpp"))); @@ -628,37 +728,27 @@ mod tests { // .vscode/settings.json let settings_path = inputs.sketch_dir.join(".vscode").join("settings.json"); - let settings: Value = - serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); - let clangd_args: Vec<&str> = settings["clangd.arguments"] - .as_array() - .unwrap() + let settings = read_document(&settings_path); + let clangd_args: Vec<&str> = array(field(&settings, "clangd.arguments")) .iter() - .map(|v| v.as_str().unwrap()) + .map(string) .collect(); assert!(clangd_args.contains(&"--compile-commands-dir=${workspaceFolder}")); assert!(clangd_args .iter() .any(|a| a.starts_with("--query-driver=") && a.contains("clang++"))); - assert_eq!(settings["files.associations"]["*.ino"], "cpp"); - - let cpptools: Value = serde_json::from_str( - &fs::read_to_string( - inputs - .sketch_dir - .join(".vscode") - .join("c_cpp_properties.json"), - ) - .unwrap(), - ) - .unwrap(); assert_eq!( - cpptools["configurations"][0]["compileCommands"], + string(field(field(&settings, "files.associations"), "*.ino")), + "cpp" + ); + + let cpptools = read_document(&inputs.sketch_dir.join(".vscode/c_cpp_properties.json")); + let configuration = &array(field(&cpptools, "configurations"))[0]; + assert_eq!( + string(field(configuration, "compileCommands")), "${workspaceFolder}/compile_commands.json" ); - assert!(cpptools["configurations"][0]["forcedInclude"][0] - .as_str() - .unwrap() + assert!(string(&array(field(configuration, "forcedInclude"))[0]) .ends_with(".fastled/intellisense/prototypes.hpp")); } @@ -676,12 +766,15 @@ mod tests { write_clangd_config(&inputs).unwrap(); - let settings: Value = - serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); - assert_eq!(settings["editor.formatOnSave"], true); - assert_eq!(settings["files.associations"]["*.h"], "cpp"); - assert_eq!(settings["files.associations"]["*.ino"], "cpp"); - assert!(settings["clangd.arguments"].is_array()); + let settings = read_document(&settings_path); + assert_eq!( + field(&settings, "editor.formatOnSave"), + &DocumentValue::Bool(true) + ); + let associations = field(&settings, "files.associations"); + assert_eq!(string(field(associations, "*.h")), "cpp"); + assert_eq!(string(field(associations, "*.ino")), "cpp"); + assert!(!array(field(&settings, "clangd.arguments")).is_empty()); } #[test] @@ -695,6 +788,60 @@ mod tests { assert_eq!(first, second); } + #[test] + fn oversized_editor_settings_are_not_replaced() { + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let inputs = stub_inputs(tmp.path()); + let path = inputs.sketch_dir.join(".vscode/settings.json"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = format!("{{\"custom\":\"{}\"}}", "x".repeat(json::MAX_INPUT_BYTES)); + fs::write(&path, &original).unwrap(); + assert!(write_clangd_config(&inputs).is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), original); + } + + #[test] + fn editor_json_preserves_unknown_cpptools_fields_and_repairs_associations() { + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let inputs = stub_inputs(tmp.path()); + let vscode = inputs.sketch_dir.join(".vscode"); + fs::create_dir_all(&vscode).unwrap(); + fs::write( + vscode.join("settings.json"), + r#"{"files.associations":false,"custom":[null,"日本",18446744073709551615]}"#, + ) + .unwrap(); + fs::write( + vscode.join("c_cpp_properties.json"), + r#"{"version":4,"custom":{"enabled":true},"configurations":false}"#, + ) + .unwrap(); + write_clangd_config(&inputs).unwrap(); + let text = fs::read_to_string(vscode.join("settings.json")).unwrap(); + assert!(text.ends_with('\n')); + let settings = json::parse(text.as_bytes()).unwrap(); + assert_eq!( + string(field(field(&settings, "files.associations"), "*.ino")), + "cpp" + ); + let custom = array(field(&settings, "custom")); + assert_eq!(string(&custom[1]), "日本"); + assert_eq!(custom[2], DocumentValue::Unsigned(u64::MAX)); + let properties = read_document(&vscode.join("c_cpp_properties.json")); + assert_eq!(field(&properties, "version"), &DocumentValue::Signed(4)); + assert_eq!( + field(field(&properties, "custom"), "enabled"), + &DocumentValue::Bool(true) + ); + assert_eq!( + string(field( + &array(field(&properties, "configurations"))[0], + "name" + )), + "FastLED WASM" + ); + } + #[test] fn emits_a_forced_prelude_entry_for_every_ino_tab() { let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); @@ -704,23 +851,17 @@ mod tests { inputs.ino_files.push(NormalizedPath::new(utility)); write_clangd_config(&inputs).unwrap(); - let commands: Value = serde_json::from_str( - &fs::read_to_string(inputs.sketch_dir.join("compile_commands.json")).unwrap(), - ) - .unwrap(); - let entries = commands.as_array().unwrap(); + let commands = read_document(&inputs.sketch_dir.join("compile_commands.json")); + let entries = array(&commands); assert_eq!( entries.len(), 3, "two visible tabs plus generated C++ source" ); for entry in &entries[..2] { - let arguments = entry["arguments"].as_array().unwrap(); + let arguments = array(field(entry, "arguments")); assert!(arguments.windows(2).any(|pair| { - pair[0].as_str() == Some("-include") - && pair[1] - .as_str() - .is_some_and(|value| value.ends_with("prototypes.hpp")) + string(&pair[0]) == "-include" && string(&pair[1]).ends_with("prototypes.hpp") })); } } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 2dab4eab..1f76b9b4 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -980,6 +980,22 @@ dependencies remain for the other eight JSON modules; this is not complete JSON adoption or a release-ready dependency graph. Published adoption, browser/Safari acceptance and measured build performance remain outstanding. +The next local checkpoint moves production editor JSON generation and merging +in `clangd_config.rs` to kernel-owned values. Compile-command arguments, forced +prelude selection, VS Code association repair, C/C++ configuration replacement, +unknown-field preservation and trailing newlines remain application policy. +Resource-limit errors are propagated before replacing editor settings. The +production boundary test observed RED then GREEN. Existing editor assertions +first passed using Serde as an independent output oracle, then migrated to +kernel values without dropping their field/argument assertions. The strengthened +boundary test bans Serde JSON throughout the module, including tests. + +All 270 library, 3 binary, 1 integration and 1 doc tests pass for this checkpoint, +including two new editor preservation regressions. Python passes 35 tests with +1 skipped. Strict all-target Clippy, formatting, Ruff and single-reviewer review +pass. Seven JSON source modules still need migration; release/runtime gates +remain incomplete. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 057f9e33..e0a4caa9 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -89,6 +89,17 @@ def test_dwarf_smoke_json_uses_kernel(): assert "kernal_api::json" in source +def test_editor_json_uses_kernel(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/clangd_config.rs").read_text() + production = source.split("#[cfg(test)]", 1)[0] + assert "serde_json::" not in source + assert "json!(" not in production + assert "install::read_json_file" not in production + assert "install::write_json_file" not in production + assert "kernal_api::json" in production + + def test_cpp_source_analysis_uses_kernel(): root = Path(__file__).resolve().parents[2] for path in (root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"): From 42c6f535383820b39284cfb6665227738fb8db3e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:34:47 -0700 Subject: [PATCH 56/94] refactor(source): use kernal-api JSON for source receipts --- crates/fastled-cli/src/source.rs | 161 ++++++++++++++++++++++++++++- docs/kernal-api-migration.md | 16 +++ tests/unit/test_kernal_boundary.py | 8 ++ 3 files changed, 180 insertions(+), 5 deletions(-) diff --git a/crates/fastled-cli/src/source.rs b/crates/fastled-cli/src/source.rs index bdbead7c..c7a766b3 100644 --- a/crates/fastled-cli/src/source.rs +++ b/crates/fastled-cli/src/source.rs @@ -11,7 +11,7 @@ use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{bail, Context, Result}; -use serde::{Deserialize, Serialize}; +use kernal_api::json::{self, Layout, Value}; use crate::cli::SourceAction; use crate::path::NormalizedPath; @@ -27,16 +27,65 @@ const RECEIPT_FILE: &str = ".fastled-source.json"; /// /// `fetched_at_unix_secs` intentionally uses a simple UTC epoch timestamp so /// receipts remain portable and can be inspected without a date-time crate. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct SourceReceipt { pub(crate) requested_ref: String, pub(crate) fetched_at_unix_secs: u64, pub(crate) source_url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) resolved_commit: Option, } impl SourceReceipt { + fn from_document(document: Value) -> Result { + const FIELDS: [&str; 4] = [ + "requested_ref", + "fetched_at_unix_secs", + "source_url", + "resolved_commit", + ]; + let mut fields = [None, None, None, None]; + match document { + Value::ObjectMembers(members) => { + for (name, value) in members { + if let Some(index) = FIELDS.iter().position(|field| *field == name) { + if fields[index].replace(value).is_some() { + bail!("duplicate source receipt field {}", FIELDS[index]); + } + } + } + } + Value::Array(values) if (3..=4).contains(&values.len()) => { + for (field, value) in fields.iter_mut().zip(values) { + *field = Some(value); + } + } + _ => bail!("source receipt must be an object or a three/four-field array"), + } + let [requested_ref, fetched_at, source_url, resolved_commit] = fields; + let Some(Value::String(requested_ref)) = requested_ref else { + bail!("source receipt requested_ref must be a string"); + }; + let fetched_at_unix_secs = match fetched_at { + Some(Value::Unsigned(value)) => value, + Some(Value::Signed(value)) if value >= 0 => value as u64, + _ => bail!("source receipt fetched_at_unix_secs must be an unsigned integer"), + }; + let Some(Value::String(source_url)) = source_url else { + bail!("source receipt source_url must be a string"); + }; + let resolved_commit = match resolved_commit { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value), + _ => bail!("source receipt resolved_commit must be a string or null"), + }; + Ok(Self { + requested_ref, + fetched_at_unix_secs, + source_url, + resolved_commit, + }) + } + pub(crate) fn new( requested_ref: impl Into, source_url: impl Into, @@ -105,7 +154,9 @@ pub(crate) fn receipt_path(repo_dir: &Path) -> NormalizedPath { pub(crate) fn read_receipt(repo_dir: &Path) -> Result> { let path = receipt_path(repo_dir); match fs::read_to_string(&path) { - Ok(text) => serde_json::from_str(&text) + Ok(text) => json::parse_members(text.as_bytes()) + .map_err(anyhow::Error::from) + .and_then(SourceReceipt::from_document) .with_context(|| format!("parse source receipt {}", path.display())) .map(Some), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), @@ -282,7 +333,25 @@ pub(crate) fn run_source_action(action: SourceAction) -> Result<()> { } pub(crate) fn write_receipt(repo_dir: &Path, receipt: &SourceReceipt) -> Result<()> { - let bytes = serde_json::to_vec_pretty(receipt).context("serialize source receipt")?; + let mut members = vec![ + ( + "requested_ref".into(), + Value::String(receipt.requested_ref.clone()), + ), + ( + "fetched_at_unix_secs".into(), + Value::Unsigned(receipt.fetched_at_unix_secs), + ), + ( + "source_url".into(), + Value::String(receipt.source_url.clone()), + ), + ]; + if let Some(commit) = &receipt.resolved_commit { + members.push(("resolved_commit".into(), Value::String(commit.clone()))); + } + let bytes = json::encode(&Value::ObjectMembers(members), Layout::Pretty) + .context("serialize source receipt")?; fs::write(receipt_path(repo_dir), bytes) .with_context(|| format!("write source receipt in {}", repo_dir.display())) } @@ -349,6 +418,88 @@ mod tests { ); } + #[test] + fn receipt_schema_preserves_optional_and_unsigned_fields() { + let directory = TemporaryDirectory::new().unwrap(); + let path = receipt_path(directory.path()); + for suffix in [ + "", + ",\"resolved_commit\":null", + ",\"future\":{\"nested\":[true,null]}", + ] { + let source = format!("{{\"requested_ref\":\"日本\",\"source_url\":\"https://example.test/source\",\"fetched_at_unix_secs\":18446744073709551615{suffix}}}"); + fs::write(&path, source).unwrap(); + let receipt = read_receipt(directory.path()).unwrap().unwrap(); + assert_eq!(receipt.requested_ref, "日本"); + assert_eq!(receipt.fetched_at_unix_secs, u64::MAX); + assert_eq!(receipt.resolved_commit, None); + write_receipt(directory.path(), &receipt).unwrap(); + let encoded = fs::read_to_string(&path).unwrap(); + assert!(!encoded.contains("resolved_commit")); + assert!(!encoded.ends_with('\n')); + assert_eq!(read_receipt(directory.path()).unwrap(), Some(receipt)); + } + } + + #[test] + fn receipt_schema_accepts_positional_records_in_declared_field_order() { + let directory = TemporaryDirectory::new().unwrap(); + for source in [ + r#"["master",1,"url"]"#, + r#"["master",1,"url",null]"#, + r#"["master",1,"url","abc"]"#, + ] { + fs::write(receipt_path(directory.path()), source).unwrap(); + let value = read_receipt(directory.path()).unwrap().unwrap(); + assert_eq!(value.requested_ref, "master"); + assert_eq!(value.fetched_at_unix_secs, 1); + assert_eq!(value.source_url, "url"); + assert_eq!( + value.resolved_commit.as_deref(), + if source.contains("abc") { + Some("abc") + } else { + None + } + ); + write_receipt(directory.path(), &value).unwrap(); + let expected = if value.resolved_commit.is_some() { + "{\n \"requested_ref\": \"master\",\n \"fetched_at_unix_secs\": 1,\n \"source_url\": \"url\",\n \"resolved_commit\": \"abc\"\n}" + } else { + "{\n \"requested_ref\": \"master\",\n \"fetched_at_unix_secs\": 1,\n \"source_url\": \"url\"\n}" + }; + assert_eq!( + fs::read_to_string(receipt_path(directory.path())).unwrap(), + expected + ); + } + } + + #[test] + fn receipt_schema_rejects_wrong_types_missing_fields_and_duplicate_known_fields() { + let directory = TemporaryDirectory::new().unwrap(); + let path = receipt_path(directory.path()); + for source in [ + r#"{}"#, + r#"["master",1]"#, + r#"["master",1,"url",null,false]"#, + r#"["master",1,"url",false]"#, + r#"{"requested_ref":"master","source_url":"url","fetched_at_unix_secs":-1}"#, + r#"{"requested_ref":"master","source_url":"url","fetched_at_unix_secs":1.0}"#, + r#"{"requested_ref":"master","source_url":"url","fetched_at_unix_secs":"1"}"#, + r#"{"requested_ref":"master","source_url":"url","fetched_at_unix_secs":1,"resolved_commit":false}"#, + r#"{"requested_ref":"master","requested_ref":"other","source_url":"url","fetched_at_unix_secs":1}"#, + r#"{"requested_ref":"master","source_url":"url","fetched_at_unix_secs":1,"resolved_commit":null,"resolved_commit":"abc"}"#, + ] { + fs::write(&path, source).unwrap(); + assert!(read_receipt(directory.path()).is_err(), "accepted {source}"); + } + // Unknown fields are ignored even when repeated; this differs from + // rejecting every duplicate key in the JSON parser. + fs::write(&path, r#"{"requested_ref":"master","source_url":"url","fetched_at_unix_secs":1,"future":false,"future":true}"#).unwrap(); + assert!(read_receipt(directory.path()).is_ok()); + } + #[test] fn master_freshness_uses_a_strict_24_hour_boundary() { let cache = TemporaryDirectory::new().unwrap(); diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 1f76b9b4..a7443a41 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -996,6 +996,22 @@ including two new editor preservation regressions. Python passes 35 tests with pass. Seven JSON source modules still need migration; release/runtime gates remain incomplete. +Typed receipt migration has an additional verified compatibility requirement. +New source-receipt baseline tests pass for optional/null commit fields, exact +unsigned timestamps, strict field types, duplicate-known-field rejection and +duplicate-unknown-field tolerance. Issue #211 and upstream PR #212 now provide +bounded `json::parse_members` and owned `ObjectMembers` values that retain +nested duplicates without exposing backend traits. Source receipts use that +surface; field recognition, type validation and positional-record support stay +local. Encoding preserves declared field order, omits absent commits and adds +no trailing newline. The receipt module no longer uses Serde; six JSON source +modules remain. The app still consumes the migration-only upstream path patch. + +Receipt checkpoint verification: 273 library, 3 binary, 1 integration and 1 doc +tests pass; Python passes 36 with 1 skipped. Strict all-target Clippy, formatting, +Ruff and single-reviewer review pass. No compiler backend or flags changed in +this checkpoint; it does not add native-browser or Safari acceptance evidence. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index e0a4caa9..82c948d7 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -184,3 +184,11 @@ def test_migrated_async_operations_use_kernel(): "tower_http::", ): assert backend not in source, path + + +def test_source_receipt_uses_kernal_json() -> None: + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/source.rs").read_text() + assert "serde::" not in source + assert "serde_json::" not in source + assert "json::parse_members" in source From 1518ef4910c531df23093bf701ee1c43847b66d7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:39:08 -0700 Subject: [PATCH 57/94] refactor(debug): use kernal-api JSON for debugger manifests --- crates/fastled-cli/src/debug_symbols.rs | 249 +++++++++++++++++++++--- docs/kernal-api-migration.md | 12 ++ tests/unit/test_kernal_boundary.py | 8 + 3 files changed, 247 insertions(+), 22 deletions(-) diff --git a/crates/fastled-cli/src/debug_symbols.rs b/crates/fastled-cli/src/debug_symbols.rs index a291fd95..a1d42c1c 100644 --- a/crates/fastled-cli/src/debug_symbols.rs +++ b/crates/fastled-cli/src/debug_symbols.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; +use kernal_api::json::{self, Layout, Value}; pub const DEFAULT_FASTLED_PREFIX: &str = "fastledsource"; pub const DEFAULT_SKETCH_PREFIX: &str = "sketchsource"; @@ -22,17 +22,12 @@ pub const DWARF_ROOTS_MANIFEST: &str = "dwarf-roots.json"; /// Configurable DWARF path prefixes loaded from /// `/platforms/wasm/compiler/build_flags.toml`. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone)] pub struct DwarfPrefixConfig { - #[serde(default = "default_fastled_prefix")] pub fastled_prefix: String, - #[serde(default = "default_sketch_prefix")] pub sketch_prefix: String, - #[serde(default = "default_dwarf_prefix")] pub dwarf_prefix: String, - #[serde(default, skip_serializing_if = "Option::is_none")] pub file_prefix_map_from: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub file_prefix_map_to: Option, } @@ -83,7 +78,7 @@ impl DwarfPrefixConfig { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone)] pub struct DebugSymbolConfig { pub sketch_dir: PathBuf, pub fastled_dir: Option, @@ -158,10 +153,153 @@ fn read_dwarf_prefixes(fastled_dir: &Path) -> Option { DwarfPrefixConfig::from_config(fields.get("dwarf")?).ok() } -#[derive(Debug, Deserialize, Serialize)] -struct DebugSymbolManifest { - version: u32, - config: DebugSymbolConfig, +// These record layouts and duplicate-field rules belong to the manifest +// schema. JSON syntax, resource bounds and member preservation live upstream. +fn manifest_fields( + value: Value, + names: [&str; N], + minimum_sequence_len: usize, +) -> Result<[Option; N]> { + let mut fields = std::array::from_fn(|_| None); + match value { + Value::ObjectMembers(members) => { + for (name, value) in members { + if let Some(index) = names.iter().position(|field| *field == name) { + if fields[index].replace(value).is_some() { + anyhow::bail!("duplicate debug manifest field {}", names[index]); + } + } + } + } + Value::Array(values) if (minimum_sequence_len..=N).contains(&values.len()) => { + for (field, value) in fields.iter_mut().zip(values) { + *field = Some(value); + } + } + _ => anyhow::bail!("invalid debug manifest record"), + } + Ok(fields) +} + +fn manifest_string(value: Option, name: &str, default: Option<&str>) -> Result { + match (value, default) { + (Some(Value::String(value)), _) => Ok(value), + (None, Some(default)) => Ok(default.to_owned()), + _ => anyhow::bail!("debug manifest {name} must be a string"), + } +} + +fn manifest_optional_string(value: Option, name: &str) -> Result> { + match value { + None | Some(Value::Null) => Ok(None), + value => manifest_string(value, name, None).map(Some), + } +} + +fn manifest_config(value: Value) -> Result { + let [sketch, fastled, emsdk, prefixes] = manifest_fields( + value, + ["sketch_dir", "fastled_dir", "emsdk_path", "prefixes"], + 4, + )?; + let [fastled_prefix, sketch_prefix, dwarf_prefix, map_from, map_to] = manifest_fields( + prefixes.context("missing debug manifest prefixes")?, + [ + "fastled_prefix", + "sketch_prefix", + "dwarf_prefix", + "file_prefix_map_from", + "file_prefix_map_to", + ], + 0, + )?; + Ok(DebugSymbolConfig { + sketch_dir: manifest_string(sketch, "sketch_dir", None)?.into(), + fastled_dir: manifest_optional_string(fastled, "fastled_dir")?.map(PathBuf::from), + emsdk_path: manifest_optional_string(emsdk, "emsdk_path")?.map(PathBuf::from), + prefixes: DwarfPrefixConfig { + fastled_prefix: manifest_string( + fastled_prefix, + "fastled_prefix", + Some(DEFAULT_FASTLED_PREFIX), + )?, + sketch_prefix: manifest_string( + sketch_prefix, + "sketch_prefix", + Some(DEFAULT_SKETCH_PREFIX), + )?, + dwarf_prefix: manifest_string( + dwarf_prefix, + "dwarf_prefix", + Some(DEFAULT_DWARF_PREFIX), + )?, + file_prefix_map_from: manifest_optional_string(map_from, "file_prefix_map_from")?, + file_prefix_map_to: manifest_optional_string(map_to, "file_prefix_map_to")?, + }, + }) +} + +fn manifest_document(config: &DebugSymbolConfig) -> Result { + let path = |path: &Path| -> Result { + Ok(Value::String( + path.to_str() + .context("debug manifest path must be UTF-8")? + .to_owned(), + )) + }; + let mut prefixes = vec![ + ( + "fastled_prefix".into(), + Value::String(config.prefixes.fastled_prefix.clone()), + ), + ( + "sketch_prefix".into(), + Value::String(config.prefixes.sketch_prefix.clone()), + ), + ( + "dwarf_prefix".into(), + Value::String(config.prefixes.dwarf_prefix.clone()), + ), + ]; + for (name, value) in [ + ( + "file_prefix_map_from", + &config.prefixes.file_prefix_map_from, + ), + ("file_prefix_map_to", &config.prefixes.file_prefix_map_to), + ] { + if let Some(value) = value { + prefixes.push((name.into(), Value::String(value.clone()))); + } + } + Ok(Value::ObjectMembers(vec![ + ("version".into(), Value::Unsigned(1)), + ( + "config".into(), + Value::ObjectMembers(vec![ + ("sketch_dir".into(), path(&config.sketch_dir)?), + ( + "fastled_dir".into(), + config + .fastled_dir + .as_deref() + .map(path) + .transpose()? + .unwrap_or(Value::Null), + ), + ( + "emsdk_path".into(), + config + .emsdk_path + .as_deref() + .map(path) + .transpose()? + .unwrap_or(Value::Null), + ), + ("prefixes".into(), Value::ObjectMembers(prefixes)), + ]), + ), + ])) } pub fn write_debug_symbol_manifest( @@ -171,11 +309,7 @@ pub fn write_debug_symbol_manifest( std::fs::create_dir_all(output_dir) .with_context(|| format!("create {}", output_dir.display()))?; let path = output_dir.join(DWARF_ROOTS_MANIFEST); - let manifest = DebugSymbolManifest { - version: 1, - config: config.clone(), - }; - let json = serde_json::to_string_pretty(&manifest)?; + let json = json::encode(&manifest_document(config)?, Layout::Pretty)?; std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?; Ok(path) } @@ -187,16 +321,31 @@ pub fn read_debug_symbol_manifest(output_dir: &Path) -> Result Result<(u32, DebugSymbolConfig)> { + let [version, config] = manifest_fields( + json::parse_members(json.as_bytes())?, + ["version", "config"], + 2, + )?; + let version = match version { + Some(Value::Signed(value)) => u32::try_from(value)?, + Some(Value::Unsigned(value)) => u32::try_from(value)?, + _ => anyhow::bail!("debug manifest version must be an unsigned integer"), + }; + Ok(( + version, + manifest_config(config.context("missing debug manifest config")?)?, + )) + }; + let (version, config) = parse().with_context(|| format!("parse {}", path.display()))?; + if version != 1 { anyhow::bail!( "unsupported debug symbol manifest version {} in {}", - manifest.version, + version, path.display() ); } - Ok(Some(manifest.config)) + Ok(Some(config)) } /// Resolves browser-issued DWARF paths to absolute file paths on disk. @@ -392,6 +541,46 @@ mod tests { use kernal_api::platform::fs::TemporaryDirectory; use std::fs; + #[test] + fn manifest_json_schema_preserves_defaults_and_nested_duplicates() { + let tmp = TemporaryDirectory::new().unwrap(); + let path = tmp.path().join(DWARF_ROOTS_MANIFEST); + for source in [ + r#"{"version":1,"config":{"sketch_dir":"日本","prefixes":{}}}"#, + r#"{"version":1,"config":{"sketch_dir":"日本","fastled_dir":null,"prefixes":{"future":1,"future":2}}}"#, + r#"[1,["日本",null,null,[]]]"#, + ] { + fs::write(&path, source).unwrap(); + let config = read_debug_symbol_manifest(tmp.path()).unwrap().unwrap(); + assert_eq!(config.sketch_dir, PathBuf::from("日本")); + assert_eq!(config.fastled_dir, None); + assert_eq!(config.emsdk_path, None); + assert_eq!(config.prefixes.sketch_prefix, DEFAULT_SKETCH_PREFIX); + assert_eq!(config.prefixes.file_prefix_map_from, None); + write_debug_symbol_manifest(tmp.path(), &config).unwrap(); + let output = fs::read_to_string(&path).unwrap(); + assert!(output.contains("\"fastled_dir\": null")); + assert!(!output.contains("file_prefix_map_from")); + assert!(!output.ends_with('\n')); + } + for source in [ + r#"{"version":2,"config":{"sketch_dir":"a","prefixes":{}}}"#, + r#"{"version":1.0,"config":{"sketch_dir":"a","prefixes":{}}}"#, + r#"{"version":1,"version":1,"config":{"sketch_dir":"a","prefixes":{}}}"#, + r#"{"version":1,"config":{"sketch_dir":"a","fastled_dir":null,"fastled_dir":null,"prefixes":{}}}"#, + r#"{"version":1,"config":{"sketch_dir":"a","prefixes":{"sketch_prefix":"a","sketch_prefix":"b"}}}"#, + r#"{"version":1,"config":{"sketch_dir":"a","prefixes":{"sketch_prefix":null}}}"#, + r#"{"version":1,"config":{"sketch_dir":"a"}}"#, + r#"[1,["a"]]"#, + ] { + fs::write(&path, source).unwrap(); + assert!( + read_debug_symbol_manifest(tmp.path()).is_err(), + "accepted {source}" + ); + } + } + #[test] fn dwarf_schema_ignores_unrelated_sections_and_defaults_invalid_overrides() { let tmp = TemporaryDirectory::new().unwrap(); @@ -418,6 +607,22 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn manifest_json_rejects_non_utf8_paths_before_replacing_file() { + use std::os::unix::ffi::OsStringExt; + let tmp = TemporaryDirectory::new().unwrap(); + let path = tmp.path().join(DWARF_ROOTS_MANIFEST); + fs::write(&path, "original").unwrap(); + let config = load_debug_symbol_config( + PathBuf::from(std::ffi::OsString::from_vec(vec![0xff])), + None, + None, + ); + assert!(write_debug_symbol_manifest(tmp.path(), &config).is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), "original"); + } + fn setup_dirs() -> (TemporaryDirectory, PathBuf, PathBuf, PathBuf) { let tmp = TemporaryDirectory::new().unwrap(); let sketch_dir = tmp.path().join("sketch"); diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index a7443a41..fe217d14 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -1012,6 +1012,18 @@ tests pass; Python passes 36 with 1 skipped. Strict all-target Clippy, formattin Ruff and single-reviewer review pass. No compiler backend or flags changed in this checkpoint; it does not add native-browser or Safari acceptance evidence. +Debugger manifests now use kernel member-preserving JSON values as well. +Manifest version checks, nested defaults, optional/null path handling, known- +field duplicate rejection, positional layouts and source-root policy stay in +FastLED. Non-UTF-8 paths fail before replacing a manifest, rather than being +lossily converted. Baseline schema and path-failure tests passed with the old +implementation before migration; the module dependency boundary went RED to +GREEN. Five JSON modules remain; no registry release or browser acceptance is +implied by this metadata migration. +Verification passes 275 library, 3 binary, 1 integration and 1 doc tests; +Python passes 37 with 1 skipped. Strict all-target Clippy, formatting, Ruff and +single-reviewer review pass. Compiler behavior is unchanged. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 82c948d7..fc28dde5 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -192,3 +192,11 @@ def test_source_receipt_uses_kernal_json() -> None: assert "serde::" not in source assert "serde_json::" not in source assert "json::parse_members" in source + + +def test_debug_manifest_uses_kernal_json() -> None: + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/debug_symbols.rs").read_text() + assert "serde::" not in source + assert "serde_json::" not in source + assert "json::parse_members" in source From a067d76025b12f57834e99154419dc96bccbc58a Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:49:29 -0700 Subject: [PATCH 58/94] refactor(server): use kernal-api JSON for HTTP schemas --- crates/fastled-cli/src/server.rs | 318 ++++++++++++++++++++++++----- docs/kernal-api-migration.md | 19 ++ tests/unit/test_kernal_boundary.py | 8 + 3 files changed, 294 insertions(+), 51 deletions(-) diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 4f0d4c65..407d813c 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -13,9 +13,8 @@ use std::sync::{Arc, RwLock}; use kernal_api::{ async_engine, http_server::{Limits, Request, Response, Server}, + json::{self, Layout, Value}, }; -use serde::{Deserialize, Serialize}; -use serde_json::json; use crate::debug_symbols::{DebugSymbolResolver, ResolveError}; @@ -27,14 +26,44 @@ use crate::debug_symbols::{DebugSymbolResolver, ResolveError}; /// the server. pub type DebugSymbolHandle = Arc>>; -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug)] pub(crate) struct TestRuntimeConfig { pub(crate) wait_ms: f64, pub(crate) interval_ms: Option, pub(crate) screenshot_names: Vec, } +impl TestRuntimeConfig { + fn document(&self) -> Value { + // Preserve the wire protocol's null representation for non-finite + // values; accepted test schedules are validated separately. + let number = |value: f64| { + if value.is_finite() { + Value::Float(value) + } else { + Value::Null + } + }; + Value::ObjectMembers(vec![ + ("waitMs".into(), number(self.wait_ms)), + ( + "intervalMs".into(), + self.interval_ms.map(number).unwrap_or(Value::Null), + ), + ( + "screenshotNames".into(), + Value::Array( + self.screenshot_names + .iter() + .cloned() + .map(Value::String) + .collect(), + ), + ), + ]) + } +} + #[derive(Clone)] pub(crate) struct TestServerOptions { pub(crate) runtime: TestRuntimeConfig, @@ -376,14 +405,21 @@ fn text(status: u16, body: impl Into) -> Reply { .with_header("content-type", "text/plain; charset=utf-8") } -fn json_reply(status: u16, value: impl Serialize) -> Reply { +fn json_reply(status: u16, value: Value) -> Reply { Response::new( status, - serde_json::to_vec(&value).map_err(std::io::Error::other)?, + json::encode(&value, Layout::Compact).map_err(std::io::Error::other)?, )? .with_header("content-type", "application/json") } +fn json_error(status: u16, message: impl Into) -> Reply { + json_reply( + status, + Value::ObjectMembers(vec![("error".into(), Value::String(message.into()))]), + ) +} + async fn stream_file(state: &AppState, path: &std::path::Path, prefix: &[u8], mime: &str) -> Reply { state .file_responses @@ -534,9 +570,31 @@ async fn viewer_screenshot(state: &AppState, request: &Request) -> Reply { empty(204) } -#[derive(Deserialize)] -struct DwarfSourceRequest { - path: String, +fn dwarf_request_path(document: Value) -> Result { + let mut path = None; + match document { + Value::ObjectMembers(members) => { + for (name, value) in members { + if name == "path" && path.replace(value).is_some() { + return Err((422, "duplicate path field")); + } + } + } + Value::Array(values) => { + let mut values = values.into_iter(); + path = values.next(); + // A valid positional path followed by extra items was classified + // as trailing input by the original endpoint. + if matches!(path, Some(Value::String(_))) && values.next().is_some() { + return Err((400, "unexpected fields after path")); + } + } + _ => return Err((422, "expected path object or positional record")), + } + match path { + Some(Value::String(path)) => Ok(path), + _ => Err((422, "path must be a string")), + } } async fn dwarf_source(state: &AppState, request: &Request) -> Reply { @@ -554,9 +612,23 @@ async fn dwarf_source(state: &AppState, request: &Request) -> Reply { { return text(415, "expected application/json"); } - let payload = match serde_json::from_slice::(request.body()) { - Ok(payload) => payload, - Err(error) => return text(if error.is_data() { 422 } else { 400 }, error.to_string()), + let document = match json::parse_members(request.body()) { + Ok(document) => document, + Err(error) => { + return text( + match error { + json::Error::InputTooLarge + | json::Error::TooManyNodes + | json::Error::TooDeep => 413, + _ => 400, + }, + error.to_string(), + ) + } + }; + let path = match dwarf_request_path(document) { + Ok(path) => path, + Err((status, message)) => return text(status, message), }; let resolver = state .debug_symbols @@ -564,19 +636,19 @@ async fn dwarf_source(state: &AppState, request: &Request) -> Reply { .map_err(|_| std::io::Error::other("debug source lock poisoned"))? .clone(); let Some(resolver) = resolver else { - return json_reply(400, json!({"error": "debug source resolver unavailable"})); + return json_error(400, "debug source resolver unavailable"); }; - let path = payload.path.trim(); + let path = path.trim(); if path.is_empty() { - return json_reply(400, json!({"error": "missing path"})); + return json_error(400, "missing path"); } match resolver.resolve(path, true) { Ok(file) => match stream_file(state, &file, &[], "text/plain; charset=utf-8").await { Ok(response) => Ok(response), - Err(error) => json_reply(500, json!({"error": error.to_string()})), + Err(error) => json_error(500, error.to_string()), }, - Err(ResolveError::NotFound(message)) => json_reply(404, json!({"error": message})), - Err(ResolveError::Invalid(message)) => json_reply(400, json!({"error": message})), + Err(ResolveError::NotFound(message)) => json_error(404, message), + Err(ResolveError::Invalid(message)) => json_error(400, message), } } @@ -586,15 +658,25 @@ fn debug_source_roots(state: &AppState) -> Reply { .read() .map_err(|_| std::io::Error::other("debug source lock poisoned"))? .clone(); - let roots = - resolver - .map(|resolver| { - resolver.config().source_roots().into_iter() - .map(|(prefix, path)| json!({"prefix": prefix, "path": path.display().to_string()})) - .collect::>() - }) - .unwrap_or_default(); - json_reply(200, json!({"roots": roots})) + let roots = resolver + .map(|resolver| { + resolver + .config() + .source_roots() + .into_iter() + .map(|(prefix, path)| { + Value::ObjectMembers(vec![ + ("path".into(), Value::String(path.display().to_string())), + ("prefix".into(), Value::String(prefix)), + ]) + }) + .collect::>() + }) + .unwrap_or_default(); + json_reply( + 200, + Value::ObjectMembers(vec![("roots".into(), Value::Array(roots))]), + ) } async fn route(state: &AppState, request: Request) -> Reply { @@ -645,7 +727,7 @@ async fn route(state: &AppState, request: Request) -> Reply { return empty(401); } match path.as_str() { - "/test-config" => return json_reply(200, &test.runtime), + "/test-config" => return json_reply(200, test.runtime.document()), "/test-ready" => { let _ = test.events.send(TestEvent::Ready); } @@ -1033,10 +1115,9 @@ mod tests { ) .await .unwrap(); - let config: serde_json::Value = - serde_json::from_str(&response_text(config_response).await).unwrap(); - assert_eq!(config["waitMs"].as_f64(), Some(25.0)); - assert_eq!(config["screenshotNames"][0], "frame-0"); + let config = json::parse(response_text(config_response).await.as_bytes()).unwrap(); + assert_eq!(field(&config, "waitMs"), &Value::Float(25.0)); + assert_eq!(field(&config, "screenshotNames"), &Value::Array(vec![Value::String("frame-0".into())])); // Product policy: four occupied slots reject another authorized sleep // with HTTP 429; releasing a slot admits the next request. @@ -1472,16 +1553,23 @@ mod tests { // DWARF source endpoint tests // ------------------------------------------------------------------ - fn json_body(value: serde_json::Value) -> String { - value.to_string() + fn field<'a>(value: &'a Value, name: &str) -> &'a Value { + let Value::Object(fields) = value else { + panic!("expected object") + }; + fields.get(name).expect("expected field") + } + + fn path_document(path: &str) -> Value { + Value::ObjectMembers(vec![("path".into(), Value::String(path.into()))]) } - async fn post_json(addr: SocketAddr, path: &str, body: serde_json::Value) -> HttpResponse { + async fn post_json(addr: SocketAddr, path: &str, body: Value) -> HttpResponse { http_request( HttpMethod::Post, &format!("http://{addr}{path}"), &[("Content-Type", "application/json")], - json_body(body).as_bytes(), + &json::encode(&body, Layout::Compact).unwrap(), ) .await .unwrap() @@ -1495,16 +1583,144 @@ mod tests { .unwrap() .run(async { let (addr, _dir) = setup_server().await; - let resp = post_json( - addr, - "/dwarfsource", - serde_json::json!({"path": "sketchsource/foo.ino"}), - ) - .await; + let resp = + post_json(addr, "/dwarfsource", path_document("sketchsource/foo.ino")).await; assert_eq!(resp.status(), 400); }); } + #[test] + fn dwarfsource_json_schema_distinguishes_media_syntax_and_field_errors() { + async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + for (content_type, body, status) in [ + ("text/plain", r#"{"path":"日本"}"#, 415), + ("application/json", "{", 400), + ("application/json", r#"{"path":1}"#, 422), + ("application/json", r#"{}"#, 422), + ("application/json", r#"{"path":"a","path":"b"}"#, 422), + ("application/json", r#"[]"#, 422), + ("application/json", r#"[1,2]"#, 422), + ("application/json", r#"["a","b"]"#, 400), + // Valid requests proceed to the missing-resolver response. + ("application/json", r#"["日本"]"#, 400), + ( + "application/json", + r#"{"path":"日本","future":null,"future":true}"#, + 400, + ), + ( + "application/vnd.fastled+json; charset=utf-8", + r#"{"path":"a"}"#, + 400, + ), + ] { + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/dwarfsource"), + &[("Content-Type", content_type)], + body.as_bytes(), + ) + .await + .unwrap(); + assert_eq!(response.status(), status, "{content_type}: {body}"); + if body.contains("日本") && status == 400 { + assert!(response_text(response) + .await + .contains("resolver unavailable")); + } + } + }); + } + + #[test] + fn runtime_json_preserves_camel_case_order_nulls_and_finite_numbers() { + for (wait_ms, interval_ms, expected) in [ + ( + 25.0, + Some(1.5), + r#"{"waitMs":25.0,"intervalMs":1.5,"screenshotNames":["日本"]}"#, + ), + ( + f64::NAN, + None, + r#"{"waitMs":null,"intervalMs":null,"screenshotNames":["日本"]}"#, + ), + ( + f64::INFINITY, + Some(f64::NEG_INFINITY), + r#"{"waitMs":null,"intervalMs":null,"screenshotNames":["日本"]}"#, + ), + ] { + let runtime = TestRuntimeConfig { + wait_ms, + interval_ms, + screenshot_names: vec!["日本".into()], + }; + assert_eq!( + json::encode(&runtime.document(), Layout::Compact).unwrap(), + expected.as_bytes() + ); + } + } + + #[test] + fn dwarfsource_json_resource_limits_and_syntax_errors_are_explicit() { + async_engine::RuntimeBuilder::current_thread() + .enable_all() + .build() + .unwrap() + .run(async { + let (addr, _dir) = setup_server().await; + let mut oversized = r#"{"path":"a"}"#.to_owned(); + oversized.extend(std::iter::repeat_n( + ' ', + json::MAX_INPUT_BYTES + 1 - oversized.len(), + )); + let too_deep = format!( + "{{\"path\":\"a\",\"extra\":{}0{}}}", + "[".repeat(json::MAX_DEPTH + 1), + "]".repeat(json::MAX_DEPTH + 1) + ); + let too_many = format!( + "{{\"path\":\"a\",\"extra\":[{}0]}}", + "0,".repeat(json::MAX_NODES) + ); + for body in [oversized, too_deep, too_many] { + let response = HttpClient::new(HttpLimits { + max_request_bytes: json::MAX_INPUT_BYTES + 4096, + ..HttpLimits::default() + }) + .unwrap() + .execute(HttpRequest { + method: HttpMethod::Post, + url: &format!("http://{addr}/dwarfsource"), + headers: &[("Content-Type", "application/json")], + body: body.as_bytes(), + }) + .await + .unwrap(); + assert_eq!(response.status(), 413); + } + // Syntax is validated before schema inspection, including when a + // wrong field type precedes malformed trailing input. + let response = http_request( + HttpMethod::Post, + &format!("http://{addr}/dwarfsource"), + &[("Content-Type", "application/json")], + br#"{"path":false,"private-marker":}"#, + ) + .await + .unwrap(); + assert_eq!(response.status(), 400); + assert!(!response_text(response).await.contains("private-marker")); + }); + } + #[test] fn debug_source_roots_empty_without_resolver() { kernal_api::async_engine::RuntimeBuilder::current_thread() @@ -1517,9 +1733,8 @@ mod tests { .await .unwrap(); assert_eq!(resp.status(), 200); - let body: serde_json::Value = - serde_json::from_str(&response_text(resp).await).unwrap(); - assert!(body["roots"].as_array().unwrap().is_empty()); + let body = json::parse(response_text(resp).await.as_bytes()).unwrap(); + assert_eq!(field(&body, "roots"), &Value::Array(vec![])); }); } @@ -1550,7 +1765,7 @@ mod tests { let resp = post_json( addr, "/dwarfsource", - serde_json::json!({"path": "sketchsource/src/demo.ino"}), + path_document("sketchsource/src/demo.ino"), ) .await; assert_eq!(resp.status(), 200); @@ -1560,13 +1775,14 @@ mod tests { let resp = http_get(format!("http://{addr}/debug/source-roots")) .await .unwrap(); - let body: serde_json::Value = - serde_json::from_str(&response_text(resp).await).unwrap(); - let roots = body["roots"].as_array().unwrap(); + let body = json::parse(response_text(resp).await.as_bytes()).unwrap(); + let Value::Array(roots) = field(&body, "roots") else { + panic!("expected roots array") + }; assert!(!roots.is_empty()); assert!(roots .iter() - .any(|r| r["prefix"].as_str() == Some("sketchsource"))); + .any(|r| field(r, "prefix") == &Value::String("sketchsource".into()))); }); } @@ -1677,7 +1893,7 @@ mod tests { let resp = post_json( addr, "/dwarfsource", - serde_json::json!({"path": "sketchsource/../escape.txt"}), + path_document("sketchsource/../escape.txt"), ) .await; assert_eq!(resp.status(), 400); diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index fe217d14..8aabb305 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -1024,6 +1024,25 @@ Verification passes 275 library, 3 binary, 1 integration and 1 doc tests; Python passes 37 with 1 skipped. Strict all-target Clippy, formatting, Ruff and single-reviewer review pass. Compiler behavior is unchanged. +Server JSON now uses kernel-owned values for test configuration, DWARF requests, +source roots and error responses, including its tests. The HTTP schema baseline +passed before replacement, and the new production path first passed existing +tests with the previous decoder as an independent output oracle. Media types, +known-field duplicate rejection, positional path requests, response field names, +runtime nulls and source resolution remain application policy. + +The bounded parser adds explicit HTTP 413 responses for its byte/node/depth +limits. It validates complete JSON syntax before schema inspection: malformed +documents return 400 even if an earlier field has the wrong type; valid documents +with invalid fields return 422. Diagnostics no longer echo backend source +snippets. These are intentional invalid-input changes, not a claim of byte-for- +byte legacy error compatibility. Four JSON source modules remain. +The full workspace passes 278 library, 3 binary, 1 integration and 1 doc tests; +Python passes 38 with 1 skipped. Strict Clippy, formatting, Ruff and review pass; +all 27 server tests were rerun after the final Clippy-only style correction. +The upstream JSON head d2879eb has 53 successful/skipped CI checks, but the +earlier webview PR #199 still has failing checks and the stack is not landed. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index fc28dde5..cea6f32f 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -200,3 +200,11 @@ def test_debug_manifest_uses_kernal_json() -> None: assert "serde::" not in source assert "serde_json::" not in source assert "json::parse_members" in source + + +def test_server_json_uses_kernal() -> None: + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/server.rs").read_text() + assert "serde::" not in source + assert "serde_json::" not in source + assert "json::parse_members" in source From a6b817fdcd5e327692d71078f7ace5e5050c6885 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 06:56:53 -0700 Subject: [PATCH 59/94] refactor(cache): use kernal-api JSON for artifact metadata --- crates/fastled-cli/src/dynamic_cache.rs | 234 +++++++++++++++++++++++- docs/kernal-api-migration.md | 19 ++ tests/unit/test_kernal_boundary.py | 8 + 3 files changed, 253 insertions(+), 8 deletions(-) diff --git a/crates/fastled-cli/src/dynamic_cache.rs b/crates/fastled-cli/src/dynamic_cache.rs index 2782ab34..894c1f1e 100644 --- a/crates/fastled-cli/src/dynamic_cache.rs +++ b/crates/fastled-cli/src/dynamic_cache.rs @@ -7,9 +7,9 @@ use std::sync::{Arc, Mutex, OnceLock}; use anyhow::{Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; +use kernal_api::json::{self, Layout, Value}; use kernal_api::platform::fs::{PatternSet, PatternSetBuilder}; use kernal_api::platform::fs_watch::{ChangeKind, RecursiveMode, WatchNotification, Watcher}; -use serde::{Deserialize, Serialize}; use crate::path::NormalizedPath; @@ -224,19 +224,113 @@ pub(crate) fn fingerprint_values<'a>(values: impl IntoIterator) format!("{:x}", hasher.finalize()) } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] struct ArtifactRecord { bytes: u64, sha256: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] struct CacheMetadata { schema: u32, fingerprint: String, artifacts: BTreeMap, } +// Cache schema adapters: unknown fields are ignored, known fields may not be +// repeated, and positional records require every declared slot. +fn cache_fields(value: Value, names: [&str; N]) -> Result<[Option; N]> { + let mut fields = std::array::from_fn(|_| None); + match value { + Value::ObjectMembers(members) => { + for (name, value) in members { + if let Some(index) = names.iter().position(|field| *field == name) { + if fields[index].replace(value).is_some() { + anyhow::bail!("duplicate cache metadata field {}", names[index]); + } + } + } + } + Value::Array(values) if values.len() == N => { + for (field, value) in fields.iter_mut().zip(values) { + *field = Some(value); + } + } + _ => anyhow::bail!("invalid cache metadata record"), + } + Ok(fields) +} + +fn cache_string(value: Option, name: &str) -> Result { + let Some(Value::String(value)) = value else { + anyhow::bail!("cache metadata {name} must be a string"); + }; + Ok(value) +} + +fn cache_unsigned(value: Option, name: &str) -> Result { + match value { + Some(Value::Unsigned(value)) => Ok(value), + Some(Value::Signed(value)) if value >= 0 => Ok(value as u64), + _ => anyhow::bail!("cache metadata {name} must be an unsigned integer"), + } +} + +impl CacheMetadata { + fn parse(source: &str) -> Result { + let [schema, fingerprint, artifacts] = cache_fields( + json::parse_members(source.as_bytes())?, + ["schema", "fingerprint", "artifacts"], + )?; + let schema = u32::try_from(cache_unsigned(schema, "schema")?)?; + let fingerprint = cache_string(fingerprint, "fingerprint")?; + let Some(Value::ObjectMembers(members)) = artifacts else { + anyhow::bail!("cache metadata artifacts must be an object"); + }; + let mut artifacts = BTreeMap::new(); + for (name, value) in members { + // Validate every record before replacing a repeated map entry. + let [bytes, sha256] = cache_fields(value, ["bytes", "sha256"])?; + artifacts.insert( + name, + ArtifactRecord { + bytes: cache_unsigned(bytes, "bytes")?, + sha256: cache_string(sha256, "sha256")?, + }, + ); + } + Ok(Self { + schema, + fingerprint, + artifacts, + }) + } + + fn document(&self) -> Value { + let artifacts = self + .artifacts + .iter() + .map(|(name, record)| { + ( + name.clone(), + Value::ObjectMembers(vec![ + ("bytes".into(), Value::Unsigned(record.bytes)), + ("sha256".into(), Value::String(record.sha256.clone())), + ]), + ) + }) + .collect(); + Value::ObjectMembers(vec![ + ("schema".into(), Value::Unsigned(self.schema.into())), + ( + "fingerprint".into(), + Value::String(self.fingerprint.clone()), + ), + ("artifacts".into(), Value::Object(artifacts)), + ]) + } +} + fn hash_file(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; let mut hasher = Sha256::new(); @@ -284,7 +378,7 @@ pub(crate) fn validate_entry( let metadata_path = entry.join(METADATA_FILE); let source = fs::read_to_string(&metadata_path) .map_err(|err| format!("cannot read {}: {err}", metadata_path.display()))?; - let metadata: CacheMetadata = serde_json::from_str(&source) + let metadata = CacheMetadata::parse(&source) .map_err(|err| format!("invalid {}: {err}", metadata_path.display()))?; if metadata.schema != CACHE_SCHEMA { return Err(format!( @@ -326,7 +420,7 @@ pub(crate) fn write_metadata(staging: &Path, fingerprint: &str, artifacts: &[&st }; fs::write( staging.join(METADATA_FILE), - serde_json::to_vec_pretty(&metadata)?, + json::encode(&metadata.document(), Layout::Pretty)?, ) .with_context(|| format!("write cache metadata under {}", staging.display()))?; Ok(()) @@ -393,13 +487,45 @@ pub(crate) fn entry_path(cache_root: &Path, fingerprint: &str) -> NormalizedPath NormalizedPath::new(cache_root.join(fingerprint)) } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] struct AttemptMetadata { status: String, phase: String, message: Option, } +impl AttemptMetadata { + fn parse(source: &str) -> Result { + let [status, phase, message] = cache_fields( + json::parse_members(source.as_bytes())?, + ["status", "phase", "message"], + )?; + let message = match message { + None | Some(Value::Null) => None, + value => Some(cache_string(value, "message")?), + }; + Ok(Self { + status: cache_string(status, "status")?, + phase: cache_string(phase, "phase")?, + message, + }) + } + + fn document(&self) -> Value { + Value::ObjectMembers(vec![ + ("status".into(), Value::String(self.status.clone())), + ("phase".into(), Value::String(self.phase.clone())), + ( + "message".into(), + self.message + .clone() + .map(Value::String) + .unwrap_or(Value::Null), + ), + ]) + } +} + fn attempt_path(cache_root: &Path, fingerprint: &str) -> NormalizedPath { NormalizedPath::new(cache_root.join(format!("{fingerprint}.attempt.json"))) } @@ -407,7 +533,7 @@ fn attempt_path(cache_root: &Path, fingerprint: &str) -> NormalizedPath { pub(crate) fn previous_attempt(cache_root: &Path, fingerprint: &str) -> Option { let path = attempt_path(cache_root, fingerprint); let source = fs::read_to_string(path).ok()?; - let attempt: AttemptMetadata = serde_json::from_str(&source).ok()?; + let attempt = AttemptMetadata::parse(&source).ok()?; Some(match attempt.message { Some(message) => format!("{} {}: {message}", attempt.status, attempt.phase), None => format!("{} {}", attempt.status, attempt.phase), @@ -448,7 +574,7 @@ fn write_attempt( }; fs::write( attempt_path(cache_root, fingerprint), - serde_json::to_vec_pretty(&attempt)?, + json::encode(&attempt.document(), Layout::Pretty)?, )?; Ok(()) } @@ -614,6 +740,98 @@ mod tests { assert!(write_metadata(&entry, "key", &["fastled.js", "fastled.wasm"]).is_err()); } + #[test] + fn cache_json_schema_preserves_record_and_artifact_map_rules() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + fs::write(temp.path().join("a.js"), "js").unwrap(); + let digest = hash_file(&temp.path().join("a.js")).unwrap().sha256; + let record = format!(r#"{{"bytes":2,"sha256":"{digest}"}}"#); + for source in [ + format!( + r#"{{"schema":1,"fingerprint":"key","artifacts":{{"a.js":{record}}},"future":1,"future":2}}"# + ), + format!(r#"[1,"key",{{"a.js":[2,"{digest}"]}}]"#), + // Map keys keep their last valid record, unlike known struct fields. + format!( + r#"{{"schema":1,"fingerprint":"key","artifacts":{{"a.js":{{"bytes":1,"sha256":"old"}},"a.js":{record}}}}}"# + ), + ] { + fs::write(temp.path().join(METADATA_FILE), source).unwrap(); + assert!(validate_entry(temp.path(), "key", &["a.js"]).is_ok()); + } + for source in [ + format!( + r#"{{"schema":1,"schema":1,"fingerprint":"key","artifacts":{{"a.js":{record}}}}}"# + ), + format!(r#"{{"schema":1.0,"fingerprint":"key","artifacts":{{"a.js":{record}}}}}"#), + format!( + r#"{{"schema":1,"fingerprint":"key","artifacts":{{"a.js":{{"bytes":2,"bytes":2,"sha256":"{digest}"}}}}}}"# + ), + format!( + r#"{{"schema":1,"fingerprint":"key","artifacts":{{"a.js":{{"bytes":null}},"a.js":{record}}}}}"# + ), + ] { + fs::write(temp.path().join(METADATA_FILE), source).unwrap(); + assert!(validate_entry(temp.path(), "key", &["a.js"]).is_err()); + } + } + + #[test] + fn attempt_json_schema_preserves_nulls_duplicates_and_positional_records() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + for (source, expected) in [ + ( + r#"{"status":"pending","phase":"日本"}"#, + Some("pending 日本"), + ), + ( + r#"{"status":"pending","phase":"日本","message":null}"#, + Some("pending 日本"), + ), + ( + r#"["failure","link","failed"]"#, + Some("failure link: failed"), + ), + (r#"["pending","link"]"#, None), + ( + r#"{"status":"pending","phase":"link","message":null,"message":null}"#, + None, + ), + ( + r#"{"status":"pending","phase":"link","message":false}"#, + None, + ), + ] { + fs::write(attempt_path(temp.path(), "key"), source).unwrap(); + assert_eq!(previous_attempt(temp.path(), "key").as_deref(), expected); + } + } + + #[test] + fn cache_json_preserves_unsigned_ranges_and_ordered_output() { + let metadata = + CacheMetadata::parse(r#"[4294967295,"key",{"a.js":[18446744073709551615,"digest"]}]"#) + .unwrap(); + assert_eq!(metadata.schema, u32::MAX); + assert_eq!(metadata.artifacts["a.js"].bytes, u64::MAX); + for source in [ + r#"[4294967296,"key",{}]"#, + r#"[1,"key",{"a.js":[-1,"digest"]}]"#, + r#"[1,"key",{"a.js":[1.0,"digest"]}]"#, + r#"[1,"key",{"a.js":[18446744073709551616,"digest"]}]"#, + ] { + assert!(CacheMetadata::parse(source).is_err(), "accepted {source}"); + } + let metadata = CacheMetadata::parse(r#"[1,"key",{"b":[2,"b"],"a":[1,"a"]}]"#).unwrap(); + assert_eq!(json::encode(&metadata.document(), Layout::Compact).unwrap(), br#"{"schema":1,"fingerprint":"key","artifacts":{"a":{"bytes":1,"sha256":"a"},"b":{"bytes":2,"sha256":"b"}}}"#); + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + mark_pending(temp.path(), "key", "link").unwrap(); + assert_eq!( + fs::read_to_string(attempt_path(temp.path(), "key")).unwrap(), + "{\n \"status\": \"pending\",\n \"phase\": \"link\",\n \"message\": null\n}" + ); + } + #[test] fn cache_lock_serializes_same_key() { let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 8aabb305..314bf96a 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -1043,6 +1043,25 @@ all 27 server tests were rerun after the final Clippy-only style correction. The upstream JSON head d2879eb has 53 successful/skipped CI checks, but the earlier webview PR #199 still has failing checks and the stack is not landed. +Dynamic cache metadata and attempt receipts now use kernel JSON. Artifact shape, +hash validation, fingerprint/schema matching and staging publication remain +FastLED policy. Schema baselines passed before migration, including the +distinction between duplicate known fields and last-valid-entry artifact maps: +every repeated artifact record is decoded before replacement, so an invalid +earlier record still rejects the cache. Attempt nulls and positional layouts +are preserved. Three JSON modules remain; the module boundary test went RED to +GREEN. This does not remove the final Serde dependency or the local path patch. +Verification: 281 library, 3 binary, 1 integration and 1 doc tests pass; Python +passes 39 with 1 skipped. Strict all-target Clippy, formatting, Ruff and review +pass. A freshly verified CLI compiled a temporary Blink sketch with quick-mode +dynamic linking, publishing both runtime and sketch metadata. A repeat build +hit both caches with zero output changes. Injecting a duplicate `bytes` field +into that temporary sketch cache caused metadata rejection and a sketch-only +relink while the runtime remained a cache hit. The published sketch hash matches +its receipt; both WASM headers are valid, and generated runtime JS has no JSPI +entry points. This is compile/cache evidence, not browser/Safari acceptance or +a measured before/after migration speedup. + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index cea6f32f..a62c39a4 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -208,3 +208,11 @@ def test_server_json_uses_kernal() -> None: assert "serde::" not in source assert "serde_json::" not in source assert "json::parse_members" in source + + +def test_cache_json_uses_kernal() -> None: + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/dynamic_cache.rs").read_text() + assert "serde::" not in source + assert "serde_json::" not in source + assert "json::parse_members" in source From 158d43ec17df3f6d496cd4138301f021599fb777 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 07:09:11 -0700 Subject: [PATCH 60/94] refactor(snapshot): use kernal-api JSON and isolate editor outputs Refs #248 --- crates/fastled-cli/src/sketch_preprocessor.rs | 310 ++++++++++++++++-- crates/fastled-cli/src/wasm_build.rs | 53 ++- docs/kernal-api-migration.md | 20 ++ tests/unit/test_kernal_boundary.py | 8 + 4 files changed, 363 insertions(+), 28 deletions(-) diff --git a/crates/fastled-cli/src/sketch_preprocessor.rs b/crates/fastled-cli/src/sketch_preprocessor.rs index 270cbe0d..3516d728 100644 --- a/crates/fastled-cli/src/sketch_preprocessor.rs +++ b/crates/fastled-cli/src/sketch_preprocessor.rs @@ -6,18 +6,18 @@ use std::collections::{BTreeMap, HashSet}; use std::fs; -use std::io::{self, Read}; +use std::io::{self, Read, Write}; use std::path::Path; use anyhow::{bail, Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; -use serde::{Deserialize, Serialize}; +use kernal_api::json::{self, Layout, Value}; use crate::path::NormalizedPath; /// A VS Code document buffer. Paths must name top-level `.ino` tabs in the /// sketch directory; unopened tabs are loaded from disk. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone)] pub struct SnapshotDocument { pub path: String, pub version: i64, @@ -25,15 +25,14 @@ pub struct SnapshotDocument { } /// JSON protocol sent over stdin by the VS Code extension. -#[derive(Debug, Deserialize)] +#[derive(Debug)] pub struct SnapshotRequest { pub sketch_dir: String, pub generation: u64, - #[serde(default)] pub documents: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct ManifestDocument { pub path: String, pub version: Option, @@ -42,7 +41,7 @@ pub struct ManifestDocument { /// Completion marker for an IntelliSense cache generation. Consumers should /// only use the source/header named by this manifest after validating hashes. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct SnapshotManifest { pub schema: u32, pub generation: u64, @@ -68,13 +67,187 @@ pub fn run_stdin_snapshot() -> Result<()> { io::stdin() .read_to_string(&mut request_json) .context("read IntelliSense snapshot request from stdin")?; - let request: SnapshotRequest = - serde_json::from_str(&request_json).context("parse IntelliSense snapshot JSON")?; + let request = parse_snapshot_request(&request_json)?; let manifest = write_live_snapshot(&request)?; - println!("{}", serde_json::to_string(&manifest)?); + let bytes = json::encode(&manifest.document(), Layout::Compact)?; + let mut stdout = io::stdout().lock(); + stdout.write_all(&bytes)?; + stdout.write_all(b"\n")?; Ok(()) } +fn parse_snapshot_request(source: &str) -> Result { + let parse = || -> Result { + let [sketch_dir, generation, documents] = snapshot_fields( + json::parse_members(source.as_bytes())?, + ["sketch_dir", "generation", "documents"], + 2, + )?; + let documents = snapshot_array(documents.or(Some(Value::Array(Vec::new()))), "documents")? + .into_iter() + .map(|value| { + let [path, version, text] = snapshot_fields(value, ["path", "version", "text"], 3)?; + Ok(SnapshotDocument { + path: snapshot_string(path, "path")?, + version: snapshot_signed(version)?, + text: snapshot_string(text, "text")?, + }) + }) + .collect::>>()?; + Ok(SnapshotRequest { + sketch_dir: snapshot_string(sketch_dir, "sketch_dir")?, + generation: snapshot_unsigned(generation)?, + documents, + }) + }; + parse().context("parse IntelliSense snapshot JSON") +} + +// Snapshot protocol field recognition and defaults remain application policy. +fn snapshot_fields( + value: Value, + names: [&str; N], + minimum: usize, +) -> Result<[Option; N]> { + let mut fields = std::array::from_fn(|_| None); + match value { + Value::ObjectMembers(members) => { + for (name, value) in members { + if let Some(index) = names.iter().position(|field| *field == name) { + if fields[index].replace(value).is_some() { + bail!("duplicate snapshot field {}", names[index]); + } + } + } + } + Value::Array(values) if (minimum..=N).contains(&values.len()) => { + for (field, value) in fields.iter_mut().zip(values) { + *field = Some(value); + } + } + _ => bail!("invalid snapshot record"), + } + Ok(fields) +} + +fn snapshot_string(value: Option, name: &str) -> Result { + let Some(Value::String(value)) = value else { + bail!("snapshot {name} must be a string") + }; + Ok(value) +} + +fn snapshot_array(value: Option, name: &str) -> Result> { + let Some(Value::Array(value)) = value else { + bail!("snapshot {name} must be an array") + }; + Ok(value) +} + +fn snapshot_unsigned(value: Option) -> Result { + match value { + Some(Value::Unsigned(value)) => Ok(value), + Some(Value::Signed(value)) if value >= 0 => Ok(value as u64), + _ => bail!("snapshot generation/schema must be an unsigned integer"), + } +} + +fn snapshot_signed(value: Option) -> Result { + match value { + Some(Value::Signed(value)) => Ok(value), + Some(Value::Unsigned(value)) => Ok(i64::try_from(value)?), + _ => bail!("snapshot version must be a signed integer"), + } +} + +impl SnapshotManifest { + fn parse(source: &str) -> Result { + let [schema, generation, documents, generated_source, generated_source_sha256, prototype_header, prototype_header_sha256] = + snapshot_fields( + json::parse_members(source.as_bytes())?, + [ + "schema", + "generation", + "documents", + "generated_source", + "generated_source_sha256", + "prototype_header", + "prototype_header_sha256", + ], + 7, + )?; + let documents = snapshot_array(documents, "documents")? + .into_iter() + .map(|value| { + let [path, version, sha256] = + snapshot_fields(value, ["path", "version", "sha256"], 3)?; + let version = match version { + None | Some(Value::Null) => None, + value => Some(snapshot_signed(value)?), + }; + Ok(ManifestDocument { + path: snapshot_string(path, "path")?, + version, + sha256: snapshot_string(sha256, "sha256")?, + }) + }) + .collect::>>()?; + Ok(Self { + schema: u32::try_from(snapshot_unsigned(schema)?)?, + generation: snapshot_unsigned(generation)?, + documents, + generated_source: snapshot_string(generated_source, "generated_source")?, + generated_source_sha256: snapshot_string( + generated_source_sha256, + "generated_source_sha256", + )?, + prototype_header: snapshot_string(prototype_header, "prototype_header")?, + prototype_header_sha256: snapshot_string( + prototype_header_sha256, + "prototype_header_sha256", + )?, + }) + } + + fn document(&self) -> Value { + let documents = self + .documents + .iter() + .map(|document| { + Value::ObjectMembers(vec![ + ("path".into(), Value::String(document.path.clone())), + ( + "version".into(), + document.version.map(Value::Signed).unwrap_or(Value::Null), + ), + ("sha256".into(), Value::String(document.sha256.clone())), + ]) + }) + .collect(); + Value::ObjectMembers(vec![ + ("schema".into(), Value::Unsigned(self.schema.into())), + ("generation".into(), Value::Unsigned(self.generation)), + ("documents".into(), Value::Array(documents)), + ( + "generated_source".into(), + Value::String(self.generated_source.clone()), + ), + ( + "generated_source_sha256".into(), + Value::String(self.generated_source_sha256.clone()), + ), + ( + "prototype_header".into(), + Value::String(self.prototype_header.clone()), + ), + ( + "prototype_header_sha256".into(), + Value::String(self.prototype_header_sha256.clone()), + ), + ]) + } +} + pub fn preprocess_disk(sketch_dir: &Path) -> Result { preprocess(sketch_dir, &BTreeMap::new()) } @@ -167,21 +340,36 @@ fn write_snapshot(request: &SnapshotRequest, force_disk_refresh: bool) -> Result prototype_header_sha256: sha256(&rendered.prototype_header), }; - fs::create_dir_all(&cache_dir)?; + publish_snapshot( + &cache_dir, + &manifest, + &rendered.translation_unit, + &rendered.prototype_header, + )?; + Ok(manifest) +} + +fn publish_snapshot( + cache_dir: &Path, + manifest: &SnapshotManifest, + source: &str, + header: &str, +) -> Result<()> { + // Encoding can fail on resource bounds. Check it before replacing either + // file referenced by the last good publication marker. + let encoded = json::encode(&manifest.document(), Layout::Pretty)?; + fs::create_dir_all(cache_dir)?; atomic_write( &cache_dir.join(&manifest.generated_source), - &rendered.translation_unit, + source.as_bytes(), )?; atomic_write( &cache_dir.join(&manifest.prototype_header), - &rendered.prototype_header, + header.as_bytes(), )?; // The manifest is written last and is the atomic publication marker. - atomic_write( - &cache_dir.join("manifest.json"), - &serde_json::to_string_pretty(&manifest)?, - )?; - Ok(manifest) + atomic_write(&cache_dir.join("manifest.json"), &encoded)?; + Ok(()) } fn preprocess( @@ -290,10 +478,10 @@ fn intellisense_dir(sketch_dir: &Path) -> NormalizedPath { } fn read_manifest(cache_dir: &Path) -> Option { - serde_json::from_str(&fs::read_to_string(cache_dir.join("manifest.json")).ok()?).ok() + SnapshotManifest::parse(&fs::read_to_string(cache_dir.join("manifest.json")).ok()?).ok() } -fn atomic_write(path: &Path, contents: &str) -> Result<()> { +fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { let temporary = path.with_extension(format!("tmp-{}", std::process::id())); fs::write(&temporary, contents).with_context(|| format!("write {}", temporary.display()))?; if path.exists() { @@ -371,6 +559,88 @@ fn extract_function_prototypes(source: &str) -> Result> { #[cfg(test)] mod tests { + #[test] + fn snapshot_json_encoding_limit_preserves_all_published_files() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let mut manifest = super::SnapshotManifest::parse(r#"[1,1,[["a",null,"digest"]],"sketch.cpp","sourcehash","prototypes.hpp","headerhash"]"#).unwrap(); + assert_eq!(super::json::encode(&manifest.document(), super::Layout::Compact).unwrap(), br#"{"schema":1,"generation":1,"documents":[{"path":"a","version":null,"sha256":"digest"}],"generated_source":"sketch.cpp","generated_source_sha256":"sourcehash","prototype_header":"prototypes.hpp","prototype_header_sha256":"headerhash"}"#); + super::publish_snapshot(temp.path(), &manifest, "old source", "old header").unwrap(); + let original = std::fs::read(temp.path().join("manifest.json")).unwrap(); + manifest.generation = 2; + manifest.documents[0].path = "x".repeat(super::json::MAX_OUTPUT_BYTES); + let error = super::publish_snapshot(temp.path(), &manifest, "new source", "new header") + .unwrap_err(); + assert_eq!( + error.downcast_ref::(), + Some(&super::json::Error::OutputTooLarge) + ); + assert_eq!( + std::fs::read(temp.path().join("manifest.json")).unwrap(), + original + ); + assert_eq!( + std::fs::read_to_string(temp.path().join("sketch.cpp")).unwrap(), + "old source" + ); + assert_eq!( + std::fs::read_to_string(temp.path().join("prototypes.hpp")).unwrap(), + "old header" + ); + } + + #[test] + fn snapshot_json_schema_preserves_defaults_signed_versions_and_duplicates() { + for source in [ + r#"{"sketch_dir":"日本","generation":18446744073709551615}"#, + r#"["日本",18446744073709551615]"#, + ] { + let request = super::parse_snapshot_request(source).unwrap(); + assert_eq!(request.sketch_dir, "日本"); + assert_eq!(request.generation, u64::MAX); + assert!(request.documents.is_empty()); + } + let request = super::parse_snapshot_request( + r#"["sketch",1,[["a.ino",-9223372036854775808,"void loop() {}"]]]"#, + ) + .unwrap(); + assert_eq!(request.documents[0].version, i64::MIN); + for source in [ + r#"{"sketch_dir":"s","generation":1,"generation":1}"#, + r#"{"sketch_dir":"s","generation":1,"documents":null}"#, + r#"["s",-1]"#, + r#"["s",1,[["a",1.0,"text"]]]"#, + r#"["s",1,[{"path":"a","version":1,"text":"x","text":"y"}]]"#, + ] { + assert!( + super::parse_snapshot_request(source).is_err(), + "accepted {source}" + ); + } + } + + #[test] + fn manifest_json_schema_preserves_optional_versions_and_required_hashes() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + for source in [ + r#"[1,18446744073709551615,[{"path":"a","sha256":"digest"}],"sketch.cpp","sourcehash","prototypes.hpp","headerhash"]"#, + r#"[1,18446744073709551615,[["a",null,"digest"]],"sketch.cpp","sourcehash","prototypes.hpp","headerhash"]"#, + ] { + std::fs::write(temp.path().join("manifest.json"), source).unwrap(); + let manifest = super::read_manifest(temp.path()).unwrap(); + assert_eq!(manifest.generation, u64::MAX); + assert_eq!(manifest.documents[0].version, None); + assert_eq!(manifest.prototype_header_sha256, "headerhash"); + } + for source in [ + r#"[1,0,[],"sketch.cpp","sourcehash","prototypes.hpp"]"#, + r#"[1,0,[["a","digest"]],"s","h","p","h"]"#, + r#"[1,0,[{"path":"a","version":null,"version":null,"sha256":"h"}],"s","h","p","h"]"#, + ] { + std::fs::write(temp.path().join("manifest.json"), source).unwrap(); + assert!(super::read_manifest(temp.path()).is_none()); + } + } + use super::*; #[test] diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index 00943197..f4c5eb01 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -1445,12 +1445,22 @@ pub(crate) fn create_wrapper( Ok(wrapper) } +fn sketch_source_fingerprint(example_dir: &Path) -> Result { + dynamic_cache::fingerprint_tree( + example_dir, + &[ + "**/*.ino", "**/*.cpp", "**/*.c", "**/*.h", "**/*.hpp", "**/*.ipp", + ], + &[".git/**", ".build/**", ".fastled/**", "fastled_js/**"], + ) +} + fn collect_cpp_files(dir: &Path, out: &mut Vec) -> Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { - if entry.file_name().to_string_lossy() == ".build" { + if matches!(entry.file_name().to_str(), Some(".build" | ".fastled")) { continue; } collect_cpp_files(&path, out)?; @@ -1592,13 +1602,7 @@ fn compile_sketch( "empp-fallback-v1".to_string(), ), }; - let source_fingerprint = dynamic_cache::fingerprint_tree( - example_dir, - &[ - "**/*.ino", "**/*.cpp", "**/*.c", "**/*.h", "**/*.hpp", "**/*.ipp", - ], - &[".git/**", ".build/**", "fastled_js/**"], - )?; + let source_fingerprint = sketch_source_fingerprint(example_dir)?; let wrapper_contents = fs::read(wrapper) .with_context(|| format!("read generated wrapper {}", wrapper.display()))?; let compile_flag_blob = compile_flags.join("\n"); @@ -3351,4 +3355,37 @@ link_flags = [] assert!(source.contains("#line 1 \"")); assert!(!source.contains("#include \"")); } + + #[test] + fn issue_248_wrapper_excludes_intellisense_but_keeps_user_cpp() { + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + fs::write( + tmp.path().join("Blink.ino"), + "void setup() {}\nvoid loop() {}\n", + ) + .unwrap(); + fs::create_dir(tmp.path().join("src")).unwrap(); + fs::write(tmp.path().join("src/helper.cpp"), "int helper = 1;\n").unwrap(); + crate::sketch_preprocessor::write_disk_snapshot(tmp.path()).unwrap(); + let wrapper = create_wrapper(tmp.path(), "Blink", &sketch_cache_dir(tmp.path())).unwrap(); + let source = fs::read_to_string(wrapper).unwrap(); + assert!(!source.contains(".fastled/intellisense/sketch.cpp")); + assert!(source.contains("src/helper.cpp")); + } + + #[test] + fn issue_248_fingerprint_ignores_editor_output_but_tracks_user_cpp() { + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + fs::write( + tmp.path().join("Blink.ino"), + "void setup() {}\nvoid loop() {}\n", + ) + .unwrap(); + fs::write(tmp.path().join("helper.cpp"), "int helper = 1;\n").unwrap(); + let before = sketch_source_fingerprint(tmp.path()).unwrap(); + crate::sketch_preprocessor::write_disk_snapshot(tmp.path()).unwrap(); + assert_eq!(sketch_source_fingerprint(tmp.path()).unwrap(), before); + fs::write(tmp.path().join("helper.cpp"), "int helper = 2;\n").unwrap(); + assert_ne!(sketch_source_fingerprint(tmp.path()).unwrap(), before); + } } diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 314bf96a..97cebcbe 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -1062,6 +1062,26 @@ its receipt; both WASM headers are valid, and generated runtime JS has no JSPI entry points. This is compile/cache evidence, not browser/Safari acceptance or a measured before/after migration speedup. +IntelliSense request and manifest JSON now use kernel-owned values. Editor +version/generation ranges, nested duplicate rejection, positional records, +optional versions, document defaults, ordered output and generation checks stay +in FastLED. Baseline schema tests passed with the old backend, and the module +boundary went RED to GREEN. Manifest encoding now finishes before replacing +snapshot source/header files, so a new resource-limit error cannot leave a +partial publication. C++ analysis and Arduino selection are unchanged. Two JSON +source modules remain; registry adoption and platform acceptance are incomplete. +The real stdin protocol published generation 8 with matching file hashes and +returned that generation unchanged for a stale generation-7 request. A real +compile then exposed #248: generated IntelliSense C++ was recursively included +in the wrapper and source fingerprint. Two focused tests observed RED to GREEN +after excluding `.fastled` from compiler inputs. Compilation succeeded with +the snapshot retained, and a subsequent unsaved editor update preserved object, +runtime and sketch cache hits. The WASM header and generated JS checks pass; +this is not browser/Safari acceptance or comparative performance evidence. +Final checks pass 286 library, 3 binary, 1 integration and 1 doc tests, Python +40 passed/1 skipped, strict Clippy, formatting, Ruff and single-reviewer review +(including the #248 follow-up). + ### Final migration audit The Axum baseline now has a raw-wire parity test for missing-file 404, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index a62c39a4..2d7720c3 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -216,3 +216,11 @@ def test_cache_json_uses_kernal() -> None: assert "serde::" not in source assert "serde_json::" not in source assert "json::parse_members" in source + + +def test_snapshot_json_uses_kernal() -> None: + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/sketch_preprocessor.rs").read_text() + assert "serde::" not in source + assert "serde_json::" not in source + assert "json::parse_members" in source From 55891c12c0fda407dd40568d7fb54a21a8a3e1c7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 07:16:56 -0700 Subject: [PATCH 61/94] refactor(build): use kernal-api JSON for compiler flags cache Refs zackees/kernal-api#211. Preserve typed cache schema and wire format while moving parsing and encoding behind the bounded kernel JSON API. --- crates/fastled-cli/src/wasm_build.rs | 148 ++++++++++++++++++++++++++- tests/unit/test_kernal_boundary.py | 13 +++ 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index f4c5eb01..cd670cc4 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -13,7 +13,7 @@ use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; -use serde::{Deserialize, Serialize}; +use kernal_api::json::{self, Layout, Value}; use crate::cli::LinkMode; use crate::{archive, debug_symbols, dynamic_cache, frontend, install, source}; @@ -472,12 +472,86 @@ fn command_with_env(program: impl AsRef, tools: &ToolPaths) -> Command { const DIRECT_CFLAGS_SCHEMA: u32 = 1; const DIRECT_CFLAGS_FILE: &str = ".fastled-direct-cflags.json"; -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default)] struct DirectCflagsCache { schema: u32, entries: BTreeMap>, } +impl DirectCflagsCache { + fn parse(bytes: &[u8]) -> Result { + let (schema, entries) = match json::parse_members(bytes)? { + Value::ObjectMembers(members) => { + let mut schema = None; + let mut entries = None; + for (key, value) in members { + let field = match key.as_str() { + "schema" => &mut schema, + "entries" => &mut entries, + _ => continue, + }; + if field.replace(value).is_some() { + bail!("duplicate direct cflags cache field"); + } + } + ( + schema.context("missing direct cflags cache schema")?, + entries.context("missing direct cflags cache entries")?, + ) + } + Value::Array(mut fields) if fields.len() == 2 => { + let entries = fields.remove(1); + (fields.remove(0), entries) + } + _ => bail!("invalid direct cflags cache record"), + }; + let schema = match schema { + Value::Signed(value) => u32::try_from(value)?, + Value::Unsigned(value) => u32::try_from(value)?, + _ => bail!("invalid direct cflags cache schema"), + }; + let Value::ObjectMembers(members) = entries else { + bail!("invalid direct cflags cache entries"); + }; + let mut entries = BTreeMap::new(); + for (key, value) in members { + let Value::Array(values) = value else { + bail!("invalid direct cflags cache flags"); + }; + let flags = values + .into_iter() + .map(|value| match value { + Value::String(value) => Ok(value), + _ => bail!("invalid direct cflags cache flag"), + }) + .collect::>>()?; + // Validate every occurrence before applying the map's last-key policy. + entries.insert(key, flags); + } + Ok(Self { schema, entries }) + } + + fn encode(&self) -> Result> { + let entries = self + .entries + .iter() + .map(|(key, flags)| { + ( + key.clone(), + Value::Array(flags.iter().cloned().map(Value::String).collect()), + ) + }) + .collect(); + Ok(json::encode( + &Value::ObjectMembers(vec![ + ("schema".into(), Value::Unsigned(u64::from(self.schema))), + ("entries".into(), Value::Object(entries)), + ]), + Layout::Pretty, + )?) + } +} + fn direct_cflags_key(toolchain_fingerprint: &str, driver_args: &[String]) -> String { let mut values = vec![toolchain_fingerprint.to_string()]; values.extend(driver_args.iter().cloned()); @@ -556,7 +630,7 @@ fn direct_clang_cflags( let key = direct_cflags_key(toolchain_fingerprint, driver_args); let mut cache = fs::read(&cache_path) .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|bytes| DirectCflagsCache::parse(&bytes).ok()) .filter(|cache| cache.schema == DIRECT_CFLAGS_SCHEMA) .unwrap_or_else(|| DirectCflagsCache { schema: DIRECT_CFLAGS_SCHEMA, @@ -587,7 +661,7 @@ fn direct_clang_cflags( bail!("em++ --cflags returned no backend flags"); } cache.entries.insert(key, flags.clone()); - fs::write(&cache_path, serde_json::to_vec_pretty(&cache)?) + fs::write(&cache_path, cache.encode()?) .with_context(|| format!("write direct cflags cache {}", cache_path.display()))?; Ok(flags) } @@ -2850,6 +2924,72 @@ mod tests { ); } + #[test] + fn direct_cflags_cache_preserves_typed_schema_policy() { + for source in [ + r#"{"schema":1,"entries":{"a":["-O1","λ"]},"unknown":null}"#, + r#"[1,{"a":["-O1","λ"]}]"#, + r#"{"schema":1,"entries":{"a":["old"],"a":["-O1","λ"]}}"#, + ] { + let cache = DirectCflagsCache::parse(source.as_bytes()).unwrap(); + assert_eq!(cache.schema, 1); + assert_eq!(cache.entries["a"], ["-O1", "λ"]); + } + for source in [ + r#"{"entries":{}}"#, + r#"{"schema":1,"schema":1,"entries":{}}"#, + r#"{"schema":1,"entries":{},"entries":{}}"#, + r#"{"schema":1,"entries":{"a":null,"a":[]}}"#, + r#"{"schema":1,"entries":{"a":[1]}}"#, + r#"{"schema":1.0,"entries":{}}"#, + r#"{"schema":-1,"entries":{}}"#, + r#"{"schema":4294967296,"entries":{}}"#, + r#"[1,{},null]"#, + r#"[1]"#, + ] { + assert!( + DirectCflagsCache::parse(source.as_bytes()).is_err(), + "{source}" + ); + } + assert_eq!( + DirectCflagsCache::parse(br#"{"schema":4294967295,"entries":{}}"#) + .unwrap() + .schema, + u32::MAX + ); + } + + #[test] + fn direct_cflags_cache_encoding_is_stable_and_bounded() { + let mut cache = DirectCflagsCache { + schema: 1, + entries: BTreeMap::from([("key".into(), vec!["-O1".into(), "λ".into()])]), + }; + let encoded = cache.encode().unwrap(); + assert_eq!( + String::from_utf8(encoded.clone()).unwrap(), + "{\n \"schema\": 1,\n \"entries\": {\n \"key\": [\n \"-O1\",\n \"λ\"\n ]\n }\n}" + ); + assert_eq!( + DirectCflagsCache::parse(&encoded).unwrap().entries, + cache.entries + ); + cache + .entries + .insert("large".into(), vec!["x".repeat(json::MAX_OUTPUT_BYTES)]); + assert!(matches!( + cache.encode().unwrap_err().downcast_ref::(), + Some(json::Error::OutputTooLarge) + )); + assert!(matches!( + DirectCflagsCache::parse(&vec![b' '; json::MAX_INPUT_BYTES + 1]) + .unwrap_err() + .downcast_ref::(), + Some(json::Error::InputTooLarge) + )); + } + #[test] fn direct_cflags_key_tracks_toolchain_and_driver_arguments() { let baseline = direct_cflags_key("toolchain-a", &["-pthread".to_string()]); diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 2d7720c3..928fe417 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -82,6 +82,19 @@ def test_project_json_uses_kernel(): assert "kernal_api::json" in source +def test_direct_cflags_cache_json_uses_kernel(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/wasm_build.rs").read_text() + cache_code = source.split("const DIRECT_CFLAGS_SCHEMA", 1)[1].split( + "fn spawn_line_reader", 1 + )[0] + assert "serde_json::" not in cache_code + assert "Serialize" not in cache_code + assert "Deserialize" not in cache_code + assert "json::parse_members" in cache_code + assert "json::encode" in cache_code + + def test_dwarf_smoke_json_uses_kernel(): root = Path(__file__).resolve().parents[2] source = (root / "crates/fastled-cli/src/dwarf_smoke.rs").read_text() From e795210b33f61bcef785188a47fab821faa0a8c8 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 07:23:51 -0700 Subject: [PATCH 62/94] refactor(build): move asset manifests to kernal-api JSON Refs zackees/kernal-api#211. Keep asset schema and selection local, encode both manifests before publication, and remove all remaining Serde usage from the WASM build module. --- crates/fastled-cli/src/wasm_build.rs | 300 +++++++++++++++++---------- tests/unit/test_kernal_boundary.py | 8 + 2 files changed, 201 insertions(+), 107 deletions(-) diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index cd670cc4..8702b5f1 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -372,7 +372,10 @@ fn resolve_tool_paths(install_dir: &Path, fastled_dir: &Path) -> Result(value.trim()).ok()) + .and_then(|value| match json::parse(value.trim().as_bytes()).ok()? { + Value::String(value) => Some(value), + _ => None, + }) .unwrap_or_default(); if !emcc.is_file() { bail!("missing emcc.py at {}", emcc.display()); @@ -2102,13 +2105,25 @@ fn copy_dynamic_output( } fn generate_manifest(example_dir: &Path, output_dir: &Path) -> Result<()> { - let mut files = Vec::::new(); + let mut files = Vec::new(); collect_data_files(example_dir, example_dir, &mut files)?; - for file in &files { - let relative = file - .get("path") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| anyhow::anyhow!("generated asset manifest entry has no path"))?; + let local_document = Value::Array( + files + .iter() + .map(|(path, size)| { + Value::Object(BTreeMap::from([ + ("path".into(), Value::String(path.clone())), + ("size".into(), Value::Unsigned(*size)), + ])) + }) + .collect(), + ); + // Resolve and encode both documents before changing published assets. + let local_bytes = json::encode(&local_document, Layout::Pretty)?; + let remote_assets = scan_remote_assets(example_dir)?; + let mut remote_bytes = json::encode(&Value::Object(remote_assets), Layout::Pretty)?; + remote_bytes.push(b'\n'); + for (relative, _) in &files { let source = example_dir.join(relative); let target = output_dir.join(relative); if let Some(parent) = target.parent() { @@ -2127,15 +2142,8 @@ fn generate_manifest(example_dir: &Path, output_dir: &Path) -> Result<()> { fs::remove_file(&legacy) .with_context(|| format!("remove legacy asset manifest {}", legacy.display()))?; } - fs::write( - output_dir.join("sketch_assets.json"), - serde_json::to_string_pretty(&files)?, - )?; - let remote_assets = scan_remote_assets(example_dir)?; - fs::write( - output_dir.join("asset_manifest.json"), - format!("{}\n", serde_json::to_string_pretty(&remote_assets)?), - )?; + fs::write(output_dir.join("sketch_assets.json"), local_bytes)?; + fs::write(output_dir.join("asset_manifest.json"), remote_bytes)?; Ok(()) } @@ -2145,57 +2153,57 @@ fn remote_asset_record( sha256: Option, size: Option, storage: Option, -) -> serde_json::Value { - let mut record = serde_json::Map::new(); - record.insert("url".into(), url.into()); - record.insert( - "sha256".into(), - sha256.map_or(serde_json::Value::Null, serde_json::Value::String), - ); +) -> Value { + let mut record = BTreeMap::new(); + record.insert("url".into(), Value::String(url)); + record.insert("sha256".into(), sha256.map_or(Value::Null, Value::String)); record.insert( "fallback".into(), - fallback.map_or(serde_json::Value::Null, serde_json::Value::String), + fallback.map_or(Value::Null, Value::String), ); if let Some(size) = size { - record.insert("size".into(), size.into()); + record.insert("size".into(), Value::Unsigned(size)); } if let Some(storage) = storage { - record.insert("storage".into(), storage.into()); + record.insert("storage".into(), Value::String(storage)); } - record.into() + Value::Object(record) } -fn normalized_storage(value: Option<&serde_json::Value>) -> Option { - let direct = value.and_then(serde_json::Value::as_str); - direct - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_ascii_lowercase) +fn normalized_storage(value: Option<&Value>) -> Option { + let Value::String(value) = value? else { + return None; + }; + let value = value.trim(); + (!value.is_empty()).then(|| value.to_ascii_lowercase()) } -fn storage_from_spec( - spec: &serde_json::Map, - default: Option<&str>, -) -> Option { +fn storage_from_spec(spec: &BTreeMap, default: Option<&str>) -> Option { normalized_storage(spec.get("storage")).or_else(|| { spec.get("dest") - .and_then(serde_json::Value::as_object) + .and_then(|value| match value { + Value::Object(value) => Some(value), + _ => None, + }) .and_then(|dest| normalized_storage(dest.get("target"))) .or_else(|| default.map(ToOwned::to_owned)) }) } -fn urls_from_spec(spec: &serde_json::Map) -> Vec { +fn urls_from_spec(spec: &BTreeMap) -> Vec { let value = spec.get("urls").or_else(|| spec.get("url")); match value { - Some(serde_json::Value::Array(values)) => values + Some(Value::Array(values)) => values .iter() - .filter_map(serde_json::Value::as_str) + .filter_map(|value| match value { + Value::String(value) => Some(value.as_str()), + _ => None, + }) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect(), - Some(serde_json::Value::String(value)) if !value.trim().is_empty() => { + Some(Value::String(value)) if !value.trim().is_empty() => { vec![value.trim().to_owned()] } _ => Vec::new(), @@ -2203,23 +2211,30 @@ fn urls_from_spec(spec: &serde_json::Map) -> Vec, + spec: &BTreeMap, default_storage: Option<&str>, -) -> Option { +) -> Option { let urls = urls_from_spec(spec); let url = urls.first()?.clone(); let fallback = urls .get(1) .cloned() - .or_else(|| spec.get("fallback")?.as_str().map(ToOwned::to_owned)); - let sha256 = spec - .get("sha256") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned); + .or_else(|| match spec.get("fallback")? { + Value::String(value) => Some(value.clone()), + _ => None, + }); + let sha256 = spec.get("sha256").and_then(|value| match value { + Value::String(value) => Some(value.clone()), + _ => None, + }); let size = spec .get("size_bytes") .or_else(|| spec.get("size")) - .and_then(serde_json::Value::as_u64); + .and_then(|value| match value { + Value::Unsigned(value) => Some(*value), + Value::Signed(value) => u64::try_from(*value).ok(), + _ => None, + }); Some(remote_asset_record( url, fallback, @@ -2229,14 +2244,16 @@ fn parse_json_asset( )) } -fn parse_lnk(content: &str) -> Option { +fn parse_lnk(content: &str) -> Option { let first = content .lines() .map(str::trim) .find(|line| !line.is_empty() && !line.starts_with('#'))?; if first.starts_with('{') { - let value: serde_json::Value = serde_json::from_str(content).ok()?; - return parse_json_asset(value.as_object()?, None); + let Value::Object(value) = json::parse(content.as_bytes()).ok()? else { + return None; + }; + return parse_json_asset(&value, None); } let mut urls = Vec::new(); @@ -2282,7 +2299,7 @@ fn safe_manifest_name(name: &str) -> Option { Some(normalized) } -fn scan_remote_assets(example_dir: &Path) -> Result> { +fn scan_remote_assets(example_dir: &Path) -> Result> { let data_dir = example_dir.join("data"); let mut manifest = BTreeMap::new(); if !data_dir.is_dir() { @@ -2291,19 +2308,21 @@ fn scan_remote_assets(example_dir: &Path) -> Result Some(value), + _ => None, + }) .and_then(|defaults| storage_from_spec(defaults, None)); - if let Some(assets) = root.get("assets").and_then(serde_json::Value::as_object) { + if let Some(Value::Object(assets)) = root.get("assets") { for (name, spec) in assets { let Some(name) = safe_manifest_name(name) else { eprintln!( @@ -2312,10 +2331,10 @@ fn scan_remote_assets(example_dir: &Path) -> Result parse_json_asset(spec, default_storage.as_deref()), + _ => None, + }) else { eprintln!( "fastled: ignoring asset without a URL in {}: {name}", assets_json.display() @@ -2353,7 +2372,7 @@ fn scan_remote_assets(example_dir: &Path) -> Result) -> Result<()> { +fn collect_data_files(root: &Path, dir: &Path, out: &mut Vec<(String, u64)>) -> Result<()> { if !dir.is_dir() { return Ok(()); } @@ -2378,10 +2397,7 @@ fn collect_data_files(root: &Path, dir: &Path, out: &mut Vec) ) { let rel = path.strip_prefix(root).unwrap_or(&path).to_string_lossy(); - out.push(serde_json::json!({ - "path": rel.replace('\\', "/"), - "size": path.metadata()?.len(), - })); + out.push((rel.replace('\\', "/"), path.metadata()?.len())); } } Ok(()) @@ -3426,34 +3442,23 @@ link_flags = [] generate_manifest(&example, &output).unwrap(); - let manifest: serde_json::Value = - serde_json::from_str(&fs::read_to_string(output.join("asset_manifest.json")).unwrap()) - .unwrap(); - assert_eq!( - manifest["data/track.mp3"]["url"], - "https://cdn.example/track.mp3" - ); - assert_eq!(manifest["data/track.mp3"]["sha256"], "abc123"); - assert_eq!( - manifest["data/track.mp3"]["fallback"], - "https://mirror.example/track.mp3" - ); - assert_eq!(manifest["data/track.mp3"]["storage"], "littlefs"); - assert_eq!( - manifest["data/video.rgb"]["url"], - "https://cdn.example/video.rgb" - ); - assert_eq!( - manifest["data/video.rgb"]["fallback"], - "https://mirror.example/video.rgb" - ); - assert_eq!(manifest["data/video.rgb"]["size"], 42); - assert_eq!(manifest["data/video.rgb"]["storage"], "vfs"); - - let local: serde_json::Value = - serde_json::from_str(&fs::read_to_string(output.join("sketch_assets.json")).unwrap()) - .unwrap(); - assert_eq!(local, serde_json::json!([])); + let manifest = json::parse(&fs::read(output.join("asset_manifest.json")).unwrap()).unwrap(); + let expected = json::parse( + br#"{ + "data/track.mp3": { + "url": "https://cdn.example/track.mp3", "sha256": "abc123", + "fallback": "https://mirror.example/track.mp3", "storage": "littlefs" + }, + "data/video.rgb": { + "url": "https://cdn.example/video.rgb", "sha256": "def456", + "fallback": "https://mirror.example/video.rgb", "size": 42, "storage": "vfs" + } + }"#, + ) + .unwrap(); + assert_eq!(manifest, expected); + let local = json::parse(&fs::read(output.join("sketch_assets.json")).unwrap()).unwrap(); + assert_eq!(local, Value::Array(Vec::new())); } #[test] @@ -3462,19 +3467,100 @@ link_flags = [] "url=https://cdn.example/a.bin\nurl=https://mirror.example/a.bin\nsize_bytes=7\n", ) .unwrap(); - assert_eq!(keyed["url"], "https://cdn.example/a.bin"); - assert_eq!(keyed["fallback"], "https://mirror.example/a.bin"); - assert_eq!(keyed["size"], 7); + assert_eq!(json::encode(&keyed, Layout::Compact).unwrap(), + br#"{"fallback":"https://mirror.example/a.bin","sha256":null,"size":7,"url":"https://cdn.example/a.bin"}"#); let json = parse_lnk( r#"{"v":1,"url":["https://cdn.example/b.bin","https://mirror.example/b.bin"],"sha256":"abc","size":9,"storage":"VFS"}"#, ) .unwrap(); - assert_eq!(json["url"], "https://cdn.example/b.bin"); - assert_eq!(json["fallback"], "https://mirror.example/b.bin"); - assert_eq!(json["sha256"], "abc"); - assert_eq!(json["size"], 9); - assert_eq!(json["storage"], "vfs"); + assert_eq!(json::encode(&json, Layout::Compact).unwrap(), + br#"{"fallback":"https://mirror.example/b.bin","sha256":"abc","size":9,"storage":"vfs","url":"https://cdn.example/b.bin"}"#); + } + + #[test] + fn asset_json_preserves_field_precedence_and_unsigned_sizes() { + let Value::Object(spec) = json::parse( + br#"{ + "urls":[null," "," https://first "," https://second "], + "url":"ignored", "fallback":"ignored", "sha256":false, + "size_bytes":18446744073709551615, + "storage":" ","dest":{"target":" LITTLEFS "} + }"#, + ) + .unwrap() else { + panic!("object"); + }; + let record = parse_json_asset(&spec, Some("vfs")).unwrap(); + assert_eq!(json::encode(&record, Layout::Compact).unwrap(), + br#"{"fallback":"https://second","sha256":null,"size":18446744073709551615,"storage":"littlefs","url":"https://first"}"#); + for source in [ + r#"{"url":"valid","urls":null}"#, + r#"{"url":"valid","urls":[]}"#, + r#"{"url":false}"#, + ] { + assert!(parse_lnk(source).is_none(), "{source}"); + } + for size in ["-1", "1.5", "18446744073709551616", "null"] { + let source = format!(r#"{{"url":"a","size":7,"size_bytes":{size}}}"#); + assert_eq!( + json::encode(&parse_lnk(&source).unwrap(), Layout::Compact).unwrap(), + br#"{"fallback":null,"sha256":null,"url":"a"}"# + ); + } + let duplicate = parse_lnk(r#"{"url":null,"url":"a","fallback":" b "}"#).unwrap(); + assert_eq!( + json::encode(&duplicate, Layout::Compact).unwrap(), + br#"{"fallback":" b ","sha256":null,"url":"a"}"# + ); + // JSON descriptors parse the entire file, unlike line-oriented links. + assert!(parse_lnk("# comment\n{\"url\":\"a\"}").is_none()); + } + + #[test] + fn asset_json_failure_preserves_published_files() { + let tmp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let example = tmp.path(); + let data = example.join("data"); + let output = example.join("fastled_js"); + fs::create_dir_all(&data).unwrap(); + fs::create_dir_all(output.join("data")).unwrap(); + fs::write(data.join("local.txt"), "new").unwrap(); + let previous = [ + "sketch_assets.json", + "asset_manifest.json", + "files.json", + "data/local.txt", + ]; + for name in previous { + fs::write(output.join(name), "previous").unwrap(); + } + for invalid in [ + "[]".to_string(), + "{".to_string(), + " ".repeat(json::MAX_INPUT_BYTES + 1), + ] { + fs::write(data.join("assets.json"), invalid).unwrap(); + assert!(generate_manifest(example, &output).is_err()); + for name in previous { + assert_eq!(fs::read_to_string(output.join(name)).unwrap(), "previous"); + } + } + // A shared default is replicated into each output record, so valid + // bounded input can still exceed the encoded document's byte limit. + let expanding = format!( + r#"{{"defaults":{{"storage":"{}"}},"assets":{{"a":{{"url":"a"}},"b":{{"url":"b"}}}}}}"#, + "x".repeat(json::MAX_OUTPUT_BYTES / 2) + ); + fs::write(data.join("assets.json"), expanding).unwrap(); + let error = generate_manifest(example, &output).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(json::Error::OutputTooLarge) + )); + for name in previous { + assert_eq!(fs::read_to_string(output.join(name)).unwrap(), "previous"); + } } #[test] diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 928fe417..af131fc6 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -95,6 +95,14 @@ def test_direct_cflags_cache_json_uses_kernel(): assert "json::encode" in cache_code +def test_wasm_build_json_uses_kernel(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/wasm_build.rs").read_text() + assert "serde_json::" not in source + assert "serde::" not in source + assert "kernal_api::json" in source + + def test_dwarf_smoke_json_uses_kernel(): root = Path(__file__).resolve().parents[2] source = (root / "crates/fastled-cli/src/dwarf_smoke.rs").read_text() From 6d77228ea252cb83355e61421ea304fea77bbfc5 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 07:28:28 -0700 Subject: [PATCH 63/94] refactor(install): use kernal-api JSON for toolchain state Refs zackees/kernal-api#211. Preserve receipt and active-state schemas, duplicate handling, positional records and serialized field order; validate bounded encoding before state publication. --- crates/fastled-cli/src/install.rs | 291 +++++++++++++++++++++++++++-- tests/unit/test_kernal_boundary.py | 14 ++ 2 files changed, 294 insertions(+), 11 deletions(-) diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index 10bb2902..76c1fe70 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -15,6 +15,8 @@ use std::sync::{Mutex, OnceLock}; use std::collections::BTreeMap; use anyhow::{bail, Context, Result}; +use kernal_api::json::{self, Layout, Value as JsonValue}; +#[cfg(test)] use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -282,10 +284,9 @@ pub struct ToolchainPart { pub sha256: &'static str, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct ToolchainReceipt { schema_version: u32, - #[serde(default)] catalog_commit: String, platform: String, arch: String, @@ -295,13 +296,164 @@ struct ToolchainReceipt { health_checked: bool, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] struct ActiveToolchainState { schema_version: u32, active: Option, previous_known_good: Option, } +// Record schemas are installer policy. The kernel owns JSON syntax, duplicate +// preservation and resource limits; unknown fields remain forward-compatible. +fn toolchain_record( + bytes: &[u8], + names: [&str; N], +) -> Result<[Option; N]> { + let mut fields = std::array::from_fn(|_| None); + match json::parse_members(bytes)? { + JsonValue::ObjectMembers(members) => { + for (name, value) in members { + let Some(index) = names.iter().position(|known| *known == name) else { + continue; + }; + if fields[index].replace(value).is_some() { + bail!("duplicate toolchain record field: {}", names[index]); + } + } + } + JsonValue::Array(values) if values.len() == N => { + for (field, value) in fields.iter_mut().zip(values) { + *field = Some(value); + } + } + _ => bail!("invalid toolchain record"), + } + Ok(fields) +} + +fn toolchain_schema(value: Option) -> Result { + match value { + Some(JsonValue::Signed(value)) => Ok(u32::try_from(value)?), + Some(JsonValue::Unsigned(value)) => Ok(u32::try_from(value)?), + _ => bail!("missing or invalid toolchain schema version"), + } +} + +fn toolchain_string(value: Option) -> Result { + match value { + Some(JsonValue::String(value)) => Ok(value), + _ => bail!("missing or invalid toolchain string field"), + } +} + +impl ActiveToolchainState { + fn parse(bytes: &[u8]) -> Result { + let [schema, active, previous] = + toolchain_record(bytes, ["schema_version", "active", "previous_known_good"])?; + let optional_string = |value| match value { + None | Some(JsonValue::Null) => Ok(None), + other => toolchain_string(other).map(Some), + }; + Ok(Self { + schema_version: toolchain_schema(schema)?, + active: optional_string(active)?, + previous_known_good: optional_string(previous)?, + }) + } + + fn encode(&self) -> Result> { + Ok(json::encode( + &JsonValue::ObjectMembers(vec![ + ( + "schema_version".into(), + JsonValue::Unsigned(self.schema_version.into()), + ), + ( + "active".into(), + self.active + .clone() + .map_or(JsonValue::Null, JsonValue::String), + ), + ( + "previous_known_good".into(), + self.previous_known_good + .clone() + .map_or(JsonValue::Null, JsonValue::String), + ), + ]), + Layout::Pretty, + )?) + } +} + +impl ToolchainReceipt { + fn parse(bytes: &[u8]) -> Result { + let [schema, catalog, platform, arch, package, url, hash, health] = toolchain_record( + bytes, + [ + "schema_version", + "catalog_commit", + "platform", + "arch", + "package_id", + "archive_url", + "archive_sha256", + "health_checked", + ], + )?; + let Some(JsonValue::Bool(health_checked)) = health else { + bail!("missing or invalid toolchain health flag"); + }; + Ok(Self { + schema_version: toolchain_schema(schema)?, + catalog_commit: match catalog { + None => String::new(), + other => toolchain_string(other)?, + }, + platform: toolchain_string(platform)?, + arch: toolchain_string(arch)?, + package_id: toolchain_string(package)?, + archive_url: toolchain_string(url)?, + archive_sha256: toolchain_string(hash)?, + health_checked, + }) + } + + fn encode(&self) -> Result> { + Ok(json::encode( + &JsonValue::ObjectMembers(vec![ + ( + "schema_version".into(), + JsonValue::Unsigned(self.schema_version.into()), + ), + ( + "catalog_commit".into(), + JsonValue::String(self.catalog_commit.clone()), + ), + ("platform".into(), JsonValue::String(self.platform.clone())), + ("arch".into(), JsonValue::String(self.arch.clone())), + ( + "package_id".into(), + JsonValue::String(self.package_id.clone()), + ), + ( + "archive_url".into(), + JsonValue::String(self.archive_url.clone()), + ), + ( + "archive_sha256".into(), + JsonValue::String(self.archive_sha256.clone()), + ), + ( + "health_checked".into(), + JsonValue::Bool(self.health_checked), + ), + ]), + Layout::Pretty, + )?) + } +} + const TOOLCHAIN_STATE_SCHEMA: u32 = 1; const TOOLCHAIN_RECEIPT_SCHEMA: u32 = 1; const TOOLCHAIN_CATALOG_COMMIT: &str = "ef4a0e4a767c46528776105815033fb870ec337a"; @@ -394,8 +546,8 @@ fn read_state(base: &Path) -> Result { ..ActiveToolchainState::default() }); } - let state: ActiveToolchainState = serde_json::from_str( - &fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?, + let state = ActiveToolchainState::parse( + &fs::read(&path).with_context(|| format!("read {}", path.display()))?, ) .with_context(|| format!("parse {}", path.display()))?; if state.schema_version != TOOLCHAIN_STATE_SCHEMA { @@ -410,7 +562,7 @@ fn read_state(base: &Path) -> Result { fn write_state(base: &Path, state: &ActiveToolchainState) -> Result<()> { let path = state_path(base); let temp = base.join(format!(".toolchain-state-{}.tmp", std::process::id())); - fs::write(&temp, serde_json::to_vec_pretty(state)?)?; + fs::write(&temp, state.encode()?)?; if path.exists() { let backup = base.join(format!(".toolchain-state-{}.bak", std::process::id())); fs::rename(&path, &backup).with_context(|| format!("backup {}", path.display()))?; @@ -436,16 +588,14 @@ fn write_receipt(install: &Path, spec: ToolchainSpec, health_checked: bool) -> R archive_sha256: spec.archive_sha256.to_string(), health_checked, }; - fs::write(receipt_path(install), serde_json::to_vec_pretty(&receipt)?)?; + fs::write(receipt_path(install), receipt.encode()?)?; Ok(()) } fn read_receipt(install: &Path) -> Result { let path = receipt_path(install); - serde_json::from_str( - &fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?, - ) - .with_context(|| format!("parse {}", path.display())) + ToolchainReceipt::parse(&fs::read(&path).with_context(|| format!("read {}", path.display()))?) + .with_context(|| format!("parse {}", path.display())) } fn validate_managed_install(install: &Path, spec: ToolchainSpec) -> Result<()> { @@ -2374,6 +2524,125 @@ mod tests { ); } + #[test] + fn toolchain_json_schema_preserves_defaults_duplicates_and_sequences() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + for source in [ + r#"{"schema_version":1}"#, + r#"{"schema_version":1,"active":null,"previous_known_good":null,"unknown":false}"#, + r#"[1,null,null]"#, + ] { + fs::write(state_path(temp.path()), source).unwrap(); + assert_eq!( + read_state(temp.path()).unwrap(), + ActiveToolchainState { + schema_version: 1, + active: None, + previous_known_good: None, + } + ); + } + for source in [ + r#"{"schema_version":1,"active":null,"active":"a"}"#, + r#"{"schema_version":1,"previous_known_good":null,"previous_known_good":null}"#, + r#"{"schema_version":1,"schema_version":1}"#, + r#"{"schema_version":1,"active":false}"#, + r#"{"schema_version":1.0}"#, + r#"{"schema_version":-1}"#, + r#"{"schema_version":4294967296}"#, + r#"{}"#, + r#"[1]"#, + r#"[1,null,null,null]"#, + ] { + fs::write(state_path(temp.path()), source).unwrap(); + assert!(read_state(temp.path()).is_err(), "{source}"); + } + let receipt = r#"{"schema_version":4294967295,"platform":"linux","arch":"x86_64","package_id":"λ","archive_url":"u","archive_sha256":"h","health_checked":false}"#; + fs::write(receipt_path(temp.path()), receipt).unwrap(); + let expected = read_receipt(temp.path()).unwrap(); + assert_eq!(expected.schema_version, u32::MAX); + assert_eq!(expected.catalog_commit, ""); + fs::write( + receipt_path(temp.path()), + r#"[4294967295,"","linux","x86_64","λ","u","h",false]"#, + ) + .unwrap(); + assert_eq!(read_receipt(temp.path()).unwrap(), expected); + for source in [ + receipt.replace("\"health_checked\":false", "\"health_checked\":null"), + receipt.replace("\"health_checked\":false", "\"health_checked\":0"), + receipt.replace( + "\"health_checked\":false", + "\"health_checked\":false,\"health_checked\":true", + ), + receipt.replace("\"platform\":\"linux\"", "\"platform\":null"), + receipt.replace("\"platform\":\"linux\",", ""), + receipt.replace( + "\"platform\":\"linux\"", + "\"platform\":\"linux\",\"catalog_commit\":null", + ), + r#"[1,"linux","x86_64","p","u","h",true]"#.to_string(), + ] { + fs::write(receipt_path(temp.path()), &source).unwrap(); + assert!(read_receipt(temp.path()).is_err(), "{source}"); + } + } + + #[test] + fn toolchain_json_encoding_preserves_wire_format_and_failed_writes() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let mut state = ActiveToolchainState { + schema_version: 1, + active: Some("λ".into()), + previous_known_good: None, + }; + write_state(temp.path(), &state).unwrap(); + let before = fs::read(state_path(temp.path())).unwrap(); + assert_eq!( + String::from_utf8(before.clone()).unwrap(), + "{\n \"schema_version\": 1,\n \"active\": \"λ\",\n \"previous_known_good\": null\n}" + ); + state.active = Some("x".repeat(json::MAX_OUTPUT_BYTES)); + assert!(matches!( + write_state(temp.path(), &state) + .unwrap_err() + .downcast_ref::(), + Some(json::Error::OutputTooLarge) + )); + assert_eq!(fs::read(state_path(temp.path())).unwrap(), before); + assert!(!temp + .path() + .join(format!(".toolchain-state-{}.tmp", std::process::id())) + .exists()); + let receipt = ToolchainReceipt { + schema_version: 1, + catalog_commit: "c".into(), + platform: "p".into(), + arch: "a".into(), + package_id: "i".into(), + archive_url: "u".into(), + archive_sha256: "h".into(), + health_checked: true, + }; + let bytes = receipt.encode().unwrap(); + assert_eq!(String::from_utf8(bytes.clone()).unwrap(), + "{\n \"schema_version\": 1,\n \"catalog_commit\": \"c\",\n \"platform\": \"p\",\n \"arch\": \"a\",\n \"package_id\": \"i\",\n \"archive_url\": \"u\",\n \"archive_sha256\": \"h\",\n \"health_checked\": true\n}"); + assert_eq!(ToolchainReceipt::parse(&bytes).unwrap(), receipt); + let oversized = vec![b' '; json::MAX_INPUT_BYTES + 1]; + assert!(matches!( + ToolchainReceipt::parse(&oversized) + .unwrap_err() + .downcast_ref::(), + Some(json::Error::InputTooLarge) + )); + assert!(matches!( + ActiveToolchainState::parse(&oversized) + .unwrap_err() + .downcast_ref::(), + Some(json::Error::InputTooLarge) + )); + } + #[test] fn legacy_marker_migrates_to_receipt_and_active_state() { let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index af131fc6..7ac4e7e5 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -103,6 +103,20 @@ def test_wasm_build_json_uses_kernel(): assert "kernal_api::json" in source +def test_installer_receipt_json_uses_kernel(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/install.rs").read_text() + records = source.split("struct ToolchainReceipt", 1)[1].split( + "fn validate_managed_install", 1 + )[0] + assert "serde_json::" not in records + assert "serde(" not in records + assert "Serialize" not in records + assert "Deserialize" not in records + assert "json::parse_members" in records + assert "json::encode" in records + + def test_dwarf_smoke_json_uses_kernel(): root = Path(__file__).resolve().parents[2] source = (root / "crates/fastled-cli/src/dwarf_smoke.rs").read_text() From b90410945f540d5390bba67c826be11cdba6fd1e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 08:44:15 -0700 Subject: [PATCH 64/94] refactor(install): finish kernal-api JSON migration and remove Serde Refs zackees/kernal-api#211. Bundle unchanged editor defaults, retain merge/schema policies, reject resource-limit failures before overwriting configuration, and remove direct serde and serde_json dependencies. --- Cargo.lock | 2 - Cargo.toml | 2 - crates/fastled-cli/Cargo.toml | 2 - crates/fastled-cli/src/install.rs | 628 +++++++++--------- .../src/install_editor_templates.json | 62 ++ .../src/install_repository_settings.json | 160 +++++ tests/unit/test_kernal_boundary.py | 16 + 7 files changed, 538 insertions(+), 334 deletions(-) create mode 100644 crates/fastled-cli/src/install_editor_templates.json create mode 100644 crates/fastled-cli/src/install_repository_settings.json diff --git a/Cargo.lock b/Cargo.lock index 0aa48873..e3b123d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1005,8 +1005,6 @@ dependencies = [ "anyhow", "clap", "kernal-api", - "serde", - "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index dcaceaae..976ef9b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,6 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "config-toml", "source-cpp", "json"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" anyhow = "1" [profile.release] diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index e0f67a06..45a28bbd 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -23,8 +23,6 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } kernal-api = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } anyhow = { workspace = true } [package.metadata.binstall] diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index 76c1fe70..64f2887a 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -11,14 +11,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; -#[cfg(test)] use std::collections::BTreeMap; use anyhow::{bail, Context, Result}; use kernal_api::json::{self, Layout, Value as JsonValue}; -#[cfg(test)] -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; use crate::archive; @@ -30,23 +26,22 @@ use crate::archive; /// `ctcb-manifest` releases expected version keys at the top level. Keeping /// the schema local insulates the CLI from upstream crate drift. #[cfg(test)] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] struct PlatformManifest { latest: String, versions: BTreeMap, } #[cfg(test)] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] struct VersionInfo { href: String, sha256: String, - #[serde(default, skip_serializing_if = "Option::is_none")] parts: Option>, } #[cfg(test)] -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] struct PartRef { href: String, sha256: String, @@ -96,28 +91,63 @@ fn toolchain_platform_arch(os: &str, architecture: &str) -> Result<(&'static str /// today, so the CLI has to handle both. #[cfg(test)] fn parse_platform_manifest(text: &str) -> Result { - if let Ok(parsed) = serde_json::from_str::(text) { + fn version(value: JsonValue) -> Result { + let value = match value { + JsonValue::Array(mut fields) if fields.len() == 2 => { + fields.push(JsonValue::Null); + JsonValue::Array(fields) + } + other => other, + }; + let [href, sha256, parts] = toolchain_record_value(value, ["href", "sha256", "parts"])?; + let parts = match parts { + None | Some(JsonValue::Null) => None, + Some(JsonValue::Array(values)) => Some( + values + .into_iter() + .map(|value| { + let [href, sha256] = toolchain_record_value(value, ["href", "sha256"])?; + Ok(PartRef { + href: toolchain_string(href)?, + sha256: toolchain_string(sha256)?, + }) + }) + .collect::>>()?, + ), + _ => bail!("invalid archive parts"), + }; + Ok(VersionInfo { + href: toolchain_string(href)?, + sha256: toolchain_string(sha256)?, + parts, + }) + } + fn nested(text: &str) -> Result { + let [latest, versions] = toolchain_record(text.as_bytes(), ["latest", "versions"])?; + let Some(JsonValue::ObjectMembers(members)) = versions else { + bail!("missing versions"); + }; + let mut versions = BTreeMap::new(); + for (key, value) in members { + versions.insert(key, version(value)?); + } + Ok(PlatformManifest { + latest: toolchain_string(latest)?, + versions, + }) + } + if let Ok(parsed) = nested(text) { return Ok(parsed); } - let value: serde_json::Value = serde_json::from_str(text)?; - let object = value - .as_object() - .ok_or_else(|| anyhow::anyhow!("manifest is not a JSON object"))?; - - let latest = object - .get("latest") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| anyhow::anyhow!("manifest is missing required field `latest`"))? - .to_string(); + let JsonValue::Object(mut object) = json::parse(text.as_bytes())? else { + bail!("manifest is not a JSON object"); + }; + let latest = toolchain_string(object.remove("latest"))?; let mut versions = BTreeMap::new(); for (key, value) in object { - if key == "latest" { - continue; - } - let info: VersionInfo = serde_json::from_value(value.clone()) - .with_context(|| format!("parse version entry `{key}`"))?; + let info = version(value).with_context(|| format!("parse version entry `{key}`"))?; versions.insert(key.clone(), info); } @@ -308,9 +338,20 @@ struct ActiveToolchainState { fn toolchain_record( bytes: &[u8], names: [&str; N], +) -> Result<[Option; N]> { + toolchain_record_value(json::parse_members(bytes)?, names) +} + +fn toolchain_record_value( + value: JsonValue, + names: [&str; N], ) -> Result<[Option; N]> { let mut fields = std::array::from_fn(|_| None); - match json::parse_members(bytes)? { + let value = match value { + JsonValue::Object(value) => JsonValue::ObjectMembers(value.into_iter().collect()), + other => other, + }; + match value { JsonValue::ObjectMembers(members) => { for (name, value) in members { let Some(index) = names.iter().position(|known| *known == name) else { @@ -1453,11 +1494,13 @@ fn fetch_latest_release_tag() -> Option { return None; } let bytes = resp.into_bytes().ok()?; - let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - value - .get("tag_name") - .and_then(serde_json::Value::as_str) - .map(str::to_string) + let JsonValue::Object(mut value) = json::parse(&bytes).ok()? else { + return None; + }; + match value.remove("tag_name") { + Some(JsonValue::String(value)) => Some(value), + _ => None, + } } fn head_check(url: &str) -> bool { @@ -1782,11 +1825,10 @@ fn detect_fastled_project() -> bool { let Ok(text) = fs::read_to_string(library_json) else { return false; }; - serde_json::from_str::(&text) - .ok() - .and_then(|value| value.get("name").and_then(Value::as_str).map(str::to_owned)) - .map(|name| name == "FastLED") - .unwrap_or(false) + let Ok(JsonValue::Object(value)) = json::parse(text.as_bytes()) else { + return false; + }; + matches!(value.get("name"), Some(JsonValue::String(name)) if name == "FastLED") } fn is_fastled_repository() -> bool { @@ -1807,18 +1849,16 @@ fn is_fastled_repository() -> bool { let Ok(text) = fs::read_to_string(cwd.join("library.json")) else { return false; }; - let Ok(value) = serde_json::from_str::(&text) else { + let Ok(JsonValue::Object(value)) = json::parse(text.as_bytes()) else { return false; }; - if value.get("name").and_then(Value::as_str) != Some("FastLED") { + if !matches!(value.get("name"), Some(JsonValue::String(name)) if name == "FastLED") { return false; } - if !value - .get("repository") - .and_then(|repo| repo.get("url")) - .and_then(Value::as_str) - .map(|url| url.contains("FastLED/FastLED")) - .unwrap_or(false) + let Some(JsonValue::Object(repository)) = value.get("repository") else { + return false; + }; + if !matches!(repository.get("url"), Some(JsonValue::String(url)) if url.contains("FastLED/FastLED")) { return false; } @@ -1864,304 +1904,121 @@ fn check_existing_arduino_content() -> bool { cwd.join("examples").exists() || has_ino_file(&cwd) } -pub(crate) fn read_json_file(path: &Path, default: Value) -> Value { - fs::read_to_string(path) - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .unwrap_or(default) +fn read_editor_object( + path: &Path, + default: BTreeMap, +) -> Result> { + let Ok(bytes) = fs::read(path) else { + return Ok(default); + }; + match json::parse(&bytes) { + Ok(JsonValue::Object(value)) => Ok(value), + Ok(_) | Err(json::Error::InvalidSyntax) => Ok(default), + Err(error) => Err(error).with_context(|| format!("parse {}", path.display())), + } } -pub(crate) fn write_json_file(path: &Path, value: &Value) -> Result<()> { +fn write_json_file(path: &Path, value: &JsonValue) -> Result<()> { + let mut bytes = json::encode(value, Layout::Pretty).context("serialize JSON")?; + bytes.push(b'\n'); if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } - let mut text = serde_json::to_string_pretty(value).context("serialize JSON")?; - text.push('\n'); - fs::write(path, text).with_context(|| format!("write {}", path.display()))?; + fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?; Ok(()) } -fn update_launch_json_for_arduino() -> Result<()> { - let cwd = std::env::current_dir().context("current dir")?; - let launch_json_path = cwd.join(".vscode").join("launch.json"); - let mut data = read_json_file( - &launch_json_path, - json!({"version": "0.2.0", "configurations": []}), - ); - - if !data.is_object() { - data = json!({"version": "0.2.0", "configurations": []}); +fn editor_templates() -> Result> { + match json::parse(include_bytes!("install_editor_templates.json"))? { + JsonValue::Object(value) => Ok(value), + _ => bail!("invalid bundled editor templates"), } +} - let arduino_config = json!({ - "name": "Auto Debug (Smart File Detection)", - "type": "auto-debug", - "request": "launch", - "map": { - "*.ino": "Arduino: Run .ino with FastLED", - "*.py": "Python: Current File (UV)" - } - }); +fn update_launch_json_for_arduino() -> Result<()> { + let cwd = std::env::current_dir().context("current dir")?; + update_launch_json_at(&cwd.join(".vscode").join("launch.json")) +} - let configs = data - .as_object_mut() - .expect("launch.json root object") - .entry("configurations") - .or_insert_with(|| Value::Array(Vec::new())); - if !configs.is_array() { - *configs = Value::Array(Vec::new()); - } - let configs_array = configs.as_array_mut().expect("configurations array"); - let exists = configs_array.iter().any(|cfg| { - cfg.get("name").and_then(Value::as_str) - == arduino_config.get("name").and_then(Value::as_str) +fn update_launch_json_at(path: &Path) -> Result<()> { + let mut data = read_editor_object( + path, + BTreeMap::from([ + ("version".into(), JsonValue::String("0.2.0".into())), + ("configurations".into(), JsonValue::Array(Vec::new())), + ]), + )?; + let arduino_config = editor_templates()? + .remove("launch") + .context("missing bundled launch configuration")?; + let mut configurations = match data.remove("configurations") { + Some(JsonValue::Array(value)) => value, + _ => Vec::new(), + }; + let exists = configurations.iter().any(|value| match value { + JsonValue::Object(value) => matches!(value.get("name"), + Some(JsonValue::String(name)) if name == "Auto Debug (Smart File Detection)"), + _ => false, }); if !exists { - configs_array.insert(0, arduino_config); + configurations.insert(0, arduino_config); } - - write_json_file(&launch_json_path, &data)?; - println!("Updated {}", launch_json_path.display()); + data.insert("configurations".into(), JsonValue::Array(configurations)); + write_json_file(path, &JsonValue::Object(data))?; + println!("Updated {}", path.display()); Ok(()) } fn generate_fastled_tasks() -> Result<()> { let cwd = std::env::current_dir().context("current dir")?; - let tasks_json_path = cwd.join(".vscode").join("tasks.json"); - let mut data = read_json_file(&tasks_json_path, json!({"version": "2.0.0", "tasks": []})); - - if !data.is_object() { - data = json!({"version": "2.0.0", "tasks": []}); - } - - let fastled_tasks = vec![ - json!({ - "type": "shell", - "label": "Run FastLED (Debug)", - "command": "fastled", - "args": ["${file}", "--debug"], - "options": {"cwd": "${workspaceFolder}"}, - "group": {"kind": "build", "isDefault": true}, - "presentation": { - "echo": true, - "reveal": "always", - "focus": true, - "panel": "new", - "showReuseMessage": false, - "clear": true - }, - "detail": "Run FastLED with debug mode and Tauri visualization", - "problemMatcher": [] - }), - json!({ - "type": "shell", - "label": "Run FastLED (Quick)", - "command": "fastled", - "args": ["${file}", "--quick"], - "options": {"cwd": "${workspaceFolder}"}, - "group": "build", - "presentation": { - "echo": true, - "reveal": "always", - "focus": true, - "panel": "new", - "showReuseMessage": false, - "clear": true - }, - "detail": "Run FastLED with quick build mode", - "problemMatcher": [] - }), - ]; + generate_fastled_tasks_at(&cwd.join(".vscode").join("tasks.json")) +} - let tasks = data - .as_object_mut() - .expect("tasks.json root object") - .entry("tasks") - .or_insert_with(|| Value::Array(Vec::new())); - if !tasks.is_array() { - *tasks = Value::Array(Vec::new()); - } - let tasks_array = tasks.as_array_mut().expect("tasks array"); - let existing_labels: Vec = tasks_array +fn generate_fastled_tasks_at(path: &Path) -> Result<()> { + let mut data = read_editor_object( + path, + BTreeMap::from([ + ("version".into(), JsonValue::String("2.0.0".into())), + ("tasks".into(), JsonValue::Array(Vec::new())), + ]), + )?; + let Some(JsonValue::Array(fastled_tasks)) = editor_templates()?.remove("tasks") else { + bail!("missing bundled FastLED tasks"); + }; + let mut tasks = match data.remove("tasks") { + Some(JsonValue::Array(value)) => value, + _ => Vec::new(), + }; + let labels: Vec<_> = tasks .iter() - .filter_map(|task| task.get("label").and_then(Value::as_str).map(str::to_owned)) + .filter_map(|value| match value { + JsonValue::Object(value) => match value.get("label") { + Some(JsonValue::String(value)) => Some(value.clone()), + _ => None, + }, + _ => None, + }) .collect(); - for task in fastled_tasks { - let Some(label) = task.get("label").and_then(Value::as_str) else { - continue; + let JsonValue::Object(ref fields) = task else { + bail!("invalid bundled task"); + }; + let Some(JsonValue::String(label)) = fields.get("label") else { + bail!("missing bundled task label"); }; - if !existing_labels.iter().any(|existing| existing == label) { - tasks_array.push(task); + if !labels.contains(label) { + tasks.push(task); } } - - write_json_file(&tasks_json_path, &data)?; - println!("Updated {}", tasks_json_path.display()); + data.insert("tasks".into(), JsonValue::Array(tasks)); + write_json_file(path, &JsonValue::Object(data))?; + println!("Updated {}", path.display()); Ok(()) } -fn fastled_repository_settings() -> Value { - json!({ - "terminal.integrated.defaultProfile.windows": "Git Bash", - "terminal.integrated.shellIntegration.enabled": false, - "terminal.integrated.profiles.windows": { - "Command Prompt": {"path": "C:\\Windows\\System32\\cmd.exe"}, - "Git Bash": { - "path": "C:\\Program Files\\Git\\bin\\bash.exe", - "args": ["--cd=."] - } - }, - "files.eol": "\n", - "files.autoDetectEol": false, - "files.insertFinalNewline": true, - "files.trimFinalNewlines": true, - "editor.tabSize": 4, - "editor.insertSpaces": true, - "editor.detectIndentation": true, - "editor.formatOnSave": false, - "debug.defaultDebuggerType": "cppdbg", - "debug.toolBarLocation": "docked", - "debug.console.fontSize": 14, - "debug.console.lineHeight": 19, - "python.defaultInterpreterPath": "uv", - "python.debugger": "debugpy", - "[cpp]": { - "editor.defaultFormatter": "llvm-vs-code-extensions.vscode-clangd", - "debug.defaultDebuggerType": "cppdbg" - }, - "[c]": { - "editor.defaultFormatter": "ms-vscode.cpptools", - "debug.defaultDebuggerType": "cppdbg" - }, - "[ino]": { - "editor.defaultFormatter": "ms-vscode.cpptools", - "debug.defaultDebuggerType": "cppdbg" - }, - "clangd.arguments": [ - "--compile-commands-dir=${workspaceFolder}", - "--clang-tidy", - "--header-insertion=never", - "--completion-style=detailed", - "--function-arg-placeholders=false", - "--background-index", - "--pch-storage=memory" - ], - "clangd.fallbackFlags": [ - "-std=c++17", - "-I${workspaceFolder}/src", - "-I${workspaceFolder}/tests", - "-Wno-global-constructors" - ], - "C_Cpp.intelliSenseEngine": "disabled", - "C_Cpp.autocomplete": "disabled", - "C_Cpp.errorSquiggles": "disabled", - "C_Cpp.suggestSnippets": false, - "C_Cpp.intelliSenseEngineFallback": "disabled", - "C_Cpp.autocompleteAddParentheses": false, - "C_Cpp.formatting": "disabled", - "C_Cpp.vcpkg.enabled": false, - "C_Cpp.configurationWarnings": "disabled", - "C_Cpp.intelliSenseCachePath": "", - "C_Cpp.intelliSenseCacheSize": 0, - "C_Cpp.intelliSenseUpdateDelay": 0, - "C_Cpp.workspaceParsingPriority": "lowest", - "C_Cpp.disabled": true, - "files.associations": { - "*.ino": "cpp", - "*.h": "cpp", - "*.hpp": "cpp", - "*.cpp": "cpp", - "*.c": "c", - "*.inc": "cpp", - "*.tcc": "cpp", - "*.embeddedhtml": "html", - "compare": "cpp", - "type_traits": "cpp", - "cmath": "cpp", - "limits": "cpp", - "iostream": "cpp", - "random": "cpp", - "functional": "cpp", - "bit": "cpp", - "vector": "cpp", - "array": "cpp", - "string": "cpp", - "memory": "cpp", - "algorithm": "cpp", - "iterator": "cpp", - "utility": "cpp", - "optional": "cpp", - "variant": "cpp", - "numeric": "cpp", - "chrono": "cpp", - "thread": "cpp", - "mutex": "cpp", - "atomic": "cpp", - "future": "cpp", - "condition_variable": "cpp" - }, - "java.enabled": false, - "java.jdt.ls.enabled": false, - "java.compile.nullAnalysis.mode": "disabled", - "java.configuration.checkProjectSettingsExclusions": false, - "java.import.gradle.enabled": false, - "java.import.maven.enabled": false, - "java.autobuild.enabled": false, - "java.maxConcurrentBuilds": 0, - "java.recommendations.enabled": false, - "java.help.showReleaseNotes": false, - "redhat.telemetry.enabled": false, - "java.project.sourcePaths": [], - "java.project.referencedLibraries": [], - "files.exclude": { - "**/.classpath": true, - "**/.project": true, - "**/.factorypath": true - }, - "platformio.disableToolchainAutoInstaller": true, - "platformio-ide.autoRebuildAutocompleteIndex": false, - "platformio-ide.activateProjectOnTextEditorChange": false, - "platformio-ide.autoOpenPlatformIOIniFile": false, - "platformio-ide.autoPreloadEnvTasks": false, - "platformio-ide.autoCloseSerialMonitor": false, - "platformio-ide.disablePIOHomeStartup": true, - "extensions.ignoreRecommendations": true, - "editor.semanticTokenColorCustomizations": { - "rules": { - "class": "#4EC9B0", - "struct": "#4EC9B0", - "type": "#4EC9B0", - "enum": "#4EC9B0", - "enumMember": "#B5CEA8", - "typedef": "#4EC9B0", - "variable": "#FAFAFA", - "variable.local": "#FAFAFA", - "parameter": "#FF8C42", - "variable.parameter": "#FF8C42", - "property": "#D197D9", - "function": "#DCDCAA", - "method": "#DCDCAA", - "function.declaration": "#DCDCAA", - "method.declaration": "#DCDCAA", - "namespace": "#86C5F7", - "variable.readonly": {"foreground": "#B5CEA8", "fontStyle": "italic"}, - "variable.defaultLibrary": "#B5CEA8", - "macro": "#E06C75", - "string": "#CE9178", - "number": "#B5CEA8", - "keyword": "#C586C0", - "keyword.storage": "#FF79C6", - "storageClass": "#FF79C6", - "type.builtin": "#569CD6", - "keyword.type": "#569CD6", - "comment": "#6A9955", - "comment.documentation": "#6A9955" - } - }, - "editor.inlayHints.fontColor": "#808080", - "editor.inlayHints.background": "#3C3C3C20" - }) +fn fastled_repository_settings() -> Result { + json::parse(include_bytes!("install_repository_settings.json")) + .context("parse bundled FastLED repository settings") } fn update_vscode_settings_for_fastled() -> Result<()> { @@ -2171,19 +2028,7 @@ fn update_vscode_settings_for_fastled() -> Result<()> { let cwd = std::env::current_dir().context("current dir")?; let settings_json_path = cwd.join(".vscode").join("settings.json"); - let mut data = read_json_file(&settings_json_path, json!({})); - if !data.is_object() { - data = json!({}); - } - - let settings = fastled_repository_settings(); - let target = data.as_object_mut().expect("settings root object"); - let source = settings.as_object().expect("settings object"); - for (key, value) in source { - target.insert(key.clone(), value.clone()); - } - - write_json_file(&settings_json_path, &data)?; + update_repository_settings_at(&settings_json_path)?; println!( "Updated {} with comprehensive FastLED development settings", settings_json_path.display() @@ -2191,6 +2036,15 @@ fn update_vscode_settings_for_fastled() -> Result<()> { Ok(()) } +fn update_repository_settings_at(path: &Path) -> Result<()> { + let mut data = read_editor_object(path, BTreeMap::new())?; + let JsonValue::Object(settings) = fastled_repository_settings()? else { + bail!("invalid bundled repository settings"); + }; + data.extend(settings); + write_json_file(path, &JsonValue::Object(data)) +} + fn download_to_path(url: &str, dest: &Path) -> Result<()> { archive::download(url, dest) } @@ -2482,8 +2336,8 @@ mod tests { #[test] fn parses_nested_versions_manifest_without_parts() { - let manifest: PlatformManifest = - serde_json::from_str(DARWIN_ARM64_MANIFEST).expect("parse darwin/arm64 manifest"); + let manifest = + parse_platform_manifest(DARWIN_ARM64_MANIFEST).expect("parse darwin/arm64 manifest"); assert_eq!( manifest.latest, "releases-d70a5da89b3e673bf6a482724478fc17e81e575e" @@ -2712,12 +2566,13 @@ mod tests { #[test] fn parses_manifest_with_multipart_archive_and_extra_size_field() { - let manifest: PlatformManifest = serde_json::from_str(LINUX_X86_64_MANIFEST_WITH_PARTS) + let manifest = parse_platform_manifest(LINUX_X86_64_MANIFEST_WITH_PARTS) .expect("parse linux/x86_64 manifest"); let entry = manifest.versions.get("4.0.21").expect("entry for 4.0.21"); let parts = multipart_parts(entry).expect("parts present"); assert_eq!(parts.len(), 1); assert!(parts[0].href.ends_with(".part-aa")); + assert!(!parts[0].sha256.is_empty()); } #[cfg(unix)] @@ -2772,6 +2627,123 @@ mod tests { assert!(entry.href.ends_with(".tar.zst")); } + #[test] + fn installer_editor_json_preserves_custom_entries_and_is_idempotent() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let launch = temp.path().join("launch.json"); + let tasks = temp.path().join("tasks.json"); + let settings = temp.path().join("settings.json"); + fs::write( + &launch, + r#"{"custom":"λ","configurations":[null,{"name":"user"}]}"#, + ) + .unwrap(); + fs::write( + &tasks, + r#"{"custom":true,"tasks":[{"label":"Run FastLED (Debug)","command":"user"},7]}"#, + ) + .unwrap(); + fs::write(&settings, r#"{"custom":[1,2],"editor.tabSize":2}"#).unwrap(); + update_launch_json_at(&launch).unwrap(); + generate_fastled_tasks_at(&tasks).unwrap(); + update_repository_settings_at(&settings).unwrap(); + let parse = |path: &Path| match json::parse(&fs::read(path).unwrap()).unwrap() { + JsonValue::Object(value) => value, + _ => panic!("expected editor object"), + }; + let launch_value = parse(&launch); + assert_eq!(launch_value["custom"], JsonValue::String("λ".into())); + let JsonValue::Array(configs) = &launch_value["configurations"] else { + panic!("configs"); + }; + assert_eq!(configs.len(), 3); + assert_eq!( + configs[0], + editor_templates().unwrap().remove("launch").unwrap() + ); + assert_eq!(configs[1], JsonValue::Null); + let task_value = parse(&tasks); + let JsonValue::Array(task_entries) = &task_value["tasks"] else { + panic!("tasks"); + }; + assert_eq!(task_entries.len(), 3); + assert_eq!( + task_entries[0], + json::parse(br#"{"label":"Run FastLED (Debug)","command":"user"}"#).unwrap() + ); + assert_eq!(task_entries[1], JsonValue::Signed(7)); + let JsonValue::Array(expected_tasks) = editor_templates().unwrap().remove("tasks").unwrap() + else { + panic!("template tasks"); + }; + assert_eq!(task_entries[2], expected_tasks[1]); + let settings_value = parse(&settings); + let JsonValue::Object(mut expected_settings) = fastled_repository_settings().unwrap() + else { + panic!("settings"); + }; + expected_settings.insert("custom".into(), json::parse(b"[1,2]").unwrap()); + assert_eq!(settings_value, expected_settings); + let before = [&launch, &tasks, &settings].map(|path| fs::read(path).unwrap()); + update_launch_json_at(&launch).unwrap(); + generate_fastled_tasks_at(&tasks).unwrap(); + update_repository_settings_at(&settings).unwrap(); + assert_eq!( + before, + [&launch, &tasks, &settings].map(|path| fs::read(path).unwrap()) + ); + assert!(before.iter().all(|bytes| bytes.last() == Some(&b'\n'))); + } + + #[test] + fn installer_editor_json_repairs_invalid_shapes_but_preserves_oversized_files() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let path = temp.path().join("editor.json"); + for invalid in ["{", "[]", "null"] { + fs::write(&path, invalid).unwrap(); + update_launch_json_at(&path).unwrap(); + let JsonValue::Object(value) = json::parse(&fs::read(&path).unwrap()).unwrap() else { + panic!("object"); + }; + assert_eq!(value["version"], JsonValue::String("0.2.0".into())); + } + fs::write(&path, r#"{"custom":1,"tasks":false}"#).unwrap(); + generate_fastled_tasks_at(&path).unwrap(); + let JsonValue::Object(value) = json::parse(&fs::read(&path).unwrap()).unwrap() else { + panic!("object"); + }; + assert_eq!(value["custom"], JsonValue::Signed(1)); + assert!(matches!(&value["tasks"], JsonValue::Array(values) if values.len() == 2)); + let oversized = format!(r#"{{"custom":"{}"}}"#, "x".repeat(json::MAX_INPUT_BYTES)); + fs::write(&path, &oversized).unwrap(); + assert!(update_launch_json_at(&path).is_err()); + assert!(generate_fastled_tasks_at(&path).is_err()); + assert!(update_repository_settings_at(&path).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), oversized); + let unpublished = temp.path().join("absent/settings.json"); + assert!(write_json_file( + &unpublished, + &JsonValue::String("x".repeat(json::MAX_OUTPUT_BYTES)) + ) + .is_err()); + assert!(!unpublished.parent().unwrap().exists()); + } + + #[test] + fn manifest_positional_version_preserves_optional_trailing_parts() { + for source in [ + r#"{"latest":"v","versions":{"v":["url","hash"]}}"#, + r#"{"latest":"v","v":["url","hash"]}"#, + r#"["v",{"v":["url","hash",null]}]"#, + ] { + let parsed = parse_platform_manifest(source).unwrap(); + let version = &parsed.versions["v"]; + assert_eq!(version.href, "url"); + assert_eq!(version.sha256, "hash"); + assert!(version.parts.is_none()); + } + } + #[test] fn legacy_manifest_with_no_versions_errors() { let text = r#"{ "latest": "4.0.19" }"#; diff --git a/crates/fastled-cli/src/install_editor_templates.json b/crates/fastled-cli/src/install_editor_templates.json new file mode 100644 index 00000000..a2b9343e --- /dev/null +++ b/crates/fastled-cli/src/install_editor_templates.json @@ -0,0 +1,62 @@ +{ + "launch": { + "name": "Auto Debug (Smart File Detection)", + "type": "auto-debug", + "request": "launch", + "map": { + "*.ino": "Arduino: Run .ino with FastLED", + "*.py": "Python: Current File (UV)" + } + }, + "tasks": [ + { + "type": "shell", + "label": "Run FastLED (Debug)", + "command": "fastled", + "args": [ + "${file}", + "--debug" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "new", + "showReuseMessage": false, + "clear": true + }, + "detail": "Run FastLED with debug mode and Tauri visualization", + "problemMatcher": [] + }, + { + "type": "shell", + "label": "Run FastLED (Quick)", + "command": "fastled", + "args": [ + "${file}", + "--quick" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "new", + "showReuseMessage": false, + "clear": true + }, + "detail": "Run FastLED with quick build mode", + "problemMatcher": [] + } + ] +} diff --git a/crates/fastled-cli/src/install_repository_settings.json b/crates/fastled-cli/src/install_repository_settings.json new file mode 100644 index 00000000..e06dc539 --- /dev/null +++ b/crates/fastled-cli/src/install_repository_settings.json @@ -0,0 +1,160 @@ +{ + "terminal.integrated.defaultProfile.windows": "Git Bash", + "terminal.integrated.shellIntegration.enabled": false, + "terminal.integrated.profiles.windows": { + "Command Prompt": {"path": "C:\\Windows\\System32\\cmd.exe"}, + "Git Bash": { + "path": "C:\\Program Files\\Git\\bin\\bash.exe", + "args": ["--cd=."] + } + }, + "files.eol": "\n", + "files.autoDetectEol": false, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.detectIndentation": true, + "editor.formatOnSave": false, + "debug.defaultDebuggerType": "cppdbg", + "debug.toolBarLocation": "docked", + "debug.console.fontSize": 14, + "debug.console.lineHeight": 19, + "python.defaultInterpreterPath": "uv", + "python.debugger": "debugpy", + "[cpp]": { + "editor.defaultFormatter": "llvm-vs-code-extensions.vscode-clangd", + "debug.defaultDebuggerType": "cppdbg" + }, + "[c]": { + "editor.defaultFormatter": "ms-vscode.cpptools", + "debug.defaultDebuggerType": "cppdbg" + }, + "[ino]": { + "editor.defaultFormatter": "ms-vscode.cpptools", + "debug.defaultDebuggerType": "cppdbg" + }, + "clangd.arguments": [ + "--compile-commands-dir=${workspaceFolder}", + "--clang-tidy", + "--header-insertion=never", + "--completion-style=detailed", + "--function-arg-placeholders=false", + "--background-index", + "--pch-storage=memory" + ], + "clangd.fallbackFlags": [ + "-std=c++17", + "-I${workspaceFolder}/src", + "-I${workspaceFolder}/tests", + "-Wno-global-constructors" + ], + "C_Cpp.intelliSenseEngine": "disabled", + "C_Cpp.autocomplete": "disabled", + "C_Cpp.errorSquiggles": "disabled", + "C_Cpp.suggestSnippets": false, + "C_Cpp.intelliSenseEngineFallback": "disabled", + "C_Cpp.autocompleteAddParentheses": false, + "C_Cpp.formatting": "disabled", + "C_Cpp.vcpkg.enabled": false, + "C_Cpp.configurationWarnings": "disabled", + "C_Cpp.intelliSenseCachePath": "", + "C_Cpp.intelliSenseCacheSize": 0, + "C_Cpp.intelliSenseUpdateDelay": 0, + "C_Cpp.workspaceParsingPriority": "lowest", + "C_Cpp.disabled": true, + "files.associations": { + "*.ino": "cpp", + "*.h": "cpp", + "*.hpp": "cpp", + "*.cpp": "cpp", + "*.c": "c", + "*.inc": "cpp", + "*.tcc": "cpp", + "*.embeddedhtml": "html", + "compare": "cpp", + "type_traits": "cpp", + "cmath": "cpp", + "limits": "cpp", + "iostream": "cpp", + "random": "cpp", + "functional": "cpp", + "bit": "cpp", + "vector": "cpp", + "array": "cpp", + "string": "cpp", + "memory": "cpp", + "algorithm": "cpp", + "iterator": "cpp", + "utility": "cpp", + "optional": "cpp", + "variant": "cpp", + "numeric": "cpp", + "chrono": "cpp", + "thread": "cpp", + "mutex": "cpp", + "atomic": "cpp", + "future": "cpp", + "condition_variable": "cpp" + }, + "java.enabled": false, + "java.jdt.ls.enabled": false, + "java.compile.nullAnalysis.mode": "disabled", + "java.configuration.checkProjectSettingsExclusions": false, + "java.import.gradle.enabled": false, + "java.import.maven.enabled": false, + "java.autobuild.enabled": false, + "java.maxConcurrentBuilds": 0, + "java.recommendations.enabled": false, + "java.help.showReleaseNotes": false, + "redhat.telemetry.enabled": false, + "java.project.sourcePaths": [], + "java.project.referencedLibraries": [], + "files.exclude": { + "**/.classpath": true, + "**/.project": true, + "**/.factorypath": true + }, + "platformio.disableToolchainAutoInstaller": true, + "platformio-ide.autoRebuildAutocompleteIndex": false, + "platformio-ide.activateProjectOnTextEditorChange": false, + "platformio-ide.autoOpenPlatformIOIniFile": false, + "platformio-ide.autoPreloadEnvTasks": false, + "platformio-ide.autoCloseSerialMonitor": false, + "platformio-ide.disablePIOHomeStartup": true, + "extensions.ignoreRecommendations": true, + "editor.semanticTokenColorCustomizations": { + "rules": { + "class": "#4EC9B0", + "struct": "#4EC9B0", + "type": "#4EC9B0", + "enum": "#4EC9B0", + "enumMember": "#B5CEA8", + "typedef": "#4EC9B0", + "variable": "#FAFAFA", + "variable.local": "#FAFAFA", + "parameter": "#FF8C42", + "variable.parameter": "#FF8C42", + "property": "#D197D9", + "function": "#DCDCAA", + "method": "#DCDCAA", + "function.declaration": "#DCDCAA", + "method.declaration": "#DCDCAA", + "namespace": "#86C5F7", + "variable.readonly": {"foreground": "#B5CEA8", "fontStyle": "italic"}, + "variable.defaultLibrary": "#B5CEA8", + "macro": "#E06C75", + "string": "#CE9178", + "number": "#B5CEA8", + "keyword": "#C586C0", + "keyword.storage": "#FF79C6", + "storageClass": "#FF79C6", + "type.builtin": "#569CD6", + "keyword.type": "#569CD6", + "comment": "#6A9955", + "comment.documentation": "#6A9955" + } + }, + "editor.inlayHints.fontColor": "#808080", + "editor.inlayHints.background": "#3C3C3C20" + } diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 7ac4e7e5..ee3acf37 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -117,6 +117,22 @@ def test_installer_receipt_json_uses_kernel(): assert "json::encode" in records +def test_installer_production_json_uses_kernel(): + root = Path(__file__).resolve().parents[2] + source = (root / "crates/fastled-cli/src/install.rs").read_text() + assert "serde_json::" not in source + assert "serde::" not in source + assert "json!(" not in source + for path in (root / "Cargo.toml", root / "crates/fastled-cli/Cargo.toml"): + data = tomllib.loads(path.read_text()) + for dependencies in ( + data.get("dependencies", {}), + data.get("workspace", {}).get("dependencies", {}), + ): + assert "serde" not in dependencies + assert "serde_json" not in dependencies + + def test_dwarf_smoke_json_uses_kernel(): root = Path(__file__).resolve().parents[2] source = (root / "crates/fastled-cli/src/dwarf_smoke.rs").read_text() From 888232b1454a828bab147d0a9046217069a89769 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 09:10:16 -0700 Subject: [PATCH 65/94] chore(kernel): enable command schema migration feature Refs zackees/kernal-api#216. Point the migration override at the command-schema facade. --- Cargo.lock | 1 + Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3b123d9..0f54bdaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,6 +2023,7 @@ version = "0.1.0" dependencies = [ "blake3", "bytes", + "clap", "dirs 6.0.0", "flate2", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index 976ef9b0..b468bf43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] clap = { version = "4", features = ["derive"] } -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "config-toml", "source-cpp", "json"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json"] } anyhow = "1" [profile.release] @@ -24,7 +24,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-json" } +kernal-api = { path = "../fastled-wasm-extern/kernal-api-cli" } [profile.dev.package.kernal-api] codegen-units = 1 From fea3d6ad179e5767f62ec6ccd963ba1a8c253623 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 09:16:36 -0700 Subject: [PATCH 66/94] refactor(cli): route management commands through kernal-api Refs zackees/kernal-api#216. Parse toolchain and source command trees with the bounded kernel schema. --- crates/fastled-cli/src/cli.rs | 125 +++++++++++++++++++++++++++++++++- crates/fastled-cli/src/lib.rs | 48 ++++++++----- 2 files changed, 154 insertions(+), 19 deletions(-) diff --git a/crates/fastled-cli/src/cli.rs b/crates/fastled-cli/src/cli.rs index 616787ed..eb549cab 100644 --- a/crates/fastled-cli/src/cli.rs +++ b/crates/fastled-cli/src/cli.rs @@ -1,4 +1,7 @@ -use std::path::PathBuf; +use std::{ + ffi::{OsStr, OsString}, + path::PathBuf, +}; use clap::{Parser, Subcommand, ValueEnum}; @@ -67,6 +70,102 @@ pub(crate) enum SourceAction { }, } +/// Parse the standalone management trees through kernal-api. The primary +/// build grammar remains on the staged migration path below. +pub(crate) fn management_command_from(arguments: I) -> Result, String> +where + I: IntoIterator, + S: AsRef, +{ + use kernal_api::command::{Command as SchemaCommand, OptionSpec, ValueKind}; + + let arguments = arguments + .into_iter() + .map(|argument| argument.as_ref().to_os_string()) + .collect::>(); + let Some(tree) = arguments.get(1).and_then(|argument| argument.to_str()) else { + return Ok(None); + }; + if !matches!(tree, "toolchain" | "source") { + return Ok(None); + } + if arguments + .iter() + .skip(1) + .any(|argument| matches!(argument.to_str(), Some("--help" | "-h"))) + { + return Ok(None); + } + let schema = SchemaCommand::new("fastled") + .subcommand( + SchemaCommand::new("toolchain") + .subcommand(SchemaCommand::new("status")) + .subcommand( + SchemaCommand::new("install") + .option(OptionSpec::value("package-id", ValueKind::string())), + ) + .subcommand( + SchemaCommand::new("activate") + .positional("activate-package-id", ValueKind::string()), + ) + .subcommand(SchemaCommand::new("update")) + .subcommand( + SchemaCommand::new("repair") + .optional_positional("repair-package-id", ValueKind::string()), + ) + .subcommand(SchemaCommand::new("rollback")) + .subcommand(SchemaCommand::new("prune")), + ) + .subcommand( + SchemaCommand::new("source") + .option(OptionSpec::value("ref", ValueKind::string()).default("master")) + .subcommand(SchemaCommand::new("status")) + .subcommand(SchemaCommand::new("update")) + .subcommand(SchemaCommand::new("purge")), + ); + let parsed = schema.parse(arguments).map_err(|error| error.to_string())?; + let path = parsed.command_path(); + let command = match path { + [_, tree, action] if tree == "toolchain" => Command::Toolchain { + action: match action.as_str() { + "status" => ToolchainAction::Status, + "install" => ToolchainAction::Install { + package_id: parsed.value("package-id").map(str::to_owned), + }, + "activate" => ToolchainAction::Activate { + package_id: parsed + .value("activate-package-id") + .unwrap_or_default() + .to_owned(), + }, + "update" => ToolchainAction::Update, + "repair" => ToolchainAction::Repair { + package_id: parsed.value("repair-package-id").map(str::to_owned), + }, + "rollback" => ToolchainAction::Rollback, + "prune" => ToolchainAction::Prune, + _ => return Err("invalid toolchain command".to_owned()), + }, + }, + [_, tree, action] if tree == "source" => Command::Source { + action: match action.as_str() { + "status" => SourceAction::Status { + reference: parsed.value("ref").unwrap_or_default().to_owned(), + }, + "update" => SourceAction::Update { + reference: parsed.value("ref").unwrap_or_default().to_owned(), + }, + "purge" => SourceAction::Purge { + reference: parsed.value("ref").unwrap_or_default().to_owned(), + }, + _ => return Err("invalid source command".to_owned()), + }, + }, + _ => return Err("invalid management command".to_owned()), + }; + Ok(Some(command)) +} + /// How the sketch code is linked into the generated WASM program. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] pub(crate) enum LinkMode { @@ -347,6 +446,30 @@ mod tests { use super::*; use clap::CommandFactory; + #[test] + fn management_commands_use_the_kernel_schema() { + assert!(matches!( + management_command_from(["fastled", "toolchain", "install", "--package-id", "package-a"]), + Ok(Some(Command::Toolchain { + action: ToolchainAction::Install { package_id: Some(package_id) } + })) if package_id == "package-a" + )); + assert!(matches!( + management_command_from(["fastled", "source", "update", "--ref", "main"]), + Ok(Some(Command::Source { + action: SourceAction::Update { reference } + })) if reference == "main" + )); + assert!(matches!( + management_command_from(["fastled", "source", "--help"]), + Ok(None) + )); + assert!(matches!( + management_command_from(["fastled", "source", "update", "--unknown"]), + Err(message) if message == "invalid command-line arguments" + )); + } + #[test] fn clangd_emission_is_opt_in() { let cli = Cli::parse_from(["fastled", "sketch"]); diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index 62f2ad81..7c7fc1c2 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -49,6 +49,15 @@ pub fn run() -> ExitCode { return ExitCode::FAILURE; } + match cli::management_command_from(std::env::args_os()) { + Ok(Some(command)) => return run_management_command(command), + Ok(None) => {} + Err(error) => { + eprintln!("fastled: {error}"); + return ExitCode::FAILURE; + } + } + let mut cli = cli::Cli::parse(); cli::apply_test_implications(&mut cli); @@ -57,24 +66,8 @@ pub fn run() -> ExitCode { return ExitCode::FAILURE; } - if let Some(cli::Command::Toolchain { action }) = cli.command.clone() { - return match install::run_toolchain_action(action) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("fastled: toolchain command failed: {error:#}"); - ExitCode::FAILURE - } - }; - } - - if let Some(cli::Command::Source { action }) = cli.command.clone() { - return match source::run_source_action(action) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("fastled: source command failed: {error:#}"); - ExitCode::FAILURE - } - }; + if let Some(command) = cli.command.clone() { + return run_management_command(command); } // Hidden plumbing for the Python side: download the FastLED repo and @@ -195,6 +188,25 @@ pub fn run() -> ExitCode { ExitCode::FAILURE } +fn run_management_command(command: cli::Command) -> ExitCode { + match command { + cli::Command::Toolchain { action } => match install::run_toolchain_action(action) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("fastled: toolchain command failed: {error:#}"); + ExitCode::FAILURE + } + }, + cli::Command::Source { action } => match source::run_source_action(action) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("fastled: source command failed: {error:#}"); + ExitCode::FAILURE + } + }, + } +} + /// Map serve-dir CLI flags to `(directory, launch_viewer)`. /// /// `--internal-serve-dir-headless` is the no-UI serve path: it never launches From 13afa60173b7fe9b034ace15f2b2eba1be95ab18 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 09:24:53 -0700 Subject: [PATCH 67/94] refactor(cli): model primary grammar with kernal-api Refs zackees/kernal-api#215. Capture the primary build and test flags in the bounded command schema. --- crates/fastled-cli/src/cli.rs | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/fastled-cli/src/cli.rs b/crates/fastled-cli/src/cli.rs index eb549cab..0ae604ad 100644 --- a/crates/fastled-cli/src/cli.rs +++ b/crates/fastled-cli/src/cli.rs @@ -166,6 +166,52 @@ where Ok(Some(command)) } +pub(crate) fn primary_schema() -> kernal_api::command::Command { + use kernal_api::command::{Command as SchemaCommand, OptionSpec, ValueKind}; + + SchemaCommand::new("fastled") + .about("FastLED WASM compilation CLI") + .version(env!("CARGO_PKG_VERSION")) + .optional_positional("directory", ValueKind::string()) + .option(OptionSpec::value("serve-dir", ValueKind::string())) + .option(OptionSpec::value("init", ValueKind::string()).optional_value("__init__")) + .option(OptionSpec::flag("just-compile")) + .option(OptionSpec::flag("no-app")) + .option(OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])).default("static")) + .option(OptionSpec::flag("profile")) + .option(OptionSpec::flag("install")) + .option(OptionSpec::flag("dry-run")) + .option(OptionSpec::flag("no-interactive")) + .option(OptionSpec::flag("no-https")) + .option(OptionSpec::flag("test").conflicts("just-compile").conflicts("no-app")) + .option(OptionSpec::flag("check").conflicts("just-compile").conflicts("no-app")) + .exclusive_group("production-test", ["test", "check"]) + .option(OptionSpec::value("test-wait-secs", ValueKind::f64()).default("1").requires_any(["test", "check"])) + .option(OptionSpec::value("test-screenshot", ValueKind::string()).requires_any(["test", "check"])) + .option(OptionSpec::value("test-interval-secs", ValueKind::f64()).requires_any(["test", "check"])) + .option(OptionSpec::value("test-count", ValueKind::u32()).requires_any(["test", "check"]).conflicts("test-duration-secs")) + .option(OptionSpec::value("test-duration-secs", ValueKind::f64()).requires_any(["test", "check"]).conflicts("test-count")) + .option(OptionSpec::value("test-log", ValueKind::string()).requires_any(["test", "check"])) + .option(OptionSpec::flag("test-exit-on-error").requires_any(["test", "check"])) + .option(OptionSpec::value("test-timeout-secs", ValueKind::f64()).default("120").requires_any(["test", "check"])) + .option(OptionSpec::value("test-ready-timeout-secs", ValueKind::f64()).default("15").requires_any(["test", "check"])) + .option(OptionSpec::value("test-cmd", ValueKind::string()).repeated().requires_any(["test", "check"])) + .option(OptionSpec::flag("latest")) + .option(OptionSpec::value("branch", ValueKind::string())) + .option(OptionSpec::value("commit", ValueKind::string())) + .option(OptionSpec::value("fastled-path", ValueKind::string())) + .option(OptionSpec::flag("purge")) + .option(OptionSpec::flag("clangd")) + .option(OptionSpec::value("write-clangd", ValueKind::string()).optional_value("__cwd__")) + .option(OptionSpec::flag("write-intellisense-snapshot").hidden()) + .option(OptionSpec::value("internal-ensure-fastled-repo", ValueKind::string()).optional_value("__latest__").hidden()) + .option(OptionSpec::flag("internal-dwarf-smoke").hidden()) + .option(OptionSpec::value("internal-serve-dir-headless", ValueKind::string()).hidden()) + .option(OptionSpec::flag("debug").conflicts("quick").conflicts("release")) + .option(OptionSpec::flag("quick").conflicts("debug").conflicts("release")) + .option(OptionSpec::flag("release").conflicts("debug").conflicts("quick")) +} + /// How the sketch code is linked into the generated WASM program. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] pub(crate) enum LinkMode { @@ -470,6 +516,29 @@ mod tests { )); } + #[test] + fn primary_schema_captures_build_test_and_hidden_flags() { + let parsed = primary_schema() + .parse([ + "fastled", + "sketch", + "--link=dynamic", + "--test", + "--test-count=2", + "--test-cmd=echo one", + "--test-cmd", + "echo two", + "--write-intellisense-snapshot", + ]) + .unwrap(); + assert_eq!(parsed.value("directory"), Some("sketch")); + assert_eq!(parsed.value("link"), Some("dynamic")); + assert_eq!(parsed.u32("test-count"), Some(2)); + assert_eq!(parsed.values("test-cmd").unwrap().len(), 2); + assert!(parsed.flag("write-intellisense-snapshot").unwrap()); + assert!(!primary_schema().render_help().contains("write-intellisense-snapshot")); + } + #[test] fn clangd_emission_is_opt_in() { let cli = Cli::parse_from(["fastled", "sketch"]); From d244805d5405b57ee50738be926501f7a656498a Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 09:39:23 -0700 Subject: [PATCH 68/94] refactor(cli): map primary arguments through kernal-api Refs zackees/kernal-api#215. Preserve Cli business mapping while moving primary parser mechanism into the bounded facade. --- crates/fastled-cli/src/cli.rs | 213 ++++++++++++++++++++++++++++++---- crates/fastled-cli/src/lib.rs | 38 ++++-- 2 files changed, 218 insertions(+), 33 deletions(-) diff --git a/crates/fastled-cli/src/cli.rs b/crates/fastled-cli/src/cli.rs index 0ae604ad..fe02ba94 100644 --- a/crates/fastled-cli/src/cli.rs +++ b/crates/fastled-cli/src/cli.rs @@ -83,12 +83,6 @@ where .into_iter() .map(|argument| argument.as_ref().to_os_string()) .collect::>(); - let Some(tree) = arguments.get(1).and_then(|argument| argument.to_str()) else { - return Ok(None); - }; - if !matches!(tree, "toolchain" | "source") { - return Ok(None); - } if arguments .iter() .skip(1) @@ -96,7 +90,10 @@ where { return Ok(None); } - let schema = SchemaCommand::new("fastled") + // Keep the root build options on this tree so options preceding a + // management subcommand retain the same acceptance as the former Clap + // grammar (for example, `--no-interactive source status`). + let schema = primary_schema() .subcommand( SchemaCommand::new("toolchain") .subcommand(SchemaCommand::new("status")) @@ -124,6 +121,11 @@ where .subcommand(SchemaCommand::new("purge")), ); let parsed = schema.parse(arguments).map_err(|error| error.to_string())?; + validate_ref_options( + parsed.flag("latest").unwrap_or(false), + parsed.value("branch").is_some(), + parsed.value("commit").is_some(), + )?; let path = parsed.command_path(); let command = match path { [_, tree, action] if tree == "toolchain" => Command::Toolchain { @@ -161,7 +163,10 @@ where _ => return Err("invalid source command".to_owned()), }, }, - _ => return Err("invalid management command".to_owned()), + [_, tree] if matches!(tree.as_str(), "toolchain" | "source") => { + return Err("invalid management command".to_owned()); + } + _ => return Ok(None), }; Ok(Some(command)) } @@ -177,25 +182,66 @@ pub(crate) fn primary_schema() -> kernal_api::command::Command { .option(OptionSpec::value("init", ValueKind::string()).optional_value("__init__")) .option(OptionSpec::flag("just-compile")) .option(OptionSpec::flag("no-app")) - .option(OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])).default("static")) + .option( + OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])) + .default("static"), + ) .option(OptionSpec::flag("profile")) .option(OptionSpec::flag("install")) .option(OptionSpec::flag("dry-run")) .option(OptionSpec::flag("no-interactive")) .option(OptionSpec::flag("no-https")) - .option(OptionSpec::flag("test").conflicts("just-compile").conflicts("no-app")) - .option(OptionSpec::flag("check").conflicts("just-compile").conflicts("no-app")) + .option( + OptionSpec::flag("test") + .conflicts("just-compile") + .conflicts("no-app"), + ) + .option( + OptionSpec::flag("check") + .conflicts("just-compile") + .conflicts("no-app"), + ) .exclusive_group("production-test", ["test", "check"]) - .option(OptionSpec::value("test-wait-secs", ValueKind::f64()).default("1").requires_any(["test", "check"])) - .option(OptionSpec::value("test-screenshot", ValueKind::string()).requires_any(["test", "check"])) - .option(OptionSpec::value("test-interval-secs", ValueKind::f64()).requires_any(["test", "check"])) - .option(OptionSpec::value("test-count", ValueKind::u32()).requires_any(["test", "check"]).conflicts("test-duration-secs")) - .option(OptionSpec::value("test-duration-secs", ValueKind::f64()).requires_any(["test", "check"]).conflicts("test-count")) + .option( + OptionSpec::value("test-wait-secs", ValueKind::f64()) + .default("1") + .requires_any(["test", "check"]), + ) + .option( + OptionSpec::value("test-screenshot", ValueKind::string()) + .requires_any(["test", "check"]), + ) + .option( + OptionSpec::value("test-interval-secs", ValueKind::f64()) + .requires_any(["test", "check"]), + ) + .option( + OptionSpec::value("test-count", ValueKind::u32()) + .requires_any(["test", "check"]) + .conflicts("test-duration-secs"), + ) + .option( + OptionSpec::value("test-duration-secs", ValueKind::f64()) + .requires_any(["test", "check"]) + .conflicts("test-count"), + ) .option(OptionSpec::value("test-log", ValueKind::string()).requires_any(["test", "check"])) .option(OptionSpec::flag("test-exit-on-error").requires_any(["test", "check"])) - .option(OptionSpec::value("test-timeout-secs", ValueKind::f64()).default("120").requires_any(["test", "check"])) - .option(OptionSpec::value("test-ready-timeout-secs", ValueKind::f64()).default("15").requires_any(["test", "check"])) - .option(OptionSpec::value("test-cmd", ValueKind::string()).repeated().requires_any(["test", "check"])) + .option( + OptionSpec::value("test-timeout-secs", ValueKind::f64()) + .default("120") + .requires_any(["test", "check"]), + ) + .option( + OptionSpec::value("test-ready-timeout-secs", ValueKind::f64()) + .default("15") + .requires_any(["test", "check"]), + ) + .option( + OptionSpec::value("test-cmd", ValueKind::string()) + .repeated() + .requires_any(["test", "check"]), + ) .option(OptionSpec::flag("latest")) .option(OptionSpec::value("branch", ValueKind::string())) .option(OptionSpec::value("commit", ValueKind::string())) @@ -204,12 +250,85 @@ pub(crate) fn primary_schema() -> kernal_api::command::Command { .option(OptionSpec::flag("clangd")) .option(OptionSpec::value("write-clangd", ValueKind::string()).optional_value("__cwd__")) .option(OptionSpec::flag("write-intellisense-snapshot").hidden()) - .option(OptionSpec::value("internal-ensure-fastled-repo", ValueKind::string()).optional_value("__latest__").hidden()) + .option( + OptionSpec::value("internal-ensure-fastled-repo", ValueKind::string()) + .optional_value("__latest__") + .hidden(), + ) .option(OptionSpec::flag("internal-dwarf-smoke").hidden()) .option(OptionSpec::value("internal-serve-dir-headless", ValueKind::string()).hidden()) - .option(OptionSpec::flag("debug").conflicts("quick").conflicts("release")) - .option(OptionSpec::flag("quick").conflicts("debug").conflicts("release")) - .option(OptionSpec::flag("release").conflicts("debug").conflicts("quick")) + .option( + OptionSpec::flag("debug") + .conflicts("quick") + .conflicts("release"), + ) + .option( + OptionSpec::flag("quick") + .conflicts("debug") + .conflicts("release"), + ) + .option( + OptionSpec::flag("release") + .conflicts("debug") + .conflicts("quick"), + ) +} + +pub(crate) fn primary_cli_from(arguments: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let parsed = primary_schema() + .parse(arguments) + .map_err(|error| error.to_string())?; + let flag = |name| parsed.flag(name).unwrap_or(false); + let string = |name| parsed.value(name).map(str::to_owned); + let link_mode = match parsed.value("link") { + Some("dynamic") => LinkMode::Dynamic, + Some("static") | None => LinkMode::Static, + Some(_) => return Err("invalid link mode".to_owned()), + }; + Ok(Cli { + command: None, + directory: string("directory"), + serve_dir: string("serve-dir"), + init: string("init"), + just_compile: flag("just-compile"), + no_app: flag("no-app"), + link_mode, + profile: flag("profile"), + install: flag("install"), + dry_run: flag("dry-run"), + no_interactive: flag("no-interactive"), + no_https: flag("no-https"), + test: flag("test"), + check: flag("check"), + test_wait_secs: parsed.f64("test-wait-secs").unwrap_or(1.0), + test_screenshot: string("test-screenshot").map(PathBuf::from), + test_interval_secs: parsed.f64("test-interval-secs"), + test_count: parsed.u32("test-count"), + test_duration_secs: parsed.f64("test-duration-secs"), + test_log: string("test-log").map(PathBuf::from), + test_exit_on_error: flag("test-exit-on-error"), + test_timeout_secs: parsed.f64("test-timeout-secs").unwrap_or(120.0), + test_ready_timeout_secs: parsed.f64("test-ready-timeout-secs").unwrap_or(15.0), + test_cmd: parsed.values("test-cmd").unwrap_or_default().to_vec(), + latest: flag("latest"), + branch: string("branch"), + commit: string("commit"), + fastled_path: string("fastled-path"), + purge: flag("purge"), + clangd: flag("clangd"), + write_clangd: string("write-clangd"), + write_intellisense_snapshot: flag("write-intellisense-snapshot"), + internal_ensure_fastled_repo: string("internal-ensure-fastled-repo"), + internal_dwarf_smoke: flag("internal-dwarf-smoke"), + internal_serve_dir_headless: string("internal-serve-dir-headless"), + debug: flag("debug"), + quick: flag("quick"), + release: flag("release"), + }) } /// How the sketch code is linked into the generated WASM program. @@ -453,7 +572,15 @@ pub(crate) struct Cli { } pub(crate) fn validate_init_ref_flags(cli: &Cli) -> Result<(), &'static str> { - if cli.latest && (cli.branch.is_some() || cli.commit.is_some()) { + validate_ref_options(cli.latest, cli.branch.is_some(), cli.commit.is_some()) +} + +fn validate_ref_options( + latest: bool, + has_branch: bool, + has_commit: bool, +) -> Result<(), &'static str> { + if latest && (has_branch || has_commit) { return Err("--latest cannot be used with --branch or --commit"); } Ok(()) @@ -506,10 +633,24 @@ mod tests { action: SourceAction::Update { reference } })) if reference == "main" )); + assert!(matches!( + management_command_from(["fastled", "--no-interactive", "source", "status"]), + Ok(Some(Command::Source { + action: SourceAction::Status { reference } + })) if reference == "master" + )); assert!(matches!( management_command_from(["fastled", "source", "--help"]), Ok(None) )); + assert!(matches!( + management_command_from(["fastled", "source"]), + Err(message) if message == "invalid management command" + )); + assert!(matches!( + management_command_from(["fastled", "--latest", "--branch", "main", "source", "status"]), + Err(message) if message == "--latest cannot be used with --branch or --commit" + )); assert!(matches!( management_command_from(["fastled", "source", "update", "--unknown"]), Err(message) if message == "invalid command-line arguments" @@ -536,7 +677,29 @@ mod tests { assert_eq!(parsed.u32("test-count"), Some(2)); assert_eq!(parsed.values("test-cmd").unwrap().len(), 2); assert!(parsed.flag("write-intellisense-snapshot").unwrap()); - assert!(!primary_schema().render_help().contains("write-intellisense-snapshot")); + assert!(!primary_schema() + .render_help() + .contains("write-intellisense-snapshot")); + } + + #[test] + fn primary_cli_mapping_preserves_typed_defaults_and_paths() { + let cli = primary_cli_from([ + "fastled", + "sketch", + "--test", + "--test-count=2", + "--test-screenshot=out.png", + "--link=dynamic", + ]) + .unwrap(); + assert_eq!(cli.directory.as_deref(), Some("sketch")); + assert_eq!(cli.test_count, Some(2)); + assert_eq!(cli.test_screenshot, Some(PathBuf::from("out.png"))); + assert_eq!(cli.test_timeout_secs, 120.0); + assert_eq!(cli.link_mode, LinkMode::Dynamic); + let defaults = primary_cli_from(["fastled", "sketch"]); + assert!(defaults.is_ok(), "default controls must not require --test"); } #[test] diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index 7c7fc1c2..1eb9f42d 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -49,16 +49,38 @@ pub fn run() -> ExitCode { return ExitCode::FAILURE; } - match cli::management_command_from(std::env::args_os()) { - Ok(Some(command)) => return run_management_command(command), - Ok(None) => {} - Err(error) => { - eprintln!("fastled: {error}"); - return ExitCode::FAILURE; + let arguments = std::env::args_os().collect::>(); + let presentation_request = arguments.iter().skip(1).any(|argument| { + matches!( + argument.to_str(), + Some("--help" | "-h" | "--version" | "-V") + ) + }); + // The bounded schema intentionally accepts UTF-8 arguments. Retain the + // existing native Clap path when a caller supplies a non-UTF-8 path so + // path-valued options remain lossless during the staged migration. + let has_non_utf8_argument = arguments.iter().any(|argument| argument.to_str().is_none()); + if !presentation_request && !has_non_utf8_argument { + match cli::management_command_from(&arguments) { + Ok(Some(command)) => return run_management_command(command), + Ok(None) => {} + Err(error) => { + eprintln!("fastled: {error}"); + return ExitCode::FAILURE; + } } } - - let mut cli = cli::Cli::parse(); + let mut cli = if presentation_request || has_non_utf8_argument { + cli::Cli::parse() + } else { + match cli::primary_cli_from(&arguments) { + Ok(cli) => cli, + Err(error) => { + eprintln!("fastled: {error}"); + return ExitCode::FAILURE; + } + } + }; cli::apply_test_implications(&mut cli); if let Err(message) = cli::validate_init_ref_flags(&cli) { From dbe77d7217648f1bf2412b5aaa4a8355a9f2eee3 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 10:04:18 -0700 Subject: [PATCH 69/94] refactor(cli): remove direct clap dependency Refs zackees/kernal-api#215. Use the bounded kernal-api command facade for parsing, help, version rendering, and native path values. --- Cargo.lock | 14 -- Cargo.toml | 1 - crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/cli.rs | 307 ++++++++++++----------- crates/fastled-cli/src/compile_stream.rs | 1 - crates/fastled-cli/src/lib.rs | 52 ++-- crates/fastled-cli/src/test_mode.rs | 1 - 7 files changed, 189 insertions(+), 188 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f54bdaf..be643c4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -414,7 +414,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", - "clap_derive", ] [[package]] @@ -429,18 +428,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "clap_lex" version = "1.1.0" @@ -1003,7 +990,6 @@ name = "fastled-cli" version = "2.0.20" dependencies = [ "anyhow", - "clap", "kernal-api", ] diff --git a/Cargo.toml b/Cargo.toml index b468bf43..53c4bb13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -clap = { version = "4", features = ["derive"] } kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json"] } anyhow = "1" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 45a28bbd..41b20a72 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -21,7 +21,6 @@ name = "fastled" path = "src/main.rs" [dependencies] -clap = { workspace = true } kernal-api = { workspace = true } anyhow = { workspace = true } diff --git a/crates/fastled-cli/src/cli.rs b/crates/fastled-cli/src/cli.rs index fe02ba94..3100f8df 100644 --- a/crates/fastled-cli/src/cli.rs +++ b/crates/fastled-cli/src/cli.rs @@ -3,30 +3,21 @@ use std::{ path::PathBuf, }; -use clap::{Parser, Subcommand, ValueEnum}; - -#[derive(Clone, Debug, Subcommand)] +#[derive(Clone, Debug)] pub(crate) enum Command { /// Inspect and explicitly manage the installed Emscripten toolchain. - Toolchain { - #[command(subcommand)] - action: ToolchainAction, - }, + Toolchain { action: ToolchainAction }, /// Inspect and explicitly manage cached FastLED source checkouts. - Source { - #[command(subcommand)] - action: SourceAction, - }, + Source { action: SourceAction }, } -#[derive(Clone, Debug, Subcommand)] +#[derive(Clone, Debug)] pub(crate) enum ToolchainAction { /// Show the active package and local installation state. Status, /// Install the current release default without activating it. Install { /// Install a specific catalog package ID. - #[arg(long)] package_id: Option, }, /// Health-check and activate an installed catalog package. @@ -48,24 +39,21 @@ pub(crate) enum ToolchainAction { } /// Explicit management actions for the cached FastLED source checkout. -#[derive(Clone, Debug, Subcommand)] +#[derive(Clone, Debug)] pub(crate) enum SourceAction { /// Show the cached source revision, fetch time, and freshness state. Status { /// Cached FastLED ref to inspect. - #[arg(long = "ref", default_value = "master")] reference: String, }, /// Download a fresh source checkout while preserving the old checkout on failure. Update { /// Cached FastLED ref to refresh. - #[arg(long = "ref", default_value = "master")] reference: String, }, /// Remove one cached FastLED source checkout. Purge { /// Cached FastLED ref to remove. - #[arg(long = "ref", default_value = "master")] reference: String, }, } @@ -178,77 +166,81 @@ pub(crate) fn primary_schema() -> kernal_api::command::Command { .about("FastLED WASM compilation CLI") .version(env!("CARGO_PKG_VERSION")) .optional_positional("directory", ValueKind::string()) - .option(OptionSpec::value("serve-dir", ValueKind::string())) - .option(OptionSpec::value("init", ValueKind::string()).optional_value("__init__")) - .option(OptionSpec::flag("just-compile")) - .option(OptionSpec::flag("no-app")) + .option(OptionSpec::value("serve-dir", ValueKind::string()).help("Serve an existing directory without compiling.")) + .option(OptionSpec::value("init", ValueKind::string()).optional_value("__init__").help("Initialize a sketch; without a value uses the default example.")) + .option(OptionSpec::flag("just-compile").help("Compile without opening the viewer.")) + .option(OptionSpec::flag("no-app").help("Emit the JavaScript API and WASM artifacts only.")) .option( OptionSpec::value("link", ValueKind::enumeration(["static", "dynamic"])) - .default("static"), + .default("static").help("Select static (default) or dynamic linking."), ) - .option(OptionSpec::flag("profile")) - .option(OptionSpec::flag("install")) - .option(OptionSpec::flag("dry-run")) - .option(OptionSpec::flag("no-interactive")) - .option(OptionSpec::flag("no-https")) + .option(OptionSpec::flag("profile").help("Enable C++ build profiling.")) + .option(OptionSpec::flag("install").help("Install the FastLED development environment.")) + .option(OptionSpec::flag("dry-run").help("Simulate actions without changing state.")) + .option(OptionSpec::flag("no-interactive").help("Fail instead of prompting.")) + .option(OptionSpec::flag("no-https").help("Use HTTP for the local server.")) .option( OptionSpec::flag("test") .conflicts("just-compile") - .conflicts("no-app"), + .conflicts("no-app").help("Compile, render, collect artifacts, and exit."), ) .option( OptionSpec::flag("check") .conflicts("just-compile") - .conflicts("no-app"), + .conflicts("no-app").help("Render one frame and fail on browser-side errors."), ) .exclusive_group("production-test", ["test", "check"]) .option( OptionSpec::value("test-wait-secs", ValueKind::f64()) .default("1") - .requires_any(["test", "check"]), + .requires_any(["test", "check"]).help("Seconds to wait before the first capture (default: 1)."), ) .option( - OptionSpec::value("test-screenshot", ValueKind::string()) - .requires_any(["test", "check"]), + OptionSpec::value("test-screenshot", ValueKind::os_string()) + .requires_any(["test", "check"]) + .help("PNG output path. Interval mode supports an unpadded index, {n:03}, and {ts}."), ) .option( OptionSpec::value("test-interval-secs", ValueKind::f64()) - .requires_any(["test", "check"]), + .requires_any(["test", "check"]) + .help("Target interval between scheduled capture starts; slow captures may delay later frames."), ) .option( OptionSpec::value("test-count", ValueKind::u32()) .requires_any(["test", "check"]) - .conflicts("test-duration-secs"), + .conflicts("test-duration-secs").help("Number of screenshots in interval mode."), ) .option( OptionSpec::value("test-duration-secs", ValueKind::f64()) .requires_any(["test", "check"]) - .conflicts("test-count"), + .conflicts("test-count").help("Total capture window in seconds."), + ) + .option( + OptionSpec::value("test-log", ValueKind::os_string()).requires_any(["test", "check"]).help("Append viewer output to this file."), ) - .option(OptionSpec::value("test-log", ValueKind::string()).requires_any(["test", "check"])) - .option(OptionSpec::flag("test-exit-on-error").requires_any(["test", "check"])) + .option(OptionSpec::flag("test-exit-on-error").requires_any(["test", "check"]).help("Exit with code 2 on a viewer error.")) .option( OptionSpec::value("test-timeout-secs", ValueKind::f64()) .default("120") - .requires_any(["test", "check"]), + .requires_any(["test", "check"]).help("Hard timeout in seconds (default: 120)."), ) .option( OptionSpec::value("test-ready-timeout-secs", ValueKind::f64()) .default("15") - .requires_any(["test", "check"]), + .requires_any(["test", "check"]).help("Canvas-ready timeout in seconds (default: 15)."), ) .option( OptionSpec::value("test-cmd", ValueKind::string()) .repeated() - .requires_any(["test", "check"]), + .requires_any(["test", "check"]).help("Trusted command after the first frame; may repeat."), ) - .option(OptionSpec::flag("latest")) - .option(OptionSpec::value("branch", ValueKind::string())) - .option(OptionSpec::value("commit", ValueKind::string())) - .option(OptionSpec::value("fastled-path", ValueKind::string())) - .option(OptionSpec::flag("purge")) - .option(OptionSpec::flag("clangd")) - .option(OptionSpec::value("write-clangd", ValueKind::string()).optional_value("__cwd__")) + .option(OptionSpec::flag("latest").help("Use the latest tagged FastLED release for initialization.")) + .option(OptionSpec::value("branch", ValueKind::string()).help("FastLED branch for initialization.")) + .option(OptionSpec::value("commit", ValueKind::string()).help("FastLED commit for initialization.")) + .option(OptionSpec::value("fastled-path", ValueKind::string()).help("Path to the FastLED library.")) + .option(OptionSpec::flag("purge").help("Purge the cached FastLED checkout.")) + .option(OptionSpec::flag("clangd").help("Emit VS Code clangd configuration after compiling.")) + .option(OptionSpec::value("write-clangd", ValueKind::string()).optional_value("__cwd__").help("Write clangd configuration and exit.")) .option(OptionSpec::flag("write-intellisense-snapshot").hidden()) .option( OptionSpec::value("internal-ensure-fastled-repo", ValueKind::string()) @@ -274,6 +266,89 @@ pub(crate) fn primary_schema() -> kernal_api::command::Command { ) } +/// Render facade-owned presentation text without reintroducing a parser dependency. +pub(crate) fn presentation_help(arguments: &[OsString]) -> String { + use kernal_api::command::{Command as SchemaCommand, OptionSpec, ValueKind}; + + let words = arguments + .iter() + .skip(1) + .take_while(|argument| argument.as_os_str() != "--") + .filter_map(|argument| argument.to_str()) + .collect::>(); + let value_options = [ + "--serve-dir", + "--init", + "--link", + "--test-wait-secs", + "--test-screenshot", + "--test-interval-secs", + "--test-count", + "--test-duration-secs", + "--test-log", + "--test-timeout-secs", + "--test-ready-timeout-secs", + "--test-cmd", + "--branch", + "--commit", + "--fastled-path", + "--write-clangd", + "--internal-ensure-fastled-repo", + "--internal-serve-dir-headless", + ]; + let mut index = 0; + let management_index = loop { + let Some(word) = words.get(index) else { + break None; + }; + if matches!(*word, "toolchain" | "source") { + break Some(index); + } + index += if value_options.contains(word) && !word.contains('=') { + 2 + } else { + 1 + }; + }; + let Some(index) = management_index else { + return format!("{}\nCommands:\n toolchain\tManage Emscripten toolchains.\n source\tManage cached FastLED sources.\n", primary_schema().render_help()); + }; + let command = match &words[index..] { + ["toolchain", "install", ..] => SchemaCommand::new("fastled toolchain install").option( + OptionSpec::value("package-id", ValueKind::string()) + .help("Catalog package ID to install."), + ), + ["toolchain", "activate", ..] => SchemaCommand::new("fastled toolchain activate") + .positional("package-id", ValueKind::string()), + ["toolchain", "repair", ..] => SchemaCommand::new("fastled toolchain repair") + .optional_positional("package-id", ValueKind::string()), + ["toolchain", action @ ("status" | "update" | "rollback" | "prune"), ..] => { + SchemaCommand::new(format!("fastled toolchain {action}")) + } + ["toolchain", ..] => SchemaCommand::new("fastled toolchain") + .subcommand(SchemaCommand::new("status")) + .subcommand(SchemaCommand::new("install")) + .subcommand(SchemaCommand::new("activate")) + .subcommand(SchemaCommand::new("update")) + .subcommand(SchemaCommand::new("repair")) + .subcommand(SchemaCommand::new("rollback")) + .subcommand(SchemaCommand::new("prune")), + ["source", action @ ("status" | "update" | "purge"), ..] => { + SchemaCommand::new(format!("fastled source {action}")).option( + OptionSpec::value("ref", ValueKind::string()) + .default("master") + .help("FastLED ref to inspect."), + ) + } + ["source", ..] => SchemaCommand::new("fastled source") + .subcommand(SchemaCommand::new("status")) + .subcommand(SchemaCommand::new("update")) + .subcommand(SchemaCommand::new("purge")), + _ => SchemaCommand::new("fastled"), + }; + command.render_help() +} + pub(crate) fn primary_cli_from(arguments: I) -> Result where I: IntoIterator, @@ -305,11 +380,11 @@ where test: flag("test"), check: flag("check"), test_wait_secs: parsed.f64("test-wait-secs").unwrap_or(1.0), - test_screenshot: string("test-screenshot").map(PathBuf::from), + test_screenshot: parsed.os_value("test-screenshot").map(PathBuf::from), test_interval_secs: parsed.f64("test-interval-secs"), test_count: parsed.u32("test-count"), test_duration_secs: parsed.f64("test-duration-secs"), - test_log: string("test-log").map(PathBuf::from), + test_log: parsed.os_value("test-log").map(PathBuf::from), test_exit_on_error: flag("test-exit-on-error"), test_timeout_secs: parsed.f64("test-timeout-secs").unwrap_or(120.0), test_ready_timeout_secs: parsed.f64("test-ready-timeout-secs").unwrap_or(15.0), @@ -332,7 +407,7 @@ where } /// How the sketch code is linked into the generated WASM program. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum LinkMode { /// Statically link the sketch into fastled.wasm. #[default] @@ -347,230 +422,166 @@ pub(crate) enum LinkMode { /// /// Native Rust owns the full user-facing CLI surface, including compile /// orchestration through `build.rs`. -#[derive(Parser, Debug)] -#[command( - name = "fastled", - version, - about = "FastLED WASM compilation CLI", - long_about = None, - group(clap::ArgGroup::new("production_test").args(["test", "check"]).multiple(false)), - // Stop Rust clap from eating flags it doesn't recognise; we want to be - // a strict mirror, so every flag is declared explicitly. -)] +#[derive(Debug)] pub(crate) struct Cli { - #[command(subcommand)] pub(crate) command: Option, /// Directory containing the FastLED sketch to compile. pub(crate) directory: Option, /// Serve an existing directory without compiling a sketch. - #[arg(long, value_name = "DIR")] pub(crate) serve_dir: Option, /// Initialize a FastLED sketch in the current directory. /// An optional example name may be provided (e.g. --init Blink). - #[arg(long, value_name = "EXAMPLE", num_args = 0..=1, default_missing_value = "__init__")] pub(crate) init: Option, /// Just compile; skip opening the browser and watching for changes. - #[arg(long)] pub(crate) just_compile: bool, /// Omit the default index.js application and emit only the JavaScript API /// plus its WASM/runtime artifacts. - #[arg(long)] pub(crate) no_app: bool, /// Select static linking (the default) or Emscripten side-module linking. - #[arg(long = "link", value_enum, default_value_t = LinkMode::Static)] pub(crate) link_mode: LinkMode, /// Enable profiling of the C++ build system used for WASM compilation. - #[arg(long)] pub(crate) profile: bool, /// Install the FastLED development environment with VSCode configuration. - #[arg(long)] pub(crate) install: bool, /// Run in dry-run mode (simulate actions without making changes). - #[arg(long)] pub(crate) dry_run: bool, /// Run in non-interactive mode (fail instead of prompting for input). - #[arg(long)] pub(crate) no_interactive: bool, /// Disable HTTPS and use HTTP for the local server. - #[arg(long)] + #[allow(dead_code)] // Parsed compatibility flag; server policy has not adopted it yet. pub(crate) no_https: bool, /// Compile, render, collect requested test artifacts, and exit. - #[arg(long, conflicts_with_all = ["just_compile", "no_app"], help_heading = "Production testing")] pub(crate) test: bool, /// Compile and render one frame, failing on browser-side errors. - #[arg(long, conflicts_with_all = ["just_compile", "no_app"], help_heading = "Production testing")] pub(crate) check: bool, /// Seconds to wait after the sketch is ready before the first capture. - #[arg( - long, - default_value_t = 1.0, - requires = "production_test", - help_heading = "Production testing" - )] pub(crate) test_wait_secs: f64, /// PNG output path. Interval mode supports an unpadded index, {n:03}, and {ts}. - #[arg( - long, - value_name = "PATH", - requires = "production_test", - verbatim_doc_comment, - help_heading = "Production testing" - )] pub(crate) test_screenshot: Option, /// Target interval between scheduled capture starts; slow captures may delay later frames. - #[arg( - long, - requires = "production_test", - help_heading = "Production testing" - )] pub(crate) test_interval_secs: Option, /// Number of screenshots to take in interval mode. - #[arg( - long, - requires = "production_test", - conflicts_with = "test_duration_secs", - help_heading = "Production testing" - )] pub(crate) test_count: Option, /// Total capture window in seconds for interval mode. - #[arg( - long, - requires = "production_test", - conflicts_with = "test_count", - help_heading = "Production testing" - )] pub(crate) test_duration_secs: Option, /// Append viewer console and error output to this file. - #[arg( - long, - value_name = "PATH", - requires = "production_test", - help_heading = "Production testing" - )] pub(crate) test_log: Option, /// Exit with code 2 when the viewer reports a page-side error. - #[arg( - long, - requires = "production_test", - help_heading = "Production testing" - )] pub(crate) test_exit_on_error: bool, /// Hard upper bound in seconds for compile, ready wait, and capture. - #[arg( - long, - default_value_t = 120.0, - requires = "production_test", - help_heading = "Production testing" - )] pub(crate) test_timeout_secs: f64, /// Seconds to wait for a rendered canvas after compilation succeeds. - #[arg( - long, - default_value_t = 15.0, - requires = "production_test", - help_heading = "Production testing" - )] pub(crate) test_ready_timeout_secs: f64, /// Run a trusted host command after the first rendered frame. May be repeated. - #[arg( - long, - requires = "production_test", - value_name = "COMMAND", - help_heading = "Production testing" - )] pub(crate) test_cmd: Vec, /// Use the latest tagged FastLED release when initialising examples with --init. /// Defaults to `master`; tagged releases older than the meson migration cannot be built. - #[arg(long)] pub(crate) latest: bool, /// Use a specific branch when initialising examples with --init. - #[arg(long, value_name = "BRANCH")] pub(crate) branch: Option, /// Use a specific commit SHA when initialising examples with --init. - #[arg(long, value_name = "SHA")] pub(crate) commit: Option, /// Path to the FastLED library for native compilation. - #[arg(long, value_name = "PATH")] pub(crate) fastled_path: Option, /// Purge the cached FastLED repo, forcing a fresh re-download on next build. - #[arg(long)] pub(crate) purge: bool, /// Also emit VS Code clangd configuration (compile_commands.json, /// .clangd, .vscode/settings.json) into the sketch directory after a /// successful compile. - #[arg(long)] pub(crate) clangd: bool, /// Write VS Code clangd configuration (compile_commands.json, /// .clangd, .vscode/settings.json) for the sketch directory and exit. /// Defaults to the current directory when no DIR is given. - #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = "__cwd__")] pub(crate) write_clangd: Option, /// Read a versioned JSON document snapshot from stdin and atomically /// refresh the ignored IntelliSense cache. Used by the VS Code extension. - #[arg(long, hide = true)] pub(crate) write_intellisense_snapshot: bool, /// Internal plumbing flag: ensure the FastLED repo for the given ref /// (defaults to latest release) is downloaded and extracted, print the /// local path to stdout, and exit. Used by the Python `Api.project_init` /// path so the Python side never has to do an HTTP download. - #[arg(long, value_name = "REF", num_args = 0..=1, default_missing_value = "__latest__", hide = true)] pub(crate) internal_ensure_fastled_repo: Option, /// Internal CI plumbing: compile a debug sketch, start the source server /// without the viewer, and verify every embedded debug source path. - #[arg(long, hide = true)] pub(crate) internal_dwarf_smoke: bool, /// Internal CI plumbing: serve compiled output without launching the viewer. - #[arg(long, value_name = "DIR", hide = true)] pub(crate) internal_serve_dir_headless: Option, // Build mode (mutually exclusive). /// Build in debug mode. - #[arg(long, conflicts_with_all = ["quick", "release"])] pub(crate) debug: bool, /// Build in quick mode (default). - #[arg(long, conflicts_with_all = ["debug", "release"])] + #[allow(dead_code)] + // Parsed compatibility flag; quick is selected by the absence of a mode. pub(crate) quick: bool, /// Build in optimised release mode. - #[arg(long, conflicts_with_all = ["debug", "quick"])] pub(crate) release: bool, } +#[cfg(test)] +impl Cli { + pub(crate) fn parse_from(arguments: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self::try_parse_from(arguments).expect("test command-line arguments must be valid") + } + + pub(crate) fn try_parse_from(arguments: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let arguments = arguments + .into_iter() + .map(|argument| argument.as_ref().to_os_string()) + .collect::>(); + if let Some(command) = management_command_from(&arguments)? { + let mut cli = primary_cli_from(["fastled"])?; + cli.command = Some(command); + return Ok(cli); + } + primary_cli_from(arguments) + } +} + pub(crate) fn validate_init_ref_flags(cli: &Cli) -> Result<(), &'static str> { validate_ref_options(cli.latest, cli.branch.is_some(), cli.commit.is_some()) } @@ -617,7 +628,6 @@ pub(crate) fn requested_init_ref(cli: &Cli) -> Option<&str> { #[cfg(test)] mod tests { use super::*; - use clap::CommandFactory; #[test] fn management_commands_use_the_kernel_schema() { @@ -700,6 +710,7 @@ mod tests { assert_eq!(cli.link_mode, LinkMode::Dynamic); let defaults = primary_cli_from(["fastled", "sketch"]); assert!(defaults.is_ok(), "default controls must not require --test"); + assert!(primary_cli_from(["fastled", "--", "--help"]).is_ok()); } #[test] @@ -811,7 +822,7 @@ mod tests { #[test] fn production_test_help_describes_filename_templates() { - let help = Cli::command().render_long_help().to_string(); + let help = primary_schema().render_help(); assert!(help.contains("supports an unpadded index, {n:03}, and {ts}")); assert!(help.contains("Target interval between scheduled capture starts")); } diff --git a/crates/fastled-cli/src/compile_stream.rs b/crates/fastled-cli/src/compile_stream.rs index 94ac0e1e..2d69c4e3 100644 --- a/crates/fastled-cli/src/compile_stream.rs +++ b/crates/fastled-cli/src/compile_stream.rs @@ -365,7 +365,6 @@ pub(crate) fn update_debug_symbol_resolver( #[cfg(test)] mod tests { use super::*; - use clap::Parser; fn read_status(dir: &Path) -> String { std::fs::read_to_string(dir.join("build-status.json")).unwrap() diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index 1eb9f42d..29755d37 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -2,8 +2,6 @@ use std::process::ExitCode; -use clap::Parser; - mod archive; mod build; mod cache_upgrade; @@ -50,17 +48,31 @@ pub fn run() -> ExitCode { } let arguments = std::env::args_os().collect::>(); - let presentation_request = arguments.iter().skip(1).any(|argument| { - matches!( - argument.to_str(), - Some("--help" | "-h" | "--version" | "-V") - ) - }); - // The bounded schema intentionally accepts UTF-8 arguments. Retain the - // existing native Clap path when a caller supplies a non-UTF-8 path so - // path-valued options remain lossless during the staged migration. - let has_non_utf8_argument = arguments.iter().any(|argument| argument.to_str().is_none()); - if !presentation_request && !has_non_utf8_argument { + let presentation_request = arguments + .iter() + .skip(1) + .take_while(|argument| argument.as_os_str() != "--") + .any(|argument| { + matches!( + argument.to_str(), + Some("--help" | "-h" | "--version" | "-V") + ) + }); + if presentation_request { + let version_request = arguments + .iter() + .skip(1) + .take_while(|argument| argument.as_os_str() != "--") + .any(|argument| matches!(argument.to_str(), Some("--version" | "-V"))); + let schema = cli::primary_schema(); + if version_request { + print!("{}", schema.render_version()); + } else { + print!("{}", cli::presentation_help(&arguments)); + } + return ExitCode::SUCCESS; + } + { match cli::management_command_from(&arguments) { Ok(Some(command)) => return run_management_command(command), Ok(None) => {} @@ -70,15 +82,11 @@ pub fn run() -> ExitCode { } } } - let mut cli = if presentation_request || has_non_utf8_argument { - cli::Cli::parse() - } else { - match cli::primary_cli_from(&arguments) { - Ok(cli) => cli, - Err(error) => { - eprintln!("fastled: {error}"); - return ExitCode::FAILURE; - } + let mut cli = match cli::primary_cli_from(&arguments) { + Ok(cli) => cli, + Err(error) => { + eprintln!("fastled: {error}"); + return ExitCode::FAILURE; } }; cli::apply_test_implications(&mut cli); diff --git a/crates/fastled-cli/src/test_mode.rs b/crates/fastled-cli/src/test_mode.rs index e595a80a..1cf4a1e0 100644 --- a/crates/fastled-cli/src/test_mode.rs +++ b/crates/fastled-cli/src/test_mode.rs @@ -494,7 +494,6 @@ pub(crate) async fn wait_for_ready( #[cfg(test)] mod tests { use super::*; - use clap::Parser; const COMMAND_TEST_TIMEOUT: Duration = Duration::from_secs(10); From 06a85e312ee320805309e642b0a3ad5ed6c92881 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 10:19:15 -0700 Subject: [PATCH 70/94] refactor(cli): route application errors through kernal-api --- Cargo.lock | 1 - Cargo.toml | 3 +- crates/fastled-cli/Cargo.toml | 1 - crates/fastled-cli/src/archive.rs | 6 +-- crates/fastled-cli/src/cache_upgrade.rs | 2 +- crates/fastled-cli/src/clangd_config.rs | 4 +- crates/fastled-cli/src/debug_symbols.rs | 19 ++++---- crates/fastled-cli/src/dwarf_smoke.rs | 12 ++--- crates/fastled-cli/src/dynamic_cache.rs | 27 ++++++----- .../src/dynamic_cache_matrix_tests.rs | 2 +- crates/fastled-cli/src/error_compat.rs | 19 ++++++++ crates/fastled-cli/src/frontend.rs | 12 ++--- crates/fastled-cli/src/install.rs | 10 ++-- crates/fastled-cli/src/lib.rs | 1 + crates/fastled-cli/src/project.rs | 2 +- crates/fastled-cli/src/runtime.rs | 2 +- crates/fastled-cli/src/server.rs | 2 +- crates/fastled-cli/src/sketch_preprocessor.rs | 2 +- crates/fastled-cli/src/source.rs | 4 +- crates/fastled-cli/src/viewer.rs | 4 +- crates/fastled-cli/src/wasm_build.rs | 48 +++++++++---------- 21 files changed, 103 insertions(+), 80 deletions(-) create mode 100644 crates/fastled-cli/src/error_compat.rs diff --git a/Cargo.lock b/Cargo.lock index be643c4f..ee434cc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -989,7 +989,6 @@ dependencies = [ name = "fastled-cli" version = "2.0.20" dependencies = [ - "anyhow", "kernal-api", ] diff --git a/Cargo.toml b/Cargo.toml index 53c4bb13..2d77f515 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,7 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json"] } -anyhow = "1" +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 41b20a72..4fdd49ab 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -22,7 +22,6 @@ path = "src/main.rs" [dependencies] kernal-api = { workspace = true } -anyhow = { workspace = true } [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/fastled-{ target }{ archive-suffix }" diff --git a/crates/fastled-cli/src/archive.rs b/crates/fastled-cli/src/archive.rs index 65931871..bfe0474c 100644 --- a/crates/fastled-cli/src/archive.rs +++ b/crates/fastled-cli/src/archive.rs @@ -10,7 +10,7 @@ use std::fs::{self, File}; use std::io::{BufWriter, Read, Write}; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use kernal_api::archive::{ArchiveFormat, DanglingLinks, ExtractionLimits}; // --------------------------------------------------------------------------- @@ -45,7 +45,7 @@ pub fn download(url: &str, dest: &Path) -> Result<()> { .with_context(|| format!("GET {url} failed"))?; if !(200..300).contains(&response.status()) { - anyhow::bail!("server returned HTTP {} for {url}", response.status()); + crate::error_compat::bail!("server returned HTTP {} for {url}", response.status()); } let file = File::create(dest).with_context(|| format!("cannot create {}", dest.display()))?; @@ -200,7 +200,7 @@ pub(crate) fn write_emscripten_config_at( node_path: &Path, ) -> Result<()> { if !node_path.is_absolute() { - anyhow::bail!( + crate::error_compat::bail!( "NODE_JS must be an absolute path, got {}", node_path.display() ); diff --git a/crates/fastled-cli/src/cache_upgrade.rs b/crates/fastled-cli/src/cache_upgrade.rs index 3070b985..64f5ffc7 100644 --- a/crates/fastled-cli/src/cache_upgrade.rs +++ b/crates/fastled-cli/src/cache_upgrade.rs @@ -2,7 +2,7 @@ use std::fs::{self, OpenOptions}; use std::io::{self, IsTerminal}; use std::path::Path; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use crate::path::NormalizedPath; diff --git a/crates/fastled-cli/src/clangd_config.rs b/crates/fastled-cli/src/clangd_config.rs index ed00c147..5f5f9eb6 100644 --- a/crates/fastled-cli/src/clangd_config.rs +++ b/crates/fastled-cli/src/clangd_config.rs @@ -16,7 +16,7 @@ use std::path::Path; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use kernal_api::json::{self, Layout, Value as DocumentValue}; use std::collections::BTreeMap; @@ -460,7 +460,7 @@ pub fn run_write_clangd(dir_arg: &str) -> Result<()> { }; let sketch_dir = crate::path::canonicalize_normalized(&sketch_dir); if !sketch_dir.is_dir() { - anyhow::bail!("sketch directory does not exist: {}", sketch_dir.display()); + crate::error_compat::bail!("sketch directory does not exist: {}", sketch_dir.display()); } let emsdk_install_dir = NormalizedPath::new(install::ensure_emscripten_installed()?); let tools_emcc_path = emsdk_install_dir.join("emscripten").join("emcc.py"); diff --git a/crates/fastled-cli/src/debug_symbols.rs b/crates/fastled-cli/src/debug_symbols.rs index a1d42c1c..5152a607 100644 --- a/crates/fastled-cli/src/debug_symbols.rs +++ b/crates/fastled-cli/src/debug_symbols.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use kernal_api::json::{self, Layout, Value}; pub const DEFAULT_FASTLED_PREFIX: &str = "fastledsource"; @@ -59,13 +59,13 @@ impl DwarfPrefixConfig { pub(crate) fn from_config(value: &kernal_api::config::Value) -> Result { use kernal_api::config::Value; let Value::Table(fields) = value else { - anyhow::bail!("dwarf must be a table"); + crate::error_compat::bail!("dwarf must be a table"); }; let string = |name: &str| -> Result> { match fields.get(name) { None => Ok(None), Some(Value::String(value)) => Ok(Some(value.clone())), - Some(_) => anyhow::bail!("dwarf.{name} must be a string"), + Some(_) => crate::error_compat::bail!("dwarf.{name} must be a string"), } }; Ok(Self { @@ -166,7 +166,10 @@ fn manifest_fields( for (name, value) in members { if let Some(index) = names.iter().position(|field| *field == name) { if fields[index].replace(value).is_some() { - anyhow::bail!("duplicate debug manifest field {}", names[index]); + crate::error_compat::bail!( + "duplicate debug manifest field {}", + names[index] + ); } } } @@ -176,7 +179,7 @@ fn manifest_fields( *field = Some(value); } } - _ => anyhow::bail!("invalid debug manifest record"), + _ => crate::error_compat::bail!("invalid debug manifest record"), } Ok(fields) } @@ -185,7 +188,7 @@ fn manifest_string(value: Option, name: &str, default: Option<&str>) -> R match (value, default) { (Some(Value::String(value)), _) => Ok(value), (None, Some(default)) => Ok(default.to_owned()), - _ => anyhow::bail!("debug manifest {name} must be a string"), + _ => crate::error_compat::bail!("debug manifest {name} must be a string"), } } @@ -330,7 +333,7 @@ pub fn read_debug_symbol_manifest(output_dir: &Path) -> Result u32::try_from(value)?, Some(Value::Unsigned(value)) => u32::try_from(value)?, - _ => anyhow::bail!("debug manifest version must be an unsigned integer"), + _ => crate::error_compat::bail!("debug manifest version must be an unsigned integer"), }; Ok(( version, @@ -339,7 +342,7 @@ pub fn read_debug_symbol_manifest(output_dir: &Path) -> Result anyhow::Result { +pub(crate) fn run_dwarf_source_smoke(output_dir: &Path) -> crate::error_compat::Result { let config = debug_symbols::read_debug_symbol_manifest(output_dir)?.ok_or_else(|| { - anyhow::anyhow!( + crate::error_compat::error!( "missing {} in {}", debug_symbols::DWARF_ROOTS_MANIFEST, output_dir.display() @@ -18,7 +18,7 @@ pub(crate) fn run_dwarf_source_smoke(output_dir: &Path) -> anyhow::Result })?; let paths = collect_debug_source_paths(output_dir, &config)?; if paths.is_empty() { - anyhow::bail!( + crate::error_compat::bail!( "no debug source paths found in {} or {}", output_dir.join("fastled.wasm").display(), output_dir.join("fastled.wasm.map").display() @@ -55,7 +55,7 @@ pub(crate) fn run_dwarf_source_smoke(output_dir: &Path) -> anyhow::Result let status = resp.status(); let bytes = resp.into_bytes().await.unwrap_or_default(); let body = String::from_utf8_lossy(&bytes); - anyhow::bail!("/dwarfsource rejected {path} with {status}: {body}"); + crate::error_compat::bail!("/dwarfsource rejected {path} with {status}: {body}"); } } Ok(paths.len()) @@ -101,7 +101,7 @@ fn source_smoke_uses_manifest_paths_and_reports_missing_source() { pub(crate) fn collect_debug_source_paths( output_dir: &Path, config: &debug_symbols::DebugSymbolConfig, -) -> anyhow::Result> { +) -> crate::error_compat::Result> { let prefixes = debug_source_prefixes(config); let mut paths = BTreeSet::new(); diff --git a/crates/fastled-cli/src/dynamic_cache.rs b/crates/fastled-cli/src/dynamic_cache.rs index 894c1f1e..dfff85fd 100644 --- a/crates/fastled-cli/src/dynamic_cache.rs +++ b/crates/fastled-cli/src/dynamic_cache.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; use kernal_api::json::{self, Layout, Value}; use kernal_api::platform::fs::{PatternSet, PatternSetBuilder}; @@ -112,7 +112,7 @@ fn fingerprint_tree_persistent(root: &Path, include: &[&str], exclude: &[&str]) let cache = FINGERPRINT_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())); let mut cache = cache .lock() - .map_err(|_| anyhow::anyhow!("persistent fingerprint cache lock poisoned"))?; + .map_err(|_| crate::error_compat::error!("persistent fingerprint cache lock poisoned"))?; // Recreate dead watchers before trusting a cached value. If registration // fails, the normal construction path below performs an authoritative scan. @@ -186,7 +186,7 @@ pub(crate) fn invalidate_persistent_fingerprints(paths: &[NormalizedPath]) -> Re }; let cache = cache .lock() - .map_err(|_| anyhow::anyhow!("persistent fingerprint cache lock poisoned"))?; + .map_err(|_| crate::error_compat::error!("persistent fingerprint cache lock poisoned"))?; let mut invalidated = 0; for (spec, entry) in cache.iter() { if paths @@ -207,7 +207,7 @@ pub(crate) fn invalidate_all_persistent_fingerprints() -> Result { }; let cache = cache .lock() - .map_err(|_| anyhow::anyhow!("persistent fingerprint cache lock poisoned"))?; + .map_err(|_| crate::error_compat::error!("persistent fingerprint cache lock poisoned"))?; for entry in cache.values() { mark_fingerprint_dirty(&entry.generation); } @@ -246,7 +246,10 @@ fn cache_fields(value: Value, names: [&str; N]) -> Result<[Optio for (name, value) in members { if let Some(index) = names.iter().position(|field| *field == name) { if fields[index].replace(value).is_some() { - anyhow::bail!("duplicate cache metadata field {}", names[index]); + crate::error_compat::bail!( + "duplicate cache metadata field {}", + names[index] + ); } } } @@ -256,14 +259,14 @@ fn cache_fields(value: Value, names: [&str; N]) -> Result<[Optio *field = Some(value); } } - _ => anyhow::bail!("invalid cache metadata record"), + _ => crate::error_compat::bail!("invalid cache metadata record"), } Ok(fields) } fn cache_string(value: Option, name: &str) -> Result { let Some(Value::String(value)) = value else { - anyhow::bail!("cache metadata {name} must be a string"); + crate::error_compat::bail!("cache metadata {name} must be a string"); }; Ok(value) } @@ -272,7 +275,7 @@ fn cache_unsigned(value: Option, name: &str) -> Result { match value { Some(Value::Unsigned(value)) => Ok(value), Some(Value::Signed(value)) if value >= 0 => Ok(value as u64), - _ => anyhow::bail!("cache metadata {name} must be an unsigned integer"), + _ => crate::error_compat::bail!("cache metadata {name} must be an unsigned integer"), } } @@ -285,7 +288,7 @@ impl CacheMetadata { let schema = u32::try_from(cache_unsigned(schema, "schema")?)?; let fingerprint = cache_string(fingerprint, "fingerprint")?; let Some(Value::ObjectMembers(members)) = artifacts else { - anyhow::bail!("cache metadata artifacts must be an object"); + crate::error_compat::bail!("cache metadata artifacts must be an object"); }; let mut artifacts = BTreeMap::new(); for (name, value) in members { @@ -410,7 +413,7 @@ pub(crate) fn write_metadata(staging: &Path, fingerprint: &str, artifacts: &[&st for name in artifacts { let path = staging.join(name); let record = hash_file(&path)?; - validate_artifact_shape(name, &path, record.bytes).map_err(anyhow::Error::msg)?; + validate_artifact_shape(name, &path, record.bytes).map_err(crate::error_compat::message)?; records.insert((*name).to_string(), record); } let metadata = CacheMetadata { @@ -548,7 +551,7 @@ pub(crate) fn mark_failure( cache_root: &Path, fingerprint: &str, phase: &str, - error: &anyhow::Error, + error: &crate::error_compat::Error, ) -> Result<()> { write_attempt( cache_root, @@ -881,7 +884,7 @@ mod tests { temp.path(), "key", "main-link", - &anyhow::anyhow!("link failed"), + &crate::error_compat::error!("link failed"), ) .unwrap(); assert!(previous_attempt(temp.path(), "key") diff --git a/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs b/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs index b2ef4e8e..7eb51652 100644 --- a/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs +++ b/crates/fastled-cli/src/dynamic_cache_matrix_tests.rs @@ -3,7 +3,7 @@ use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use anyhow::Result; +use crate::error_compat::Result; use kernal_api::hash::Sha256Hasher as Sha256; use crate::dynamic_cache; diff --git a/crates/fastled-cli/src/error_compat.rs b/crates/fastled-cli/src/error_compat.rs new file mode 100644 index 00000000..c222e896 --- /dev/null +++ b/crates/fastled-cli/src/error_compat.rs @@ -0,0 +1,19 @@ +//! Migration bridge to the kernal-api-owned error facade. + +pub(crate) use kernal_api::error::{message, Context, Error, Result}; + +macro_rules! bail { + ($($argument:tt)*) => { + return Err($crate::error_compat::message(format_args!($($argument)*))) + }; +} + +pub(crate) use bail; + +macro_rules! error { + ($($argument:tt)*) => { + $crate::error_compat::message(format_args!($($argument)*)) + }; +} + +pub(crate) use error; diff --git a/crates/fastled-cli/src/frontend.rs b/crates/fastled-cli/src/frontend.rs index aac766b5..377b9a9a 100644 --- a/crates/fastled-cli/src/frontend.rs +++ b/crates/fastled-cli/src/frontend.rs @@ -12,7 +12,7 @@ use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::time::UNIX_EPOCH; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; use crate::install; @@ -31,7 +31,7 @@ fn workspace_root() -> PathBuf { fn validate_source_dir(candidate: PathBuf, origin: &str) -> Result { if !candidate.is_dir() { - anyhow::bail!("{origin} is not a directory: {}", candidate.display()); + crate::error_compat::bail!("{origin} is not a directory: {}", candidate.display()); } // These are the source inputs that build_dist requires. Validate all of @@ -46,7 +46,7 @@ fn validate_source_dir(candidate: PathBuf, origin: &str) -> Result { ] { let required = candidate.join(relative); if !required.is_file() { - anyhow::bail!( + crate::error_compat::bail!( "{origin} does not contain required frontend file {}", required.display() ); @@ -64,7 +64,7 @@ fn packaged_source_dir() -> Result> { return Ok(None); }; if value.trim().is_empty() { - anyhow::bail!("FASTLED_FRONTEND_DIR is set but empty"); + crate::error_compat::bail!("FASTLED_FRONTEND_DIR is set but empty"); } validate_source_dir(PathBuf::from(value), "FASTLED_FRONTEND_DIR").map(Some) } @@ -91,7 +91,7 @@ fn default_source_dir() -> Result { } current = dir.parent().map(Path::to_path_buf); } - anyhow::bail!( + crate::error_compat::bail!( "could not locate src/fastled/frontend relative to CARGO_MANIFEST_DIR={}", env!("CARGO_MANIFEST_DIR") ) @@ -263,7 +263,7 @@ fn run_esbuild(esbuild: &Path, source_dir: &Path, args: &[String]) -> Result<()> .status() .with_context(|| format!("spawn esbuild at {}", esbuild.display()))?; if !status.success() { - anyhow::bail!( + crate::error_compat::bail!( "esbuild failed with exit code {}", status.code().unwrap_or(-1) ); diff --git a/crates/fastled-cli/src/install.rs b/crates/fastled-cli/src/install.rs index 64f2887a..0f29fa32 100644 --- a/crates/fastled-cli/src/install.rs +++ b/crates/fastled-cli/src/install.rs @@ -13,7 +13,7 @@ use std::sync::{Mutex, OnceLock}; use std::collections::BTreeMap; -use anyhow::{bail, Context, Result}; +use crate::error_compat::{bail, Context, Result}; use kernal_api::json::{self, Layout, Value as JsonValue}; use crate::archive; @@ -1340,7 +1340,7 @@ pub fn ensure_emscripten_installed() -> Result { let cache = EMSCRIPTEN_INSTALL_CACHE.get_or_init(|| Mutex::new(None)); let mut cached = cache .lock() - .map_err(|_| anyhow::anyhow!("emscripten install cache lock poisoned"))?; + .map_err(|_| crate::error_compat::error!("emscripten install cache lock poisoned"))?; if let Some(path) = cached.clone() { if validate_complete_emscripten_install(&path).is_ok() { return Ok(path); @@ -1365,7 +1365,7 @@ pub fn ensure_emscripten_installed() -> Result { })?; let mut cached = cache .lock() - .map_err(|_| anyhow::anyhow!("emscripten install cache lock poisoned"))?; + .map_err(|_| crate::error_compat::error!("emscripten install cache lock poisoned"))?; *cached = Some(installed.clone()); Ok(installed) } @@ -1388,7 +1388,7 @@ fn esbuild_platform_arch() -> Result<(&'static str, &'static str)> { } else if cfg!(target_arch = "aarch64") { "arm64" } else { - anyhow::bail!( + crate::error_compat::bail!( "unsupported architecture for esbuild: {}", std::env::consts::ARCH ); @@ -1577,7 +1577,7 @@ fn find_fastled_extract_root(dir: &Path) -> Result { return Ok(entry.path()); } } - anyhow::bail!("no FastLED* directory found inside {}", dir.display()) + crate::error_compat::bail!("no FastLED* directory found inside {}", dir.display()) } /// Remove the derived short-path checkout when it represents `reference`. diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index 29755d37..ce9564d4 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -15,6 +15,7 @@ mod dwarf_smoke; mod dynamic_cache; #[cfg(test)] mod dynamic_cache_matrix_tests; +mod error_compat; pub mod frontend; pub mod install; mod install_unlock; diff --git a/crates/fastled-cli/src/project.rs b/crates/fastled-cli/src/project.rs index 6fc078ba..837dea4b 100644 --- a/crates/fastled-cli/src/project.rs +++ b/crates/fastled-cli/src/project.rs @@ -13,7 +13,7 @@ use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{bail, Context, Result}; +use crate::error_compat::{bail, Context, Result}; use kernal_api::json::{self, Layout, Value}; // --------------------------------------------------------------------------- diff --git a/crates/fastled-cli/src/runtime.rs b/crates/fastled-cli/src/runtime.rs index 2214f356..46b3183e 100644 --- a/crates/fastled-cli/src/runtime.rs +++ b/crates/fastled-cli/src/runtime.rs @@ -6,7 +6,7 @@ //! dropped into `~/.fastled/update/`, and periodically removes stale copies. use crate::archive; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use std::cmp::Ordering; use std::ffi::{OsStr, OsString}; use std::fs::{self, OpenOptions}; diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index 407d813c..ed3c3266 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -807,7 +807,7 @@ pub async fn start_server( build_tx: Option>, debug_symbols: DebugSymbolHandle, test: Option, -) -> anyhow::Result { +) -> crate::error_compat::Result { let state = AppState { serve_dir: Arc::new(serve_dir), build_tx, diff --git a/crates/fastled-cli/src/sketch_preprocessor.rs b/crates/fastled-cli/src/sketch_preprocessor.rs index 3516d728..92960833 100644 --- a/crates/fastled-cli/src/sketch_preprocessor.rs +++ b/crates/fastled-cli/src/sketch_preprocessor.rs @@ -9,7 +9,7 @@ use std::fs; use std::io::{self, Read, Write}; use std::path::Path; -use anyhow::{bail, Context, Result}; +use crate::error_compat::{bail, Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; use kernal_api::json::{self, Layout, Value}; diff --git a/crates/fastled-cli/src/source.rs b/crates/fastled-cli/src/source.rs index c7a766b3..7149066a 100644 --- a/crates/fastled-cli/src/source.rs +++ b/crates/fastled-cli/src/source.rs @@ -10,7 +10,7 @@ use std::io; use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use anyhow::{bail, Context, Result}; +use crate::error_compat::{bail, Context, Result}; use kernal_api::json::{self, Layout, Value}; use crate::cli::SourceAction; @@ -155,7 +155,7 @@ pub(crate) fn read_receipt(repo_dir: &Path) -> Result> { let path = receipt_path(repo_dir); match fs::read_to_string(&path) { Ok(text) => json::parse_members(text.as_bytes()) - .map_err(anyhow::Error::from) + .map_err(crate::error_compat::Error::from) .and_then(SourceReceipt::from_document) .with_context(|| format!("parse source receipt {}", path.display())) .map(Some), diff --git a/crates/fastled-cli/src/viewer.rs b/crates/fastled-cli/src/viewer.rs index 933f46ee..07193c25 100644 --- a/crates/fastled-cli/src/viewer.rs +++ b/crates/fastled-cli/src/viewer.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::process::Stdio; -use anyhow::{Context, Result}; +use crate::error_compat::{Context, Result}; use kernal_api::platform::process::{ spawn_sync, SpawnStdio, SpawnedChild, StdioSource, SyncEnvironment, }; @@ -179,7 +179,7 @@ pub(crate) fn launch_tauri_test_viewer(url: &str) -> Result { fn launch_tauri_viewer_with_options(url: &str, inject_test_runtime: bool) -> Result { if !url.starts_with("http://") && !url.starts_with("https://") { - anyhow::bail!( + crate::error_compat::bail!( "viewer must be launched with an http(s) URL pointing at the FastLED server, got: {url}" ); } diff --git a/crates/fastled-cli/src/wasm_build.rs b/crates/fastled-cli/src/wasm_build.rs index 8702b5f1..b5757578 100644 --- a/crates/fastled-cli/src/wasm_build.rs +++ b/crates/fastled-cli/src/wasm_build.rs @@ -11,7 +11,7 @@ use std::io::BufRead; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use anyhow::{bail, Context, Result}; +use crate::error_compat::{bail, Context, Result}; use kernal_api::hash::Sha256Hasher as Sha256; use kernal_api::json::{self, Layout, Value}; @@ -132,13 +132,13 @@ impl BuildFingerprints { toolchain_fingerprint(tools) .map(|value| (value, started.elapsed().as_secs_f64())) }); - let source = source - .join() - .map_err(|_| anyhow::anyhow!("FastLED fingerprint worker panicked"))??; - let toolchain = toolchain - .join() - .map_err(|_| anyhow::anyhow!("toolchain fingerprint worker panicked"))??; - Ok::<_, anyhow::Error>((source, toolchain)) + let source = source.join().map_err(|_| { + crate::error_compat::error!("FastLED fingerprint worker panicked") + })??; + let toolchain = toolchain.join().map_err(|_| { + crate::error_compat::error!("toolchain fingerprint worker panicked") + })??; + Ok::<_, crate::error_compat::Error>((source, toolchain)) })?; let mode_value = format!("mode={};link-mode={link_mode:?}", mode.as_str()); let library = dynamic_cache::fingerprint_values([ @@ -197,7 +197,7 @@ impl BuildFlagsToml { fn table(value: &Value) -> Result<&BTreeMap> { match value { Value::Table(fields) => Ok(fields), - _ => anyhow::bail!("build flag section must be a table"), + _ => crate::error_compat::bail!("build flag section must be a table"), } } fn strings(fields: &BTreeMap, name: &str) -> Result>> { @@ -205,13 +205,13 @@ impl BuildFlagsToml { .get(name) .map(|value| { let Value::Array(values) = value else { - anyhow::bail!("{name} must be an array of strings"); + crate::error_compat::bail!("{name} must be an array of strings"); }; values .iter() .map(|value| match value { Value::String(value) => Ok(value.clone()), - _ => anyhow::bail!("{name} must contain only strings"), + _ => crate::error_compat::bail!("{name} must contain only strings"), }) .collect() }) @@ -617,7 +617,7 @@ fn direct_clang_cflags( let install_dir = tools .emscripten_dir .parent() - .ok_or_else(|| anyhow::anyhow!("Emscripten directory has no install parent"))?; + .ok_or_else(|| crate::error_compat::error!("Emscripten directory has no install parent"))?; let cache_path = install_dir.join(DIRECT_CFLAGS_FILE); let lock_path = install_dir.join(format!("{DIRECT_CFLAGS_FILE}.lock")); let lock = fs::OpenOptions::new() @@ -883,7 +883,7 @@ fn link_wasm_dynamic( &runtime_fingerprint, &["fastled.js", "fastled.wasm"], ) - .map_err(anyhow::Error::msg) + .map_err(crate::error_compat::message) })(); if let Err(error) = rebuild { if let Err(mark_error) = dynamic_cache::mark_failure( @@ -1023,7 +1023,7 @@ fn link_wasm_dynamic( )?; dynamic_cache::publish_staging(staging, &sketch_entry)?; dynamic_cache::validate_entry(&sketch_entry, &sketch_fingerprint, &["sketch.wasm"]) - .map_err(anyhow::Error::msg) + .map_err(crate::error_compat::message) })(); if let Err(error) = rebuild { if let Err(mark_error) = dynamic_cache::mark_failure( @@ -1753,7 +1753,7 @@ fn compile_sketch( .args(direct_clang_compile_args(&args)); run_status(command, "clang++ sketch compile", log) } else { - Err(anyhow::anyhow!( + Err(crate::error_compat::error!( "{}", direct_cflags_error.unwrap_or_else(|| "em++ --cflags unavailable".to_string()) )) @@ -1776,7 +1776,7 @@ fn compile_sketch( dynamic_cache::write_metadata(staging.path(), &object_fingerprint, &["sketch.o"])?; dynamic_cache::publish_staging(staging, &object_entry)?; dynamic_cache::validate_entry(&object_entry, &object_fingerprint, &["sketch.o"]) - .map_err(anyhow::Error::msg)?; + .map_err(crate::error_compat::message)?; log( &format!( "[WASM] Sketch compile published: {} ({:.2}s)", @@ -1898,7 +1898,7 @@ fn run_em_link_with_retries( log: LogSink, ) -> Result<()> { let max_attempts = if cfg!(windows) { 6 } else { 1 }; - let mut last_err: Option = None; + let mut last_err: Option = None; for attempt in 1..=max_attempts { let mut command = command_with_env(&tools.python, tools); command.current_dir(fastled_dir).arg(&tools.empp).args(args); @@ -1918,7 +1918,7 @@ fn run_em_link_with_retries( } } } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("em++ wasm link failed"))) + Err(last_err.unwrap_or_else(|| crate::error_compat::error!("em++ wasm link failed"))) } fn direct_side_link_supported(tools: &ToolPaths, mode: BuildMode, wasm_bigint: &str) -> bool { @@ -2013,9 +2013,9 @@ fn run_direct_side_link_4019( } fn copy_linked_output(sketch_cache_dir: &Path, output_js: &Path) -> Result<()> { - let output_dir = output_js - .parent() - .ok_or_else(|| anyhow::anyhow!("output path has no parent: {}", output_js.display()))?; + let output_dir = output_js.parent().ok_or_else(|| { + crate::error_compat::error!("output path has no parent: {}", output_js.display()) + })?; fs::create_dir_all(output_dir)?; for name in [ "fastled.js", @@ -2078,9 +2078,9 @@ fn copy_dynamic_output( sketch_entry: &Path, output_js: &Path, ) -> Result { - let output_dir = output_js - .parent() - .ok_or_else(|| anyhow::anyhow!("output path has no parent: {}", output_js.display()))?; + let output_dir = output_js.parent().ok_or_else(|| { + crate::error_compat::error!("output path has no parent: {}", output_js.display()) + })?; fs::create_dir_all(output_dir)?; let mut copied = 0; for (source_dir, name) in [ From ced1acb9292c2d0bd78b36de73d84ff2fccaa87d Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 10:45:33 -0700 Subject: [PATCH 71/94] chore: rebase kernal migration onto terminal mainline --- Cargo.lock | 212 ++++++++++++++++++++++++++++++++-- Cargo.toml | 2 +- crates/fastled-cli/src/lib.rs | 3 +- 3 files changed, 203 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee434cc5..ceaefaef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,6 +385,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "chrono" version = "0.4.44" @@ -716,6 +722,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deflate64" version = "0.1.12" @@ -881,6 +893,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -949,7 +967,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1017,6 +1035,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1144,38 +1173,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -2012,6 +2041,7 @@ dependencies = [ "dirs 6.0.0", "flate2", "futures-core", + "futures-util", "getrandom 0.4.3", "globset", "gtk", @@ -2023,6 +2053,7 @@ dependencies = [ "mach2", "memmap2", "notify", + "portable-pty", "reflink-copy", "reqwest 0.12.28", "running-process", @@ -2041,6 +2072,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tokio-stream", + "tokio-tungstenite", "toml 0.8.23", "tree-sitter", "tree-sitter-cpp", @@ -2085,6 +2117,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -2302,6 +2340,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify" version = "7.0.0" @@ -2807,6 +2857,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2822,6 +2893,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2919,6 +2999,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3471,6 +3580,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3524,6 +3644,16 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -4191,6 +4321,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4427,6 +4569,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5349,6 +5508,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5504,6 +5672,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 2d77f515..f947d878 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "event-stream", "secure-random", "text-similarity", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } +kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index ce9564d4..ddec1e74 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -28,7 +28,8 @@ mod selection; mod server; mod sketch_preprocessor; mod source; -mod terminal; +// The terminal route is being reintroduced through kernal-api's owned +// WebSocket/PTY facade; do not compile the former Axum/portable-pty adapter. mod test_events; mod test_mode; pub mod viewer; From 06f6961c390b77c6dd9b09c9b80322bb3a9c40e7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 10:51:03 -0700 Subject: [PATCH 72/94] refactor(terminal): use kernal websocket and pty sessions --- crates/fastled-cli/src/lib.rs | 3 +- crates/fastled-cli/src/server.rs | 60 +++-- crates/fastled-cli/src/terminal.rs | 394 ++++++++++------------------- 3 files changed, 178 insertions(+), 279 deletions(-) diff --git a/crates/fastled-cli/src/lib.rs b/crates/fastled-cli/src/lib.rs index ddec1e74..ce9564d4 100644 --- a/crates/fastled-cli/src/lib.rs +++ b/crates/fastled-cli/src/lib.rs @@ -28,8 +28,7 @@ mod selection; mod server; mod sketch_preprocessor; mod source; -// The terminal route is being reintroduced through kernal-api's owned -// WebSocket/PTY facade; do not compile the former Axum/portable-pty adapter. +mod terminal; mod test_events; mod test_mode; pub mod viewer; diff --git a/crates/fastled-cli/src/server.rs b/crates/fastled-cli/src/server.rs index ed3c3266..05d3a780 100644 --- a/crates/fastled-cli/src/server.rs +++ b/crates/fastled-cli/src/server.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, RwLock}; use kernal_api::{ async_engine, - http_server::{Limits, Request, Response, Server}, + http_server::{Limits, Request, Response, Server, WebSocketLimits}, json::{self, Layout, Value}, }; @@ -379,6 +379,9 @@ self.addEventListener('message', (event) => { #[derive(Clone)] struct AppState { serve_dir: Arc, + terminal_cwd: Arc, + terminal_origin: Arc, + terminal_slots: async_engine::Semaphore, /// Broadcast channel for SSE build streaming. `None` when serving a /// static directory (no compilation happening). build_tx: Option>, @@ -701,6 +704,30 @@ async fn route(state: &AppState, request: Request) -> Reply { | "/viewer-screenshot" | "/test-done" ); + if path == "/terminal/ws" { + if request.method() != "GET" { + return empty(405)?.with_header("allow", "GET"); + } + let origin = request + .header("origin") + .and_then(|value| std::str::from_utf8(value).ok()); + let host = request + .header("host") + .and_then(|value| std::str::from_utf8(value).ok()); + if origin != Some(state.terminal_origin.as_str()) + || host != state.terminal_origin.strip_prefix("http://") + { + return empty(403); + } + let Some(permit) = state.terminal_slots.try_acquire() else { + return empty(429); + }; + let upgrade = request.into_websocket()?; + return upgrade.on_upgrade(WebSocketLimits::default(), { + let cwd = state.terminal_cwd.clone(); + move |socket| crate::terminal::connect(socket, cwd, permit) + }); + } if (post && request.method() != "POST") || (!post && !matches!(request.method(), "GET" | "HEAD")) { @@ -808,8 +835,24 @@ pub async fn start_server( debug_symbols: DebugSymbolHandle, test: Option, ) -> crate::error_compat::Result { + let terminal_cwd = Arc::new(crate::path::NormalizedPath::new(std::env::current_dir()?)); + let limits = server_limits(test.as_ref().map(|test| &test.runtime))?; + let server = Server::bind(SocketAddr::from(([127, 0, 0, 1], port)), limits) + .await? + .with_response_header("cross-origin-embedder-policy", "require-corp")? + .with_response_header("cross-origin-opener-policy", "same-origin")? + .with_response_header("cache-control", "no-cache, no-store, must-revalidate")? + .with_response_header("access-control-allow-origin", "*")? + .with_response_header( + "vary", + "origin, access-control-request-method, access-control-request-headers", + )?; + let addr = server.local_addr()?; let state = AppState { serve_dir: Arc::new(serve_dir), + terminal_cwd, + terminal_origin: Arc::new(format!("http://{addr}")), + terminal_slots: async_engine::Semaphore::new(4), build_tx, debug_symbols, test: test.map(Arc::new), @@ -825,21 +868,6 @@ pub async fn start_server( std::time::Duration::from_secs(30), )?, }; - - let server = Server::bind( - SocketAddr::from(([127, 0, 0, 1], port)), - server_limits(state.test.as_ref().map(|test| &test.runtime))?, - ) - .await? - .with_response_header("cross-origin-embedder-policy", "require-corp")? - .with_response_header("cross-origin-opener-policy", "same-origin")? - .with_response_header("cache-control", "no-cache, no-store, must-revalidate")? - .with_response_header("access-control-allow-origin", "*")? - .with_response_header( - "vary", - "origin, access-control-request-method, access-control-request-headers", - )?; - let addr = server.local_addr()?; let diagnostics = server.diagnostics(); async_engine::launch(async move { let result = server diff --git a/crates/fastled-cli/src/terminal.rs b/crates/fastled-cli/src/terminal.rs index 1ce2d175..4de16315 100644 --- a/crates/fastled-cli/src/terminal.rs +++ b/crates/fastled-cli/src/terminal.rs @@ -1,31 +1,32 @@ -//! Interactive terminal owned by the loopback server, never by the webview. -use std::io::{Read, Write}; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use axum::extract::ws::{Message, WebSocket}; -use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use serde::Deserialize; -use tokio::sync::{mpsc, OwnedSemaphorePermit}; - +//! Interactive terminal mapped onto kernal-owned WebSocket and PTY facades. use crate::path::NormalizedPath; +use kernal_api::{ + async_engine, + http_server::{WebSocket, WebSocketMessage}, + json::{self, Value}, + platform::terminal::PtySize, + pty::{PtyCommand, PtySession}, +}; +use std::{ + io::{self, Read}, + path::Path, + sync::Arc, + time::Duration, +}; -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)] -pub(crate) enum ClientMessage { +enum ClientMessage { Ack, - Input { data: String }, - Binary { data: Vec }, + Input(String), + Binary(Vec), Resize { cols: u16, rows: u16 }, } - -fn size(cols: u16, rows: u16) -> Result { - anyhow::ensure!( - (2..=500).contains(&cols) && (1..=300).contains(&rows), - "invalid terminal dimensions" - ); +fn size(cols: u16, rows: u16) -> io::Result { + if !(2..=500).contains(&cols) || !(1..=300).contains(&rows) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid terminal dimensions", + )); + } Ok(PtySize { rows, cols, @@ -33,146 +34,102 @@ fn size(cols: u16, rows: u16) -> Result { pixel_height: 0, }) } - -/// Preserve the launch environment and add uv's normal tool directory even -/// when the parent was started by a desktop entry. Login files run in the PTY. -fn shell_command(cwd: &Path) -> Result { +fn shell_command(cwd: &Path) -> PtyCommand { #[cfg(unix)] let mut command = { let shell = std::env::var_os("SHELL") .filter(|s| !s.is_empty()) .unwrap_or_else(|| "/bin/sh".into()); - let mut command = CommandBuilder::new(shell); - command.args(["-l", "-i"]); + let mut command = PtyCommand::new(shell); + command.arguments = vec!["-l".into(), "-i".into()]; command }; #[cfg(windows)] let mut command = - CommandBuilder::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(".local").join("bin")); - let nix_profile = home.join(".nix-profile").join("bin"); - if nix_profile.is_dir() { - paths.push(nix_profile); - } - } - if let Some(path) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(&path)); - } - #[cfg(unix)] - paths.extend(["/usr/local/bin", "/usr/bin", "/bin"].map(Into::into)); - #[cfg(unix)] - if Path::new("/run/current-system/sw/bin").is_dir() { - // NixOS has no /usr/bin tool set; inherited profile guards can prevent - // a login shell from restoring it after a desktop strips PATH. - paths.push("/run/current-system/sw/bin".into()); - } - command.env( - "PATH", - std::env::join_paths(paths).context("build terminal PATH")?, - ); - command.env("TERM", "xterm-256color"); - command.cwd(cwd); - Ok(command) -} - -struct Session { - master: Box, - writer: Option>, - child: Box, + PtyCommand::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())); + command.cwd = Some(cwd.to_path_buf()); + command } - -impl Session { - fn spawn(command: CommandBuilder) -> Result<(Self, Box)> { - let pair = native_pty_system().openpty(size(80, 24)?)?; - let reader = pair.master.try_clone_reader()?; - let writer = pair.master.take_writer()?; - let child = pair - .slave - .spawn_command(command) - .context("spawn interactive shell")?; - drop(pair.slave); - Ok(( - Self { - master: pair.master, - writer: Some(writer), - child, - }, - reader, - )) - } - - #[cfg(all(test, unix))] - fn input(&mut self, input: ClientMessage) -> Result<()> { - let writer = self.writer.as_mut().context("writer already taken")?; - match input { - ClientMessage::Ack => {} - ClientMessage::Input { data } => writer.write_all(data.as_bytes())?, - ClientMessage::Binary { data } => writer.write_all(&data)?, - ClientMessage::Resize { cols, rows } => self.master.resize(size(cols, rows)?)?, - } - writer.flush()?; - Ok(()) +fn parse_text(text: &str) -> io::Result { + let Value::Object(fields) = json::parse(text.as_bytes()).map_err(io::Error::other)? else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal message must be an object", + )); + }; + let Some(Value::String(kind)) = fields.get("type") else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal message type missing", + )); + }; + match kind.as_str() { + "ack" => Ok(ClientMessage::Ack), + "input" => match fields.get("data") { + Some(Value::String(data)) => Ok(ClientMessage::Input(data.clone())), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal input missing", + )), + }, + "resize" => match ( + fields.get("cols").and_then(port_dimension), + fields.get("rows").and_then(port_dimension), + ) { + (Some(cols), Some(rows)) => Ok(ClientMessage::Resize { cols, rows }), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal resize invalid", + )), + }, + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal message unknown", + )), } } -impl Drop for Session { - fn drop(&mut self) { - // Closing the master also hangs up the foreground terminal job. Kill - // and reap the shell explicitly rather than leaving zombies behind. - #[cfg(unix)] - if let Some(group) = self.master.process_group_leader() { - // Leave the shell alive to handle SIGHUP and forward it to its - // background jobs. A foreground program must not block cleanup. - if Some(group as u32) != self.child.process_id() { - // SAFETY: group is the positive foreground process group of - // our own controlling PTY. Negative pid targets that group. - unsafe { - libc::kill(-group, libc::SIGKILL); - } - } - } - let _ = self.child.kill(); - let _ = self.child.wait(); +fn port_dimension(value: &Value) -> Option { + match value { + Value::Unsigned(value) => (*value).try_into().ok(), + Value::Signed(value) if *value >= 0 => (*value).try_into().ok(), + _ => None, } } - -/// The async future owns input_tx; cancellation (including runtime shutdown) -/// disconnects the worker, whose Session guard kills/reaps the shell. pub(crate) async fn connect( - mut socket: WebSocket, + socket: WebSocket, cwd: Arc, - permit: OwnedSemaphorePermit, + permit: async_engine::SemaphorePermit, ) { - let (input_tx, mut input_rx) = mpsc::channel::(32); - let (output_tx, mut output_rx) = mpsc::channel::(32); + let (mut writer, mut reader) = socket.split(); + let (input_tx, mut input_rx) = async_engine::channel(32); + let (output_tx, mut output_rx) = async_engine::channel(32); + let input_task = async_engine::launch(async move { + while let Ok(Some(message)) = reader.receive().await { + let input = match message { + WebSocketMessage::Text(text) => parse_text(&text), + WebSocketMessage::Binary(data) => Ok(ClientMessage::Binary(data)), + WebSocketMessage::Ping(_) | WebSocketMessage::Pong(_) => continue, + WebSocketMessage::Close => break, + }; + match input { + Ok(message) => { + if input_tx.try_send(message).is_err() { + break; + } + } + Err(_) => break, + } + } + }); let worker_tx = output_tx.clone(); - // Dedicated threads instead of spawn_blocking: a PTY reader is long lived - // and must not keep Tokio's runtime shutdown waiting for blocking tasks. let worker = std::thread::Builder::new() .name("fastled-terminal".into()) .spawn(move || { let _permit = permit; - let result = (|| -> Result<()> { - let (mut session, mut reader) = Session::spawn(shell_command(cwd.as_path())?)?; - // A stopped/no-reader foreground program can block write_all. - // Keep writes off the supervisor so disconnect always drops - // Session and terminates the child, releasing blocked I/O. - let mut writer = session.writer.take().context("PTY writer missing")?; - let (write_tx, write_rx) = std::sync::mpsc::sync_channel::>(32); - let (failure_tx, failure_rx) = std::sync::mpsc::channel(); - std::thread::Builder::new() - .name("fastled-terminal-input".into()) - .spawn(move || { - while let Ok(data) = write_rx.recv() { - if let Err(error) = writer.write_all(&data).and_then(|_| writer.flush()) - { - let _ = failure_tx.send(error); - break; - } - } - })?; + let result = (|| -> io::Result<()> { + let (mut session, mut reader) = + PtySession::spawn(shell_command(cwd.as_path()), size(80, 24)?)?; let reader_tx = worker_tx.clone(); std::thread::Builder::new() .name("fastled-terminal-output".into()) @@ -180,50 +137,34 @@ pub(crate) async fn connect( let mut buffer = [0u8; 8192]; loop { match reader.read(&mut buffer) { - Ok(0) => break, + Ok(0) | Err(_) => break, Ok(count) => { if reader_tx - .blocking_send(Message::Binary( - buffer[..count].to_vec().into(), + .blocking_send(WebSocketMessage::Binary( + buffer[..count].to_vec(), )) .is_err() { break; } } - Err(error) if error.kind() == std::io::ErrorKind::Interrupted => { - continue - } - // Unix PTYs commonly report EIO at normal slave close. - Err(_) => break, } } })?; loop { - if let Ok(error) = failure_rx.try_recv() { - return Err(error).context("write terminal input"); - } match input_rx.try_recv() { - Ok(ClientMessage::Input { data }) => { - write_tx - .try_send(data.into_bytes()) - .context("terminal input queue full")? - } - Ok(ClientMessage::Binary { data }) => write_tx - .try_send(data) - .context("terminal input queue full")?, + Ok(ClientMessage::Input(data)) => session.write(data.as_bytes())?, + Ok(ClientMessage::Binary(data)) => session.write(&data)?, Ok(ClientMessage::Resize { cols, rows }) => { - session.master.resize(size(cols, rows)?)? + session.resize(size(cols, rows)?)? } Ok(ClientMessage::Ack) => {} - Err(mpsc::error::TryRecvError::Disconnected) => break, - Err(mpsc::error::TryRecvError::Empty) => { - if let Some(status) = session.child.try_wait()? { - let _ = worker_tx.blocking_send(Message::Text( - serde_json::json!({"exit": status.exit_code()}) - .to_string() - .into(), - )); + Err(async_engine::TryRecvError::Disconnected) => break, + Err(async_engine::TryRecvError::Empty) => { + if let Some(status) = session.try_wait()? { + let _ = worker_tx.blocking_send(WebSocketMessage::Text(format!( + r#"{{\"exit\":{status}}}"# + ))); break; } std::thread::sleep(Duration::from_millis(10)); @@ -232,54 +173,27 @@ pub(crate) async fn connect( } Ok(()) })(); - if let Err(error) = result { - let _ = worker_tx.blocking_send(Message::Text( - serde_json::json!({"error": format!("Terminal: {error:#}")}) - .to_string() - .into(), + if result.is_err() { + let _ = worker_tx.blocking_send(WebSocketMessage::Text( + r#"{"error":"Terminal failed"}"#.into(), )); } }); drop(output_tx); if worker.is_err() { - let _ = socket - .send(Message::Text( - "{\"error\":\"Could not start terminal worker\"}".into(), + let _ = writer + .send(WebSocketMessage::Text( + r#"{"error":"Could not start terminal worker"}"#.into(), )) .await; return; } - let mut pending_writes = 0usize; - loop { - tokio::select! { - output = output_rx.recv(), if pending_writes < 16 => match output { - Some(message) => { - if matches!(message, Message::Binary(_)) { pending_writes += 1; } - if socket.send(message).await.is_err() { break; } - }, - None => break, - }, - input = socket.recv() => match input { - Some(Ok(Message::Text(text))) => { - let parsed = serde_json::from_str::(&text); - match parsed { - Ok(ClientMessage::Ack) => pending_writes = pending_writes.saturating_sub(1), - Ok(input) => if input_tx.try_send(input).is_err() { break; }, - Err(_) => { - let _ = socket.send(Message::Text("{\"error\":\"Invalid terminal message\"}".into())).await; - break; - } - } - } - Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => {}, - _ => break, - } + while let Some(message) = output_rx.recv().await { + if writer.send(message).await.is_err() { + break; } } - // Drop the queues before awaiting websocket closure so cleanup is prompt. - drop(input_tx); - drop(output_rx); - let _ = socket.send(Message::Close(None)).await; + input_task.cancel(); } #[cfg(test)] @@ -287,65 +201,23 @@ mod tests { use super::*; #[test] - fn terminal_240_dimensions_and_protocol() { + fn terminal_dimensions_and_json_protocol_are_bounded() { assert!(size(0, 24).is_err()); assert!(size(80, 0).is_err()); assert!(size(501, 24).is_err()); assert!(size(120, 40).is_ok()); - assert!(serde_json::from_str::(r#"{"type":"exec","data":"oops"}"#).is_err()); - } - - #[test] - #[cfg(unix)] - fn terminal_240_real_pty_cwd_input_resize_and_utf8() { - let cwd = tempfile::tempdir().unwrap(); - let mut command = CommandBuilder::new("/bin/sh"); - command.arg("-i"); - command.cwd(cwd.path()); - let (mut session, mut reader) = Session::spawn(command).unwrap(); - session - .input(ClientMessage::Resize { - cols: 111, - rows: 37, - }) - .unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut buffer = [0; 4096]; - while let Ok(count) = reader.read(&mut buffer) { - if count == 0 || tx.send(buffer[..count].to_vec()).is_err() { - break; - } - } - }); - session - .input(ClientMessage::Input { - data: "pwd; stty size; printf '\\033[31mPTY_é_OK\\033[0m\\n'\n".into(), - }) - .unwrap(); - let deadline = std::time::Instant::now() + Duration::from_secs(10); - let mut output = Vec::new(); - loop { - output.extend( - rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) - .unwrap(), - ); - let text = String::from_utf8_lossy(&output); - if text.contains(&format!("{}\r\n", cwd.path().display())) - && text.contains("37 111\r\n") - && text.contains("\x1b[31mPTY_é_OK\x1b[0m") - { - break; - } - } - session - .input(ClientMessage::Input { - data: "exit\n".into(), - }) - .unwrap(); - while session.child.try_wait().unwrap().is_none() { - assert!(std::time::Instant::now() < deadline); - std::thread::sleep(Duration::from_millis(10)); - } + assert!(matches!( + parse_text(r#"{"type":"ack"}"#), + Ok(ClientMessage::Ack) + )); + assert!(matches!( + parse_text(r#"{"type":"input","data":"echo hi\n"}"#), + Ok(ClientMessage::Input(_)) + )); + assert!(matches!( + parse_text(r#"{"type":"resize","cols":120,"rows":40}"#), + Ok(ClientMessage::Resize { .. }) + )); + assert!(parse_text(r#"{"type":"exec","data":"oops"}"#).is_err()); } } From 95ba65315bcda594c94e3c75fd075c5974ee9fa0 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 10:58:37 -0700 Subject: [PATCH 73/94] fix(ci): resolve kernal migration override remotely --- Cargo.lock | 93 ++++-------------------------- Cargo.toml | 3 +- crates/fastled-cli/src/terminal.rs | 88 +++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ceaefaef..90d39854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -814,7 +814,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1000,7 +1000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2034,6 +2034,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.0" +source = "git+https://github.com/zackees/kernal-api.git?rev=7457d73fdc4b4453678159476dd2ee6c443a58fa#7457d73fdc4b4453678159476dd2ee6c443a58fa" dependencies = [ "blake3", "bytes", @@ -2290,7 +2291,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3112,7 +3113,7 @@ dependencies = [ "cfg-if", "libc", "rustix", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -3271,7 +3272,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4175,10 +4176,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4999,7 +5000,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5250,15 +5251,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5292,30 +5284,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -5355,12 +5330,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5373,12 +5342,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5391,24 +5354,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5421,12 +5372,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5439,12 +5384,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5457,12 +5396,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5475,12 +5408,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" diff --git a/Cargo.toml b/Cargo.toml index f947d878..daed086d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,9 @@ split-debuginfo = "packed" strip = "none" # Temporary migration override; replace with the verified published release. +# A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { path = "../fastled-wasm-extern/kernal-api-cli" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "7457d73fdc4b4453678159476dd2ee6c443a58fa" } [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/src/terminal.rs b/crates/fastled-cli/src/terminal.rs index 4de16315..57f66688 100644 --- a/crates/fastled-cli/src/terminal.rs +++ b/crates/fastled-cli/src/terminal.rs @@ -8,6 +8,8 @@ use kernal_api::{ pty::{PtyCommand, PtySession}, }; use std::{ + collections::BTreeSet, + ffi::OsString, io::{self, Read}, path::Path, sync::Arc, @@ -34,7 +36,9 @@ fn size(cols: u16, rows: u16) -> io::Result { pixel_height: 0, }) } -fn shell_command(cwd: &Path) -> PtyCommand { +/// Preserve the launch environment and repair the common desktop-entry PATH. +/// Login files still run inside the interactive shell. +fn shell_command(cwd: &Path) -> io::Result { #[cfg(unix)] let mut command = { let shell = std::env::var_os("SHELL") @@ -47,34 +51,64 @@ fn shell_command(cwd: &Path) -> PtyCommand { #[cfg(windows)] let mut command = PtyCommand::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())); + let mut paths = Vec::new(); + if let Some(home) = kernal_api::platform::host::home_dir() { + paths.push(home.join(".local").join("bin")); + let nix_profile = home.join(".nix-profile").join("bin"); + if nix_profile.is_dir() { + paths.push(nix_profile); + } + } + if let Some(path) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&path)); + } + #[cfg(unix)] + paths.extend(["/usr/local/bin", "/usr/bin", "/bin"].map(Into::into)); + #[cfg(unix)] + if Path::new("/run/current-system/sw/bin").is_dir() { + paths.push("/run/current-system/sw/bin".into()); + } + let mut environment: Vec<_> = std::env::vars_os() + .filter(|(key, _)| key != "PATH" && key != "TERM") + .collect(); + environment.push(( + OsString::from("PATH"), + std::env::join_paths(paths).map_err(io::Error::other)?, + )); + environment.push((OsString::from("TERM"), OsString::from("xterm-256color"))); + command.environment = Some(environment); command.cwd = Some(cwd.to_path_buf()); - command + Ok(command) } fn parse_text(text: &str) -> io::Result { - let Value::Object(fields) = json::parse(text.as_bytes()).map_err(io::Error::other)? else { + let Value::ObjectMembers(fields) = + json::parse_members(text.as_bytes()).map_err(io::Error::other)? + else { return Err(io::Error::new( io::ErrorKind::InvalidInput, "terminal message must be an object", )); }; - let Some(Value::String(kind)) = fields.get("type") else { + let Some(Value::String(kind)) = unique_member(&fields, "type") else { return Err(io::Error::new( io::ErrorKind::InvalidInput, "terminal message type missing", )); }; match kind.as_str() { - "ack" => Ok(ClientMessage::Ack), - "input" => match fields.get("data") { - Some(Value::String(data)) => Ok(ClientMessage::Input(data.clone())), - _ => Err(io::Error::new( - io::ErrorKind::InvalidInput, - "terminal input missing", - )), - }, - "resize" => match ( - fields.get("cols").and_then(port_dimension), - fields.get("rows").and_then(port_dimension), + "ack" if has_only_fields(&fields, &["type"]) => Ok(ClientMessage::Ack), + "input" if has_only_fields(&fields, &["type", "data"]) => { + match unique_member(&fields, "data") { + Some(Value::String(data)) => Ok(ClientMessage::Input(data.clone())), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal input missing", + )), + } + } + "resize" if has_only_fields(&fields, &["type", "cols", "rows"]) => match ( + unique_member(&fields, "cols").and_then(port_dimension), + unique_member(&fields, "rows").and_then(port_dimension), ) { (Some(cols), Some(rows)) => Ok(ClientMessage::Resize { cols, rows }), _ => Err(io::Error::new( @@ -89,6 +123,26 @@ fn parse_text(text: &str) -> io::Result { } } +/// Return a member only when it appears exactly once. The protocol intentionally +/// rejects duplicate and unknown fields just as the previous typed decoder did. +fn unique_member<'a>(fields: &'a [(String, Value)], name: &str) -> Option<&'a Value> { + let mut members = fields + .iter() + .filter_map(|(key, value)| (key == name).then_some(value)); + let member = members.next()?; + members.next().is_none().then_some(member) +} + +fn has_only_fields(fields: &[(String, Value)], permitted: &[&str]) -> bool { + let permitted: BTreeSet<_> = permitted.iter().copied().collect(); + fields + .iter() + .all(|(key, _)| permitted.contains(key.as_str())) + && permitted + .iter() + .all(|key| unique_member(fields, key).is_some()) +} + fn port_dimension(value: &Value) -> Option { match value { Value::Unsigned(value) => (*value).try_into().ok(), @@ -129,7 +183,7 @@ pub(crate) async fn connect( let _permit = permit; let result = (|| -> io::Result<()> { let (mut session, mut reader) = - PtySession::spawn(shell_command(cwd.as_path()), size(80, 24)?)?; + PtySession::spawn(shell_command(cwd.as_path())?, size(80, 24)?)?; let reader_tx = worker_tx.clone(); std::thread::Builder::new() .name("fastled-terminal-output".into()) @@ -219,5 +273,7 @@ mod tests { Ok(ClientMessage::Resize { .. }) )); assert!(parse_text(r#"{"type":"exec","data":"oops"}"#).is_err()); + assert!(parse_text(r#"{"type":"ack","ignored":true}"#).is_err()); + assert!(parse_text(r#"{"type":"ack","type":"input","data":"oops"}"#).is_err()); } } From 318959d9905e766b53ff6bb336c49a62b92d2a4d Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 11:01:03 -0700 Subject: [PATCH 74/94] fix(terminal): retain bounded acknowledgement flow --- crates/fastled-cli/src/terminal.rs | 31 +++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/fastled-cli/src/terminal.rs b/crates/fastled-cli/src/terminal.rs index 57f66688..0eee7ce9 100644 --- a/crates/fastled-cli/src/terminal.rs +++ b/crates/fastled-cli/src/terminal.rs @@ -12,7 +12,10 @@ use std::{ ffi::OsString, io::{self, Read}, path::Path, - sync::Arc, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, time::Duration, }; @@ -143,6 +146,12 @@ fn has_only_fields(fields: &[(String, Value)], permitted: &[&str]) -> bool { .all(|key| unique_member(fields, key).is_some()) } +fn acknowledge(pending_writes: &AtomicUsize) { + let _ = pending_writes.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |pending| { + pending.checked_sub(1) + }); +} + fn port_dimension(value: &Value) -> Option { match value { Value::Unsigned(value) => (*value).try_into().ok(), @@ -158,6 +167,10 @@ pub(crate) async fn connect( let (mut writer, mut reader) = socket.split(); let (input_tx, mut input_rx) = async_engine::channel(32); let (output_tx, mut output_rx) = async_engine::channel(32); + let pending_writes = Arc::new(AtomicUsize::new(0)); + let input_closed = Arc::new(AtomicBool::new(false)); + let input_pending_writes = Arc::clone(&pending_writes); + let input_closed_task = Arc::clone(&input_closed); let input_task = async_engine::launch(async move { while let Ok(Some(message)) = reader.receive().await { let input = match message { @@ -167,6 +180,7 @@ pub(crate) async fn connect( WebSocketMessage::Close => break, }; match input { + Ok(ClientMessage::Ack) => acknowledge(&input_pending_writes), Ok(message) => { if input_tx.try_send(message).is_err() { break; @@ -175,6 +189,7 @@ pub(crate) async fn connect( Err(_) => break, } } + input_closed_task.store(true, Ordering::Relaxed); }); let worker_tx = output_tx.clone(); let worker = std::thread::Builder::new() @@ -243,6 +258,16 @@ pub(crate) async fn connect( return; } while let Some(message) = output_rx.recv().await { + if matches!(message, WebSocketMessage::Binary(_)) { + while pending_writes.load(Ordering::Relaxed) >= 16 { + if input_closed.load(Ordering::Relaxed) { + input_task.cancel(); + return; + } + async_engine::sleep(Duration::from_millis(5)).await; + } + pending_writes.fetch_add(1, Ordering::Relaxed); + } if writer.send(message).await.is_err() { break; } @@ -275,5 +300,9 @@ mod tests { assert!(parse_text(r#"{"type":"exec","data":"oops"}"#).is_err()); assert!(parse_text(r#"{"type":"ack","ignored":true}"#).is_err()); assert!(parse_text(r#"{"type":"ack","type":"input","data":"oops"}"#).is_err()); + let pending_writes = AtomicUsize::new(1); + acknowledge(&pending_writes); + acknowledge(&pending_writes); + assert_eq!(pending_writes.load(Ordering::Relaxed), 0); } } From 8d3b9edb67894a53c8a05b2d829888764063c941 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 11:05:04 -0700 Subject: [PATCH 75/94] chore: ingest kernal Windows portability fix --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90d39854..0e3e7640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.0" -source = "git+https://github.com/zackees/kernal-api.git?rev=7457d73fdc4b4453678159476dd2ee6c443a58fa#7457d73fdc4b4453678159476dd2ee6c443a58fa" +source = "git+https://github.com/zackees/kernal-api.git?rev=264308e4db3b6da79cdb7cd66f7a4816c6f2cb49#264308e4db3b6da79cdb7cd66f7a4816c6f2cb49" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index daed086d..3b6c72d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "7457d73fdc4b4453678159476dd2ee6c443a58fa" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "264308e4db3b6da79cdb7cd66f7a4816c6f2cb49" } [profile.dev.package.kernal-api] codegen-units = 1 From 59e5a917e2152ab38c7c38f6ddfc41d2c9936a44 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 11:13:08 -0700 Subject: [PATCH 76/94] test: ingest kernal PTY ownership coverage --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e3e7640..a9f530d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2034,7 +2034,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.0" -source = "git+https://github.com/zackees/kernal-api.git?rev=264308e4db3b6da79cdb7cd66f7a4816c6f2cb49#264308e4db3b6da79cdb7cd66f7a4816c6f2cb49" +source = "git+https://github.com/zackees/kernal-api.git?rev=c021e1eea366bb6d87f493e488bb29689ae10c71#c021e1eea366bb6d87f493e488bb29689ae10c71" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 3b6c72d2..1b802234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "264308e4db3b6da79cdb7cd66f7a4816c6f2cb49" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "c021e1eea366bb6d87f493e488bb29689ae10c71" } [profile.dev.package.kernal-api] codegen-units = 1 From f57645bd1838ec7a4174d200968c881b1612a298 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 11:18:14 -0700 Subject: [PATCH 77/94] chore: target kernal-api 0.1.1 release candidate --- Cargo.lock | 41 ++++++----------------------------------- Cargo.toml | 4 ++-- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a9f530d9..b0f6acd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1298,16 +1298,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link 0.2.1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2033,8 +2023,8 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.0" -source = "git+https://github.com/zackees/kernal-api.git?rev=c021e1eea366bb6d87f493e488bb29689ae10c71#c021e1eea366bb6d87f493e488bb29689ae10c71" +version = "0.1.1" +source = "git+https://github.com/zackees/kernal-api.git?rev=50c1d4a62e39505e14205fa094f7e95732ed55c0#50c1d4a62e39505e14205fa094f7e95732ed55c0" dependencies = [ "blake3", "bytes", @@ -3217,9 +3207,9 @@ dependencies = [ [[package]] name = "running-process" -version = "4.10.10" +version = "4.10.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d49ccdbfa59730688bbbeca9c9f3f4ee4c8976e38e9e5929fee8128bbd046db" +checksum = "492c9f25a9a886d7e4281eae987e2d90732ee07f01a35860899618b3b3484cc3" dependencies = [ "libc", "running-process-platform-internal", @@ -3232,19 +3222,17 @@ dependencies = [ [[package]] name = "running-process-platform-internal" -version = "4.10.11" +version = "4.10.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9be8d7fb159393e54586b070fec01912fab04444f98f4db54fd3ceebdae59" +checksum = "ce466bd3d8c281e4f3f409bf0931011306172fbd74032787958eaf5272505391" dependencies = [ "libc", - "png 0.17.16", "sysinfo", "thiserror 2.0.20", "tokio", "widestring", "winapi", "windows-sys 0.61.2", - "x11rb", ] [[package]] @@ -5550,23 +5538,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "gethostname", - "rustix", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - [[package]] name = "xz2" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 1b802234..f5237a3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { version = "=0.1.0", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } +kernal-api = { version = "=0.1.1", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "c021e1eea366bb6d87f493e488bb29689ae10c71" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "50c1d4a62e39505e14205fa094f7e95732ed55c0" } [profile.dev.package.kernal-api] codegen-units = 1 From 78642049c263b2735dd1a44cef37dbbef3849867 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 11:22:06 -0700 Subject: [PATCH 78/94] chore: target kernal-api 0.1.2 release candidate --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0f6acd5..5a10b90a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,8 +2023,8 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.1" -source = "git+https://github.com/zackees/kernal-api.git?rev=50c1d4a62e39505e14205fa094f7e95732ed55c0#50c1d4a62e39505e14205fa094f7e95732ed55c0" +version = "0.1.2" +source = "git+https://github.com/zackees/kernal-api.git?rev=caab3ed#caab3ed841e8c00cb735e76f63d661080473ab98" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index f5237a3f..0c66b5dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { version = "=0.1.1", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } +kernal-api = { version = "=0.1.2", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "50c1d4a62e39505e14205fa094f7e95732ed55c0" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "caab3ed" } [profile.dev.package.kernal-api] codegen-units = 1 From 242c53b60acc90f4114fcee04cbee23fb53c490d Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 11:44:03 -0700 Subject: [PATCH 79/94] chore: ingest kernal Windows ACL fix --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a10b90a..8b1929af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.2" -source = "git+https://github.com/zackees/kernal-api.git?rev=caab3ed#caab3ed841e8c00cb735e76f63d661080473ab98" +source = "git+https://github.com/zackees/kernal-api.git?rev=cca183871aee7f55a340844a916805cda0c5c727#cca183871aee7f55a340844a916805cda0c5c727" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 0c66b5dd..63e0dca1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "caab3ed" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "cca183871aee7f55a340844a916805cda0c5c727" } [profile.dev.package.kernal-api] codegen-units = 1 From c923119dac45d9d1b9bcc1602fe6918160958d64 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 11:53:42 -0700 Subject: [PATCH 80/94] chore: ingest kernal ACL test diagnostics --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b1929af..9ec10d49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.2" -source = "git+https://github.com/zackees/kernal-api.git?rev=cca183871aee7f55a340844a916805cda0c5c727#cca183871aee7f55a340844a916805cda0c5c727" +source = "git+https://github.com/zackees/kernal-api.git?rev=1fdd2a5f0bc567068684aba552fee8cf153988ee#1fdd2a5f0bc567068684aba552fee8cf153988ee" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 63e0dca1..9ac15cad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "cca183871aee7f55a340844a916805cda0c5c727" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "1fdd2a5f0bc567068684aba552fee8cf153988ee" } [profile.dev.package.kernal-api] codegen-units = 1 From 59ae1e25dcc159043d44e8a1865308953419c758 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 14:03:22 -0700 Subject: [PATCH 81/94] chore: advance kernal-api migration pin --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ec10d49..b2879d25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.2" -source = "git+https://github.com/zackees/kernal-api.git?rev=1fdd2a5f0bc567068684aba552fee8cf153988ee#1fdd2a5f0bc567068684aba552fee8cf153988ee" +source = "git+https://github.com/zackees/kernal-api.git?rev=8078ce8c95e55811ce3f359ac424340199f0e5c7#8078ce8c95e55811ce3f359ac424340199f0e5c7" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 9ac15cad..1f841cf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "1fdd2a5f0bc567068684aba552fee8cf153988ee" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "8078ce8c95e55811ce3f359ac424340199f0e5c7" } [profile.dev.package.kernal-api] codegen-units = 1 From 784b328df9aa7b577bc000339fcb41e344caab06 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 15:29:25 -0700 Subject: [PATCH 82/94] fix: advance kernal-api migration pin --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2879d25..223bfb19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.2" -source = "git+https://github.com/zackees/kernal-api.git?rev=8078ce8c95e55811ce3f359ac424340199f0e5c7#8078ce8c95e55811ce3f359ac424340199f0e5c7" +source = "git+https://github.com/zackees/kernal-api.git?rev=b63506dc1e5d963d57235ab1d26e0e86f1ed4f51#b63506dc1e5d963d57235ab1d26e0e86f1ed4f51" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 1f841cf2..dece0294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "8078ce8c95e55811ce3f359ac424340199f0e5c7" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "b63506dc1e5d963d57235ab1d26e0e86f1ed4f51" } [profile.dev.package.kernal-api] codegen-units = 1 From ea41521c5596dff54a2b9c40b6de9f41236cef4c Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 15:34:23 -0700 Subject: [PATCH 83/94] fix: pin Windows-compilable kernal-api revision --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 223bfb19..ec193424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.2" -source = "git+https://github.com/zackees/kernal-api.git?rev=b63506dc1e5d963d57235ab1d26e0e86f1ed4f51#b63506dc1e5d963d57235ab1d26e0e86f1ed4f51" +source = "git+https://github.com/zackees/kernal-api.git?rev=c5dd324#c5dd324f65e6e970566cc5f349b9e456ca9a2e3e" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index dece0294..a1bd4134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "b63506dc1e5d963d57235ab1d26e0e86f1ed4f51" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "c5dd324" } [profile.dev.package.kernal-api] codegen-units = 1 From f38eae20ddaad5c2fff4953329a06f0ab2aeda2e Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sun, 13 Sep 2026 15:38:11 -0700 Subject: [PATCH 84/94] fix: pin merged kernal-api Windows fix --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec193424..7109df8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "kernal-api" version = "0.1.2" -source = "git+https://github.com/zackees/kernal-api.git?rev=c5dd324#c5dd324f65e6e970566cc5f349b9e456ca9a2e3e" +source = "git+https://github.com/zackees/kernal-api.git?rev=762a1d2eb09c226971eb3f30be67ee7f903a35ab#762a1d2eb09c226971eb3f30be67ee7f903a35ab" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index a1bd4134..dc1267be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ strip = "none" # Temporary migration override; replace with the verified published release. # A git revision keeps CI hermetic while the crates.io release is pending. [patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "c5dd324" } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "762a1d2eb09c226971eb3f30be67ee7f903a35ab" } [profile.dev.package.kernal-api] codegen-units = 1 From eb5a51de796bd83c059aad4dc721018971a93a6a Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 14 Sep 2026 08:26:37 -0700 Subject: [PATCH 85/94] build: consume released kernal-api v0.1.3 and enforce sole dependency - Replace the migration-only [patch.crates-io] git revision with the kernal-api v0.1.3 release tag (cut from the validated 762a1d2 pin). - Boundary test now requires kernal-api to be the only Rust dependency, forbids [patch], build.rs and generated Tauri output, and requires the hash-sha256 feature. - Remove obsolete generated Tauri ACL schemas. - Port the SHA-256 encoding lock-down tests from #244 onto kernel JSON. Refs #229, #235 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- Cargo.lock | 4 +- Cargo.toml | 7 +- .../gen/schemas/acl-manifests.json | 1 - .../fastled-cli/gen/schemas/capabilities.json | 1 - .../gen/schemas/desktop-schema.json | 2244 ----------------- .../gen/schemas/windows-schema.json | 2244 ----------------- crates/fastled-cli/src/archive.rs | 16 +- crates/fastled-cli/src/dynamic_cache.rs | 42 + tests/unit/test_kernal_boundary.py | 24 +- 9 files changed, 83 insertions(+), 4500 deletions(-) delete mode 100644 crates/fastled-cli/gen/schemas/acl-manifests.json delete mode 100644 crates/fastled-cli/gen/schemas/capabilities.json delete mode 100644 crates/fastled-cli/gen/schemas/desktop-schema.json delete mode 100644 crates/fastled-cli/gen/schemas/windows-schema.json diff --git a/Cargo.lock b/Cargo.lock index 7109df8a..8ce98afc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,8 +2023,8 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.2" -source = "git+https://github.com/zackees/kernal-api.git?rev=762a1d2eb09c226971eb3f30be67ee7f903a35ab#762a1d2eb09c226971eb3f30be67ee7f903a35ab" +version = "0.1.3" +source = "git+https://github.com/zackees/kernal-api.git?tag=v0.1.3#ac2659fe397e98a13c5a6a2a50eff2771fce900f" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index dc1267be..bbcf216e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,18 +13,13 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { version = "=0.1.2", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.3", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" split-debuginfo = "packed" strip = "none" -# Temporary migration override; replace with the verified published release. -# A git revision keeps CI hermetic while the crates.io release is pending. -[patch.crates-io] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", rev = "762a1d2eb09c226971eb3f30be67ee7f903a35ab" } - [profile.dev.package.kernal-api] codegen-units = 1 diff --git a/crates/fastled-cli/gen/schemas/acl-manifests.json b/crates/fastled-cli/gen/schemas/acl-manifests.json deleted file mode 100644 index 43da9ef6..00000000 --- a/crates/fastled-cli/gen/schemas/acl-manifests.json +++ /dev/null @@ -1 +0,0 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/crates/fastled-cli/gen/schemas/capabilities.json b/crates/fastled-cli/gen/schemas/capabilities.json deleted file mode 100644 index 9e26dfee..00000000 --- a/crates/fastled-cli/gen/schemas/capabilities.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/crates/fastled-cli/gen/schemas/desktop-schema.json b/crates/fastled-cli/gen/schemas/desktop-schema.json deleted file mode 100644 index 260dbe05..00000000 --- a/crates/fastled-cli/gen/schemas/desktop-schema.json +++ /dev/null @@ -1,2244 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CapabilityFile", - "description": "Capability formats accepted in a capability file.", - "anyOf": [ - { - "description": "A single capability.", - "allOf": [ - { - "$ref": "#/definitions/Capability" - } - ] - }, - { - "description": "A list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - }, - { - "description": "A list of capabilities.", - "type": "object", - "required": [ - "capabilities" - ], - "properties": { - "capabilities": { - "description": "The list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - } - } - } - ], - "definitions": { - "Capability": { - "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", - "type": "object", - "required": [ - "identifier", - "permissions" - ], - "properties": { - "identifier": { - "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", - "type": "string" - }, - "description": { - "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", - "default": "", - "type": "string" - }, - "remote": { - "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", - "anyOf": [ - { - "$ref": "#/definitions/CapabilityRemote" - }, - { - "type": "null" - } - ] - }, - "local": { - "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", - "default": true, - "type": "boolean" - }, - "windows": { - "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "webviews": { - "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "permissions": { - "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", - "type": "array", - "items": { - "$ref": "#/definitions/PermissionEntry" - }, - "uniqueItems": true - }, - "platforms": { - "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Target" - } - } - } - }, - "CapabilityRemote": { - "description": "Configuration for remote URLs that are associated with the capability.", - "type": "object", - "required": [ - "urls" - ], - "properties": { - "urls": { - "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "PermissionEntry": { - "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", - "anyOf": [ - { - "description": "Reference a permission or permission set by identifier.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - { - "description": "Reference a permission or permission set by identifier and extends its scope.", - "type": "object", - "allOf": [ - { - "properties": { - "identifier": { - "description": "Identifier of the permission or permission set.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - "allow": { - "description": "Data that defines what is allowed by the scope.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - }, - "deny": { - "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - } - } - } - ], - "required": [ - "identifier" - ] - } - ] - }, - "Identifier": { - "description": "Permission identifier", - "oneOf": [ - { - "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", - "type": "string", - "const": "core:default", - "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", - "type": "string", - "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" - }, - { - "description": "Enables the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-hide", - "markdownDescription": "Enables the app_hide command without any pre-configured scope." - }, - { - "description": "Enables the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-show", - "markdownDescription": "Enables the app_show command without any pre-configured scope." - }, - { - "description": "Enables the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-bundle-type", - "markdownDescription": "Enables the bundle_type command without any pre-configured scope." - }, - { - "description": "Enables the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-default-window-icon", - "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." - }, - { - "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-fetch-data-store-identifiers", - "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Enables the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-identifier", - "markdownDescription": "Enables the identifier command without any pre-configured scope." - }, - { - "description": "Enables the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-name", - "markdownDescription": "Enables the name command without any pre-configured scope." - }, - { - "description": "Enables the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-register-listener", - "markdownDescription": "Enables the register_listener command without any pre-configured scope." - }, - { - "description": "Enables the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-data-store", - "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." - }, - { - "description": "Enables the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-listener", - "markdownDescription": "Enables the remove_listener command without any pre-configured scope." - }, - { - "description": "Enables the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-app-theme", - "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-dock-visibility", - "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Enables the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-tauri-version", - "markdownDescription": "Enables the tauri_version command without any pre-configured scope." - }, - { - "description": "Enables the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-version", - "markdownDescription": "Enables the version command without any pre-configured scope." - }, - { - "description": "Denies the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-hide", - "markdownDescription": "Denies the app_hide command without any pre-configured scope." - }, - { - "description": "Denies the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-show", - "markdownDescription": "Denies the app_show command without any pre-configured scope." - }, - { - "description": "Denies the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-bundle-type", - "markdownDescription": "Denies the bundle_type command without any pre-configured scope." - }, - { - "description": "Denies the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-default-window-icon", - "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." - }, - { - "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-fetch-data-store-identifiers", - "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Denies the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-identifier", - "markdownDescription": "Denies the identifier command without any pre-configured scope." - }, - { - "description": "Denies the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-name", - "markdownDescription": "Denies the name command without any pre-configured scope." - }, - { - "description": "Denies the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-register-listener", - "markdownDescription": "Denies the register_listener command without any pre-configured scope." - }, - { - "description": "Denies the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-data-store", - "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." - }, - { - "description": "Denies the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-listener", - "markdownDescription": "Denies the remove_listener command without any pre-configured scope." - }, - { - "description": "Denies the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-app-theme", - "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-dock-visibility", - "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Denies the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-tauri-version", - "markdownDescription": "Denies the tauri_version command without any pre-configured scope." - }, - { - "description": "Denies the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-version", - "markdownDescription": "Denies the version command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", - "type": "string", - "const": "core:event:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" - }, - { - "description": "Enables the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit", - "markdownDescription": "Enables the emit command without any pre-configured scope." - }, - { - "description": "Enables the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit-to", - "markdownDescription": "Enables the emit_to command without any pre-configured scope." - }, - { - "description": "Enables the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-listen", - "markdownDescription": "Enables the listen command without any pre-configured scope." - }, - { - "description": "Enables the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-unlisten", - "markdownDescription": "Enables the unlisten command without any pre-configured scope." - }, - { - "description": "Denies the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit", - "markdownDescription": "Denies the emit command without any pre-configured scope." - }, - { - "description": "Denies the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit-to", - "markdownDescription": "Denies the emit_to command without any pre-configured scope." - }, - { - "description": "Denies the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-listen", - "markdownDescription": "Denies the listen command without any pre-configured scope." - }, - { - "description": "Denies the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-unlisten", - "markdownDescription": "Denies the unlisten command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", - "type": "string", - "const": "core:image:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" - }, - { - "description": "Enables the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-bytes", - "markdownDescription": "Enables the from_bytes command without any pre-configured scope." - }, - { - "description": "Enables the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-path", - "markdownDescription": "Enables the from_path command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-rgba", - "markdownDescription": "Enables the rgba command without any pre-configured scope." - }, - { - "description": "Enables the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-size", - "markdownDescription": "Enables the size command without any pre-configured scope." - }, - { - "description": "Denies the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-bytes", - "markdownDescription": "Denies the from_bytes command without any pre-configured scope." - }, - { - "description": "Denies the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-path", - "markdownDescription": "Denies the from_path command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-rgba", - "markdownDescription": "Denies the rgba command without any pre-configured scope." - }, - { - "description": "Denies the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-size", - "markdownDescription": "Denies the size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", - "type": "string", - "const": "core:menu:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" - }, - { - "description": "Enables the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-append", - "markdownDescription": "Enables the append command without any pre-configured scope." - }, - { - "description": "Enables the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-create-default", - "markdownDescription": "Enables the create_default command without any pre-configured scope." - }, - { - "description": "Enables the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-get", - "markdownDescription": "Enables the get command without any pre-configured scope." - }, - { - "description": "Enables the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-insert", - "markdownDescription": "Enables the insert command without any pre-configured scope." - }, - { - "description": "Enables the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-checked", - "markdownDescription": "Enables the is_checked command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-items", - "markdownDescription": "Enables the items command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-popup", - "markdownDescription": "Enables the popup command without any pre-configured scope." - }, - { - "description": "Enables the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-prepend", - "markdownDescription": "Enables the prepend command without any pre-configured scope." - }, - { - "description": "Enables the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove", - "markdownDescription": "Enables the remove command without any pre-configured scope." - }, - { - "description": "Enables the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove-at", - "markdownDescription": "Enables the remove_at command without any pre-configured scope." - }, - { - "description": "Enables the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-accelerator", - "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." - }, - { - "description": "Enables the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-app-menu", - "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-help-menu-for-nsapp", - "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-window-menu", - "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-windows-menu-for-nsapp", - "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-checked", - "markdownDescription": "Enables the set_checked command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-text", - "markdownDescription": "Enables the set_text command without any pre-configured scope." - }, - { - "description": "Enables the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-text", - "markdownDescription": "Enables the text command without any pre-configured scope." - }, - { - "description": "Denies the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-append", - "markdownDescription": "Denies the append command without any pre-configured scope." - }, - { - "description": "Denies the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-create-default", - "markdownDescription": "Denies the create_default command without any pre-configured scope." - }, - { - "description": "Denies the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-get", - "markdownDescription": "Denies the get command without any pre-configured scope." - }, - { - "description": "Denies the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-insert", - "markdownDescription": "Denies the insert command without any pre-configured scope." - }, - { - "description": "Denies the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-checked", - "markdownDescription": "Denies the is_checked command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-items", - "markdownDescription": "Denies the items command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-popup", - "markdownDescription": "Denies the popup command without any pre-configured scope." - }, - { - "description": "Denies the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-prepend", - "markdownDescription": "Denies the prepend command without any pre-configured scope." - }, - { - "description": "Denies the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove", - "markdownDescription": "Denies the remove command without any pre-configured scope." - }, - { - "description": "Denies the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove-at", - "markdownDescription": "Denies the remove_at command without any pre-configured scope." - }, - { - "description": "Denies the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-accelerator", - "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." - }, - { - "description": "Denies the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-app-menu", - "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-help-menu-for-nsapp", - "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-window-menu", - "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-windows-menu-for-nsapp", - "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-checked", - "markdownDescription": "Denies the set_checked command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-text", - "markdownDescription": "Denies the set_text command without any pre-configured scope." - }, - { - "description": "Denies the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-text", - "markdownDescription": "Denies the text command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", - "type": "string", - "const": "core:path:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" - }, - { - "description": "Enables the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-basename", - "markdownDescription": "Enables the basename command without any pre-configured scope." - }, - { - "description": "Enables the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-dirname", - "markdownDescription": "Enables the dirname command without any pre-configured scope." - }, - { - "description": "Enables the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-extname", - "markdownDescription": "Enables the extname command without any pre-configured scope." - }, - { - "description": "Enables the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-is-absolute", - "markdownDescription": "Enables the is_absolute command without any pre-configured scope." - }, - { - "description": "Enables the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-join", - "markdownDescription": "Enables the join command without any pre-configured scope." - }, - { - "description": "Enables the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-normalize", - "markdownDescription": "Enables the normalize command without any pre-configured scope." - }, - { - "description": "Enables the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve", - "markdownDescription": "Enables the resolve command without any pre-configured scope." - }, - { - "description": "Enables the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve-directory", - "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." - }, - { - "description": "Denies the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-basename", - "markdownDescription": "Denies the basename command without any pre-configured scope." - }, - { - "description": "Denies the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-dirname", - "markdownDescription": "Denies the dirname command without any pre-configured scope." - }, - { - "description": "Denies the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-extname", - "markdownDescription": "Denies the extname command without any pre-configured scope." - }, - { - "description": "Denies the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-is-absolute", - "markdownDescription": "Denies the is_absolute command without any pre-configured scope." - }, - { - "description": "Denies the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-join", - "markdownDescription": "Denies the join command without any pre-configured scope." - }, - { - "description": "Denies the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-normalize", - "markdownDescription": "Denies the normalize command without any pre-configured scope." - }, - { - "description": "Denies the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve", - "markdownDescription": "Denies the resolve command without any pre-configured scope." - }, - { - "description": "Denies the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve-directory", - "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", - "type": "string", - "const": "core:resources:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", - "type": "string", - "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" - }, - { - "description": "Enables the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-get-by-id", - "markdownDescription": "Enables the get_by_id command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-remove-by-id", - "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon-as-template", - "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Enables the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-menu", - "markdownDescription": "Enables the set_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-show-menu-on-left-click", - "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Enables the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-temp-dir-path", - "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-tooltip", - "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." - }, - { - "description": "Enables the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-visible", - "markdownDescription": "Enables the set_visible command without any pre-configured scope." - }, - { - "description": "Denies the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-get-by-id", - "markdownDescription": "Denies the get_by_id command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-remove-by-id", - "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon-as-template", - "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Denies the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-menu", - "markdownDescription": "Denies the set_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-show-menu-on-left-click", - "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Denies the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-temp-dir-path", - "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-tooltip", - "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." - }, - { - "description": "Denies the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-visible", - "markdownDescription": "Denies the set_visible command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", - "type": "string", - "const": "core:webview:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" - }, - { - "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-clear-all-browsing-data", - "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Enables the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview", - "markdownDescription": "Enables the create_webview command without any pre-configured scope." - }, - { - "description": "Enables the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview-window", - "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." - }, - { - "description": "Enables the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-get-all-webviews", - "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-internal-toggle-devtools", - "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Enables the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-print", - "markdownDescription": "Enables the print command without any pre-configured scope." - }, - { - "description": "Enables the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-reparent", - "markdownDescription": "Enables the reparent command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-auto-resize", - "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-background-color", - "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-focus", - "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-position", - "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-size", - "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-zoom", - "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Enables the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-close", - "markdownDescription": "Enables the webview_close command without any pre-configured scope." - }, - { - "description": "Enables the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-hide", - "markdownDescription": "Enables the webview_hide command without any pre-configured scope." - }, - { - "description": "Enables the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-position", - "markdownDescription": "Enables the webview_position command without any pre-configured scope." - }, - { - "description": "Enables the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-show", - "markdownDescription": "Enables the webview_show command without any pre-configured scope." - }, - { - "description": "Enables the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-size", - "markdownDescription": "Enables the webview_size command without any pre-configured scope." - }, - { - "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-clear-all-browsing-data", - "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Denies the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview", - "markdownDescription": "Denies the create_webview command without any pre-configured scope." - }, - { - "description": "Denies the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview-window", - "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." - }, - { - "description": "Denies the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-get-all-webviews", - "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-internal-toggle-devtools", - "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Denies the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-print", - "markdownDescription": "Denies the print command without any pre-configured scope." - }, - { - "description": "Denies the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-reparent", - "markdownDescription": "Denies the reparent command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-auto-resize", - "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-background-color", - "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-focus", - "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-position", - "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-size", - "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-zoom", - "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Denies the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-close", - "markdownDescription": "Denies the webview_close command without any pre-configured scope." - }, - { - "description": "Denies the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-hide", - "markdownDescription": "Denies the webview_hide command without any pre-configured scope." - }, - { - "description": "Denies the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-position", - "markdownDescription": "Denies the webview_position command without any pre-configured scope." - }, - { - "description": "Denies the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-show", - "markdownDescription": "Denies the webview_show command without any pre-configured scope." - }, - { - "description": "Denies the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-size", - "markdownDescription": "Denies the webview_size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", - "type": "string", - "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" - }, - { - "description": "Enables the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-available-monitors", - "markdownDescription": "Enables the available_monitors command without any pre-configured scope." - }, - { - "description": "Enables the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-center", - "markdownDescription": "Enables the center command without any pre-configured scope." - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Enables the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-create", - "markdownDescription": "Enables the create command without any pre-configured scope." - }, - { - "description": "Enables the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-current-monitor", - "markdownDescription": "Enables the current_monitor command without any pre-configured scope." - }, - { - "description": "Enables the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-cursor-position", - "markdownDescription": "Enables the cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-destroy", - "markdownDescription": "Enables the destroy command without any pre-configured scope." - }, - { - "description": "Enables the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-get-all-windows", - "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." - }, - { - "description": "Enables the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-hide", - "markdownDescription": "Enables the hide command without any pre-configured scope." - }, - { - "description": "Enables the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-position", - "markdownDescription": "Enables the inner_position command without any pre-configured scope." - }, - { - "description": "Enables the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-size", - "markdownDescription": "Enables the inner_size command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-internal-toggle-maximize", - "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-always-on-top", - "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-closable", - "markdownDescription": "Enables the is_closable command without any pre-configured scope." - }, - { - "description": "Enables the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-decorated", - "markdownDescription": "Enables the is_decorated command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-focused", - "markdownDescription": "Enables the is_focused command without any pre-configured scope." - }, - { - "description": "Enables the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-fullscreen", - "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximizable", - "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximized", - "markdownDescription": "Enables the is_maximized command without any pre-configured scope." - }, - { - "description": "Enables the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimizable", - "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimized", - "markdownDescription": "Enables the is_minimized command without any pre-configured scope." - }, - { - "description": "Enables the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-resizable", - "markdownDescription": "Enables the is_resizable command without any pre-configured scope." - }, - { - "description": "Enables the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-visible", - "markdownDescription": "Enables the is_visible command without any pre-configured scope." - }, - { - "description": "Enables the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-maximize", - "markdownDescription": "Enables the maximize command without any pre-configured scope." - }, - { - "description": "Enables the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-minimize", - "markdownDescription": "Enables the minimize command without any pre-configured scope." - }, - { - "description": "Enables the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-monitor-from-point", - "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Enables the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-position", - "markdownDescription": "Enables the outer_position command without any pre-configured scope." - }, - { - "description": "Enables the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-size", - "markdownDescription": "Enables the outer_size command without any pre-configured scope." - }, - { - "description": "Enables the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-primary-monitor", - "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." - }, - { - "description": "Enables the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-request-user-attention", - "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." - }, - { - "description": "Enables the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-scale-factor", - "markdownDescription": "Enables the scale_factor command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-bottom", - "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-top", - "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-background-color", - "markdownDescription": "Enables the set_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-count", - "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-label", - "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." - }, - { - "description": "Enables the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-closable", - "markdownDescription": "Enables the set_closable command without any pre-configured scope." - }, - { - "description": "Enables the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-content-protected", - "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-grab", - "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-icon", - "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-position", - "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-visible", - "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Enables the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-decorations", - "markdownDescription": "Enables the set_decorations command without any pre-configured scope." - }, - { - "description": "Enables the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-effects", - "markdownDescription": "Enables the set_effects command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focus", - "markdownDescription": "Enables the set_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focusable", - "markdownDescription": "Enables the set_focusable command without any pre-configured scope." - }, - { - "description": "Enables the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-fullscreen", - "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-ignore-cursor-events", - "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Enables the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-max-size", - "markdownDescription": "Enables the set_max_size command without any pre-configured scope." - }, - { - "description": "Enables the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-maximizable", - "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-min-size", - "markdownDescription": "Enables the set_min_size command without any pre-configured scope." - }, - { - "description": "Enables the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-minimizable", - "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-overlay-icon", - "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-position", - "markdownDescription": "Enables the set_position command without any pre-configured scope." - }, - { - "description": "Enables the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-progress-bar", - "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Enables the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-resizable", - "markdownDescription": "Enables the set_resizable command without any pre-configured scope." - }, - { - "description": "Enables the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-shadow", - "markdownDescription": "Enables the set_shadow command without any pre-configured scope." - }, - { - "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-simple-fullscreen", - "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size", - "markdownDescription": "Enables the set_size command without any pre-configured scope." - }, - { - "description": "Enables the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size-constraints", - "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Enables the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-skip-taskbar", - "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Enables the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-theme", - "markdownDescription": "Enables the set_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title-bar-style", - "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-visible-on-all-workspaces", - "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Enables the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-show", - "markdownDescription": "Enables the show command without any pre-configured scope." - }, - { - "description": "Enables the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-dragging", - "markdownDescription": "Enables the start_dragging command without any pre-configured scope." - }, - { - "description": "Enables the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-resize-dragging", - "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Enables the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-theme", - "markdownDescription": "Enables the theme command without any pre-configured scope." - }, - { - "description": "Enables the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-title", - "markdownDescription": "Enables the title command without any pre-configured scope." - }, - { - "description": "Enables the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-toggle-maximize", - "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unmaximize", - "markdownDescription": "Enables the unmaximize command without any pre-configured scope." - }, - { - "description": "Enables the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unminimize", - "markdownDescription": "Enables the unminimize command without any pre-configured scope." - }, - { - "description": "Denies the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-available-monitors", - "markdownDescription": "Denies the available_monitors command without any pre-configured scope." - }, - { - "description": "Denies the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-center", - "markdownDescription": "Denies the center command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Denies the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-create", - "markdownDescription": "Denies the create command without any pre-configured scope." - }, - { - "description": "Denies the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-current-monitor", - "markdownDescription": "Denies the current_monitor command without any pre-configured scope." - }, - { - "description": "Denies the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-cursor-position", - "markdownDescription": "Denies the cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-destroy", - "markdownDescription": "Denies the destroy command without any pre-configured scope." - }, - { - "description": "Denies the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-get-all-windows", - "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." - }, - { - "description": "Denies the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-hide", - "markdownDescription": "Denies the hide command without any pre-configured scope." - }, - { - "description": "Denies the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-position", - "markdownDescription": "Denies the inner_position command without any pre-configured scope." - }, - { - "description": "Denies the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-size", - "markdownDescription": "Denies the inner_size command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-internal-toggle-maximize", - "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-always-on-top", - "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-closable", - "markdownDescription": "Denies the is_closable command without any pre-configured scope." - }, - { - "description": "Denies the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-decorated", - "markdownDescription": "Denies the is_decorated command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-focused", - "markdownDescription": "Denies the is_focused command without any pre-configured scope." - }, - { - "description": "Denies the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-fullscreen", - "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximizable", - "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximized", - "markdownDescription": "Denies the is_maximized command without any pre-configured scope." - }, - { - "description": "Denies the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimizable", - "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimized", - "markdownDescription": "Denies the is_minimized command without any pre-configured scope." - }, - { - "description": "Denies the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-resizable", - "markdownDescription": "Denies the is_resizable command without any pre-configured scope." - }, - { - "description": "Denies the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-visible", - "markdownDescription": "Denies the is_visible command without any pre-configured scope." - }, - { - "description": "Denies the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-maximize", - "markdownDescription": "Denies the maximize command without any pre-configured scope." - }, - { - "description": "Denies the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-minimize", - "markdownDescription": "Denies the minimize command without any pre-configured scope." - }, - { - "description": "Denies the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-monitor-from-point", - "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Denies the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-position", - "markdownDescription": "Denies the outer_position command without any pre-configured scope." - }, - { - "description": "Denies the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-size", - "markdownDescription": "Denies the outer_size command without any pre-configured scope." - }, - { - "description": "Denies the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-primary-monitor", - "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." - }, - { - "description": "Denies the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-request-user-attention", - "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." - }, - { - "description": "Denies the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-scale-factor", - "markdownDescription": "Denies the scale_factor command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-bottom", - "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-top", - "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-background-color", - "markdownDescription": "Denies the set_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-count", - "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-label", - "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." - }, - { - "description": "Denies the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-closable", - "markdownDescription": "Denies the set_closable command without any pre-configured scope." - }, - { - "description": "Denies the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-content-protected", - "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-grab", - "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-icon", - "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-position", - "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-visible", - "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Denies the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-decorations", - "markdownDescription": "Denies the set_decorations command without any pre-configured scope." - }, - { - "description": "Denies the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-effects", - "markdownDescription": "Denies the set_effects command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focus", - "markdownDescription": "Denies the set_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focusable", - "markdownDescription": "Denies the set_focusable command without any pre-configured scope." - }, - { - "description": "Denies the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-fullscreen", - "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-ignore-cursor-events", - "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Denies the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-max-size", - "markdownDescription": "Denies the set_max_size command without any pre-configured scope." - }, - { - "description": "Denies the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-maximizable", - "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-min-size", - "markdownDescription": "Denies the set_min_size command without any pre-configured scope." - }, - { - "description": "Denies the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-minimizable", - "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-overlay-icon", - "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-position", - "markdownDescription": "Denies the set_position command without any pre-configured scope." - }, - { - "description": "Denies the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-progress-bar", - "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Denies the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-resizable", - "markdownDescription": "Denies the set_resizable command without any pre-configured scope." - }, - { - "description": "Denies the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-shadow", - "markdownDescription": "Denies the set_shadow command without any pre-configured scope." - }, - { - "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-simple-fullscreen", - "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size", - "markdownDescription": "Denies the set_size command without any pre-configured scope." - }, - { - "description": "Denies the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size-constraints", - "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Denies the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-skip-taskbar", - "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Denies the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-theme", - "markdownDescription": "Denies the set_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title-bar-style", - "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-visible-on-all-workspaces", - "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Denies the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-show", - "markdownDescription": "Denies the show command without any pre-configured scope." - }, - { - "description": "Denies the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-dragging", - "markdownDescription": "Denies the start_dragging command without any pre-configured scope." - }, - { - "description": "Denies the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-resize-dragging", - "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Denies the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-theme", - "markdownDescription": "Denies the theme command without any pre-configured scope." - }, - { - "description": "Denies the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-title", - "markdownDescription": "Denies the title command without any pre-configured scope." - }, - { - "description": "Denies the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-toggle-maximize", - "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unmaximize", - "markdownDescription": "Denies the unmaximize command without any pre-configured scope." - }, - { - "description": "Denies the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unminimize", - "markdownDescription": "Denies the unminimize command without any pre-configured scope." - } - ] - }, - "Value": { - "description": "All supported ACL values.", - "anyOf": [ - { - "description": "Represents a null JSON value.", - "type": "null" - }, - { - "description": "Represents a [`bool`].", - "type": "boolean" - }, - { - "description": "Represents a valid ACL [`Number`].", - "allOf": [ - { - "$ref": "#/definitions/Number" - } - ] - }, - { - "description": "Represents a [`String`].", - "type": "string" - }, - { - "description": "Represents a list of other [`Value`]s.", - "type": "array", - "items": { - "$ref": "#/definitions/Value" - } - }, - { - "description": "Represents a map of [`String`] keys to [`Value`]s.", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/Value" - } - } - ] - }, - "Number": { - "description": "A valid ACL number.", - "anyOf": [ - { - "description": "Represents an [`i64`].", - "type": "integer", - "format": "int64" - }, - { - "description": "Represents a [`f64`].", - "type": "number", - "format": "double" - } - ] - }, - "Target": { - "description": "Platform target.", - "oneOf": [ - { - "description": "MacOS.", - "type": "string", - "enum": [ - "macOS" - ] - }, - { - "description": "Windows.", - "type": "string", - "enum": [ - "windows" - ] - }, - { - "description": "Linux.", - "type": "string", - "enum": [ - "linux" - ] - }, - { - "description": "Android.", - "type": "string", - "enum": [ - "android" - ] - }, - { - "description": "iOS.", - "type": "string", - "enum": [ - "iOS" - ] - } - ] - } - } -} \ No newline at end of file diff --git a/crates/fastled-cli/gen/schemas/windows-schema.json b/crates/fastled-cli/gen/schemas/windows-schema.json deleted file mode 100644 index 260dbe05..00000000 --- a/crates/fastled-cli/gen/schemas/windows-schema.json +++ /dev/null @@ -1,2244 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CapabilityFile", - "description": "Capability formats accepted in a capability file.", - "anyOf": [ - { - "description": "A single capability.", - "allOf": [ - { - "$ref": "#/definitions/Capability" - } - ] - }, - { - "description": "A list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - }, - { - "description": "A list of capabilities.", - "type": "object", - "required": [ - "capabilities" - ], - "properties": { - "capabilities": { - "description": "The list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - } - } - } - ], - "definitions": { - "Capability": { - "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", - "type": "object", - "required": [ - "identifier", - "permissions" - ], - "properties": { - "identifier": { - "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", - "type": "string" - }, - "description": { - "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", - "default": "", - "type": "string" - }, - "remote": { - "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", - "anyOf": [ - { - "$ref": "#/definitions/CapabilityRemote" - }, - { - "type": "null" - } - ] - }, - "local": { - "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", - "default": true, - "type": "boolean" - }, - "windows": { - "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "webviews": { - "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "permissions": { - "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", - "type": "array", - "items": { - "$ref": "#/definitions/PermissionEntry" - }, - "uniqueItems": true - }, - "platforms": { - "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Target" - } - } - } - }, - "CapabilityRemote": { - "description": "Configuration for remote URLs that are associated with the capability.", - "type": "object", - "required": [ - "urls" - ], - "properties": { - "urls": { - "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "PermissionEntry": { - "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", - "anyOf": [ - { - "description": "Reference a permission or permission set by identifier.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - { - "description": "Reference a permission or permission set by identifier and extends its scope.", - "type": "object", - "allOf": [ - { - "properties": { - "identifier": { - "description": "Identifier of the permission or permission set.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - "allow": { - "description": "Data that defines what is allowed by the scope.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - }, - "deny": { - "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - } - } - } - ], - "required": [ - "identifier" - ] - } - ] - }, - "Identifier": { - "description": "Permission identifier", - "oneOf": [ - { - "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", - "type": "string", - "const": "core:default", - "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", - "type": "string", - "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" - }, - { - "description": "Enables the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-hide", - "markdownDescription": "Enables the app_hide command without any pre-configured scope." - }, - { - "description": "Enables the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-show", - "markdownDescription": "Enables the app_show command without any pre-configured scope." - }, - { - "description": "Enables the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-bundle-type", - "markdownDescription": "Enables the bundle_type command without any pre-configured scope." - }, - { - "description": "Enables the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-default-window-icon", - "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." - }, - { - "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-fetch-data-store-identifiers", - "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Enables the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-identifier", - "markdownDescription": "Enables the identifier command without any pre-configured scope." - }, - { - "description": "Enables the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-name", - "markdownDescription": "Enables the name command without any pre-configured scope." - }, - { - "description": "Enables the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-register-listener", - "markdownDescription": "Enables the register_listener command without any pre-configured scope." - }, - { - "description": "Enables the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-data-store", - "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." - }, - { - "description": "Enables the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-listener", - "markdownDescription": "Enables the remove_listener command without any pre-configured scope." - }, - { - "description": "Enables the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-app-theme", - "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-dock-visibility", - "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Enables the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-tauri-version", - "markdownDescription": "Enables the tauri_version command without any pre-configured scope." - }, - { - "description": "Enables the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-version", - "markdownDescription": "Enables the version command without any pre-configured scope." - }, - { - "description": "Denies the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-hide", - "markdownDescription": "Denies the app_hide command without any pre-configured scope." - }, - { - "description": "Denies the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-show", - "markdownDescription": "Denies the app_show command without any pre-configured scope." - }, - { - "description": "Denies the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-bundle-type", - "markdownDescription": "Denies the bundle_type command without any pre-configured scope." - }, - { - "description": "Denies the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-default-window-icon", - "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." - }, - { - "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-fetch-data-store-identifiers", - "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Denies the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-identifier", - "markdownDescription": "Denies the identifier command without any pre-configured scope." - }, - { - "description": "Denies the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-name", - "markdownDescription": "Denies the name command without any pre-configured scope." - }, - { - "description": "Denies the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-register-listener", - "markdownDescription": "Denies the register_listener command without any pre-configured scope." - }, - { - "description": "Denies the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-data-store", - "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." - }, - { - "description": "Denies the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-listener", - "markdownDescription": "Denies the remove_listener command without any pre-configured scope." - }, - { - "description": "Denies the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-app-theme", - "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-dock-visibility", - "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Denies the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-tauri-version", - "markdownDescription": "Denies the tauri_version command without any pre-configured scope." - }, - { - "description": "Denies the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-version", - "markdownDescription": "Denies the version command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", - "type": "string", - "const": "core:event:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" - }, - { - "description": "Enables the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit", - "markdownDescription": "Enables the emit command without any pre-configured scope." - }, - { - "description": "Enables the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit-to", - "markdownDescription": "Enables the emit_to command without any pre-configured scope." - }, - { - "description": "Enables the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-listen", - "markdownDescription": "Enables the listen command without any pre-configured scope." - }, - { - "description": "Enables the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-unlisten", - "markdownDescription": "Enables the unlisten command without any pre-configured scope." - }, - { - "description": "Denies the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit", - "markdownDescription": "Denies the emit command without any pre-configured scope." - }, - { - "description": "Denies the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit-to", - "markdownDescription": "Denies the emit_to command without any pre-configured scope." - }, - { - "description": "Denies the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-listen", - "markdownDescription": "Denies the listen command without any pre-configured scope." - }, - { - "description": "Denies the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-unlisten", - "markdownDescription": "Denies the unlisten command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", - "type": "string", - "const": "core:image:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" - }, - { - "description": "Enables the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-bytes", - "markdownDescription": "Enables the from_bytes command without any pre-configured scope." - }, - { - "description": "Enables the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-path", - "markdownDescription": "Enables the from_path command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-rgba", - "markdownDescription": "Enables the rgba command without any pre-configured scope." - }, - { - "description": "Enables the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-size", - "markdownDescription": "Enables the size command without any pre-configured scope." - }, - { - "description": "Denies the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-bytes", - "markdownDescription": "Denies the from_bytes command without any pre-configured scope." - }, - { - "description": "Denies the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-path", - "markdownDescription": "Denies the from_path command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-rgba", - "markdownDescription": "Denies the rgba command without any pre-configured scope." - }, - { - "description": "Denies the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-size", - "markdownDescription": "Denies the size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", - "type": "string", - "const": "core:menu:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" - }, - { - "description": "Enables the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-append", - "markdownDescription": "Enables the append command without any pre-configured scope." - }, - { - "description": "Enables the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-create-default", - "markdownDescription": "Enables the create_default command without any pre-configured scope." - }, - { - "description": "Enables the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-get", - "markdownDescription": "Enables the get command without any pre-configured scope." - }, - { - "description": "Enables the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-insert", - "markdownDescription": "Enables the insert command without any pre-configured scope." - }, - { - "description": "Enables the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-checked", - "markdownDescription": "Enables the is_checked command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-items", - "markdownDescription": "Enables the items command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-popup", - "markdownDescription": "Enables the popup command without any pre-configured scope." - }, - { - "description": "Enables the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-prepend", - "markdownDescription": "Enables the prepend command without any pre-configured scope." - }, - { - "description": "Enables the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove", - "markdownDescription": "Enables the remove command without any pre-configured scope." - }, - { - "description": "Enables the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove-at", - "markdownDescription": "Enables the remove_at command without any pre-configured scope." - }, - { - "description": "Enables the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-accelerator", - "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." - }, - { - "description": "Enables the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-app-menu", - "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-help-menu-for-nsapp", - "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-window-menu", - "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-windows-menu-for-nsapp", - "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-checked", - "markdownDescription": "Enables the set_checked command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-text", - "markdownDescription": "Enables the set_text command without any pre-configured scope." - }, - { - "description": "Enables the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-text", - "markdownDescription": "Enables the text command without any pre-configured scope." - }, - { - "description": "Denies the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-append", - "markdownDescription": "Denies the append command without any pre-configured scope." - }, - { - "description": "Denies the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-create-default", - "markdownDescription": "Denies the create_default command without any pre-configured scope." - }, - { - "description": "Denies the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-get", - "markdownDescription": "Denies the get command without any pre-configured scope." - }, - { - "description": "Denies the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-insert", - "markdownDescription": "Denies the insert command without any pre-configured scope." - }, - { - "description": "Denies the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-checked", - "markdownDescription": "Denies the is_checked command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-items", - "markdownDescription": "Denies the items command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-popup", - "markdownDescription": "Denies the popup command without any pre-configured scope." - }, - { - "description": "Denies the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-prepend", - "markdownDescription": "Denies the prepend command without any pre-configured scope." - }, - { - "description": "Denies the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove", - "markdownDescription": "Denies the remove command without any pre-configured scope." - }, - { - "description": "Denies the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove-at", - "markdownDescription": "Denies the remove_at command without any pre-configured scope." - }, - { - "description": "Denies the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-accelerator", - "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." - }, - { - "description": "Denies the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-app-menu", - "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-help-menu-for-nsapp", - "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-window-menu", - "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-windows-menu-for-nsapp", - "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-checked", - "markdownDescription": "Denies the set_checked command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-text", - "markdownDescription": "Denies the set_text command without any pre-configured scope." - }, - { - "description": "Denies the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-text", - "markdownDescription": "Denies the text command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", - "type": "string", - "const": "core:path:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" - }, - { - "description": "Enables the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-basename", - "markdownDescription": "Enables the basename command without any pre-configured scope." - }, - { - "description": "Enables the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-dirname", - "markdownDescription": "Enables the dirname command without any pre-configured scope." - }, - { - "description": "Enables the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-extname", - "markdownDescription": "Enables the extname command without any pre-configured scope." - }, - { - "description": "Enables the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-is-absolute", - "markdownDescription": "Enables the is_absolute command without any pre-configured scope." - }, - { - "description": "Enables the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-join", - "markdownDescription": "Enables the join command without any pre-configured scope." - }, - { - "description": "Enables the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-normalize", - "markdownDescription": "Enables the normalize command without any pre-configured scope." - }, - { - "description": "Enables the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve", - "markdownDescription": "Enables the resolve command without any pre-configured scope." - }, - { - "description": "Enables the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve-directory", - "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." - }, - { - "description": "Denies the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-basename", - "markdownDescription": "Denies the basename command without any pre-configured scope." - }, - { - "description": "Denies the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-dirname", - "markdownDescription": "Denies the dirname command without any pre-configured scope." - }, - { - "description": "Denies the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-extname", - "markdownDescription": "Denies the extname command without any pre-configured scope." - }, - { - "description": "Denies the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-is-absolute", - "markdownDescription": "Denies the is_absolute command without any pre-configured scope." - }, - { - "description": "Denies the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-join", - "markdownDescription": "Denies the join command without any pre-configured scope." - }, - { - "description": "Denies the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-normalize", - "markdownDescription": "Denies the normalize command without any pre-configured scope." - }, - { - "description": "Denies the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve", - "markdownDescription": "Denies the resolve command without any pre-configured scope." - }, - { - "description": "Denies the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve-directory", - "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", - "type": "string", - "const": "core:resources:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", - "type": "string", - "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" - }, - { - "description": "Enables the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-get-by-id", - "markdownDescription": "Enables the get_by_id command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-remove-by-id", - "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon-as-template", - "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Enables the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-menu", - "markdownDescription": "Enables the set_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-show-menu-on-left-click", - "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Enables the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-temp-dir-path", - "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-tooltip", - "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." - }, - { - "description": "Enables the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-visible", - "markdownDescription": "Enables the set_visible command without any pre-configured scope." - }, - { - "description": "Denies the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-get-by-id", - "markdownDescription": "Denies the get_by_id command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-remove-by-id", - "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon-as-template", - "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Denies the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-menu", - "markdownDescription": "Denies the set_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-show-menu-on-left-click", - "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Denies the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-temp-dir-path", - "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-tooltip", - "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." - }, - { - "description": "Denies the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-visible", - "markdownDescription": "Denies the set_visible command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", - "type": "string", - "const": "core:webview:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" - }, - { - "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-clear-all-browsing-data", - "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Enables the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview", - "markdownDescription": "Enables the create_webview command without any pre-configured scope." - }, - { - "description": "Enables the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview-window", - "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." - }, - { - "description": "Enables the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-get-all-webviews", - "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-internal-toggle-devtools", - "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Enables the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-print", - "markdownDescription": "Enables the print command without any pre-configured scope." - }, - { - "description": "Enables the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-reparent", - "markdownDescription": "Enables the reparent command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-auto-resize", - "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-background-color", - "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-focus", - "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-position", - "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-size", - "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-zoom", - "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Enables the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-close", - "markdownDescription": "Enables the webview_close command without any pre-configured scope." - }, - { - "description": "Enables the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-hide", - "markdownDescription": "Enables the webview_hide command without any pre-configured scope." - }, - { - "description": "Enables the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-position", - "markdownDescription": "Enables the webview_position command without any pre-configured scope." - }, - { - "description": "Enables the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-show", - "markdownDescription": "Enables the webview_show command without any pre-configured scope." - }, - { - "description": "Enables the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-size", - "markdownDescription": "Enables the webview_size command without any pre-configured scope." - }, - { - "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-clear-all-browsing-data", - "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Denies the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview", - "markdownDescription": "Denies the create_webview command without any pre-configured scope." - }, - { - "description": "Denies the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview-window", - "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." - }, - { - "description": "Denies the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-get-all-webviews", - "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-internal-toggle-devtools", - "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Denies the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-print", - "markdownDescription": "Denies the print command without any pre-configured scope." - }, - { - "description": "Denies the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-reparent", - "markdownDescription": "Denies the reparent command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-auto-resize", - "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-background-color", - "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-focus", - "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-position", - "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-size", - "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-zoom", - "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Denies the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-close", - "markdownDescription": "Denies the webview_close command without any pre-configured scope." - }, - { - "description": "Denies the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-hide", - "markdownDescription": "Denies the webview_hide command without any pre-configured scope." - }, - { - "description": "Denies the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-position", - "markdownDescription": "Denies the webview_position command without any pre-configured scope." - }, - { - "description": "Denies the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-show", - "markdownDescription": "Denies the webview_show command without any pre-configured scope." - }, - { - "description": "Denies the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-size", - "markdownDescription": "Denies the webview_size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", - "type": "string", - "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" - }, - { - "description": "Enables the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-available-monitors", - "markdownDescription": "Enables the available_monitors command without any pre-configured scope." - }, - { - "description": "Enables the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-center", - "markdownDescription": "Enables the center command without any pre-configured scope." - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Enables the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-create", - "markdownDescription": "Enables the create command without any pre-configured scope." - }, - { - "description": "Enables the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-current-monitor", - "markdownDescription": "Enables the current_monitor command without any pre-configured scope." - }, - { - "description": "Enables the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-cursor-position", - "markdownDescription": "Enables the cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-destroy", - "markdownDescription": "Enables the destroy command without any pre-configured scope." - }, - { - "description": "Enables the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-get-all-windows", - "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." - }, - { - "description": "Enables the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-hide", - "markdownDescription": "Enables the hide command without any pre-configured scope." - }, - { - "description": "Enables the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-position", - "markdownDescription": "Enables the inner_position command without any pre-configured scope." - }, - { - "description": "Enables the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-size", - "markdownDescription": "Enables the inner_size command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-internal-toggle-maximize", - "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-always-on-top", - "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-closable", - "markdownDescription": "Enables the is_closable command without any pre-configured scope." - }, - { - "description": "Enables the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-decorated", - "markdownDescription": "Enables the is_decorated command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-focused", - "markdownDescription": "Enables the is_focused command without any pre-configured scope." - }, - { - "description": "Enables the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-fullscreen", - "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximizable", - "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximized", - "markdownDescription": "Enables the is_maximized command without any pre-configured scope." - }, - { - "description": "Enables the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimizable", - "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimized", - "markdownDescription": "Enables the is_minimized command without any pre-configured scope." - }, - { - "description": "Enables the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-resizable", - "markdownDescription": "Enables the is_resizable command without any pre-configured scope." - }, - { - "description": "Enables the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-visible", - "markdownDescription": "Enables the is_visible command without any pre-configured scope." - }, - { - "description": "Enables the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-maximize", - "markdownDescription": "Enables the maximize command without any pre-configured scope." - }, - { - "description": "Enables the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-minimize", - "markdownDescription": "Enables the minimize command without any pre-configured scope." - }, - { - "description": "Enables the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-monitor-from-point", - "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Enables the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-position", - "markdownDescription": "Enables the outer_position command without any pre-configured scope." - }, - { - "description": "Enables the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-size", - "markdownDescription": "Enables the outer_size command without any pre-configured scope." - }, - { - "description": "Enables the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-primary-monitor", - "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." - }, - { - "description": "Enables the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-request-user-attention", - "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." - }, - { - "description": "Enables the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-scale-factor", - "markdownDescription": "Enables the scale_factor command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-bottom", - "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-top", - "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-background-color", - "markdownDescription": "Enables the set_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-count", - "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-label", - "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." - }, - { - "description": "Enables the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-closable", - "markdownDescription": "Enables the set_closable command without any pre-configured scope." - }, - { - "description": "Enables the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-content-protected", - "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-grab", - "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-icon", - "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-position", - "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-visible", - "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Enables the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-decorations", - "markdownDescription": "Enables the set_decorations command without any pre-configured scope." - }, - { - "description": "Enables the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-effects", - "markdownDescription": "Enables the set_effects command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focus", - "markdownDescription": "Enables the set_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focusable", - "markdownDescription": "Enables the set_focusable command without any pre-configured scope." - }, - { - "description": "Enables the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-fullscreen", - "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-ignore-cursor-events", - "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Enables the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-max-size", - "markdownDescription": "Enables the set_max_size command without any pre-configured scope." - }, - { - "description": "Enables the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-maximizable", - "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-min-size", - "markdownDescription": "Enables the set_min_size command without any pre-configured scope." - }, - { - "description": "Enables the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-minimizable", - "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-overlay-icon", - "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-position", - "markdownDescription": "Enables the set_position command without any pre-configured scope." - }, - { - "description": "Enables the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-progress-bar", - "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Enables the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-resizable", - "markdownDescription": "Enables the set_resizable command without any pre-configured scope." - }, - { - "description": "Enables the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-shadow", - "markdownDescription": "Enables the set_shadow command without any pre-configured scope." - }, - { - "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-simple-fullscreen", - "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size", - "markdownDescription": "Enables the set_size command without any pre-configured scope." - }, - { - "description": "Enables the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size-constraints", - "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Enables the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-skip-taskbar", - "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Enables the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-theme", - "markdownDescription": "Enables the set_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title-bar-style", - "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-visible-on-all-workspaces", - "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Enables the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-show", - "markdownDescription": "Enables the show command without any pre-configured scope." - }, - { - "description": "Enables the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-dragging", - "markdownDescription": "Enables the start_dragging command without any pre-configured scope." - }, - { - "description": "Enables the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-resize-dragging", - "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Enables the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-theme", - "markdownDescription": "Enables the theme command without any pre-configured scope." - }, - { - "description": "Enables the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-title", - "markdownDescription": "Enables the title command without any pre-configured scope." - }, - { - "description": "Enables the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-toggle-maximize", - "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unmaximize", - "markdownDescription": "Enables the unmaximize command without any pre-configured scope." - }, - { - "description": "Enables the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unminimize", - "markdownDescription": "Enables the unminimize command without any pre-configured scope." - }, - { - "description": "Denies the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-available-monitors", - "markdownDescription": "Denies the available_monitors command without any pre-configured scope." - }, - { - "description": "Denies the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-center", - "markdownDescription": "Denies the center command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Denies the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-create", - "markdownDescription": "Denies the create command without any pre-configured scope." - }, - { - "description": "Denies the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-current-monitor", - "markdownDescription": "Denies the current_monitor command without any pre-configured scope." - }, - { - "description": "Denies the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-cursor-position", - "markdownDescription": "Denies the cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-destroy", - "markdownDescription": "Denies the destroy command without any pre-configured scope." - }, - { - "description": "Denies the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-get-all-windows", - "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." - }, - { - "description": "Denies the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-hide", - "markdownDescription": "Denies the hide command without any pre-configured scope." - }, - { - "description": "Denies the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-position", - "markdownDescription": "Denies the inner_position command without any pre-configured scope." - }, - { - "description": "Denies the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-size", - "markdownDescription": "Denies the inner_size command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-internal-toggle-maximize", - "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-always-on-top", - "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-closable", - "markdownDescription": "Denies the is_closable command without any pre-configured scope." - }, - { - "description": "Denies the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-decorated", - "markdownDescription": "Denies the is_decorated command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-focused", - "markdownDescription": "Denies the is_focused command without any pre-configured scope." - }, - { - "description": "Denies the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-fullscreen", - "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximizable", - "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximized", - "markdownDescription": "Denies the is_maximized command without any pre-configured scope." - }, - { - "description": "Denies the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimizable", - "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimized", - "markdownDescription": "Denies the is_minimized command without any pre-configured scope." - }, - { - "description": "Denies the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-resizable", - "markdownDescription": "Denies the is_resizable command without any pre-configured scope." - }, - { - "description": "Denies the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-visible", - "markdownDescription": "Denies the is_visible command without any pre-configured scope." - }, - { - "description": "Denies the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-maximize", - "markdownDescription": "Denies the maximize command without any pre-configured scope." - }, - { - "description": "Denies the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-minimize", - "markdownDescription": "Denies the minimize command without any pre-configured scope." - }, - { - "description": "Denies the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-monitor-from-point", - "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Denies the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-position", - "markdownDescription": "Denies the outer_position command without any pre-configured scope." - }, - { - "description": "Denies the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-size", - "markdownDescription": "Denies the outer_size command without any pre-configured scope." - }, - { - "description": "Denies the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-primary-monitor", - "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." - }, - { - "description": "Denies the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-request-user-attention", - "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." - }, - { - "description": "Denies the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-scale-factor", - "markdownDescription": "Denies the scale_factor command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-bottom", - "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-top", - "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-background-color", - "markdownDescription": "Denies the set_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-count", - "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-label", - "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." - }, - { - "description": "Denies the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-closable", - "markdownDescription": "Denies the set_closable command without any pre-configured scope." - }, - { - "description": "Denies the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-content-protected", - "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-grab", - "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-icon", - "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-position", - "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-visible", - "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Denies the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-decorations", - "markdownDescription": "Denies the set_decorations command without any pre-configured scope." - }, - { - "description": "Denies the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-effects", - "markdownDescription": "Denies the set_effects command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focus", - "markdownDescription": "Denies the set_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focusable", - "markdownDescription": "Denies the set_focusable command without any pre-configured scope." - }, - { - "description": "Denies the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-fullscreen", - "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-ignore-cursor-events", - "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Denies the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-max-size", - "markdownDescription": "Denies the set_max_size command without any pre-configured scope." - }, - { - "description": "Denies the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-maximizable", - "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-min-size", - "markdownDescription": "Denies the set_min_size command without any pre-configured scope." - }, - { - "description": "Denies the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-minimizable", - "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-overlay-icon", - "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-position", - "markdownDescription": "Denies the set_position command without any pre-configured scope." - }, - { - "description": "Denies the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-progress-bar", - "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Denies the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-resizable", - "markdownDescription": "Denies the set_resizable command without any pre-configured scope." - }, - { - "description": "Denies the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-shadow", - "markdownDescription": "Denies the set_shadow command without any pre-configured scope." - }, - { - "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-simple-fullscreen", - "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size", - "markdownDescription": "Denies the set_size command without any pre-configured scope." - }, - { - "description": "Denies the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size-constraints", - "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Denies the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-skip-taskbar", - "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Denies the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-theme", - "markdownDescription": "Denies the set_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title-bar-style", - "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-visible-on-all-workspaces", - "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Denies the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-show", - "markdownDescription": "Denies the show command without any pre-configured scope." - }, - { - "description": "Denies the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-dragging", - "markdownDescription": "Denies the start_dragging command without any pre-configured scope." - }, - { - "description": "Denies the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-resize-dragging", - "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Denies the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-theme", - "markdownDescription": "Denies the theme command without any pre-configured scope." - }, - { - "description": "Denies the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-title", - "markdownDescription": "Denies the title command without any pre-configured scope." - }, - { - "description": "Denies the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-toggle-maximize", - "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unmaximize", - "markdownDescription": "Denies the unmaximize command without any pre-configured scope." - }, - { - "description": "Denies the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unminimize", - "markdownDescription": "Denies the unminimize command without any pre-configured scope." - } - ] - }, - "Value": { - "description": "All supported ACL values.", - "anyOf": [ - { - "description": "Represents a null JSON value.", - "type": "null" - }, - { - "description": "Represents a [`bool`].", - "type": "boolean" - }, - { - "description": "Represents a valid ACL [`Number`].", - "allOf": [ - { - "$ref": "#/definitions/Number" - } - ] - }, - { - "description": "Represents a [`String`].", - "type": "string" - }, - { - "description": "Represents a list of other [`Value`]s.", - "type": "array", - "items": { - "$ref": "#/definitions/Value" - } - }, - { - "description": "Represents a map of [`String`] keys to [`Value`]s.", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/Value" - } - } - ] - }, - "Number": { - "description": "A valid ACL number.", - "anyOf": [ - { - "description": "Represents an [`i64`].", - "type": "integer", - "format": "int64" - }, - { - "description": "Represents a [`f64`].", - "type": "number", - "format": "double" - } - ] - }, - "Target": { - "description": "Platform target.", - "oneOf": [ - { - "description": "MacOS.", - "type": "string", - "enum": [ - "macOS" - ] - }, - { - "description": "Windows.", - "type": "string", - "enum": [ - "windows" - ] - }, - { - "description": "Linux.", - "type": "string", - "enum": [ - "linux" - ] - }, - { - "description": "Android.", - "type": "string", - "enum": [ - "android" - ] - }, - { - "description": "iOS.", - "type": "string", - "enum": [ - "iOS" - ] - } - ] - } - } -} \ No newline at end of file diff --git a/crates/fastled-cli/src/archive.rs b/crates/fastled-cli/src/archive.rs index bfe0474c..2c629498 100644 --- a/crates/fastled-cli/src/archive.rs +++ b/crates/fastled-cli/src/archive.rs @@ -84,7 +84,7 @@ pub fn sha256_file(path: &Path) -> Result { /// Return `true` when the SHA-256 digest of `path` matches `expected`. /// -/// `expected` must be a lower-case hex string (64 characters). +/// `expected` is a 64-character hex string, compared case-insensitively. /// /// # Errors /// Returns an error if the file cannot be read. @@ -348,6 +348,20 @@ mod tests { assert!(!ok, "digest should not match different content"); } + #[test] + fn test_verify_sha256_accepts_uppercase_seal() { + let dir = temp_dir(); + let file = dir.path().join("data.bin"); + fs::write(&file, b"hello fastled").unwrap(); + assert!(verify_sha256(&file, &HELLO_SHA256.to_uppercase()).unwrap()); + } + + #[test] + fn test_verify_sha256_missing_artifact_is_an_error() { + let dir = temp_dir(); + assert!(verify_sha256(&dir.path().join("missing.bin"), HELLO_SHA256).is_err()); + } + // ------------------------------------------------------------------ // .emscripten config generation // ------------------------------------------------------------------ diff --git a/crates/fastled-cli/src/dynamic_cache.rs b/crates/fastled-cli/src/dynamic_cache.rs index dfff85fd..8d6dc5de 100644 --- a/crates/fastled-cli/src/dynamic_cache.rs +++ b/crates/fastled-cli/src/dynamic_cache.rs @@ -602,6 +602,48 @@ mod tests { bytes } + #[test] + fn sha256_cache_encoding_preserves_domain_lengths_and_leading_zeroes() { + // Fixed legacy encodings, independently checked with Python hashlib. + // These protect FastLED's cache protocol; generic SHA vectors live upstream. + for (values, expected) in [ + ( + vec![], + "a176241cca24eb86c1fc3b441c63aa8d37d409f5821163583f3ce7320e625e81", + ), + ( + vec![&b"ab"[..], &b"c"[..]], + "0d9a687b8e558f37b5d34c32b8a27fdd0c34b90e2313c922b977a1fbfd8c2979", + ), + ( + vec![&b"a"[..], &b"bc"[..]], + "8cf4aafa0e399335648f6c2dc69f36b88f4608dfecc89dbe37fdd8e2d1c3d7ca", + ), + ( + vec![&b""[..], &b"ab"[..], &b"c"[..]], + "fe43050a4dd2a7da0ae131b5c170ed024abc0544f71ea44e1778ff9f8de8c36a", + ), + ] { + assert_eq!(fingerprint_values(values), expected); + } + } + + #[test] + fn sha256_artifact_record_preserves_serialized_encoding() { + let temp = kernal_api::platform::fs::TemporaryDirectory::new().unwrap(); + let path = temp.path().join("firmware.wasm"); + fs::write(&path, wasm_bytes(b"")).unwrap(); + let metadata = CacheMetadata { + schema: 1, + fingerprint: "key".into(), + artifacts: BTreeMap::from([("firmware.wasm".into(), hash_file(&path).unwrap())]), + }; + assert_eq!( + json::encode(&metadata.document(), Layout::Compact).unwrap(), + br#"{"schema":1,"fingerprint":"key","artifacts":{"firmware.wasm":{"bytes":8,"sha256":"93a44bbb96c751218e4c00d479e4c14358122a389acca16205b1e4d0dc5f9476"}}}"# + ); + } + // Regression coverage for #193: the rebuild-triggering event must dirty // the persistent fingerprint before the next lookup. #[test] diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index ee3acf37..f003ec1b 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -181,6 +181,26 @@ def test_viewer_backend_is_owned_by_kernel(): assert backend not in source.read_text(), (source, backend) +def test_kernal_api_is_the_only_rust_dependency(): + root = Path(__file__).resolve().parents[2] + package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) + workspace = tomllib.loads((root / "Cargo.toml").read_text()) + sections = [package, workspace["workspace"]] + sections.extend(package.get("target", {}).values()) + for section in sections: + assert set(section.get("dependencies", {})) <= {"kernal-api"}, section + assert not section.get("build-dependencies"), section + assert not section.get("dev-dependencies"), section + assert set(package["dependencies"]) == {"kernal-api"} + kernal = workspace["workspace"]["dependencies"]["kernal-api"] + assert kernal["tag"] == "v0.1.3" + assert "rev" not in kernal and "path" not in kernal + assert "hash-sha256" in kernal["features"] + assert "patch" not in workspace + assert not (root / "crates/fastled-cli/build.rs").exists() + assert not (root / "crates/fastled-cli/gen").exists() + + def test_obsolete_manifest_dependencies_are_removed(): root = Path(__file__).resolve().parents[2] package = tomllib.loads((root / "crates/fastled-cli/Cargo.toml").read_text()) @@ -196,7 +216,9 @@ def test_python_shim_has_no_obsolete_runtime_requirements(): for obsolete in ("typeguard", "zcmds_win32", "zcmds-win32"): assert not any(requirement.startswith(obsolete) for requirement in requirements) for required in ("meson", "ninja", "uv"): - assert any(requirement.startswith(required + ">=") for requirement in requirements) + assert any( + requirement.startswith(required + ">=") for requirement in requirements + ) def test_http_callers_use_kernel(): From 0ff511261f48e3d541b25e4f4ce10ff3005b4737 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 14 Sep 2026 08:50:30 -0700 Subject: [PATCH 86/94] docs: record final kernal-api migration audit Adds a current-status section and replaces the final audit checklist with results: resolved dependency inventory, the kernal-api v0.1.3 release cut from the validated 762a1d2 pin, CI-enforced sole-dependency boundary, Linux validation, build timing measurements (69 fewer crates, no speedup claimed), and the #247 viewer comparison showing identical WebGL2 failure on pre-migration main and on NixOS generation 118 (no hardware GL until the NVIDIA driver mismatch is cleared by reboot). Refs #229, #247 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- docs/kernal-api-migration.md | 105 ++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 97cebcbe..13bd4a96 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -27,14 +27,23 @@ capability migration, with the same toolchain, features, and cache conditions. | Build-only support | indexmap | Direct unused entry removed; transitive Tauri copies remain until viewer migration | | Python launcher/tool provisioning | meson, ninja, uv, typeguard, zcmds_win32 | Audit runtime necessity and move shared provisioning into the base without breaking wheel installation | -## Development state +## Current status -Work is on `feat/kernal-api-migration`. The migration uses an exact `=0.1.0` -declaration plus a temporary sibling path patch to +The inventory table above and the checkpoint sections below are historical +records of each slice; see "Final migration audit" at the end for the current +state. `fastled-cli` now has exactly one direct Rust dependency, `kernal-api`, +consumed from the `v0.1.3` release tag with no `[patch]` override. Every +backend in the inventory is absent from the manifests and sources and remains +only as a private transitive implementation of the kernel. + +## Development state (historical) + +Work is on `feat/kernal-api-migration`. The migration originally used an exact +`=0.1.0` declaration plus a temporary sibling path patch to `../fastled-wasm-extern/kernal-api`, initially checked out at -`76172bf3ee41ec601d3fa834291dfbe6bfc11789`. Remove the path patch and verify -an exact published release before landing on a release branch. The application -MSRV now matches its existing 1.95 toolchain and kernal-api's requirement. +`76172bf3ee41ec601d3fa834291dfbe6bfc11789`; later a pinned git revision replaced +the path patch. Both are now removed in favor of the `v0.1.3` release tag. The +application MSRV matches its existing 1.95 toolchain and kernal-api's requirement. The boundary test failed before each dependency migration and passed afterward. All Python unit tests pass (24 passed, one skipped). The default-feature Rust @@ -1084,17 +1093,75 @@ Final checks pass 286 library, 3 binary, 1 integration and 1 doc tests, Python ### Final migration audit -The Axum baseline now has a raw-wire parity test for missing-file 404, +The Axum baseline has a raw-wire parity test for missing-file 404, unsupported-method 405, HEAD content length with no body, browser-isolation and -cache headers, wildcard CORS, and OPTIONS preflight methods/headers. This test -passes before transport replacement and must remain green after adoption. - -- Resolve every inventory row with code and dependency-graph evidence. -- Move generic mechanism tests upstream while retaining application integration - and product-policy coverage here. -- Consume an exact published kernal-api release without a local path patch. -- Enforce the final dependency boundary in CI. -- Run lint, Rust workspace tests, Python smoke tests, and native compiler/viewer - integration checks; verify supported-platform behavior and Safari invariants. -- Record comparable clean and incremental build measurements and explain any - remaining implementation dependencies. +cache headers, wildcard CORS, and OPTIONS preflight methods/headers. It passed +before transport replacement and remains green with kernel serving. + +**Inventory resolved.** The `fastled-cli` lockfile entry lists only +`kernal-api`. Every original backend row (locks, globs/watchers, fingerprints, +paths, process containment, Tokio, HTTP client/server, archive/hash, viewer and +terminal, compiler target facts, C++ parsing, CLI/configuration/JSON/utility +crates, and build-only `indexmap`) has been removed from the manifests and +sources. The Python launcher row was audited: `typeguard` and `zcmds_win32` +were removed (#245); Meson, Ninja and uv remain because native compilation +still uses them. Generic mechanism tests live upstream; FastLED retains product +policy and integration coverage, including the SHA-256 cache and artifact +encoding lock-down tests ported from #244. + +**Exact release.** kernal-api `v0.1.3` was cut on branch `release/0.1.3` from +`762a1d2`, the revision this branch had already validated and the last +kernal-api main commit with green CI. Main was failing CI at release time, so +the release deliberately excludes later main commits. The release contains +only a version bump over `762a1d2`. crates.io publishing is not enabled for +kernal-api, and `fastled-cli` is not published to crates.io, so the workspace +uses a git tag dependency. The pre-release pin, path and `[patch]` overrides +are gone. + +**Boundary enforced in CI.** `tests/unit/test_kernal_boundary.py` runs in the +unit-test workflows. It requires `kernal-api` to be the only dependency in every +package, workspace and target table. It also forbids build and dev dependencies, +`[patch]`, `build.rs` and generated Tauri output, and requires the +`hash-sha256` feature, alongside the per-backend source bans. + +**Validation on Linux x86-64 (NixOS).** Rust workspace tests pass: 303 library, +3 binary, 1 integration and 1 doc test. `bash lint` passes, covering rustfmt, +strict Clippy, dylint, Ruff, Black, isort and Pyright. Python unit tests pass +(45 passed, 1 skipped). Other platforms rely on the PR's native CI matrix. + +**Real viewer (#247) is not a migration regression.** The same Blink sketch, +FastLED source (`9aa7254bba`), Xvfb/software-GL environment and `--test` flags +fail identically with the pre-migration `origin/main` Tauri binary and with +this branch. Both report `webgl2: false` from OffscreenCanvas and exit 125 +without a canvas. The branch binary was rechecked after NixOS system +generation 118 (commit `9f90cf4`, which adds the `tauri-run` wrapper), launched +through that wrapper on the real Wayland session, real X11 `:0` and Xvfb. All +three still report `webgl2: false`. The session has no hardware GL: `eglinfo` +reports only Mesa `llvmpipe`, GBM initialization fails, and NVIDIA reports a +driver/library version mismatch (kernel 595.71.05, userspace 595.99.02) until +reboot. Real-render acceptance needs hardware GL (e.g. after that reboot), and +the Safari invariant still needs a real Safari smoke test on macOS; neither was +possible here. + +**Build measurements.** Recorded on the same Linux host, toolchain and native +environment. Each figure is one `soldr cargo build --locked -p fastled-cli --bin +fastled` (debug, default `viewer` feature) into a fresh target directory. Both +dependency graphs were built once, untimed, before measuring. soldr has no +switch to disable its compilation cache, so truly uncached clean builds were +not measured; cache hits and misses are reported instead. "Incremental" means +rebuilding after touching `main.rs` and `lib.rs`. + +| Build | Round | Clean (ms) | Cache hits / misses | Crates compiled | Incremental (ms) | +| --- | --- | --- | --- | --- | --- | +| `origin/main` `22ea046` (Tauri) | 1 | 72938 | 1025 / 202 | 455 | 10082 | +| migration branch `eb5a51d` | 1 | 60531 | 391 / 134 | 386 | 1428 | +| `origin/main` `22ea046` (Tauri) | 2 | 12093 | 170 / 0 | 455 | 1545 | +| migration branch `eb5a51d` | 2 | 15519 | 521 / 8 | 386 | 1534 | + +Round 1 is not comparable: both sides still missed the cache, so the untimed +builds did not fully warm it. Round 2 is the closest comparison. The migration +compiles 69 fewer crates (455 to 386), but the warm-cache clean build is not +faster (15.5 s vs 12.1 s, with 8 remaining branch misses), and incremental +rebuilds are the same (about 1.5 s). **No build-speed improvement is +claimed.** The gain is ownership and a smaller direct dependency surface; the +backends remain transitive through kernal-api. From 966c7354665a3b616a43392f2c00379d58f074d6 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 14 Sep 2026 18:30:03 -0700 Subject: [PATCH 87/94] build: consume kernal-api v0.1.4 for WebKitGTK OffscreenCanvas WebGL kernal-api v0.1.4 enables WebKitGTK's AllowWebGLInWorkers runtime feature in its Linux webview adapter (zackees/kernal-api#268). WebKitGTK ships it disabled, and it gates WebGL on every OffscreenCanvas, so the viewer's WebGL2 compatibility gate always failed on Linux. The fix lives in kernal-api; no local workaround is added here. - Pin the workspace dependency to the v0.1.4 tag and require it in the boundary test. - Log the frontend's OffscreenCanvas WebGL2 probe as unavailable when getContext returns null, instead of reporting SUCCESS unconditionally. - Correct the migration audit: the shared #247 failure was the WebKitGTK feature flag, not host GL. A WebKitGTK 2.52 + NVIDIA Wayland crash with document plus worker WebGL still blocks real rendering and is tracked upstream in #247. Refs #229, #247 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- Cargo.lock | 4 +-- Cargo.toml | 2 +- docs/kernal-api-migration.md | 33 ++++++++++++------- .../modules/core/fastled_worker_manager.ts | 9 +++-- tests/unit/test_kernal_boundary.py | 2 +- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ce98afc..47f67a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,8 +2023,8 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.3" -source = "git+https://github.com/zackees/kernal-api.git?tag=v0.1.3#ac2659fe397e98a13c5a6a2a50eff2771fce900f" +version = "0.1.4" +source = "git+https://github.com/zackees/kernal-api.git?tag=v0.1.4#d69567365d5768c338d198f4694db49e7e2ad0be" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index bbcf216e..fc694142 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.3", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.4", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 13bd4a96..30ebd5dd 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -32,7 +32,7 @@ capability migration, with the same toolchain, features, and cache conditions. The inventory table above and the checkpoint sections below are historical records of each slice; see "Final migration audit" at the end for the current state. `fastled-cli` now has exactly one direct Rust dependency, `kernal-api`, -consumed from the `v0.1.3` release tag with no `[patch]` override. Every +consumed from the `v0.1.4` release tag with no `[patch]` override. Every backend in the inventory is absent from the manifests and sources and remains only as a private transitive implementation of the kernel. @@ -42,7 +42,7 @@ Work is on `feat/kernal-api-migration`. The migration originally used an exact `=0.1.0` declaration plus a temporary sibling path patch to `../fastled-wasm-extern/kernal-api`, initially checked out at `76172bf3ee41ec601d3fa834291dfbe6bfc11789`; later a pinned git revision replaced -the path patch. Both are now removed in favor of the `v0.1.3` release tag. The +the path patch. Both are now removed in favor of the `v0.1.4` release tag. The application MSRV matches its existing 1.95 toolchain and kernal-api's requirement. The boundary test failed before each dependency migration and passed afterward. @@ -1113,7 +1113,9 @@ encoding lock-down tests ported from #244. `762a1d2`, the revision this branch had already validated and the last kernal-api main commit with green CI. Main was failing CI at release time, so the release deliberately excludes later main commits. The release contains -only a version bump over `762a1d2`. crates.io publishing is not enabled for +only a version bump over `762a1d2`. kernal-api `v0.1.4` is cut from the same +`release/0.1.3` branch and adds only the WebKitGTK OffscreenCanvas WebGL fix +(zackees/kernal-api#268) and its version bump; the workspace consumes that tag. crates.io publishing is not enabled for kernal-api, and `fastled-cli` is not published to crates.io, so the workspace uses a git tag dependency. The pre-release pin, path and `[patch]` overrides are gone. @@ -1133,15 +1135,22 @@ strict Clippy, dylint, Ruff, Black, isort and Pyright. Python unit tests pass FastLED source (`9aa7254bba`), Xvfb/software-GL environment and `--test` flags fail identically with the pre-migration `origin/main` Tauri binary and with this branch. Both report `webgl2: false` from OffscreenCanvas and exit 125 -without a canvas. The branch binary was rechecked after NixOS system -generation 118 (commit `9f90cf4`, which adds the `tauri-run` wrapper), launched -through that wrapper on the real Wayland session, real X11 `:0` and Xvfb. All -three still report `webgl2: false`. The session has no hardware GL: `eglinfo` -reports only Mesa `llvmpipe`, GBM initialization fails, and NVIDIA reports a -driver/library version mismatch (kernel 595.71.05, userspace 595.99.02) until -reboot. Real-render acceptance needs hardware GL (e.g. after that reboot), and -the Safari invariant still needs a real Safari smoke test on macOS; neither was -possible here. +without a canvas. That shared failure is WebKitGTK's `AllowWebGLInWorkers` +runtime feature, which is disabled by default and gates WebGL on every +OffscreenCanvas, including canvases a document creates. It reproduces with +hardware GL on NVIDIA and with software GL, in the stock `MiniBrowser` as well +as the viewer; an NVIDIA driver/library mismatch seen on this host before a +reboot was real but was not the cause. kernal-api v0.1.4 enables the feature in +its Linux adapter (zackees/kernal-api#268, zackees/kernal-api#269), after which +FastLED's WebGL2 gate passes and its worker creates a WebGL2 context. + +The viewer still does not reach readiness on NVIDIA 595.99.02 under Wayland. +With WebKitGTK 2.52.5, a document WebGL2 context followed by worker rendering +on a transferred canvas breaks the UI process with a Wayland protocol error and +crashes the web process in NVIDIA EGL during GL context teardown. This also +reproduces in `MiniBrowser`, so it is tracked in #247 as an upstream +WebKitGTK/driver issue rather than worked around here. Real-render acceptance +and the required real Safari smoke test on macOS remain open. **Build measurements.** Recorded on the same Linux host, toolchain and native environment. Each figure is one `soldr cargo build --locked -p fastled-cli --bin diff --git a/src/fastled/frontend/modules/core/fastled_worker_manager.ts b/src/fastled/frontend/modules/core/fastled_worker_manager.ts index 2dc43bb8..5a3ff79d 100644 --- a/src/fastled/frontend/modules/core/fastled_worker_manager.ts +++ b/src/fastled/frontend/modules/core/fastled_worker_manager.ts @@ -110,8 +110,13 @@ export class FastLEDWorkerManager { const testCanvas = new OffscreenCanvas(1, 1); const ctx = testCanvas.getContext('webgl2'); capabilities.webgl2 = !!ctx; - console.log('🔧 WebGL2 OffscreenCanvas test: SUCCESS'); - FASTLED_DEBUG_LOG('WORKER_MANAGER', 'WebGL2 OffscreenCanvas test successful'); + if (ctx) { + console.log('🔧 WebGL2 OffscreenCanvas test: SUCCESS'); + FASTLED_DEBUG_LOG('WORKER_MANAGER', 'WebGL2 OffscreenCanvas test successful'); + } else { + console.warn('🔧 WebGL2 OffscreenCanvas test: UNAVAILABLE (getContext returned null)'); + FASTLED_DEBUG_LOG('WORKER_MANAGER', 'WebGL2 OffscreenCanvas context unavailable'); + } } catch (error) { console.error('🔧 WebGL2 OffscreenCanvas test: FAILED', error); FASTLED_DEBUG_ERROR('WORKER_MANAGER', 'WebGL2 OffscreenCanvas test failed', error); diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index f003ec1b..6e0a376c 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -193,7 +193,7 @@ def test_kernal_api_is_the_only_rust_dependency(): assert not section.get("dev-dependencies"), section assert set(package["dependencies"]) == {"kernal-api"} kernal = workspace["workspace"]["dependencies"]["kernal-api"] - assert kernal["tag"] == "v0.1.3" + assert kernal["tag"] == "v0.1.4" assert "rev" not in kernal and "path" not in kernal assert "hash-sha256" in kernal["features"] assert "patch" not in workspace From dc5665c93e8f8fdba63b2ee82ef0a6c2b9854fbd Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 14 Sep 2026 20:21:45 -0700 Subject: [PATCH 88/94] fix(viewer): render on the main thread when OffscreenCanvas has no WebGL2 Port FastLED#4405 to the bundled frontend. WebKitGTK (the Linux viewer) has WebGL2 on a regular canvas but, by default, not on OffscreenCanvas. The sketch stays in the worker (the pthread build blocks in extern_setup()/extern_loop()), which now posts each frame's pixel buffers to the main thread; the page draws them with the regular-canvas WebGL2 graphics manager. The compatibility check requires WebGL2 on a regular canvas and only warns about OffscreenCanvas. This is the path the pre-kernal-api viewer used. Forcing worker WebGL (kernal-api v0.1.4) instead crashes the WebKit web process on NVIDIA Wayland (Skia GrResourceCache SIGILL) and freezes the canvas: 5 of 8 runs froze, versus 0 of 10 with main-thread rendering (7 Wayland, 3 X11). The --test harness reads the page canvas in this mode (drawing buffers are already preserved) instead of asking the worker for a WebGL readback. Refs #247 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- crates/fastled-cli/src/tauri_viewer.rs | 14 ++++ src/fastled/frontend/bootstrap.ts | 26 +++---- .../modules/core/fastled_background_worker.ts | 67 +++++++++++++++---- .../modules/core/fastled_callbacks.ts | 12 ++-- .../modules/core/fastled_worker_manager.ts | 64 ++++++++++++++++-- 5 files changed, 142 insertions(+), 41 deletions(-) diff --git a/crates/fastled-cli/src/tauri_viewer.rs b/crates/fastled-cli/src/tauri_viewer.rs index c4c1a4dc..53345cc6 100644 --- a/crates/fastled-cli/src/tauri_viewer.rs +++ b/crates/fastled-cli/src/tauri_viewer.rs @@ -231,6 +231,18 @@ const TEST_RUNTIME_SCRIPT: &str = r#" } }; const capture = async (canvas, name) => { + // Without WebGL2 on OffscreenCanvas (WebKitGTK) the page canvas draws the + // frames itself, and preserveDrawingBuffer above keeps them readable. + if (window.fastLEDWorkerManager && window.fastLEDWorkerManager.renderOnMainThread) { + const blob = await (await fetch(canvas.toDataURL('image/png'))).blob(); + const response = await testFetch('/viewer-screenshot?name=' + encodeURIComponent(name), { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: blob + }); + if (!response.ok) throw new Error('screenshot upload failed: ' + response.status); + return; + } let blob = await webglFrameBlob(); if (!blob) { const dataUrl = await compositedFrameDataUrl(canvas) @@ -449,6 +461,8 @@ mod tests { #[test] fn test_runtime_patches_webgl_before_capturing() { assert!(TEST_RUNTIME_SCRIPT.contains("preserveDrawingBuffer: true")); + assert!(TEST_RUNTIME_SCRIPT.contains("fastLEDWorkerManager.renderOnMainThread")); + assert!(TEST_RUNTIME_SCRIPT.contains("canvas.toDataURL('image/png')")); assert!(TEST_RUNTIME_SCRIPT.contains("type: 'start_recording'")); assert!(TEST_RUNTIME_SCRIPT.contains("canvas.captureStream(0)")); assert!(TEST_RUNTIME_SCRIPT.contains("new ImageCapture(track).grabFrame()")); diff --git a/src/fastled/frontend/bootstrap.ts b/src/fastled/frontend/bootstrap.ts index 438f1eb3..42b7cadd 100644 --- a/src/fastled/frontend/bootstrap.ts +++ b/src/fastled/frontend/bootstrap.ts @@ -34,28 +34,22 @@ export function checkBrowserCompatibility() { const workerCapabilities = fastLEDWorkerManager.capabilities; const skipWebGL2Compatibility = urlParams.get('runtime_stack_smoke') === '1'; - // Check OffscreenCanvas support - if (workerCapabilities?.offscreenCanvas === true) { - // Reuse the worker manager probe when it has already run during module import. - } else if (typeof OffscreenCanvas === 'undefined') { - errors.push('OffscreenCanvas not supported'); - } else { - // Check WebGL2 support with OffscreenCanvas + // WebGL2 on a regular canvas is the hard requirement. WebGL2 on an + // OffscreenCanvas is optional: without it (WebKitGTK behind the Tauri viewer + // on Linux) the worker still runs the sketch and posts frames to the main + // thread, which draws them. FastLEDWorkerManager detects that itself. + if (!skipWebGL2Compatibility) { try { - const testCanvas = new OffscreenCanvas(1, 1); - const ctx = testCanvas.getContext('webgl2'); - if (!ctx && !skipWebGL2Compatibility && workerCapabilities?.webgl2 !== true) { - errors.push('WebGL2 not supported with OffscreenCanvas'); + if (!document.createElement('canvas').getContext('webgl2')) { + errors.push('WebGL2 not supported'); } } catch (error) { - if (!skipWebGL2Compatibility && workerCapabilities?.webgl2 !== true) { - errors.push(`OffscreenCanvas WebGL2 test failed: ${error.message}`); - } + errors.push(`WebGL2 test failed: ${error.message}`); } } - if (!skipWebGL2Compatibility && workerCapabilities && workerCapabilities.webgl2 !== true) { - errors.push('WebGL2 not supported with OffscreenCanvas'); + if (workerCapabilities?.webgl2 !== true) { + console.warn('WebGL2 on OffscreenCanvas is unavailable; frames will be rendered on the main thread'); } if (errors.length > 0) { diff --git a/src/fastled/frontend/modules/core/fastled_background_worker.ts b/src/fastled/frontend/modules/core/fastled_background_worker.ts index 76c0151b..d8b8a999 100644 --- a/src/fastled/frontend/modules/core/fastled_background_worker.ts +++ b/src/fastled/frontend/modules/core/fastled_background_worker.ts @@ -139,6 +139,8 @@ const workerState = { // Zero polling overhead - completely event-driven // Dictionary format: { "0": {strips: {...}, absMin: [...], absMax: [...]}, "1": {...} } screenMaps: {}, + screenMapsDirty: false, // screenMaps changed and the main thread has not been told yet (main-thread rendering) + renderOnMainThread: false, // frames are posted to the main thread instead of drawn on an OffscreenCanvas // Audio sample queue - samples buffered here from onmessage, flushed to WASM at frame start audioSampleQueue: [], @@ -329,11 +331,18 @@ async function handleInitialize(payload) { workerState.capabilities = payload.capabilities; workerState.frameRate = payload.frameRate || 60; workerState.urlParams = payload.urlParams || {}; // Store URL parameters from main thread + // When the browser cannot create WebGL2 on an OffscreenCanvas (WebKitGTK + // behind the Tauri viewer on Linux), the sketch still runs here — blocking + // calls must stay off the main thread — and each frame is posted back to + // the main thread, which draws it on the real canvas. + workerState.renderOnMainThread = !!payload.renderOnMainThread; workerLog('LOG', 'BACKGROUND_WORKER', 'URL parameters received from main thread', workerState.urlParams); // Validate OffscreenCanvas - if (!workerState.canvas || !(workerState.canvas instanceof OffscreenCanvas)) { + if (workerState.renderOnMainThread) { + workerLog('LOG', 'BACKGROUND_WORKER', 'Main-thread rendering: no OffscreenCanvas, frames will be posted to the main thread'); + } else if (!workerState.canvas || !(workerState.canvas instanceof OffscreenCanvas)) { throw new Error('Invalid OffscreenCanvas provided to worker'); } @@ -343,18 +352,20 @@ async function handleInitialize(payload) { await initializeFastLEDModule(); // Set up graphics manager for OffscreenCanvas - await initializeGraphicsManager(); + if (!workerState.renderOnMainThread) { + await initializeGraphicsManager(); + } workerState.initialized = true; const result = { success: true, capabilities: workerState.capabilities, - canvas: { + canvas: workerState.canvas ? { width: workerState.canvas.width, height: workerState.canvas.height - }, - contextType: 'webgl2' + } : null, + contextType: workerState.renderOnMainThread ? 'main_thread' : 'webgl2' }; workerLog('LOG', 'BACKGROUND_WORKER', 'Worker initialized successfully', result); @@ -654,6 +665,7 @@ async function handleStart(_payload) { // Update worker state and notify graphics manager workerState.screenMaps = screenMapData; + workerState.screenMapsDirty = true; if (workerState.graphicsManager && workerState.graphicsManager.updateScreenMap) { workerState.graphicsManager.updateScreenMap(screenMapData); workerLog('LOG', 'BACKGROUND_WORKER', 'ScreenMaps fetched and sent to graphics manager', { @@ -866,6 +878,9 @@ async function handleStartRecording(payload) { try { // Check if canvas is available + if (workerState.renderOnMainThread) { + throw new Error('Frame capture is unavailable in main-thread rendering mode (no OffscreenCanvas WebGL2)'); + } if (!workerState.canvas) { throw new Error('Canvas not available for frame capture'); } @@ -936,6 +951,7 @@ function handleScreenMapUpdate(payload) { // Cache the screenmap data (dictionary format) // This is sent from C++ only when screenmaps change (strip added, layout updated) workerState.screenMaps = payload.screenMapData; + workerState.screenMapsDirty = true; workerLog('LOG', 'BACKGROUND_WORKER', 'Screenmap cache updated', { screenMapCount: Object.keys(workerState.screenMaps || {}).length @@ -1066,6 +1082,28 @@ function startAnimationLoop() { }); } +/** + * Posts one frame to the main thread for rendering (no OffscreenCanvas mode). + * Pixel buffers are transferred, not copied; the screenmap dictionary rides + * along only when it changed since the last post. + * @param {Array} frameData - Strip data with pixel_data copies from extractFrameData() + */ +function postFrameToMainThread(frameData) { + const transfer = []; + for (const strip of frameData) { + if (strip.pixel_data && strip.pixel_data.buffer) { + transfer.push(strip.pixel_data.buffer); + } + } + const payload = { + frameData, + frameNumber: workerState.frameCount, + screenMaps: workerState.screenMapsDirty ? workerState.screenMaps : undefined + }; + workerState.screenMapsDirty = false; + postMessage({ type: 'frame_data', payload }, /** @type {*} */ (transfer)); +} + /** * Executes a single frame of the animation loop * @param {number} currentTime - Current timestamp @@ -1095,14 +1133,19 @@ async function executeFrameLoop(currentTime) { const frameData = extractFrameData(); if (frameData) { - // Render frame to OffscreenCanvas (automatically syncs to main thread canvas) - workerState.graphicsManager.updateCanvas(frameData); + if (workerState.renderOnMainThread) { + // No OffscreenCanvas here: hand the frame to the main thread to draw + postFrameToMainThread(frameData); + } else { + // Render frame to OffscreenCanvas (automatically syncs to main thread canvas) + workerState.graphicsManager.updateCanvas(frameData); - // Capture frame for main-thread recording if enabled - if (workerState.isCapturingFrames) { - captureAndTransferFrame().catch((err) => { - workerLog('ERROR', 'BACKGROUND_WORKER', 'Frame capture error', err); - }); + // Capture frame for main-thread recording if enabled + if (workerState.isCapturingFrames) { + captureAndTransferFrame().catch((err) => { + workerLog('ERROR', 'BACKGROUND_WORKER', 'Frame capture error', err); + }); + } } // Send lightweight performance telemetry to main thread diff --git a/src/fastled/frontend/modules/core/fastled_callbacks.ts b/src/fastled/frontend/modules/core/fastled_callbacks.ts index 98d6ef9f..33048066 100644 --- a/src/fastled/frontend/modules/core/fastled_callbacks.ts +++ b/src/fastled/frontend/modules/core/fastled_callbacks.ts @@ -110,12 +110,12 @@ globalThis.FastLED_onFrame = async function (frameData) { } } - // Final defensive check: ensure screenMap has proper structure - if (frameData.screenMap && (!frameData.screenMap.strips || typeof frameData.screenMap.strips !== 'object')) { - console.warn('FastLED_onFrame: screenMap exists but has invalid structure, using fallback:', frameData.screenMap); - frameData.screenMap = { - strips: {}, - }; + // Final defensive check: screenMap is a dictionary keyed by strip id + // ({ "0": { strips, absMin, absMax }, ... }), the same shape the graphics + // managers' updateScreenMap() consumes. Drop anything that is not an object. + if (frameData.screenMap && typeof frameData.screenMap !== 'object') { + console.warn('FastLED_onFrame: screenMap has invalid structure, dropping it:', frameData.screenMap); + delete frameData.screenMap; } // Render to canvas using existing graphics manager (main thread) diff --git a/src/fastled/frontend/modules/core/fastled_worker_manager.ts b/src/fastled/frontend/modules/core/fastled_worker_manager.ts index 5a3ff79d..c84a4a8c 100644 --- a/src/fastled/frontend/modules/core/fastled_worker_manager.ts +++ b/src/fastled/frontend/modules/core/fastled_worker_manager.ts @@ -55,6 +55,13 @@ export class FastLEDWorkerManager { /** @type {HTMLCanvasElement|null} Original main thread canvas */ this.mainCanvas = null; + /** @type {boolean} Worker posts frames here when OffscreenCanvas WebGL2 is unavailable */ + this.renderOnMainThread = false; + /** @type {Object|null} Last screenmap dictionary received from the worker */ + this.lastScreenMaps = null; + /** @type {Object|null} Graphics manager that already received lastScreenMaps */ + this.screenMapsTarget = null; + /** @type {WorkerCapabilities} Detected browser capabilities */ this.capabilities = this.detectCapabilities(); @@ -153,11 +160,20 @@ export class FastLEDWorkerManager { this.mainCanvas = config.canvas; this.maxRetries = config.maxRetries || 3; - // Transfer canvas control to OffscreenCanvas - console.log('🔧 About to transfer canvas to offscreen...'); - this.offscreenCanvas = this.mainCanvas.transferControlToOffscreen(); - console.log('🔧 Canvas transferred successfully'); - FASTLED_DEBUG_LOG('WORKER_MANAGER', `Canvas control transferred to OffscreenCanvas`); + // Transfer canvas control to OffscreenCanvas — unless this browser cannot + // create WebGL2 on one (WebKitGTK behind the Tauri viewer on Linux). Then + // the worker still runs the sketch, so blocking calls stay off the main + // thread, and posts every frame back here to draw on the real canvas. + this.renderOnMainThread = !this.capabilities.webgl2; + if (this.renderOnMainThread) { + console.log('🔧 No WebGL2 on OffscreenCanvas: worker will post frames for main-thread rendering'); + this.offscreenCanvas = null; + } else { + console.log('🔧 About to transfer canvas to offscreen...'); + this.offscreenCanvas = this.mainCanvas.transferControlToOffscreen(); + console.log('🔧 Canvas transferred successfully'); + FASTLED_DEBUG_LOG('WORKER_MANAGER', `Canvas control transferred to OffscreenCanvas`); + } // Create and configure worker console.log('🔧 About to create worker...'); @@ -180,7 +196,8 @@ export class FastLEDWorkerManager { fastLEDEvents.emit('worker:initialized', { mode: 'background_worker', capabilities: this.capabilities, - canvas: { width: this.offscreenCanvas.width, height: this.offscreenCanvas.height } + renderOnMainThread: this.renderOnMainThread, + canvas: { width: this.mainCanvas.width, height: this.mainCanvas.height } }); FASTLED_DEBUG_LOG('WORKER_MANAGER', 'Background worker initialized successfully'); @@ -244,6 +261,7 @@ export class FastLEDWorkerManager { id: this.generateMessageId(), payload: { canvas: this.offscreenCanvas, + renderOnMainThread: this.renderOnMainThread, capabilities: this.capabilities, frameRate: config.frameRate, urlParams: urlParamsObject, // Pass URL parameters to worker @@ -258,7 +276,7 @@ export class FastLEDWorkerManager { console.log('🔧 createWorker: About to call sendMessageWithResponse...'); console.log('🔧 createWorker: isWorkerActive BEFORE send:', this.isWorkerActive); - const success = await this.sendMessageWithResponse(initMessage, [this.offscreenCanvas]); + const success = await this.sendMessageWithResponse(initMessage, this.offscreenCanvas ? [this.offscreenCanvas] : null); console.log('🔧 createWorker: sendMessageWithResponse returned:', success); if (!success) { throw new Error('Worker initialization message failed'); @@ -402,6 +420,34 @@ export class FastLEDWorkerManager { * Handles messages received from the worker * @param {MessageEvent} event - Worker message event */ + /** + * Draws a frame the worker posted because it has no OffscreenCanvas + * (main-thread rendering). The screenmap dictionary arrives only when it + * changed; it is cached and pushed to whichever graphics manager is current. + * @param {{frameData: Array, frameNumber: number, screenMaps?: Object}} payload + */ + handleFrameData(payload) { + if (!payload || typeof window.updateCanvas !== 'function') return; + if (payload.screenMaps) { + this.lastScreenMaps = payload.screenMaps; + this.screenMapsTarget = null; + } + const pushScreenMaps = () => { + const gm = window.graphicsManager; + if (this.lastScreenMaps && gm && typeof gm.updateScreenMap === 'function' && this.screenMapsTarget !== gm) { + gm.updateScreenMap(this.lastScreenMaps); + this.screenMapsTarget = gm; + } + }; + const frameData = payload.frameData || []; + // null, not undefined: updateCanvas() treats undefined as "no screenmap yet" + // and skips the frame; the screenmap is pushed separately. + frameData.screenMap = null; + pushScreenMaps(); // manager already exists: new layout applies to this frame + window.updateCanvas(frameData); + pushScreenMaps(); // manager was just created by updateCanvas() + } + handleWorkerMessage(event) { const { data } = event; FASTLED_DEBUG_TRACE('WORKER_MANAGER', 'handleWorkerMessage', 'ENTER', { messageType: data.type }); @@ -430,6 +476,10 @@ export class FastLEDWorkerManager { this.handleFrameRendered(data.payload); break; + case 'frame_data': + this.handleFrameData(data.payload); + break; + case 'error': this.handleWorkerErrorMessage(data.payload); break; From 427d19fb224f7ce33a7885acb80efa5618acbe52 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 14 Sep 2026 20:51:28 -0700 Subject: [PATCH 89/94] build: consume kernal-api v0.1.5, which stops forcing worker WebGL kernal-api v0.1.5 reverts zackees/kernal-api#268, so WebKitGTK views keep their default of no WebGL on OffscreenCanvas. The viewer now renders on the main thread in that case (previous commit), which is the path the pre-migration viewer used. Forcing worker WebGL froze the canvas on NVIDIA Wayland. The migration record now corrects the earlier claim that #247 was an upstream WebKitGTK/driver crash. It records the renderer comparison and the Linux --test results. Refs #247 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- Cargo.lock | 4 +- Cargo.toml | 2 +- docs/kernal-api-migration.md | 61 ++++++++++++++++++------------ tests/unit/test_kernal_boundary.py | 2 +- 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47f67a34..cd0abbde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,8 +2023,8 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.4" -source = "git+https://github.com/zackees/kernal-api.git?tag=v0.1.4#d69567365d5768c338d198f4694db49e7e2ad0be" +version = "0.1.5" +source = "git+https://github.com/zackees/kernal-api.git?tag=v0.1.5#e5700f02d3c11f43047caca6457efef080bf3cbe" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index fc694142..acbe5d6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.4", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.5", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 30ebd5dd..4a0de218 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -32,7 +32,7 @@ capability migration, with the same toolchain, features, and cache conditions. The inventory table above and the checkpoint sections below are historical records of each slice; see "Final migration audit" at the end for the current state. `fastled-cli` now has exactly one direct Rust dependency, `kernal-api`, -consumed from the `v0.1.4` release tag with no `[patch]` override. Every +consumed from the `v0.1.5` release tag with no `[patch]` override. Every backend in the inventory is absent from the manifests and sources and remains only as a private transitive implementation of the kernel. @@ -42,7 +42,7 @@ Work is on `feat/kernal-api-migration`. The migration originally used an exact `=0.1.0` declaration plus a temporary sibling path patch to `../fastled-wasm-extern/kernal-api`, initially checked out at `76172bf3ee41ec601d3fa834291dfbe6bfc11789`; later a pinned git revision replaced -the path patch. Both are now removed in favor of the `v0.1.4` release tag. The +the path patch. Both are now removed in favor of the `v0.1.5` release tag. The application MSRV matches its existing 1.95 toolchain and kernal-api's requirement. The boundary test failed before each dependency migration and passed afterward. @@ -1113,9 +1113,11 @@ encoding lock-down tests ported from #244. `762a1d2`, the revision this branch had already validated and the last kernal-api main commit with green CI. Main was failing CI at release time, so the release deliberately excludes later main commits. The release contains -only a version bump over `762a1d2`. kernal-api `v0.1.4` is cut from the same -`release/0.1.3` branch and adds only the WebKitGTK OffscreenCanvas WebGL fix -(zackees/kernal-api#268) and its version bump; the workspace consumes that tag. crates.io publishing is not enabled for +only a version bump over `762a1d2`. kernal-api `v0.1.4`, cut from the same +`release/0.1.3` branch, forced WebKitGTK's worker WebGL feature +(zackees/kernal-api#268). That froze the viewer on NVIDIA Wayland, so `v0.1.5` +reverts it (zackees/kernal-api#272); code-wise it is `v0.1.3` again. The +workspace consumes the `v0.1.5` tag. crates.io publishing is not enabled for kernal-api, and `fastled-cli` is not published to crates.io, so the workspace uses a git tag dependency. The pre-release pin, path and `[patch]` overrides are gone. @@ -1131,26 +1133,35 @@ package, workspace and target table. It also forbids build and dev dependencies, strict Clippy, dylint, Ruff, Black, isort and Pyright. Python unit tests pass (45 passed, 1 skipped). Other platforms rely on the PR's native CI matrix. -**Real viewer (#247) is not a migration regression.** The same Blink sketch, -FastLED source (`9aa7254bba`), Xvfb/software-GL environment and `--test` flags -fail identically with the pre-migration `origin/main` Tauri binary and with -this branch. Both report `webgl2: false` from OffscreenCanvas and exit 125 -without a canvas. That shared failure is WebKitGTK's `AllowWebGLInWorkers` -runtime feature, which is disabled by default and gates WebGL on every -OffscreenCanvas, including canvases a document creates. It reproduces with -hardware GL on NVIDIA and with software GL, in the stock `MiniBrowser` as well -as the viewer; an NVIDIA driver/library mismatch seen on this host before a -reboot was real but was not the cause. kernal-api v0.1.4 enables the feature in -its Linux adapter (zackees/kernal-api#268, zackees/kernal-api#269), after which -FastLED's WebGL2 gate passes and its worker creates a WebGL2 context. - -The viewer still does not reach readiness on NVIDIA 595.99.02 under Wayland. -With WebKitGTK 2.52.5, a document WebGL2 context followed by worker rendering -on a transferred canvas breaks the UI process with a Wayland protocol error and -crashes the web process in NVIDIA EGL during GL context teardown. This also -reproduces in `MiniBrowser`, so it is tracked in #247 as an upstream -WebKitGTK/driver issue rather than worked around here. Real-render acceptance -and the required real Safari smoke test on macOS remain open. +**Real viewer (#247).** WebKitGTK exposes WebGL2 on a regular canvas but, by +default, not on OffscreenCanvas. The bundled frontend required OffscreenCanvas +WebGL2, so it rejected the viewer on Linux. It now carries FastLED#4405: the +sketch stays in the worker, which posts each frame to the page, and the page +draws it with the regular-canvas WebGL2 renderer. The `--test` harness reads +the page canvas in that mode. This is the path the pre-migration viewer used. + +Forcing worker WebGL in kernal-api instead (v0.1.4) was tried and reverted. +On NVIDIA 595.99.02 / WebKitGTK 2.52.5 under KDE Wayland, the web process then +crashes with a SIGILL in Skia's `GrResourceCache` and the canvas freezes. The +table shows focused-window OS captures with core dumps matched by timestamp: + +| Renderer | Runs | Animated throughout | Froze | +| --- | --- | --- | --- | +| Worker WebGL forced on (kernal-api v0.1.4) | 8 Wayland | 3 | 5, each with the Skia crash | +| Main-thread rendering (WebKitGTK default) | 8 Wayland, 3 X11 | 11 | 0 | + +A separate `libnvidia-eglcore` SEGV occurs with both renderers and with the +pre-migration binary; it does not stop rendering. Earlier notes called the +failure an upstream WebKitGTK/driver crash reproduced in `MiniBrowser`. That +was wrong: the `MiniBrowser` run lacked `__NV_DISABLE_EXPLICIT_SYNC=1`, and the +Blink captures failed because Blink sets no screen map in `setup()` (#250). + +On Linux, `fastled --test` passes with the red/blue sketch alternating +across 12 in-page captures: under X11, and under Wayland with the viewer window +visible. With the DMA-BUF renderer, WebKit produces no frames while the +compositor is not drawing the window, so a Wayland run whose window never +becomes visible times out waiting for the first frame. The real Safari smoke +test on macOS remains open. **Build measurements.** Recorded on the same Linux host, toolchain and native environment. Each figure is one `soldr cargo build --locked -p fastled-cli --bin diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 6e0a376c..943f416b 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -193,7 +193,7 @@ def test_kernal_api_is_the_only_rust_dependency(): assert not section.get("dev-dependencies"), section assert set(package["dependencies"]) == {"kernal-api"} kernal = workspace["workspace"]["dependencies"]["kernal-api"] - assert kernal["tag"] == "v0.1.4" + assert kernal["tag"] == "v0.1.5" assert "rev" not in kernal and "path" not in kernal assert "hash-sha256" in kernal["features"] assert "patch" not in workspace From a919ed7975370373cdf507c7b16903ec82e0bc3d Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 15 Sep 2026 10:29:10 -0700 Subject: [PATCH 90/94] ci: cross-compiled macOS live test on pull requests macos-arm-live-test.yml builds everything for aarch64-apple-darwin on Linux through Soldr's cross toolchain: - the release fastled binary (checked to be an arm64 Mach-O), - the macOS wheel, - a nextest archive of the workspace tests. A macos-15 runner then only executes: - the test archive, remapped onto its checkout; - the installed wheel's live render in the shipped WKWebView viewer; - a real Safari smoke test of the same compiled sketch. safaridriver loads it from the shipped server, and the check requires cross-origin isolation, rendered frames and no page errors. The native macOS build/lint/unit/integration workflows no longer run on pull requests; they still run on main pushes and manual dispatch. The installed-wheel smoke moves from _build.yml into ci/smoke_installed_wheel.sh so both lanes run the same steps. Refs #242, #251 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- .github/workflows/_build.yml | 91 +------- .github/workflows/macos-arm-build.yml | 3 +- .../workflows/macos-arm-integration-test.yml | 3 +- .github/workflows/macos-arm-lint.yml | 3 +- .github/workflows/macos-arm-live-test.yml | 148 ++++++++++++ .github/workflows/macos-arm-unit-test.yml | 3 +- .github/workflows/macos-x86-build.yml | 3 +- .../workflows/macos-x86-integration-test.yml | 3 +- .github/workflows/macos-x86-lint.yml | 3 +- .github/workflows/macos-x86-unit-test.yml | 3 +- ci/safari_smoke.py | 218 ++++++++++++++++++ ci/smoke_installed_wheel.sh | 102 ++++++++ 12 files changed, 487 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/macos-arm-live-test.yml create mode 100644 ci/safari_smoke.py create mode 100755 ci/smoke_installed_wheel.sh diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index e1c749c5..eaa23bad 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -116,96 +116,11 @@ jobs: uv run --with "setuptools>=69" --with "wheel>=0.43" python setup.py bdist_wheel fi - # This deliberately runs the packaged console script outside this - # checkout. It exercises the wheel's bundled Rust binary and frontend, - # rather than development fallbacks such as CARGO_MANIFEST_DIR. + # Installs the wheel into a fresh venv and runs the packaged console + # script outside this checkout, in the shipped viewer. - name: Smoke test installed wheel in shipped viewer if: inputs.wheel-smoke - run: | - set -euo pipefail - SMOKE_ROOT="$RUNNER_TEMP/fastled-wheel-smoke" - rm -rf "$SMOKE_ROOT" - mkdir -p "$SMOKE_ROOT/home" "$SMOKE_ROOT/sketch/data" "$SMOKE_ROOT/artifacts" "$SMOKE_ROOT/asset-origin" - uv venv "$SMOKE_ROOT/venv" --python "$UV_PYTHON" - uv pip install --python "$SMOKE_ROOT/venv/bin/python" dist/*.whl - test -x "$SMOKE_ROOT/venv/bin/uv" - BASE_PYTHON_DIR="$("$SMOKE_ROOT/venv/bin/python" -c 'from pathlib import Path; import sys; print(Path(sys.executable).resolve().parent)')" - test ! -e "$BASE_PYTHON_DIR/uv" - cp -R "$GITHUB_WORKSPACE/tests/fixtures/wasm_vfs/." "$SMOKE_ROOT/sketch/" - cp "$GITHUB_WORKSPACE/tests/fixtures/wasm_vfs_origin/probe.txt" "$SMOKE_ROOT/asset-origin/probe.txt" - cat > "$SMOKE_ROOT/sketch/data/probe.txt.lnk" <<'EOF' - http://127.0.0.1:18765/probe.txt - sha256=2da77f7c1452125633beeeb43f366350018643c799306b4d24d8610d98f02b55 - size_bytes=15 - storage=vfs - EOF - cat > "$SMOKE_ROOT/asset_server.py" <<'PY' - import http.server - import sys - - class Handler(http.server.SimpleHTTPRequestHandler): - def end_headers(self): - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Cross-Origin-Resource-Policy", "cross-origin") - super().end_headers() - - def log_message(self, *_args): - pass - - handler = lambda *args, **kwargs: Handler(*args, directory=sys.argv[1], **kwargs) - http.server.ThreadingHTTPServer(("127.0.0.1", 18765), handler).serve_forever() - PY - "$SMOKE_ROOT/venv/bin/python" "$SMOKE_ROOT/asset_server.py" "$SMOKE_ROOT/asset-origin" & - ASSET_SERVER_PID=$! - trap 'kill "$ASSET_SERVER_PID" 2>/dev/null || true' EXIT - for _attempt in {1..20}; do - if curl --fail --silent http://127.0.0.1:18765/probe.txt >/dev/null; then break; fi - sleep 0.25 - done - curl --fail --silent http://127.0.0.1:18765/probe.txt >/dev/null - - # The installed launcher must supply absolute wheel-venv uv, Python, - # and frontend paths to the Rust binary. Deliberately omit ambient - # uv, node, and bare python so this cannot accidentally pass by - # resolving a developer tool from the runner image. - cd "$SMOKE_ROOT" - env -i \ - HOME="$SMOKE_ROOT/home" \ - PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ - /bin/bash -ceu ' - for tool in uv node python; do - if command -v "$tool" >/dev/null 2>&1; then - echo "unexpected ambient $tool on restricted PATH" >&2 - exit 1 - fi - done - ' - env -i \ - HOME="$SMOKE_ROOT/home" \ - PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ - "$SMOKE_ROOT/venv/bin/fastled" "$SMOKE_ROOT/sketch" \ - --check \ - --test-wait-secs=2 \ - --test-timeout-secs=240 \ - --test-ready-timeout-secs=45 \ - --test-screenshot="$SMOKE_ROOT/artifacts/screenmap.png" \ - --test-log="$SMOKE_ROOT/artifacts/viewer.log" - grep -F "Asset 'data/probe.txt' sha256 verified" "$SMOKE_ROOT/artifacts/viewer.log" - grep -F "All 1 filesystem asset(s) loaded completely before setup()." "$SMOKE_ROOT/artifacts/viewer.log" - "$SMOKE_ROOT/venv/bin/python" - "$SMOKE_ROOT/artifacts/screenmap.png" <<'PY' - from pathlib import Path - import sys - - image = Path(sys.argv[1]).read_bytes() - if image[:8] != b"\x89PNG\r\n\x1a\n": - raise SystemExit("viewer screenshot is not a PNG") - if image[12:16] != b"IHDR" or len(image) < 24: - raise SystemExit("viewer screenshot is missing its IHDR header") - width = int.from_bytes(image[16:20], "big") - height = int.from_bytes(image[20:24], "big") - if width <= 0 or height <= 0: - raise SystemExit(f"viewer screenshot has invalid dimensions: {width}x{height}") - PY + run: bash ci/smoke_installed_wheel.sh - name: Upload installed wheel viewer screenshot if: always() && inputs.wheel-smoke diff --git a/.github/workflows/macos-arm-build.yml b/.github/workflows/macos-arm-build.yml index 3073b887..a14df090 100644 --- a/.github/workflows/macos-arm-build.yml +++ b/.github/workflows/macos-arm-build.yml @@ -3,7 +3,8 @@ name: macOS ARM Build on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/.github/workflows/macos-arm-integration-test.yml b/.github/workflows/macos-arm-integration-test.yml index d32d9f0c..7d818969 100644 --- a/.github/workflows/macos-arm-integration-test.yml +++ b/.github/workflows/macos-arm-integration-test.yml @@ -3,7 +3,8 @@ name: macOS ARM Integration Test on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/.github/workflows/macos-arm-lint.yml b/.github/workflows/macos-arm-lint.yml index df737e7f..aa3bf385 100644 --- a/.github/workflows/macos-arm-lint.yml +++ b/.github/workflows/macos-arm-lint.yml @@ -3,7 +3,8 @@ name: macOS ARM Lint on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/.github/workflows/macos-arm-live-test.yml b/.github/workflows/macos-arm-live-test.yml new file mode 100644 index 00000000..890b7322 --- /dev/null +++ b/.github/workflows/macos-arm-live-test.yml @@ -0,0 +1,148 @@ +name: macOS ARM Live Test + +# Apple Silicon coverage for pull requests without compiling on a Mac. +# +# `cross-build` produces everything on Linux through Soldr's cross toolchain +# (bundled LLVM plus the managed macOS SDK): +# - the release `fastled` binary, +# - the macOS wheel that bundles it, +# - a nextest archive of the workspace's Rust tests. +# +# `run-on-macos` holds the macos-15 runner only to execute them: +# - the Rust test archive, +# - the installed wheel's live render in the shipped WKWebView viewer, +# - a real Safari smoke test of the same compiled sketch. +# +# The native macOS build/lint/test workflows no longer run on pull requests; +# they still run on main pushes and manual dispatch. +# #251 tracks moving the execution into a docker-mac-x64 guest on Linux. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# One live run per workflow per ref: a new push to a branch or PR cancels the +# superseded run instead of queuing behind it in the shared account-wide runner +# pool. Runs on main get a unique group, so they are never cancelled or coalesced. +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + RUST_TARGET: aarch64-apple-darwin + TEST_ARCHIVE: fastled-macos-arm64-tests.tar.zst + +jobs: + cross-build: + runs-on: ubuntu-latest + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: "3.11.9" + + - name: Install Python + run: uv python install "$UV_PYTHON" + + - uses: zackees/setup-soldr@v0.9.62 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + cache-key-suffix: macos-arm-live-cross + cross-targets: aarch64-apple-darwin + toolchain-file: rust-toolchain.toml + prebuild-deps-flags: "" + + - name: Cross-build the release fastled binary + run: | + soldr build --release --bin fastled --target "$RUST_TARGET" + mkdir -p src/fastled/bin + cp -f "target/$RUST_TARGET/release/fastled" src/fastled/bin/fastled + # A Mach-O arm64 header, not a host binary. + test "$(od -A n -t x1 -N 8 src/fastled/bin/fastled | tr -d ' \n')" = "cffaedfe0c000001" + + - name: Package the macOS wheel + run: uv run --with "setuptools>=69" --with "wheel>=0.43" python setup.py bdist_wheel --plat-name macosx_11_0_arm64 + + - name: Archive the Rust tests for macOS + run: soldr cargo nextest archive --workspace --target "$RUST_TARGET" --archive-file "$TEST_ARCHIVE" + + - uses: actions/upload-artifact@v4 + with: + name: macos-arm-live-inputs + path: | + dist/*.whl + ${{ env.TEST_ARCHIVE }} + if-no-files-found: error + retention-days: 1 + + run-on-macos: + needs: cross-build + runs-on: macos-15 + timeout-minutes: 40 + defaults: + run: + shell: bash + env: + PYTHONIOENCODING: utf-8 + steps: + # The checkout provides the test fixtures and smoke scripts, and the + # workspace the test archive is remapped onto. Nothing is compiled. + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: "3.11.9" + + - name: Install Python + run: uv python install "$UV_PYTHON" + + - uses: actions/download-artifact@v4 + with: + name: macos-arm-live-inputs + path: macos-arm-live-inputs + + - name: Install cargo-nextest + run: | + mkdir -p "$HOME/.cargo/bin" + curl -LsSf https://get.nexte.st/latest/mac | tar zxf - -C "$HOME/.cargo/bin" + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Run the cross-built Rust tests + # The frontend path is compiled in from the Linux builder + # (CARGO_MANIFEST_DIR); FASTLED_FRONTEND_DIR points tests at this + # checkout instead, the same override the installed wheel uses. + env: + FASTLED_FRONTEND_DIR: ${{ github.workspace }}/src/fastled/frontend + run: >- + cargo-nextest nextest run + --archive-file "macos-arm-live-inputs/$TEST_ARCHIVE" + --workspace-remap . + + - name: Enable Safari automation + run: sudo safaridriver --enable + + - name: Live viewer render and Safari smoke of the installed wheel + env: + FASTLED_SAFARI_SMOKE: "1" + run: | + mkdir -p dist + cp macos-arm-live-inputs/dist/*.whl dist/ + bash ci/smoke_installed_wheel.sh + + - name: Upload screenshots, logs and Safari state + if: always() + uses: actions/upload-artifact@v4 + with: + name: macos-arm-live-results + path: ${{ runner.temp }}/fastled-wheel-smoke/artifacts/ + if-no-files-found: warn diff --git a/.github/workflows/macos-arm-unit-test.yml b/.github/workflows/macos-arm-unit-test.yml index 34fa348c..ccbad0c6 100644 --- a/.github/workflows/macos-arm-unit-test.yml +++ b/.github/workflows/macos-arm-unit-test.yml @@ -3,7 +3,8 @@ name: macOS ARM Unit Test on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/.github/workflows/macos-x86-build.yml b/.github/workflows/macos-x86-build.yml index 69a7448d..76c84b2d 100644 --- a/.github/workflows/macos-x86-build.yml +++ b/.github/workflows/macos-x86-build.yml @@ -3,7 +3,8 @@ name: macOS x86 Build on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/.github/workflows/macos-x86-integration-test.yml b/.github/workflows/macos-x86-integration-test.yml index 0eb88dc6..a41fc143 100644 --- a/.github/workflows/macos-x86-integration-test.yml +++ b/.github/workflows/macos-x86-integration-test.yml @@ -3,7 +3,8 @@ name: macOS x86 Integration Test on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/.github/workflows/macos-x86-lint.yml b/.github/workflows/macos-x86-lint.yml index 7a23585a..e6334e16 100644 --- a/.github/workflows/macos-x86-lint.yml +++ b/.github/workflows/macos-x86-lint.yml @@ -3,7 +3,8 @@ name: macOS x86 Lint on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/.github/workflows/macos-x86-unit-test.yml b/.github/workflows/macos-x86-unit-test.yml index 548cc0b8..90d5f851 100644 --- a/.github/workflows/macos-x86-unit-test.yml +++ b/.github/workflows/macos-x86-unit-test.yml @@ -3,7 +3,8 @@ name: macOS x86 Unit Test on: push: branches: [main] - pull_request: + # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on + # Linux and holds a macOS runner only to execute the result. workflow_dispatch: # One live run per workflow per ref: a new push to a branch or PR cancels the diff --git a/ci/safari_smoke.py b/ci/safari_smoke.py new file mode 100644 index 00000000..42cad975 --- /dev/null +++ b/ci/safari_smoke.py @@ -0,0 +1,218 @@ +"""Real Safari smoke test for a compiled FastLED sketch (macOS only). + +Serves the compiled output with the shipped binary's headless server (which +sends the COOP/COEP headers the pthread build needs), drives Safari through +safaridriver's W3C WebDriver endpoint, and requires the page to render frames +without a JavaScript error. A WebDriver screenshot is kept as an artifact. + +This is the Safari invariant from CLAUDE.md: Chrome-only or Node-only checks +do not prove Safari compatibility (for example, a build that depends on JSPI +fails here). +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import re +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +FRAMES_REQUIRED = 30 + +PROBE_SCRIPT = """ +const state = window.__fastledSafariSmoke || (window.__fastledSafariSmoke = { + frames: 0, errors: [], errorsHooked: false, framesHooked: false +}); +if (!state.errorsHooked) { + state.errorsHooked = true; + window.addEventListener('error', (e) => state.errors.push(String(e.message || e.error))); + window.addEventListener('unhandledrejection', (e) => state.errors.push(String(e.reason))); +} +if (!state.framesHooked && window.fastLEDEvents && typeof window.fastLEDEvents.on === 'function') { + state.framesHooked = true; + window.fastLEDEvents.on('frame:rendered', () => { state.frames += 1; }); +} +const canvas = document.getElementById('myCanvas'); +const rect = canvas ? canvas.getBoundingClientRect() : null; +return { + frames: state.frames, + framesHooked: state.framesHooked, + errors: state.errors, + crossOriginIsolated: self.crossOriginIsolated === true, + viewportWidth: window.innerWidth, + userAgent: navigator.userAgent, + canvas: rect ? {x: rect.x, y: rect.y, width: rect.width, height: rect.height, + dpr: window.devicePixelRatio || 1} : null, + text: document.body ? document.body.innerText.slice(0, 400) : '' +}; +""" + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def webdriver( + method: str, url: str, body: dict | None = None, timeout: float = 60 +) -> dict: + data = None if body is None else json.dumps(body).encode() + request = urllib.request.Request( + url, data=data, method=method, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read() or b"{}") + except urllib.error.HTTPError as error: + raise RuntimeError( + f"{method} {url} -> {error.code}: {error.read().decode(errors='replace')}" + ) + + +def start_server( + fastled: str, serve_dir: str, log_path: Path +) -> tuple[subprocess.Popen, str]: + log = log_path.open("w") + process = subprocess.Popen( + [fastled, "--internal-serve-dir-headless", serve_dir], + stdout=subprocess.PIPE, + stderr=log, + text=True, + ) + deadline = time.monotonic() + 30 + assert process.stdout is not None + while time.monotonic() < deadline: + line = process.stdout.readline() + if not line: + if process.poll() is not None: + raise RuntimeError(f"fastled server exited with {process.returncode}") + continue + log.write(line) + log.flush() + match = re.search(r"Serving .* at (http://\S+)", line) + if match: + return process, match.group(1) + raise RuntimeError("fastled server did not report its URL within 30 s") + + +def start_safaridriver() -> tuple[subprocess.Popen, str]: + port = free_port() + process = subprocess.Popen(["safaridriver", "-p", str(port)]) + base = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + if ( + webdriver("GET", f"{base}/status", timeout=5) + .get("value", {}) + .get("ready") + ): + return process, base + except (OSError, RuntimeError): + pass + time.sleep(0.5) + raise RuntimeError("safaridriver did not become ready within 30 s") + + +def lit_pixels(png: bytes, state: dict) -> tuple[int, int]: + from io import BytesIO + + from PIL import Image + + image = Image.open(BytesIO(png)).convert("RGB") + rect = state.get("canvas") + if rect and rect["width"] > 0 and rect["height"] > 0: + scale = image.width / max(1.0, float(state.get("viewportWidth") or image.width)) + dpr = rect["dpr"] if abs(scale - 1.0) < 0.01 else scale + box = ( + int(rect["x"] * dpr), + int(rect["y"] * dpr), + int((rect["x"] + rect["width"]) * dpr), + int((rect["y"] + rect["height"]) * dpr), + ) + image = image.crop(box) + pixels = list(image.getdata()) + return sum(1 for pixel in pixels if max(pixel) > 60), len(pixels) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fastled", required=True) + parser.add_argument("--serve-dir", required=True) + parser.add_argument("--artifacts", required=True, type=Path) + parser.add_argument("--ready-timeout", type=float, default=120) + args = parser.parse_args() + args.artifacts.mkdir(parents=True, exist_ok=True) + + server, page_url = start_server( + args.fastled, args.serve_dir, args.artifacts / "safari-server.log" + ) + driver, base = start_safaridriver() + session = None + state: dict = {} + try: + created = webdriver( + "POST", + f"{base}/session", + {"capabilities": {"alwaysMatch": {"browserName": "safari"}}}, + ) + session = f"{base}/session/{created['value']['sessionId']}" + webdriver("POST", f"{session}/url", {"url": page_url}, timeout=120) + deadline = time.monotonic() + args.ready_timeout + while time.monotonic() < deadline: + state = webdriver( + "POST", f"{session}/execute/sync", {"script": PROBE_SCRIPT, "args": []} + )["value"] + if state["errors"] or state["frames"] >= FRAMES_REQUIRED: + break + time.sleep(0.5) + png = base64.b64decode(webdriver("GET", f"{session}/screenshot")["value"]) + (args.artifacts / "safari.png").write_bytes(png) + lit, total = lit_pixels(png, state) + state["canvasLitPixels"] = f"{lit}/{total}" + (args.artifacts / "safari-state.json").write_text(json.dumps(state, indent=2)) + print(f"Safari: {state['userAgent']}") + print( + f"Safari: frames={state['frames']} crossOriginIsolated={state['crossOriginIsolated']} " + f"canvas lit pixels={lit}/{total}" + ) + if state["errors"]: + print( + "Safari page errors:\n " + "\n ".join(state["errors"]), + file=sys.stderr, + ) + return 1 + if not state["crossOriginIsolated"]: + print( + "Safari page is not cross-origin isolated (SharedArrayBuffer unavailable)", + file=sys.stderr, + ) + return 1 + if state["frames"] < FRAMES_REQUIRED: + print( + f"Safari rendered {state['frames']} frames in {args.ready_timeout:.0f} s " + f"(need {FRAMES_REQUIRED}); page text: {state.get('text', '')!r}", + file=sys.stderr, + ) + return 1 + return 0 + finally: + if session: + try: + webdriver("DELETE", session, timeout=30) + except (OSError, RuntimeError): + pass + driver.terminate() + server.terminate() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/smoke_installed_wheel.sh b/ci/smoke_installed_wheel.sh new file mode 100755 index 00000000..a85a195e --- /dev/null +++ b/ci/smoke_installed_wheel.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Installs the built wheel from dist/ into a fresh venv and runs the packaged +# console script outside this checkout, in the shipped viewer. It exercises the +# wheel's bundled Rust binary and frontend, rather than development fallbacks +# such as CARGO_MANIFEST_DIR. +# +# Set FASTLED_SAFARI_SMOKE=1 (macOS only) to also load the compiled sketch in +# real Safari through safaridriver while the asset origin is still running. +set -euo pipefail + +SMOKE_ROOT="$RUNNER_TEMP/fastled-wheel-smoke" +rm -rf "$SMOKE_ROOT" +mkdir -p "$SMOKE_ROOT/home" "$SMOKE_ROOT/sketch/data" "$SMOKE_ROOT/artifacts" "$SMOKE_ROOT/asset-origin" +uv venv "$SMOKE_ROOT/venv" --python "$UV_PYTHON" +uv pip install --python "$SMOKE_ROOT/venv/bin/python" dist/*.whl +test -x "$SMOKE_ROOT/venv/bin/uv" +BASE_PYTHON_DIR="$("$SMOKE_ROOT/venv/bin/python" -c 'from pathlib import Path; import sys; print(Path(sys.executable).resolve().parent)')" +test ! -e "$BASE_PYTHON_DIR/uv" +cp -R "$GITHUB_WORKSPACE/tests/fixtures/wasm_vfs/." "$SMOKE_ROOT/sketch/" +cp "$GITHUB_WORKSPACE/tests/fixtures/wasm_vfs_origin/probe.txt" "$SMOKE_ROOT/asset-origin/probe.txt" +cat > "$SMOKE_ROOT/sketch/data/probe.txt.lnk" <<'EOF' +http://127.0.0.1:18765/probe.txt +sha256=2da77f7c1452125633beeeb43f366350018643c799306b4d24d8610d98f02b55 +size_bytes=15 +storage=vfs +EOF +cat > "$SMOKE_ROOT/asset_server.py" <<'PY' +import http.server +import sys + +class Handler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cross-Origin-Resource-Policy", "cross-origin") + super().end_headers() + + def log_message(self, *_args): + pass + +handler = lambda *args, **kwargs: Handler(*args, directory=sys.argv[1], **kwargs) +http.server.ThreadingHTTPServer(("127.0.0.1", 18765), handler).serve_forever() +PY +"$SMOKE_ROOT/venv/bin/python" "$SMOKE_ROOT/asset_server.py" "$SMOKE_ROOT/asset-origin" & +ASSET_SERVER_PID=$! +trap 'kill "$ASSET_SERVER_PID" 2>/dev/null || true' EXIT +for _attempt in {1..20}; do + if curl --fail --silent http://127.0.0.1:18765/probe.txt >/dev/null; then break; fi + sleep 0.25 +done +curl --fail --silent http://127.0.0.1:18765/probe.txt >/dev/null + +# The installed launcher must supply absolute wheel-venv uv, Python, +# and frontend paths to the Rust binary. Deliberately omit ambient +# uv, node, and bare python so this cannot accidentally pass by +# resolving a developer tool from the runner image. +cd "$SMOKE_ROOT" +env -i \ + HOME="$SMOKE_ROOT/home" \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + /bin/bash -ceu ' + for tool in uv node python; do + if command -v "$tool" >/dev/null 2>&1; then + echo "unexpected ambient $tool on restricted PATH" >&2 + exit 1 + fi + done + ' +env -i \ + HOME="$SMOKE_ROOT/home" \ + PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ + "$SMOKE_ROOT/venv/bin/fastled" "$SMOKE_ROOT/sketch" \ + --check \ + --test-wait-secs=2 \ + --test-timeout-secs=240 \ + --test-ready-timeout-secs=45 \ + --test-screenshot="$SMOKE_ROOT/artifacts/screenmap.png" \ + --test-log="$SMOKE_ROOT/artifacts/viewer.log" +grep -F "Asset 'data/probe.txt' sha256 verified" "$SMOKE_ROOT/artifacts/viewer.log" +grep -F "All 1 filesystem asset(s) loaded completely before setup()." "$SMOKE_ROOT/artifacts/viewer.log" +"$SMOKE_ROOT/venv/bin/python" - "$SMOKE_ROOT/artifacts/screenmap.png" <<'PY' +from pathlib import Path +import sys + +image = Path(sys.argv[1]).read_bytes() +if image[:8] != b"\x89PNG\r\n\x1a\n": + raise SystemExit("viewer screenshot is not a PNG") +if image[12:16] != b"IHDR" or len(image) < 24: + raise SystemExit("viewer screenshot is missing its IHDR header") +width = int.from_bytes(image[16:20], "big") +height = int.from_bytes(image[20:24], "big") +if width <= 0 or height <= 0: + raise SystemExit(f"viewer screenshot has invalid dimensions: {width}x{height}") +PY + +if [ "${FASTLED_SAFARI_SMOKE:-0}" = "1" ]; then + # The viewer run above left the compiled sketch in sketch/fastled_js; the + # asset origin is still serving, so Safari loads the same program. + uv run --no-project --with pillow python "$GITHUB_WORKSPACE/ci/safari_smoke.py" \ + --fastled "$SMOKE_ROOT/venv/bin/fastled" \ + --serve-dir "$SMOKE_ROOT/sketch/fastled_js" \ + --artifacts "$SMOKE_ROOT/artifacts" +fi From cae98764214334e2cbf1b16472af44e8c00417f0 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 15 Sep 2026 10:33:59 -0700 Subject: [PATCH 91/94] ci: install current Soldr for the macOS cross-build lane setup-soldr v0.9.62 pins Soldr 0.7.51. That version has no `build` subcommand, so `soldr build --target aarch64-apple-darwin` was looked up as a downloadable tool ("webbrandon/build", HTTP 404), and it has no managed macOS SDK. Use the setup-soldr revision kernal-api's Apple cross lanes use, which installs a current Soldr (0.9.x locally cross-builds fastled and its nextest archive for aarch64-apple-darwin). Refs #242 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- .github/workflows/macos-arm-live-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macos-arm-live-test.yml b/.github/workflows/macos-arm-live-test.yml index 890b7322..c988f90d 100644 --- a/.github/workflows/macos-arm-live-test.yml +++ b/.github/workflows/macos-arm-live-test.yml @@ -52,7 +52,11 @@ jobs: - name: Install Python run: uv python install "$UV_PYTHON" - - uses: zackees/setup-soldr@v0.9.62 + # Same pin as kernal-api's Apple cross lanes. It installs a current + # Soldr with `soldr build --target` and the managed macOS SDK; the + # v0.9.62 action used elsewhere here pins Soldr 0.7.51 (see #158), + # which has neither. + - uses: zackees/setup-soldr@bb28e96d2dc32c058242f56722297caf1efcbd90 env: GITHUB_TOKEN: ${{ github.token }} with: From 43568d8fdee116b192c209bab1fb63b0d6aa157c Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 15 Sep 2026 10:41:39 -0700 Subject: [PATCH 92/94] ci: package the macOS wheel for the cross target setup.py always rebuilds the bundled binary and passes --target only when FASTLED_RUST_TARGET is set. Without it, the wheel step rebuilt fastled for the Linux host while Soldr's aarch64-apple-darwin cross environment was active. A host build script (serde_core) was then linked with ld64.lld, which rejects `-z`. Set FASTLED_RUST_TARGET, pre-build with the exact command setup.py runs, and check the binary that ends up in the wheel is an arm64 Mach-O. Refs #242 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- .github/workflows/macos-arm-live-test.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/macos-arm-live-test.yml b/.github/workflows/macos-arm-live-test.yml index c988f90d..2d7c1e68 100644 --- a/.github/workflows/macos-arm-live-test.yml +++ b/.github/workflows/macos-arm-live-test.yml @@ -65,16 +65,21 @@ jobs: toolchain-file: rust-toolchain.toml prebuild-deps-flags: "" + # setup.py always rebuilds the bundled binary with + # `soldr cargo build --release --bin fastled [--target $FASTLED_RUST_TARGET]`. + # Build with exactly that command first so its rebuild is a no-op. The + # explicit target also keeps host build scripts on the host linker. - name: Cross-build the release fastled binary - run: | - soldr build --release --bin fastled --target "$RUST_TARGET" - mkdir -p src/fastled/bin - cp -f "target/$RUST_TARGET/release/fastled" src/fastled/bin/fastled - # A Mach-O arm64 header, not a host binary. - test "$(od -A n -t x1 -N 8 src/fastled/bin/fastled | tr -d ' \n')" = "cffaedfe0c000001" + run: soldr cargo build --release --bin fastled --target "$RUST_TARGET" - name: Package the macOS wheel - run: uv run --with "setuptools>=69" --with "wheel>=0.43" python setup.py bdist_wheel --plat-name macosx_11_0_arm64 + env: + FASTLED_RUST_TARGET: ${{ env.RUST_TARGET }} + run: | + uv run --with "setuptools>=69" --with "wheel>=0.43" python setup.py bdist_wheel --plat-name macosx_11_0_arm64 + # The bundled binary must be an arm64 Mach-O, not a host binary. + test "$(od -A n -t x1 -N 8 src/fastled/bin/fastled | tr -d ' \n')" = "cffaedfe0c000001" + ls -l dist/ - name: Archive the Rust tests for macOS run: soldr cargo nextest archive --workspace --target "$RUST_TARGET" --archive-file "$TEST_ARCHIVE" From e1c9580fa628e8b60776b428599bb7f3a69eea16 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 15 Sep 2026 10:50:05 -0700 Subject: [PATCH 93/94] test: locate the fastled CLI at runtime when run from a nextest archive cache_upgrade_cli started the CLI from env!("CARGO_BIN_EXE_fastled"), a path fixed when the test was compiled. The macOS lane cross-builds the tests on Linux and runs them on a Mac, where that path does not exist ("No such file or directory"). Prefer nextest's runtime NEXTEST_BIN_EXE_fastled and keep the compile-time path for plain `cargo test`. The other 303 archived tests already passed on macOS. The macOS lane also runs the viewer render and Safari smoke when a Rust test fails, so one cycle reports every result. Refs #242 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- .github/workflows/macos-arm-live-test.yml | 4 ++++ crates/fastled-cli/tests/cache_upgrade_cli.rs | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macos-arm-live-test.yml b/.github/workflows/macos-arm-live-test.yml index 2d7c1e68..e37a16ed 100644 --- a/.github/workflows/macos-arm-live-test.yml +++ b/.github/workflows/macos-arm-live-test.yml @@ -137,10 +137,14 @@ jobs: --archive-file "macos-arm-live-inputs/$TEST_ARCHIVE" --workspace-remap . + # The render and Safari checks still run when a Rust test fails, so one + # cycle reports every macOS result. - name: Enable Safari automation + if: ${{ !cancelled() }} run: sudo safaridriver --enable - name: Live viewer render and Safari smoke of the installed wheel + if: ${{ !cancelled() }} env: FASTLED_SAFARI_SMOKE: "1" run: | diff --git a/crates/fastled-cli/tests/cache_upgrade_cli.rs b/crates/fastled-cli/tests/cache_upgrade_cli.rs index 2b5b3e5d..ab4f0e7a 100644 --- a/crates/fastled-cli/tests/cache_upgrade_cli.rs +++ b/crates/fastled-cli/tests/cache_upgrade_cli.rs @@ -1,8 +1,16 @@ use std::fs; use std::process::{Command, Output}; +/// The CLI under test. `CARGO_BIN_EXE_fastled` is fixed at compile time, so a +/// test binary built on one machine and run from a nextest archive elsewhere +/// (the macOS lane cross-builds on Linux) must use nextest's runtime path. +fn fastled_exe() -> std::ffi::OsString { + std::env::var_os("NEXTEST_BIN_EXE_fastled") + .unwrap_or_else(|| env!("CARGO_BIN_EXE_fastled").into()) +} + fn run_fastled(root: &std::path::Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_fastled")) + Command::new(fastled_exe()) .arg("--version") .env("FASTLED_HOME", root) .env("FASTLED_MANAGED_RUNTIME", "1") From 80b77998c1ec486f4fdb947cfde95100249284ad Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 15 Sep 2026 15:39:23 -0700 Subject: [PATCH 94/94] build: embed Windows executable resources through kernal-api v0.1.6 The Tauri-era fastled.exe carried RT_ICON, RT_GROUP_ICON (32512), RT_VERSION ("FastLED Viewer", "fastled", package version) and a Common-Controls v6 RT_MANIFEST from tauri-build; the kernal-api build carried no resources. kernal-api v0.1.6 adds the windows-app-resources build-script capability (zackees/kernal-api#276). crates/fastled-cli/build.rs declares the product and icon and calls it; it does nothing on non-Windows targets. The boundary test now allows exactly this one build script: kernal-api is its only build dependency, from the same release, with only that feature, and the script names no other crate. A fastled.exe cross-built from Linux for x86_64-pc-windows-msvc embeds the same four resources as the Tauri-era release. Linux: Rust workspace tests, Python unit tests and bash lint pass. Refs #242 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfXV1EpgAvMZzXJfbGZPRG --- Cargo.lock | 26 +++++++++++++++++++++----- Cargo.toml | 2 +- crates/fastled-cli/Cargo.toml | 7 +++++++ crates/fastled-cli/build.rs | 17 +++++++++++++++++ docs/kernal-api-migration.md | 27 ++++++++++++++++++++------- tests/unit/test_kernal_boundary.py | 25 ++++++++++++++++++++++--- 6 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 crates/fastled-cli/build.rs diff --git a/Cargo.lock b/Cargo.lock index cd0abbde..5a3f0af5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,14 +958,14 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "embed-resource" -version = "3.0.8" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -2023,13 +2023,14 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.5" -source = "git+https://github.com/zackees/kernal-api.git?tag=v0.1.5#e5700f02d3c11f43047caca6457efef080bf3cbe" +version = "0.1.6" +source = "git+https://github.com/zackees/kernal-api.git?tag=v0.1.6#560a5137d6371464dd13f0541d8ad1efa9745c8e" dependencies = [ "blake3", "bytes", "clap", "dirs 6.0.0", + "embed-resource", "flate2", "futures-core", "futures-util", @@ -4362,6 +4363,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + [[package]] name = "toml_datetime" version = "0.6.11" diff --git a/Cargo.toml b/Cargo.toml index acbe5d6c..e7d63f78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/zackees/fastled-wasm" homepage = "https://github.com/zackees/fastled-wasm" [workspace.dependencies] -kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.5", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } +kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.6", features = ["fs", "fs-watch", "hash-sha256", "archive", "http-client", "http-server", "websocket", "event-stream", "secure-random", "text-similarity", "pty", "terminal-input", "terminal-style", "command-arguments", "command-schema", "config-toml", "source-cpp", "json", "error-context"] } [profile.release] debug = "line-tables-only" diff --git a/crates/fastled-cli/Cargo.toml b/crates/fastled-cli/Cargo.toml index 4fdd49ab..a67d73e4 100644 --- a/crates/fastled-cli/Cargo.toml +++ b/crates/fastled-cli/Cargo.toml @@ -23,6 +23,13 @@ path = "src/main.rs" [dependencies] kernal-api = { workspace = true } +# build.rs embeds the Windows icon, version information and manifest through +# kernal-api's build-script capability. Same source and tag as the workspace +# dependency, but only that feature, so the build script does not compile the +# runtime capabilities a second time. +[build-dependencies] +kernal-api = { git = "https://github.com/zackees/kernal-api.git", tag = "v0.1.6", default-features = false, features = ["windows-app-resources"] } + [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/fastled-{ target }{ archive-suffix }" bin-dir = "fastled{ binary-ext }" diff --git a/crates/fastled-cli/build.rs b/crates/fastled-cli/build.rs new file mode 100644 index 00000000..a4257e7c --- /dev/null +++ b/crates/fastled-cli/build.rs @@ -0,0 +1,17 @@ +//! Embeds the Windows executable's icon, version information and application +//! manifest. Only the binary's own build script can link these resources, so +//! the mechanics live in kernal-api and this file only declares the product. + +use kernal_api::windows_resources::{embed_windows_app_resources, WindowsAppResources}; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + let resources = + match WindowsAppResources::new("FastLED Viewer", "fastled", env!("CARGO_PKG_VERSION")) { + Ok(resources) => resources.with_icon("icons/icon.ico"), + Err(error) => panic!("fastled: invalid Windows resource metadata: {error}"), + }; + if let Err(error) = embed_windows_app_resources(&resources) { + panic!("fastled: {error}"); + } +} diff --git a/docs/kernal-api-migration.md b/docs/kernal-api-migration.md index 4a0de218..dd7b5477 100644 --- a/docs/kernal-api-migration.md +++ b/docs/kernal-api-migration.md @@ -32,7 +32,7 @@ capability migration, with the same toolchain, features, and cache conditions. The inventory table above and the checkpoint sections below are historical records of each slice; see "Final migration audit" at the end for the current state. `fastled-cli` now has exactly one direct Rust dependency, `kernal-api`, -consumed from the `v0.1.5` release tag with no `[patch]` override. Every +consumed from the `v0.1.6` release tag with no `[patch]` override. Every backend in the inventory is absent from the manifests and sources and remains only as a private transitive implementation of the kernel. @@ -42,7 +42,7 @@ Work is on `feat/kernal-api-migration`. The migration originally used an exact `=0.1.0` declaration plus a temporary sibling path patch to `../fastled-wasm-extern/kernal-api`, initially checked out at `76172bf3ee41ec601d3fa834291dfbe6bfc11789`; later a pinned git revision replaced -the path patch. Both are now removed in favor of the `v0.1.5` release tag. The +the path patch. Both are now removed in favor of the `v0.1.6` release tag. The application MSRV matches its existing 1.95 toolchain and kernal-api's requirement. The boundary test failed before each dependency migration and passed afterward. @@ -1116,17 +1116,30 @@ the release deliberately excludes later main commits. The release contains only a version bump over `762a1d2`. kernal-api `v0.1.4`, cut from the same `release/0.1.3` branch, forced WebKitGTK's worker WebGL feature (zackees/kernal-api#268). That froze the viewer on NVIDIA Wayland, so `v0.1.5` -reverts it (zackees/kernal-api#272); code-wise it is `v0.1.3` again. The -workspace consumes the `v0.1.5` tag. crates.io publishing is not enabled for +reverts it (zackees/kernal-api#272); code-wise it is `v0.1.3` again. `v0.1.6` +adds only the opt-in `windows-app-resources` build-script capability +(zackees/kernal-api#276). The workspace consumes the `v0.1.6` tag. crates.io publishing is not enabled for kernal-api, and `fastled-cli` is not published to crates.io, so the workspace uses a git tag dependency. The pre-release pin, path and `[patch]` overrides are gone. **Boundary enforced in CI.** `tests/unit/test_kernal_boundary.py` runs in the unit-test workflows. It requires `kernal-api` to be the only dependency in every -package, workspace and target table. It also forbids build and dev dependencies, -`[patch]`, `build.rs` and generated Tauri output, and requires the -`hash-sha256` feature, alongside the per-backend source bans. +package, workspace and target table. It also forbids dev dependencies, +`[patch]` and generated Tauri output, and requires the `hash-sha256` feature, +alongside the per-backend source bans. `fastled-cli` has exactly one +`build.rs`: its only build dependency is `kernal-api` from the same release +with only `windows-app-resources`, and it names no other crate. + +**Windows executable resources.** The Tauri-era `fastled.exe` carried an icon, +version information and a Common-Controls v6 manifest from `tauri-build`; the +kernal-api build carried no resources at all. Cargo only links a build script's +resources into its own package's binaries, so kernal-api `v0.1.6` provides a +build-script capability and `crates/fastled-cli/build.rs` declares the product +("FastLED Viewer", "fastled", the package version, `icons/icon.ico`). A +`fastled.exe` cross-built from Linux for `x86_64-pc-windows-msvc` embeds the +same `RT_ICON`, `RT_GROUP_ICON` (32512), `RT_VERSION` strings and manifest as +the Tauri-era release. The build is a no-op on other targets. **Validation on Linux x86-64 (NixOS).** Rust workspace tests pass: 303 library, 3 binary, 1 integration and 1 doc test. `bash lint` passes, covering rustfmt, diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 943f416b..a00d5259 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -1,5 +1,6 @@ """Keep migrated infrastructure behind the shared kernal-api boundary.""" +import re from pathlib import Path try: @@ -189,17 +190,35 @@ def test_kernal_api_is_the_only_rust_dependency(): sections.extend(package.get("target", {}).values()) for section in sections: assert set(section.get("dependencies", {})) <= {"kernal-api"}, section - assert not section.get("build-dependencies"), section assert not section.get("dev-dependencies"), section + for section in [workspace["workspace"], *package.get("target", {}).values()]: + assert not section.get("build-dependencies"), section assert set(package["dependencies"]) == {"kernal-api"} kernal = workspace["workspace"]["dependencies"]["kernal-api"] - assert kernal["tag"] == "v0.1.5" + assert kernal["tag"] == "v0.1.6" assert "rev" not in kernal and "path" not in kernal assert "hash-sha256" in kernal["features"] assert "patch" not in workspace - assert not (root / "crates/fastled-cli/build.rs").exists() assert not (root / "crates/fastled-cli/gen").exists() + # The one build script embeds Windows executable resources through + # kernal-api's build capability: kernal-api is its only build dependency, + # from the same release, with only that feature, and it names no other crate. + build = package["build-dependencies"] + assert set(build) == {"kernal-api"}, build + assert build["kernal-api"] == { + "git": kernal["git"], + "tag": kernal["tag"], + "default-features": False, + "features": ["windows-app-resources"], + } + build_rs = (root / "crates/fastled-cli/build.rs").read_text() + # Leading path segments only: `kernal_api::windows_resources::...` names + # the crate `kernal_api`, not a crate called `windows_resources`. + crates_used = set(re.findall(r"(?