diff --git a/CHANGELOG.md b/CHANGELOG.md index cee4523..95a0e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,3 +62,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `CountDownloadsByStateQuery`: state-grouped counts for UI filter badges - Tauri IPC: `download_list` (filter/sort/search/pagination), `download_detail`, `download_count_by_state` - String-based filter/sort parsing in IPC layer (DownloadState, SortField, SortDirection) +- Plugin infrastructure: WASM plugin system via Extism with hot-reload + - `plugin.toml` manifest parser with category/capabilities/version validation + - `PluginRegistry` backed by DashMap for concurrent in-memory tracking + - `ExtismPluginLoader` implementing `PluginLoader` port: load, unload, resolve_url, list + - Hot-reload file watcher via `notify` crate with tokio integration + - `InstallPluginCommand` / `UninstallPluginCommand` CQRS handlers with domain events + - `EnablePluginCommand` / `DisablePluginCommand` handlers (validation-only for MVP) + - `ListPluginsQuery` handler returning `PluginViewDto` read models + - Tauri IPC: `plugin_install`, `plugin_uninstall`, `plugin_enable`, `plugin_disable`, `plugin_list` + - Path traversal protection on plugin_install IPC (canonicalize + prefix check) + - WASM file size limit (100 MB) to prevent OOM + - Atomic insert via DashMap entry API to prevent TOCTOU races + - `Container` and `Notifier` plugin categories added to domain model diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 185fda7..b604a67 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -55,6 +64,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -120,6 +135,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arrayvec" version = "0.7.6" @@ -317,6 +338,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -397,6 +427,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecheck" @@ -475,6 +508,72 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.5", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -508,6 +607,24 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbindgen" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" +dependencies = [ + "heck 0.5.0", + "indexmap 2.13.1", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "tempfile", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "cc" version = "1.2.59" @@ -544,7 +661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", ] [[package]] @@ -620,6 +737,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -717,6 +843,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -726,6 +861,144 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a04121a197fde2fe896f8e7cac9812fc41ed6ee9c63e1906090f9f497845f6" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a09e699a94f477303820fb2167024f091543d6240783a2d3b01a3f21c42bc744" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f07732c662a9755529e332d86f8c5842171f6e98ba4d5976a178043dad838654" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18391da761cf362a06def7a7cf11474d79e55801dd34c2e9ba105b33dc0aef88" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a09b3042c69810d255aef59ddc3b3e4c0644d1d90ecfd6e3837798cc88a3c" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon 0.13.5", + "wasmtime-internal-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75817926ec812241889208d1b190cadb7fedded4592a4bb01b8524babb9e4849" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck 0.5.0", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859158f87a59476476eda3884d883c32e08a143cf3d315095533b362a3250a63" + +[[package]] +name = "cranelift-control" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b65a9aec442d715cbf54d14548b8f395476c09cef7abe03e104a378291ab88" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8334c99a7e86060c24028732efd23bac84585770dcb752329c69f135d64f2fc1" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ac6c095aa5b3e845d7ca3461e67e2b65249eb5401477a5ff9100369b745111" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon 0.13.5", +] + +[[package]] +name = "cranelift-isle" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d3d992870ed4f0f2e82e2175275cb3a123a46e9660c6558c46417b822c91fa" + +[[package]] +name = "cranelift-native" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee32e36beaf80f309edb535274cfe0349e1c5cf5799ba2d9f42e828285c6b52e" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon 0.13.5", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.128.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903adeaf4938e60209a97b53a2e4326cd2d356aab9764a1934630204bae381c9" + [[package]] name = "crc" version = "3.4.0" @@ -759,6 +1032,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -902,6 +1194,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -920,6 +1226,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -988,6 +1303,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1005,10 +1330,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -1141,6 +1477,18 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1200,45 +1548,135 @@ dependencies = [ ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "extism" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "ed8c5859bdab81d2eb4cd963eeacd8031d353b1ffb2fde43ee9179a0d6295120" +dependencies = [ + "anyhow", + "async-trait", + "cbindgen", + "extism-convert", + "extism-manifest", + "glob", + "libc", + "serde", + "serde_json", + "sha2", + "toml 0.9.12+spec-1.1.0", + "tracing", + "tracing-subscriber", + "ureq", + "url", + "uuid", + "wasi-common", + "wasmtime", + "wiggle", +] [[package]] -name = "fdeflate" -version = "0.3.7" +name = "extism-convert" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +checksum = "ec1a8eac059a1730a21aa47f99a0c2075ba0ab88fd0c4e52e35027cf99cdf3e7" dependencies = [ - "simd-adler32", + "anyhow", + "base64 0.22.1", + "bytemuck", + "extism-convert-macros", + "prost", + "rmp-serde", + "serde", + "serde_json", ] [[package]] -name = "field-offset" -version = "0.3.6" +name = "extism-convert-macros" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +checksum = "848f105dd6e1af2ea4bb4a76447658e8587167df3c4e4658c4258e5b14a5b051" dependencies = [ - "memoffset", - "rustc_version", + "manyhow", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "extism-manifest" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "953a22ad322939ae4567ec73a34913a3a43dcbdfa648b8307d38fe56bb3a0acd" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] [[package]] -name = "flate2" -version = "1.1.9" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] [[package]] name = "flume" @@ -1320,12 +1758,32 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1450,6 +1908,20 @@ dependencies = [ "byteorder", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags 2.11.0", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "gdk" version = "0.18.2" @@ -1610,6 +2082,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap 2.13.1", + "stable_deref_trait", +] + [[package]] name = "gio" version = "0.18.4" @@ -1786,6 +2269,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1795,6 +2284,7 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", + "serde", ] [[package]] @@ -2142,6 +2632,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "im-rc" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2185,6 +2689,42 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.12.0" @@ -2207,12 +2747,41 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "javascriptcore-rs" version = "1.1.2" @@ -2335,6 +2904,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2356,6 +2945,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2431,6 +3026,12 @@ dependencies = [ "vcpkg", ] +[[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" @@ -2481,6 +3082,38 @@ dependencies = [ "winapi", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2532,6 +3165,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "md-5" version = "0.10.6" @@ -2548,6 +3187,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2580,6 +3228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2677,6 +3326,33 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2903,6 +3579,18 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap 2.13.1", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3067,6 +3755,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.13.1", +] + [[package]] name = "pgvector" version = "0.4.1" @@ -3328,6 +4026,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3449,6 +4159,17 @@ version = "0.5.20+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3472,25 +4193,71 @@ dependencies = [ ] [[package]] -name = "ptr_meta" -version = "0.1.4" +name = "prost" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ - "ptr_meta_derive", + "bytes", + "prost-derive", ] [[package]] -name = "ptr_meta_derive" -version = "0.1.4" +name = "prost-derive" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] +[[package]] +name = "pulley-interpreter" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9812652c1feb63cf39f8780cecac154a32b22b3665806c733cd4072547233a4" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56000349b6896e3d44286eb9c330891237f40b27fd43c1ccc84547d0b463cb40" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3693,12 +4460,41 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3717,6 +4513,17 @@ 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" @@ -3748,6 +4555,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regalloc2" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -3872,6 +4693,25 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3909,6 +4749,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3924,6 +4770,19 @@ 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" @@ -3933,10 +4792,20 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.37" @@ -3944,7 +4813,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4478,6 +5349,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.1", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4600,6 +5484,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + [[package]] name = "slab" version = "0.4.12" @@ -5077,6 +5971,22 @@ dependencies = [ "version-compare", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags 2.11.0", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + [[package]] name = "tao" version = "0.34.8" @@ -5138,6 +6048,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tauri" version = "2.10.3" @@ -5361,7 +6277,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5386,6 +6302,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5581,6 +6506,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.13.1", + "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.3" @@ -5880,12 +6820,24 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -5898,6 +6850,35 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5929,6 +6910,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5989,7 +6976,11 @@ version = "0.0.0" dependencies = [ "bincode", "bytes", + "dashmap", + "dirs", + "extism", "futures-util", + "notify", "reqwest", "sea-orm", "sea-orm-migration", @@ -6001,6 +6992,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-subscriber", "wiremock", @@ -6057,6 +7049,32 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi-common" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49ffbbd04665d04028f66aee8f24ae7a1f46063f59a28fddfa52ca3091754a2" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.11.0", + "cap-fs-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "io-extras", + "io-lifetimes", + "log", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wiggle", + "windows-sys 0.61.2", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -6137,6 +7155,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-compose" +version = "0.243.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af801b6f36459023eaec63fdbaedad2fd5a4ab7dc74ecc110a8b5d375c5775e4" +dependencies = [ + "anyhow", + "heck 0.5.0", + "im-rc", + "indexmap 2.13.1", + "log", + "petgraph", + "serde", + "serde_derive", + "serde_yaml", + "smallvec", + "wasm-encoder 0.243.0", + "wasmparser 0.243.0", + "wat", +] + +[[package]] +name = "wasm-encoder" +version = "0.243.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" +dependencies = [ + "leb128fmt", + "wasmparser 0.243.0", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -6144,7 +7193,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +dependencies = [ + "leb128fmt", + "wasmparser 0.246.2", ] [[package]] @@ -6155,8 +7214,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.13.1", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -6172,6 +7231,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.243.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.1", + "semver", + "serde", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6184,6 +7256,319 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +dependencies = [ + "bitflags 2.11.0", + "indexmap 2.13.1", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.243.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.243.0", +] + +[[package]] +name = "wasmtime" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2a83182bf04af87571b4c642300479501684f26bab5597f68f68cded5b098fd" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags 2.11.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "hashbrown 0.15.5", + "indexmap 2.13.1", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon 0.13.5", + "tempfile", + "wasm-compose", + "wasm-encoder 0.243.0", + "wasmparser 0.243.0", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb201c41aa23a3642365cfb2e4a183573d85127a3c9d528f56b9997c984541ab" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap 2.13.1", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon 0.13.5", + "wasm-encoder 0.243.0", + "wasmparser 0.243.0", + "wasmprinter", + "wasmtime-internal-component-util", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5b3069d1a67ba5969d0eb1ccd7e141367d4e713f4649aa90356c98e8f19bea" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2", + "toml 0.9.12+spec-1.1.0", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c924400db7b6ca996fef1b23beb0f41d5c809836b1ec60fc25b4057e2d25d9b" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.243.0", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3f65daf4bf3d74ca2fbbe20af0589c42e2b398a073486451425d94fd4afef4" + +[[package]] +name = "wasmtime-internal-cranelift" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633e889cdae76829738db0114ab3b02fce51ea4a1cd9675a67a65fce92e8b418" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon 0.13.5", + "thiserror 2.0.18", + "wasmparser 0.243.0", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb126adc5d0c72695cfb77260b357f1b81705a0f8fa30b3944e7c2219c17341" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e66ff7f90a8002187691ff6237ffd09f954a0ebb9de8b2ff7f5c62632134120" +dependencies = [ + "cc", + "object", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b96df23179ae16d54fb3a420f84ffe4383ec9dd06fad3e5bc782f85f66e8e08" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-math" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d1380926682b44c383e9a67f47e7a95e60c6d3fa8c072294dab2c7de6168a0" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-slab" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b63cbea1c0192c7feb7c0dfb35f47166988a3742f29f46b585ef57246c65764" + +[[package]] +name = "wasmtime-internal-unwinder" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f25c392c7e5fb891a7416e3c34cfbd148849271e8c58744fda875dde4bec4d6a" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f8b9796a3f0451a7b702508b303d654de640271ac80287176de222f187a237" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0063e61f1d0b2c20e9cfc58361a6513d074a23c80b417aac3033724f51648a0" +dependencies = [ + "cranelift-codegen", + "gimli", + "log", + "object", + "target-lexicon 0.13.5", + "wasmparser 0.243.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587699ca7cae16b4a234ffcc834f37e75675933d533809919b52975f5609e2ef" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "heck 0.5.0", + "indexmap 2.13.1", + "wit-parser 0.243.0", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "246.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.246.2", +] + +[[package]] +name = "wat" +version = "1.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c" +dependencies = [ + "wast 246.0.2", +] + [[package]] name = "web-sys" version = "0.3.94" @@ -6269,6 +7654,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -6315,6 +7709,47 @@ dependencies = [ "wasite", ] +[[package]] +name = "wiggle" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69a60bcbe1475c5dc9ec89210ade54823d44f742e283cba64f98f89697c4cec" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wiggle-macro", + "witx", +] + +[[package]] +name = "wiggle-generate" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f3dc0fd4dcfc7736434bb216179a2147835309abc09bf226736a40d484548f" +dependencies = [ + "anyhow", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea2aea744eded58ae092bf57110c27517dab7d5a300513ff13897325c5c5021" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6346,6 +7781,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "41.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55de3ac5b8bd71e5f6c87a9e511dd3ceb194bdb58183c6a7bf21cd8c0e46fbc" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon 0.13.5", + "thiserror 2.0.18", + "wasmparser 0.243.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", +] + [[package]] name = "window-vibrancy" version = "0.6.0" @@ -6860,6 +8315,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "wiremock" version = "0.6.5" @@ -6900,7 +8365,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck 0.5.0", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -6947,10 +8412,28 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", - "wit-parser", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.243.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.1", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.243.0", ] [[package]] @@ -6968,7 +8451,19 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", ] [[package]] @@ -7165,3 +8660,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c639bfa..0633324 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,11 @@ tokio-util = { version = "0.7.18", features = ["rt"] } futures-util = "0.3.32" bytes = "1.11.1" bincode = "2" +extism = "1.21.0" +notify = "8.2.0" +toml = "1.1.2" +dashmap = "6.1.0" +dirs = "6.0.0" [dev-dependencies] tempfile = "3.27.0" diff --git a/src-tauri/src/adapters/driven/filesystem/file_storage.rs b/src-tauri/src/adapters/driven/filesystem/file_storage.rs index 10c2f01..a4a76cf 100644 --- a/src-tauri/src/adapters/driven/filesystem/file_storage.rs +++ b/src-tauri/src/adapters/driven/filesystem/file_storage.rs @@ -205,7 +205,6 @@ mod tests { use super::*; use crate::domain::model::download::DownloadId; use crate::domain::model::meta::SegmentMeta; - use std::io::Read as _; fn make_meta() -> DownloadMeta { DownloadMeta { diff --git a/src-tauri/src/adapters/driven/mod.rs b/src-tauri/src/adapters/driven/mod.rs index 78aeb5a..6178171 100644 --- a/src-tauri/src/adapters/driven/mod.rs +++ b/src-tauri/src/adapters/driven/mod.rs @@ -3,4 +3,5 @@ pub mod event; pub mod filesystem; pub mod network; +pub mod plugin; pub mod sqlite; diff --git a/src-tauri/src/adapters/driven/plugin/extism_loader.rs b/src-tauri/src/adapters/driven/plugin/extism_loader.rs new file mode 100644 index 0000000..a6f0618 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/extism_loader.rs @@ -0,0 +1,228 @@ +//! Implements [`PluginLoader`] using Extism and [`PluginRegistry`]. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::domain::error::DomainError; +use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::PluginLoader; + +use super::manifest::find_wasm_file; +use super::registry::{LoadedPlugin, PluginRegistry}; + +pub struct ExtismPluginLoader { + registry: Arc, + plugins_dir: PathBuf, +} + +impl ExtismPluginLoader { + pub fn new(plugins_dir: PathBuf) -> Self { + Self { + registry: Arc::new(PluginRegistry::new()), + plugins_dir, + } + } + + pub fn registry(&self) -> &Arc { + &self.registry + } + + pub fn plugins_dir(&self) -> &Path { + &self.plugins_dir + } +} + +impl PluginLoader for ExtismPluginLoader { + fn load(&self, manifest: &PluginManifest) -> Result<(), DomainError> { + let name = manifest.info().name().to_string(); + + // Reject names containing path separators or traversal sequences + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err(DomainError::ValidationError(format!( + "invalid plugin name: '{name}'" + ))); + } + + // Derive wasm path directly from convention: plugins_dir//.wasm + let plugin_dir = self.plugins_dir.join(&name); + let wasm_path = find_wasm_file(&plugin_dir)?; + + const MAX_WASM_SIZE: u64 = 100 * 1024 * 1024; // 100 MB + let metadata = std::fs::metadata(&wasm_path).map_err(|e| { + DomainError::PluginError(format!("failed to stat wasm {}: {e}", wasm_path.display())) + })?; + if metadata.len() > MAX_WASM_SIZE { + return Err(DomainError::PluginError(format!( + "wasm file {} exceeds 100 MB limit ({} bytes)", + wasm_path.display(), + metadata.len() + ))); + } + let wasm_bytes = std::fs::read(&wasm_path).map_err(|e| { + DomainError::PluginError(format!("failed to read wasm {}: {e}", wasm_path.display())) + })?; + + let extism_manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)]); + let plugin = extism::Plugin::new(&extism_manifest, [], true) + .map_err(|e| DomainError::PluginError(format!("failed to load plugin: {e}")))?; + + let loaded = LoadedPlugin { + manifest: manifest.clone(), + plugin: std::sync::Arc::new(std::sync::Mutex::new(plugin)), + enabled: true, + }; + + // Atomic insert-if-absent via DashMap::entry() + if self.registry.try_insert(name.clone(), loaded) { + Ok(()) + } else { + Err(DomainError::AlreadyExists(name)) + } + } + + fn unload(&self, name: &str) -> Result<(), DomainError> { + self.registry + .remove(name) + .map(|_| ()) + .ok_or_else(|| DomainError::NotFound(name.to_string())) + } + + fn resolve_url(&self, url: &str) -> Result, DomainError> { + let mut infos: Vec<_> = self + .registry + .list_info() + .into_iter() + .filter(|i| i.is_enabled()) + .collect(); + infos.sort_by(|a, b| a.name().cmp(b.name())); + for info in infos { + let name = info.name().to_string(); + match self.registry.call_plugin(&name, "can_handle", url) { + Ok(result) if result.trim() == "true" => return Ok(Some(info)), + Ok(_) => {} + Err(e) => { + tracing::warn!("plugin '{name}' failed can_handle call: {e}"); + } + } + } + Ok(None) + } + + fn list_loaded(&self) -> Result, DomainError> { + Ok(self.registry.list_info()) + } + + fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), DomainError> { + self.registry.set_enabled(name, enabled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; + use std::io::Write; + use tempfile::TempDir; + + fn make_manifest(name: &str) -> PluginManifest { + let info = PluginInfo::new( + name.to_string(), + "1.0.0".to_string(), + "Test plugin".to_string(), + "tester".to_string(), + PluginCategory::Utility, + ); + PluginManifest::new(info) + } + + fn setup_plugin_dir(plugins_dir: &Path, name: &str) { + let plugin_dir = plugins_dir.join(name); + std::fs::create_dir_all(&plugin_dir).unwrap(); + + let toml_content = format!( + r#"[plugin] +name = "{name}" +version = "1.0.0" +category = "utility" +author = "tester" +description = "Test plugin" +"# + ); + let mut f = std::fs::File::create(plugin_dir.join("plugin.toml")).unwrap(); + f.write_all(toml_content.as_bytes()).unwrap(); + + // Write minimal valid WASM binary + let wasm_bytes: &[u8] = &[0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let mut wf = std::fs::File::create(plugin_dir.join(format!("{name}.wasm"))).unwrap(); + wf.write_all(wasm_bytes).unwrap(); + } + + #[test] + fn test_load_nonexistent_wasm() { + let tmp = TempDir::new().unwrap(); + let loader = ExtismPluginLoader::new(tmp.path().to_path_buf()); + let manifest = make_manifest("ghost-plugin"); + + // Plugin dir doesn't exist — should fail + let result = loader.load(&manifest); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), DomainError::PluginError(_))); + } + + #[test] + fn test_unload_not_found() { + let tmp = TempDir::new().unwrap(); + let loader = ExtismPluginLoader::new(tmp.path().to_path_buf()); + + let result = loader.unload("nonexistent"); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), DomainError::NotFound(_))); + } + + #[test] + fn test_resolve_url_no_plugins_returns_none() { + let tmp = TempDir::new().unwrap(); + let loader = ExtismPluginLoader::new(tmp.path().to_path_buf()); + + let result = loader.resolve_url("https://example.com/file.zip"); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_list_loaded_empty() { + let tmp = TempDir::new().unwrap(); + let loader = ExtismPluginLoader::new(tmp.path().to_path_buf()); + + let result = loader.list_loaded(); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_load_already_loaded_returns_error() { + let tmp = TempDir::new().unwrap(); + setup_plugin_dir(tmp.path(), "dup-plugin"); + let loader = ExtismPluginLoader::new(tmp.path().to_path_buf()); + let manifest = make_manifest("dup-plugin"); + + loader.load(&manifest).unwrap(); + let result = loader.load(&manifest); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), DomainError::AlreadyExists(_))); + } + + #[test] + fn test_unload_after_load() { + let tmp = TempDir::new().unwrap(); + setup_plugin_dir(tmp.path(), "removable-plugin"); + let loader = ExtismPluginLoader::new(tmp.path().to_path_buf()); + let manifest = make_manifest("removable-plugin"); + + loader.load(&manifest).unwrap(); + assert_eq!(loader.list_loaded().unwrap().len(), 1); + + loader.unload("removable-plugin").unwrap(); + assert_eq!(loader.list_loaded().unwrap().len(), 0); + } +} diff --git a/src-tauri/src/adapters/driven/plugin/manifest.rs b/src-tauri/src/adapters/driven/plugin/manifest.rs new file mode 100644 index 0000000..300362e --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/manifest.rs @@ -0,0 +1,343 @@ +//! Parse `plugin.toml` files into domain [`PluginManifest`]. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::domain::error::DomainError; +use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; + +#[derive(Deserialize)] +struct RawManifest { + plugin: RawPluginSection, + capabilities: Option, +} + +#[derive(Deserialize)] +struct RawPluginSection { + name: String, + version: String, + category: String, + author: String, + description: String, + _license: Option, + min_vortex_version: Option, +} + +#[derive(Deserialize)] +struct RawCapabilities { + http: Option, + filesystem: Option, + subprocess: Option>, +} + +/// Parse a plugin directory containing `plugin.toml` and a `.wasm` file. +/// +/// Returns the domain manifest and the path to the `.wasm` file. +pub fn parse_manifest(dir: &Path) -> Result<(PluginManifest, PathBuf), DomainError> { + let toml_path = dir.join("plugin.toml"); + let content = std::fs::read_to_string(&toml_path).map_err(|e| { + DomainError::PluginError(format!( + "failed to read plugin.toml at {}: {e}", + toml_path.display() + )) + })?; + + let raw: RawManifest = toml::from_str(&content).map_err(|e| { + DomainError::PluginError(format!( + "invalid plugin.toml at {}: {e}", + toml_path.display() + )) + })?; + + // Enforce convention: directory name must match plugin name + if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str()) + && dir_name != raw.plugin.name + { + return Err(DomainError::PluginError(format!( + "directory name '{dir_name}' does not match plugin name '{}'", + raw.plugin.name + ))); + } + + let category = parse_category(&raw.plugin.category)?; + let info = PluginInfo::new( + raw.plugin.name, + raw.plugin.version, + raw.plugin.description, + raw.plugin.author, + category, + ); + + let caps = raw + .capabilities + .as_ref() + .map(build_capabilities) + .unwrap_or_default(); + + let mut manifest = PluginManifest::new(info).with_capabilities(caps); + if let Some(v) = raw.plugin.min_vortex_version { + manifest = manifest.with_min_version(v); + } + + let wasm_path = find_wasm_file(dir)?; + Ok((manifest, wasm_path)) +} + +fn parse_category(s: &str) -> Result { + match s { + "crawler" => Ok(PluginCategory::Crawler), + "hoster" => Ok(PluginCategory::Hoster), + "debrid" => Ok(PluginCategory::Debrid), + "container" => Ok(PluginCategory::Container), + "captcha" => Ok(PluginCategory::Captcha), + "extractor" => Ok(PluginCategory::Extractor), + "notifier" => Ok(PluginCategory::Notifier), + "utility" => Ok(PluginCategory::Utility), + other => Err(DomainError::PluginError(format!( + "unknown plugin category: '{other}'" + ))), + } +} + +fn build_capabilities(caps: &RawCapabilities) -> Vec { + let mut result = Vec::new(); + if caps.http.unwrap_or(false) { + result.push("http".to_string()); + } + if caps.filesystem.unwrap_or(false) { + result.push("filesystem".to_string()); + } + if let Some(progs) = &caps.subprocess { + for prog in progs { + result.push(format!("subprocess:{prog}")); + } + } + result +} + +/// Find exactly one `.wasm` file in the plugin directory. +/// Returns an error if zero or more than one `.wasm` file is found. +pub fn find_wasm_file(dir: &Path) -> Result { + let entries = std::fs::read_dir(dir).map_err(|e| { + DomainError::PluginError(format!("cannot read plugin dir {}: {e}", dir.display())) + })?; + + let mut wasm_files = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| DomainError::PluginError(format!("dir entry error: {e}")))?; + let path = entry.path(); + if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wasm") { + wasm_files.push(path); + } + } + + match wasm_files.len() { + 0 => Err(DomainError::PluginError(format!( + "no .wasm file found in {}", + dir.display() + ))), + 1 => Ok(wasm_files.into_iter().next().expect("checked len == 1")), + n => Err(DomainError::PluginError(format!( + "expected exactly one .wasm file in {}, found {n}", + dir.display() + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + fn write_plugin_toml(dir: &Path, content: &str) { + let mut f = std::fs::File::create(dir.join("plugin.toml")).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + } + + fn write_dummy_wasm(dir: &Path, name: &str) { + std::fs::File::create(dir.join(name)).unwrap(); + } + + #[test] + fn test_parse_manifest_valid() { + let tmp = TempDir::new().unwrap(); + let plugin_dir = tmp.path().join("my-hoster"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_plugin_toml( + &plugin_dir, + r#" +[plugin] +name = "my-hoster" +version = "1.0.0" +category = "hoster" +author = "Alice" +description = "A hoster plugin" +min_vortex_version = "0.5.0" + +[capabilities] +http = true +filesystem = false +subprocess = ["ffmpeg"] +"#, + ); + write_dummy_wasm(&plugin_dir, "my-hoster.wasm"); + + let (manifest, wasm_path) = parse_manifest(&plugin_dir).unwrap(); + assert_eq!(manifest.info().name(), "my-hoster"); + assert_eq!(manifest.info().version(), "1.0.0"); + assert_eq!(manifest.info().category(), PluginCategory::Hoster); + assert_eq!(manifest.min_vortex_version(), Some("0.5.0")); + assert!(manifest.has_capability("http")); + assert!(!manifest.has_capability("filesystem")); + assert!(manifest.has_capability("subprocess:ffmpeg")); + assert!(wasm_path.ends_with("my-hoster.wasm")); + } + + #[test] + fn test_parse_manifest_missing_field() { + let tmp = TempDir::new().unwrap(); + let plugin_dir = tmp.path().join("bad-plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + // Missing `version` field + write_plugin_toml( + &plugin_dir, + r#" +[plugin] +name = "bad-plugin" +category = "hoster" +author = "Alice" +description = "Missing version" +"#, + ); + write_dummy_wasm(&plugin_dir, "bad-plugin.wasm"); + + let result = parse_manifest(&plugin_dir); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), DomainError::PluginError(_))); + } + + #[test] + fn test_parse_manifest_unknown_category() { + let tmp = TempDir::new().unwrap(); + let plugin_dir = tmp.path().join("weird"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_plugin_toml( + &plugin_dir, + r#" +[plugin] +name = "weird" +version = "1.0.0" +category = "spaceship" +author = "Bob" +description = "Unknown category" +"#, + ); + write_dummy_wasm(&plugin_dir, "weird.wasm"); + + let result = parse_manifest(&plugin_dir); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("spaceship"), + "expected category error, got: {err_msg}" + ); + } + + #[test] + fn test_parse_manifest_missing_wasm() { + let tmp = TempDir::new().unwrap(); + let plugin_dir = tmp.path().join("no-wasm"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_plugin_toml( + &plugin_dir, + r#" +[plugin] +name = "no-wasm" +version = "1.0.0" +category = "utility" +author = "Charlie" +description = "No wasm file" +"#, + ); + + let result = parse_manifest(&plugin_dir); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("no .wasm"), + "expected wasm error, got: {err_msg}" + ); + } + + #[test] + fn test_parse_manifest_dir_name_mismatch() { + let tmp = TempDir::new().unwrap(); + let plugin_dir = tmp.path().join("wrong-dir-name"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_plugin_toml( + &plugin_dir, + r#" +[plugin] +name = "actual-name" +version = "1.0.0" +category = "utility" +author = "Alice" +description = "Dir name mismatch" +"#, + ); + write_dummy_wasm(&plugin_dir, "actual-name.wasm"); + + let result = parse_manifest(&plugin_dir); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("does not match"), + "expected name mismatch error, got: {err_msg}" + ); + } + + #[test] + fn test_parse_category_valid() { + assert!(matches!( + parse_category("crawler"), + Ok(PluginCategory::Crawler) + )); + assert!(matches!( + parse_category("hoster"), + Ok(PluginCategory::Hoster) + )); + assert!(matches!( + parse_category("debrid"), + Ok(PluginCategory::Debrid) + )); + assert!(matches!( + parse_category("container"), + Ok(PluginCategory::Container) + )); + assert!(matches!( + parse_category("captcha"), + Ok(PluginCategory::Captcha) + )); + assert!(matches!( + parse_category("extractor"), + Ok(PluginCategory::Extractor) + )); + assert!(matches!( + parse_category("notifier"), + Ok(PluginCategory::Notifier) + )); + assert!(matches!( + parse_category("utility"), + Ok(PluginCategory::Utility) + )); + } + + #[test] + fn test_parse_category_invalid() { + let result = parse_category("unknown"); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), DomainError::PluginError(_))); + } +} diff --git a/src-tauri/src/adapters/driven/plugin/mod.rs b/src-tauri/src/adapters/driven/plugin/mod.rs new file mode 100644 index 0000000..2fce1a0 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/mod.rs @@ -0,0 +1,8 @@ +pub mod extism_loader; +pub mod manifest; +pub mod registry; +pub mod watcher; + +pub use extism_loader::ExtismPluginLoader; +pub use registry::PluginRegistry; +pub use watcher::PluginWatcher; diff --git a/src-tauri/src/adapters/driven/plugin/registry.rs b/src-tauri/src/adapters/driven/plugin/registry.rs new file mode 100644 index 0000000..6505e36 --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/registry.rs @@ -0,0 +1,212 @@ +//! In-memory plugin registry backed by a [`DashMap`]. + +use std::sync::{Arc, Mutex}; + +use dashmap::DashMap; + +use crate::domain::error::DomainError; +use crate::domain::model::plugin::{PluginInfo, PluginManifest}; +use crate::domain::ports::driven::PluginReadRepository; + +pub struct LoadedPlugin { + pub manifest: PluginManifest, + pub plugin: Arc>, + pub enabled: bool, +} + +pub struct PluginRegistry { + plugins: DashMap, +} + +impl PluginRegistry { + pub fn new() -> Self { + Self { + plugins: DashMap::new(), + } + } + + /// Unconditional insert (no duplicate check). Used in tests for setup. + #[cfg(test)] + pub fn insert(&self, name: String, loaded: LoadedPlugin) { + self.plugins.insert(name, loaded); + } + + /// Atomically insert only if the key is absent. Returns true on success. + pub fn try_insert(&self, name: String, loaded: LoadedPlugin) -> bool { + use dashmap::mapref::entry::Entry; + match self.plugins.entry(name) { + Entry::Vacant(vacant) => { + vacant.insert(loaded); + true + } + Entry::Occupied(_) => false, + } + } + + pub fn remove(&self, name: &str) -> Option<(String, LoadedPlugin)> { + self.plugins.remove(name) + } + + pub fn contains(&self, name: &str) -> bool { + self.plugins.contains_key(name) + } + + /// Returns info for all plugins (enabled and disabled). + pub fn list_info(&self) -> Vec { + self.plugins + .iter() + .map(|entry| { + let mut info = entry.manifest.info().clone(); + if !entry.enabled { + info.disable(); + } + info + }) + .collect() + } + + pub fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), DomainError> { + let mut entry = self + .plugins + .get_mut(name) + .ok_or_else(|| DomainError::NotFound(name.to_string()))?; + entry.enabled = enabled; + Ok(()) + } + + pub fn call_plugin(&self, name: &str, func: &str, input: &str) -> Result { + // Clone the Arc> and drop the DashMap shard guard + // before locking. This prevents holding the shard during slow WASM execution. + let plugin_handle = { + let entry = self + .plugins + .get(name) + .ok_or_else(|| DomainError::NotFound(name.to_string()))?; + Arc::clone(&entry.plugin) + }; // DashMap shard guard dropped here + let mut plugin = plugin_handle + .lock() + .map_err(|_| DomainError::PluginError(format!("plugin '{name}' mutex poisoned")))?; + let result = plugin + .call::<&str, &str>(func, input) + .map_err(|e| DomainError::PluginError(format!("plugin call failed: {e}")))?; + Ok(result.to_string()) + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +impl PluginReadRepository for PluginRegistry { + fn list_loaded(&self) -> Result, DomainError> { + Ok(self.list_info()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; + + fn make_manifest(name: &str) -> PluginManifest { + let info = PluginInfo::new( + name.to_string(), + "1.0.0".to_string(), + "Test plugin".to_string(), + "tester".to_string(), + PluginCategory::Utility, + ); + PluginManifest::new(info) + } + + /// Create a minimal extism plugin from a hardcoded empty WASM module. + fn make_extism_plugin() -> extism::Plugin { + // Minimal valid WASM binary: magic + version (8 bytes) + let wasm_bytes: &[u8] = &[0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)]); + extism::Plugin::new(&manifest, [], true).expect("extism plugin creation failed") + } + + fn make_loaded(name: &str) -> LoadedPlugin { + LoadedPlugin { + manifest: make_manifest(name), + plugin: Arc::new(Mutex::new(make_extism_plugin())), + enabled: true, + } + } + + #[test] + fn test_insert_and_list() { + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + registry.insert("plug-b".to_string(), make_loaded("plug-b")); + + let infos = registry.list_info(); + assert_eq!(infos.len(), 2); + let names: Vec<&str> = infos.iter().map(|i| i.name()).collect(); + assert!(names.contains(&"plug-a")); + assert!(names.contains(&"plug-b")); + } + + #[test] + fn test_remove() { + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + assert!(registry.contains("plug-a")); + + let removed = registry.remove("plug-a"); + assert!(removed.is_some()); + assert!(!registry.contains("plug-a")); + } + + #[test] + fn test_contains() { + let registry = PluginRegistry::new(); + assert!(!registry.contains("missing")); + registry.insert("present".to_string(), make_loaded("present")); + assert!(registry.contains("present")); + } + + #[test] + fn test_set_enabled() { + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + + registry.set_enabled("plug-a", false).unwrap(); + let infos = registry.list_info(); + let info = infos.iter().find(|i| i.name() == "plug-a").unwrap(); + assert!(!info.is_enabled()); + + registry.set_enabled("plug-a", true).unwrap(); + let infos = registry.list_info(); + let info = infos.iter().find(|i| i.name() == "plug-a").unwrap(); + assert!(info.is_enabled()); + } + + #[test] + fn test_set_enabled_not_found() { + let registry = PluginRegistry::new(); + let result = registry.set_enabled("ghost", false); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), DomainError::NotFound(_))); + } + + #[test] + fn test_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } + + #[test] + fn test_plugin_read_repository_impl() { + let registry = PluginRegistry::new(); + registry.insert("plug-a".to_string(), make_loaded("plug-a")); + + let result = registry.list_loaded(); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 1); + } +} diff --git a/src-tauri/src/adapters/driven/plugin/watcher.rs b/src-tauri/src/adapters/driven/plugin/watcher.rs new file mode 100644 index 0000000..4987e8a --- /dev/null +++ b/src-tauri/src/adapters/driven/plugin/watcher.rs @@ -0,0 +1,240 @@ +//! Hot-reload file system watcher for plugins using `notify` + tokio. + +use std::path::PathBuf; +use std::sync::Arc; + +use notify::{EventKind, RecursiveMode, Watcher}; +use tokio::sync::mpsc; + +use crate::domain::error::DomainError; +use crate::domain::ports::driven::PluginLoader; + +use super::extism_loader::ExtismPluginLoader; +use super::manifest::parse_manifest; + +pub struct PluginWatcher { + _watcher: notify::RecommendedWatcher, +} + +impl PluginWatcher { + pub fn start( + plugins_dir: PathBuf, + loader: Arc, + ) -> Result { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }) + .map_err(|e| DomainError::PluginError(format!("watcher init failed: {e}")))?; + + watcher + .watch(&plugins_dir, RecursiveMode::Recursive) + .map_err(|e| DomainError::PluginError(format!("watch failed: {e}")))?; + + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + handle_fs_event(&event, &loader); + } + }); + + Ok(Self { _watcher: watcher }) + } +} + +fn handle_fs_event(event: ¬ify::Event, loader: &ExtismPluginLoader) { + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) => { + for path in &event.paths { + if (is_plugin_toml(path) || is_wasm_file(path)) + && let Some(plugin_dir) = path.parent() + { + tracing::info!( + "plugin file changed, attempting load from {}", + plugin_dir.display() + ); + match parse_manifest(plugin_dir) { + Ok((manifest, _)) => { + let name = manifest.info().name().to_string(); + // Unload first if present (reload case). Ignore NotFound. + let _ = loader.unload(&name); + if let Err(e) = loader.load(&manifest) { + tracing::warn!("failed to load plugin '{name}': {e}"); + } else { + tracing::info!("plugin '{name}' loaded"); + } + } + Err(e) => { + tracing::warn!( + "failed to parse manifest at {}: {e}", + plugin_dir.display() + ); + } + } + } + } + } + EventKind::Remove(_) => { + // Convention: plugin directory name must match the plugin's `name` field in + // plugin.toml. On removal, the toml may already be gone so we rely on dir name. + for path in &event.paths { + if (is_plugin_toml(path) || is_wasm_file(path)) + && let Some(plugin_dir) = path.parent() + && let Some(name) = plugin_dir.file_name().and_then(|n| n.to_str()) + { + // Try unload directly — unload returns NotFound if not loaded, + // which is fine (avoids TOCTOU between contains and unload). + tracing::info!("plugin file removed, unloading '{name}'"); + if let Err(e) = loader.unload(name) { + tracing::debug!("unload '{name}' after removal: {e}"); + } + } + } + } + _ => {} + } +} + +fn is_plugin_toml(path: &std::path::Path) -> bool { + path.file_name().and_then(|n| n.to_str()) == Some("plugin.toml") +} + +fn is_wasm_file(path: &std::path::Path) -> bool { + path.extension().and_then(|e| e.to_str()) == Some("wasm") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; + use notify::event::{CreateKind, RemoveKind}; + use std::io::Write; + use tempfile::TempDir; + + fn make_loader(plugins_dir: &std::path::Path) -> Arc { + Arc::new(ExtismPluginLoader::new(plugins_dir.to_path_buf())) + } + + fn setup_plugin_dir(plugins_dir: &std::path::Path, name: &str) { + let plugin_dir = plugins_dir.join(name); + std::fs::create_dir_all(&plugin_dir).unwrap(); + + let toml_content = format!( + r#"[plugin] +name = "{name}" +version = "1.0.0" +category = "utility" +author = "tester" +description = "Test plugin" +"# + ); + let mut f = std::fs::File::create(plugin_dir.join("plugin.toml")).unwrap(); + f.write_all(toml_content.as_bytes()).unwrap(); + + let wasm_bytes: &[u8] = &[0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let mut wf = std::fs::File::create(plugin_dir.join(format!("{name}.wasm"))).unwrap(); + wf.write_all(wasm_bytes).unwrap(); + } + + fn make_manifest_for(name: &str) -> PluginManifest { + let info = PluginInfo::new( + name.to_string(), + "1.0.0".to_string(), + "desc".to_string(), + "author".to_string(), + PluginCategory::Utility, + ); + PluginManifest::new(info) + } + + #[test] + fn test_handle_fs_event_create_loads_plugin() { + let tmp = TempDir::new().unwrap(); + setup_plugin_dir(tmp.path(), "new-plugin"); + let loader = make_loader(tmp.path()); + + let toml_path = tmp.path().join("new-plugin").join("plugin.toml"); + let event = notify::Event { + kind: EventKind::Create(CreateKind::File), + paths: vec![toml_path], + attrs: Default::default(), + }; + + handle_fs_event(&event, &loader); + assert!(loader.registry().contains("new-plugin")); + } + + #[test] + fn test_handle_fs_event_create_reloads_existing_plugin() { + let tmp = TempDir::new().unwrap(); + setup_plugin_dir(tmp.path(), "hot-plugin"); + let loader = make_loader(tmp.path()); + + // Pre-load + loader.load(&make_manifest_for("hot-plugin")).unwrap(); + + let toml_path = tmp.path().join("hot-plugin").join("plugin.toml"); + let event = notify::Event { + kind: EventKind::Create(CreateKind::File), + paths: vec![toml_path], + attrs: Default::default(), + }; + + handle_fs_event(&event, &loader); + // Should still be loaded after reload + assert!(loader.registry().contains("hot-plugin")); + } + + #[test] + fn test_handle_fs_event_remove_unloads_plugin() { + let tmp = TempDir::new().unwrap(); + setup_plugin_dir(tmp.path(), "doomed-plugin"); + let loader = make_loader(tmp.path()); + + loader.load(&make_manifest_for("doomed-plugin")).unwrap(); + assert!(loader.registry().contains("doomed-plugin")); + + let wasm_path = tmp.path().join("doomed-plugin").join("doomed-plugin.wasm"); + let event = notify::Event { + kind: EventKind::Remove(RemoveKind::File), + paths: vec![wasm_path], + attrs: Default::default(), + }; + + handle_fs_event(&event, &loader); + assert!(!loader.registry().contains("doomed-plugin")); + } + + #[test] + fn test_handle_fs_event_unrelated_path_ignored() { + let tmp = TempDir::new().unwrap(); + let loader = make_loader(tmp.path()); + + let event = notify::Event { + kind: EventKind::Create(CreateKind::File), + paths: vec![tmp.path().join("some-dir").join("readme.txt")], + attrs: Default::default(), + }; + + handle_fs_event(&event, &loader); + assert!(loader.list_loaded().unwrap().is_empty()); + } + + #[test] + fn test_is_plugin_toml() { + assert!(is_plugin_toml(std::path::Path::new("/foo/bar/plugin.toml"))); + assert!(!is_plugin_toml(std::path::Path::new("/foo/bar/other.toml"))); + assert!(!is_plugin_toml(std::path::Path::new( + "/foo/bar/plugin.wasm" + ))); + } + + #[test] + fn test_is_wasm_file() { + assert!(is_wasm_file(std::path::Path::new("/foo/plugin.wasm"))); + assert!(!is_wasm_file(std::path::Path::new("/foo/plugin.toml"))); + assert!(!is_wasm_file(std::path::Path::new("/foo/plugin.wat"))); + } +} diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index 15cd7e9..b5769a3 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -10,16 +10,18 @@ use tauri::State; use crate::application::command_bus::CommandBus; use crate::application::commands::{ - CancelDownloadCommand, PauseAllDownloadsCommand, PauseDownloadCommand, RemoveDownloadCommand, + CancelDownloadCommand, DisablePluginCommand, EnablePluginCommand, InstallPluginCommand, + PauseAllDownloadsCommand, PauseDownloadCommand, RemoveDownloadCommand, ResumeAllDownloadsCommand, ResumeDownloadCommand, RetryDownloadCommand, SetPriorityCommand, - StartDownloadCommand, + StartDownloadCommand, UninstallPluginCommand, }; use crate::application::queries::{ - CountDownloadsByStateQuery, GetDownloadDetailQuery, GetDownloadsQuery, + CountDownloadsByStateQuery, GetDownloadDetailQuery, GetDownloadsQuery, ListPluginsQuery, }; use crate::application::query_bus::QueryBus; use crate::application::read_models::download_detail_view::DownloadDetailViewDto; use crate::application::read_models::download_view::DownloadViewDto; +use crate::application::read_models::plugin_view::PluginViewDto; use crate::domain::model::download::{DownloadId, DownloadState}; use crate::domain::model::views::{DownloadFilter, SortDirection, SortField, SortOrder}; @@ -139,6 +141,69 @@ pub async fn download_remove( .map_err(|e| e.to_string()) } +// --- Plugin Commands --- + +#[tauri::command] +pub async fn plugin_install(state: State<'_, AppState>, path: String) -> Result<(), String> { + let plugin_dir = std::path::PathBuf::from(&path); + let canonical = plugin_dir + .canonicalize() + .map_err(|e| format!("invalid plugin path: {e}"))?; + let config_dir = + dirs::config_dir().ok_or_else(|| "cannot determine system config directory".to_string())?; + let allowed_parent = config_dir.join("vortex").join("plugins"); + std::fs::create_dir_all(&allowed_parent) + .map_err(|e| format!("cannot create plugins dir: {e}"))?; + let allowed_parent = allowed_parent + .canonicalize() + .map_err(|e| format!("cannot resolve plugins dir: {e}"))?; + if !canonical.starts_with(&allowed_parent) { + return Err(format!( + "plugin path must be under {}", + allowed_parent.display() + )); + } + let (manifest, _wasm_path) = + crate::adapters::driven::plugin::manifest::parse_manifest(&canonical) + .map_err(|e| e.to_string())?; + let cmd = InstallPluginCommand { manifest }; + state + .command_bus + .handle_install_plugin(cmd) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn plugin_uninstall(state: State<'_, AppState>, name: String) -> Result<(), String> { + let cmd = UninstallPluginCommand { name }; + state + .command_bus + .handle_uninstall_plugin(cmd) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn plugin_enable(state: State<'_, AppState>, name: String) -> Result<(), String> { + let cmd = EnablePluginCommand { name }; + state + .command_bus + .handle_enable_plugin(cmd) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn plugin_disable(state: State<'_, AppState>, name: String) -> Result<(), String> { + let cmd = DisablePluginCommand { name }; + state + .command_bus + .handle_disable_plugin(cmd) + .await + .map_err(|e| e.to_string()) +} + // --- Queries --- #[tauri::command] @@ -212,6 +277,15 @@ pub async fn download_count_by_state( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn plugin_list(state: State<'_, AppState>) -> Result, String> { + state + .query_bus + .handle_list_plugins(ListPluginsQuery) + .await + .map_err(|e| e.to_string()) +} + fn parse_download_state(s: &str) -> Option { match s.to_lowercase().as_str() { "queued" => Some(DownloadState::Queued), diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index a20afde..3f3a398 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -87,6 +87,10 @@ impl CommandBus { pub fn clipboard_observer(&self) -> &dyn ClipboardObserver { self.clipboard_observer.as_ref() } + + pub(crate) fn plugin_loader_arc(&self) -> Arc { + Arc::clone(&self.plugin_loader) + } } #[cfg(test)] @@ -318,6 +322,10 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(self.plugins.lock().unwrap().values().cloned().collect()) } + + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore { diff --git a/src-tauri/src/application/commands/cancel_download.rs b/src-tauri/src/application/commands/cancel_download.rs index 3f9470f..4885cde 100644 --- a/src-tauri/src/application/commands/cancel_download.rs +++ b/src-tauri/src/application/commands/cancel_download.rs @@ -227,6 +227,10 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/install_plugin.rs b/src-tauri/src/application/commands/install_plugin.rs new file mode 100644 index 0000000..0f7e661 --- /dev/null +++ b/src-tauri/src/application/commands/install_plugin.rs @@ -0,0 +1,299 @@ +//! Handler for `InstallPluginCommand`. +//! +//! Loads a pre-parsed plugin manifest via the PluginLoader port +//! and emits a `PluginLoaded` domain event on success. + +use crate::application::command_bus::CommandBus; +use crate::application::error::AppError; +use crate::domain::event::DomainEvent; + +impl CommandBus { + pub async fn handle_install_plugin( + &self, + cmd: super::InstallPluginCommand, + ) -> Result<(), AppError> { + let loader = self.plugin_loader_arc(); + let manifest = cmd.manifest.clone(); + tokio::task::spawn_blocking(move || loader.load(&manifest)) + .await + .map_err(|e| AppError::Plugin(format!("plugin install task failed: {e}")))? + .map_err(AppError::from)?; + + self.event_bus().publish(DomainEvent::PluginLoaded { + name: cmd.manifest.info().name().to_string(), + version: cmd.manifest.info().version().to_string(), + }); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::Path; + use std::sync::{Arc, Mutex}; + + use crate::application::command_bus::CommandBus; + use crate::application::commands::InstallPluginCommand; + use crate::domain::error::DomainError; + use crate::domain::event::DomainEvent; + use crate::domain::model::config::{AppConfig, ConfigPatch}; + use crate::domain::model::credential::Credential; + use crate::domain::model::download::{Download, DownloadId, DownloadState}; + use crate::domain::model::http::HttpResponse; + use crate::domain::model::meta::DownloadMeta; + use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; + use crate::domain::ports::driven::{ + ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, + EventBus, FileStorage, HttpClient, PluginLoader, + }; + + struct MockDownloadRepo; + impl DownloadRepository for MockDownloadRepo { + fn find_by_id(&self, _id: DownloadId) -> Result, DomainError> { + Ok(None) + } + fn save(&self, _d: &Download) -> Result<(), DomainError> { + Ok(()) + } + fn delete(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn find_by_state(&self, _s: DownloadState) -> Result, DomainError> { + Ok(vec![]) + } + } + + struct MockDownloadEngine; + impl DownloadEngine for MockDownloadEngine { + fn start(&self, _download: &Download) -> Result<(), DomainError> { + Ok(()) + } + fn pause(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn resume(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn cancel(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockEventBus { + events: Mutex>, + } + + impl MockEventBus { + fn new() -> Self { + Self { + events: Mutex::new(Vec::new()), + } + } + } + + impl EventBus for MockEventBus { + fn publish(&self, event: DomainEvent) { + self.events.lock().unwrap().push(event); + } + fn subscribe(&self, _handler: Box) {} + } + + struct MockFileStorage; + impl FileStorage for MockFileStorage { + fn create_file(&self, _path: &Path, _size: u64) -> Result<(), DomainError> { + Ok(()) + } + fn write_segment( + &self, + _path: &Path, + _offset: u64, + _data: &[u8], + ) -> Result<(), DomainError> { + Ok(()) + } + fn read_meta(&self, _path: &Path) -> Result, DomainError> { + Ok(None) + } + fn write_meta(&self, _path: &Path, _meta: &DownloadMeta) -> Result<(), DomainError> { + Ok(()) + } + fn delete_meta(&self, _path: &Path) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockHttpClient; + impl HttpClient for MockHttpClient { + fn head(&self, _url: &str) -> Result { + Ok(HttpResponse { + status_code: 200, + headers: HashMap::new(), + body: vec![], + }) + } + fn get_range(&self, _url: &str, start: u64, end: u64) -> Result, DomainError> { + Ok(vec![ + 0u8; + end.saturating_sub(start).saturating_add(1) as usize + ]) + } + fn supports_range(&self, _url: &str) -> Result { + Ok(true) + } + } + + struct MockPluginLoader { + loaded: Mutex>, + should_fail: bool, + } + + impl MockPluginLoader { + fn new() -> Self { + Self { + loaded: Mutex::new(Vec::new()), + should_fail: false, + } + } + + fn failing() -> Self { + Self { + loaded: Mutex::new(Vec::new()), + should_fail: true, + } + } + } + + impl PluginLoader for MockPluginLoader { + fn load(&self, manifest: &PluginManifest) -> Result<(), DomainError> { + if self.should_fail { + return Err(DomainError::PluginError("load failed".to_string())); + } + self.loaded + .lock() + .unwrap() + .push(manifest.info().name().to_string()); + Ok(()) + } + fn unload(&self, _name: &str) -> Result<(), DomainError> { + Ok(()) + } + fn resolve_url(&self, _url: &str) -> Result, DomainError> { + Ok(None) + } + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![]) + } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockConfigStore; + impl ConfigStore for MockConfigStore { + fn get_config(&self) -> Result { + Ok(AppConfig::default()) + } + fn update_config(&self, _patch: ConfigPatch) -> Result { + Ok(AppConfig::default()) + } + } + + struct MockCredentialStore; + impl CredentialStore for MockCredentialStore { + fn get(&self, _service: &str) -> Result, DomainError> { + Ok(None) + } + fn store(&self, _service: &str, _credential: &Credential) -> Result<(), DomainError> { + Ok(()) + } + fn delete(&self, _service: &str) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockClipboardObserver; + impl ClipboardObserver for MockClipboardObserver { + fn start(&self) -> Result<(), DomainError> { + Ok(()) + } + fn stop(&self) -> Result<(), DomainError> { + Ok(()) + } + fn get_urls(&self) -> Result, DomainError> { + Ok(vec![]) + } + } + + fn make_manifest(name: &str, version: &str) -> PluginManifest { + let info = PluginInfo::new( + name.to_string(), + version.to_string(), + "A test plugin".to_string(), + "test-author".to_string(), + PluginCategory::Hoster, + ); + PluginManifest::new(info) + } + + fn make_bus(plugin_loader: Arc, event_bus: Arc) -> CommandBus { + CommandBus::new( + Arc::new(MockDownloadRepo), + Arc::new(MockDownloadEngine), + event_bus, + Arc::new(MockFileStorage), + Arc::new(MockHttpClient), + plugin_loader, + Arc::new(MockConfigStore), + Arc::new(MockCredentialStore), + Arc::new(MockClipboardObserver), + ) + } + + #[tokio::test] + async fn test_install_plugin_loads_and_emits_event() { + let loader = Arc::new(MockPluginLoader::new()); + let event_bus = Arc::new(MockEventBus::new()); + let bus = make_bus(loader.clone(), event_bus.clone()); + + let manifest = make_manifest("my-plugin", "1.0.0"); + let cmd = InstallPluginCommand { + manifest: manifest.clone(), + }; + + let result = bus.handle_install_plugin(cmd).await; + assert!(result.is_ok()); + + let loaded = loader.loaded.lock().unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0], "my-plugin"); + + let events = event_bus.events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0], + DomainEvent::PluginLoaded { + name: "my-plugin".to_string(), + version: "1.0.0".to_string(), + } + ); + } + + #[tokio::test] + async fn test_install_plugin_loader_error_returns_err() { + let loader = Arc::new(MockPluginLoader::failing()); + let event_bus = Arc::new(MockEventBus::new()); + let bus = make_bus(loader, event_bus.clone()); + + let cmd = InstallPluginCommand { + manifest: make_manifest("bad-plugin", "0.1.0"), + }; + + let result = bus.handle_install_plugin(cmd).await; + assert!(result.is_err()); + + let events = event_bus.events.lock().unwrap(); + assert!(events.is_empty(), "no event should be emitted on failure"); + } +} diff --git a/src-tauri/src/application/commands/mod.rs b/src-tauri/src/application/commands/mod.rs index 5589dcf..03c4168 100644 --- a/src-tauri/src/application/commands/mod.rs +++ b/src-tauri/src/application/commands/mod.rs @@ -4,6 +4,7 @@ //! Handler implementations live in submodules and add methods to `CommandBus`. mod cancel_download; +mod install_plugin; mod pause_all; mod pause_download; mod remove_download; @@ -12,6 +13,8 @@ mod resume_download; mod retry_download; mod set_priority; mod start_download; +mod toggle_plugin; +mod uninstall_plugin; use std::path::PathBuf; @@ -58,22 +61,34 @@ impl Command for PauseAllDownloadsCommand {} pub struct ResumeAllDownloadsCommand; impl Command for ResumeAllDownloadsCommand {} -// Handler: task 13 (plugin infrastructure) +/// Install a plugin from a pre-parsed manifest. +/// +/// The driving adapter (IPC) is responsible for parsing the manifest from the +/// source path before constructing this command. #[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] pub struct InstallPluginCommand { - pub url: String, + pub manifest: crate::domain::model::plugin::PluginManifest, } impl Command for InstallPluginCommand {} -// Handler: task 13 (plugin infrastructure) #[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] pub struct UninstallPluginCommand { pub name: String, } impl Command for UninstallPluginCommand {} +#[derive(Debug)] +pub struct EnablePluginCommand { + pub name: String, +} +impl Command for EnablePluginCommand {} + +#[derive(Debug)] +pub struct DisablePluginCommand { + pub name: String, +} +impl Command for DisablePluginCommand {} + #[derive(Debug)] pub struct SetPriorityCommand { pub id: DownloadId, @@ -90,7 +105,7 @@ impl Command for RemoveDownloadCommand {} // Handler: task 23 (settings) #[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] +#[expect(dead_code)] pub struct UpdateConfigCommand { pub patch: ConfigPatch, } diff --git a/src-tauri/src/application/commands/pause_all.rs b/src-tauri/src/application/commands/pause_all.rs index 4793c0c..0caae5f 100644 --- a/src-tauri/src/application/commands/pause_all.rs +++ b/src-tauri/src/application/commands/pause_all.rs @@ -214,6 +214,10 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/pause_download.rs b/src-tauri/src/application/commands/pause_download.rs index 80b2c97..5c03712 100644 --- a/src-tauri/src/application/commands/pause_download.rs +++ b/src-tauri/src/application/commands/pause_download.rs @@ -202,6 +202,9 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/remove_download.rs b/src-tauri/src/application/commands/remove_download.rs index ce79e90..d552dc6 100644 --- a/src-tauri/src/application/commands/remove_download.rs +++ b/src-tauri/src/application/commands/remove_download.rs @@ -234,6 +234,9 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/resume_all.rs b/src-tauri/src/application/commands/resume_all.rs index 76a9300..34a6b12 100644 --- a/src-tauri/src/application/commands/resume_all.rs +++ b/src-tauri/src/application/commands/resume_all.rs @@ -212,6 +212,10 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/resume_download.rs b/src-tauri/src/application/commands/resume_download.rs index 7ff6d88..f946578 100644 --- a/src-tauri/src/application/commands/resume_download.rs +++ b/src-tauri/src/application/commands/resume_download.rs @@ -205,6 +205,9 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/retry_download.rs b/src-tauri/src/application/commands/retry_download.rs index 30b52a3..77c84a4 100644 --- a/src-tauri/src/application/commands/retry_download.rs +++ b/src-tauri/src/application/commands/retry_download.rs @@ -190,6 +190,10 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/set_priority.rs b/src-tauri/src/application/commands/set_priority.rs index 9d27aad..b21b926 100644 --- a/src-tauri/src/application/commands/set_priority.rs +++ b/src-tauri/src/application/commands/set_priority.rs @@ -168,6 +168,9 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/start_download.rs b/src-tauri/src/application/commands/start_download.rs index 9688cb2..8e6bb83 100644 --- a/src-tauri/src/application/commands/start_download.rs +++ b/src-tauri/src/application/commands/start_download.rs @@ -271,6 +271,9 @@ mod tests { fn list_loaded(&self) -> Result, DomainError> { Ok(vec![]) } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } struct MockConfigStore; diff --git a/src-tauri/src/application/commands/toggle_plugin.rs b/src-tauri/src/application/commands/toggle_plugin.rs new file mode 100644 index 0000000..1064efa --- /dev/null +++ b/src-tauri/src/application/commands/toggle_plugin.rs @@ -0,0 +1,281 @@ +//! Handlers for `EnablePluginCommand` and `DisablePluginCommand`. +//! +//! Toggles the enabled state of a loaded plugin via `PluginLoader::set_enabled`. + +use crate::application::command_bus::CommandBus; +use crate::application::error::AppError; + +impl CommandBus { + pub async fn handle_enable_plugin( + &self, + cmd: super::EnablePluginCommand, + ) -> Result<(), AppError> { + self.plugin_loader().set_enabled(&cmd.name, true)?; + Ok(()) + } + + pub async fn handle_disable_plugin( + &self, + cmd: super::DisablePluginCommand, + ) -> Result<(), AppError> { + self.plugin_loader().set_enabled(&cmd.name, false)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::Path; + use std::sync::Arc; + + use crate::application::command_bus::CommandBus; + use crate::application::commands::{DisablePluginCommand, EnablePluginCommand}; + use crate::domain::error::DomainError; + use crate::domain::event::DomainEvent; + use crate::domain::model::config::{AppConfig, ConfigPatch}; + use crate::domain::model::credential::Credential; + use crate::domain::model::download::{Download, DownloadId, DownloadState}; + use crate::domain::model::http::HttpResponse; + use crate::domain::model::meta::DownloadMeta; + use crate::domain::model::plugin::{PluginCategory, PluginInfo, PluginManifest}; + use crate::domain::ports::driven::{ + ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, + EventBus, FileStorage, HttpClient, PluginLoader, + }; + + struct MockDownloadRepo; + impl DownloadRepository for MockDownloadRepo { + fn find_by_id(&self, _id: DownloadId) -> Result, DomainError> { + Ok(None) + } + fn save(&self, _d: &Download) -> Result<(), DomainError> { + Ok(()) + } + fn delete(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn find_by_state(&self, _s: DownloadState) -> Result, DomainError> { + Ok(vec![]) + } + } + + struct MockDownloadEngine; + impl DownloadEngine for MockDownloadEngine { + fn start(&self, _download: &Download) -> Result<(), DomainError> { + Ok(()) + } + fn pause(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn resume(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn cancel(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockEventBus; + impl EventBus for MockEventBus { + fn publish(&self, _event: DomainEvent) {} + fn subscribe(&self, _handler: Box) {} + } + + struct MockFileStorage; + impl FileStorage for MockFileStorage { + fn create_file(&self, _path: &Path, _size: u64) -> Result<(), DomainError> { + Ok(()) + } + fn write_segment( + &self, + _path: &Path, + _offset: u64, + _data: &[u8], + ) -> Result<(), DomainError> { + Ok(()) + } + fn read_meta(&self, _path: &Path) -> Result, DomainError> { + Ok(None) + } + fn write_meta(&self, _path: &Path, _meta: &DownloadMeta) -> Result<(), DomainError> { + Ok(()) + } + fn delete_meta(&self, _path: &Path) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockHttpClient; + impl HttpClient for MockHttpClient { + fn head(&self, _url: &str) -> Result { + Ok(HttpResponse { + status_code: 200, + headers: HashMap::new(), + body: vec![], + }) + } + fn get_range(&self, _url: &str, start: u64, end: u64) -> Result, DomainError> { + Ok(vec![ + 0u8; + end.saturating_sub(start).saturating_add(1) as usize + ]) + } + fn supports_range(&self, _url: &str) -> Result { + Ok(true) + } + } + + struct MockPluginLoader { + plugins: Vec, + } + + impl MockPluginLoader { + fn empty() -> Self { + Self { plugins: vec![] } + } + + fn with_plugin(name: &str) -> Self { + let info = PluginInfo::new( + name.to_string(), + "1.0.0".to_string(), + "desc".to_string(), + "author".to_string(), + PluginCategory::Utility, + ); + Self { + plugins: vec![info], + } + } + } + + impl PluginLoader for MockPluginLoader { + fn load(&self, _manifest: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + fn unload(&self, _name: &str) -> Result<(), DomainError> { + Ok(()) + } + fn resolve_url(&self, _url: &str) -> Result, DomainError> { + Ok(None) + } + fn list_loaded(&self) -> Result, DomainError> { + Ok(self.plugins.clone()) + } + fn set_enabled(&self, name: &str, _enabled: bool) -> Result<(), DomainError> { + if self.plugins.iter().any(|p| p.name() == name) { + Ok(()) + } else { + Err(DomainError::NotFound(name.to_string())) + } + } + } + + struct MockConfigStore; + impl ConfigStore for MockConfigStore { + fn get_config(&self) -> Result { + Ok(AppConfig::default()) + } + fn update_config(&self, _patch: ConfigPatch) -> Result { + Ok(AppConfig::default()) + } + } + + struct MockCredentialStore; + impl CredentialStore for MockCredentialStore { + fn get(&self, _service: &str) -> Result, DomainError> { + Ok(None) + } + fn store(&self, _service: &str, _credential: &Credential) -> Result<(), DomainError> { + Ok(()) + } + fn delete(&self, _service: &str) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockClipboardObserver; + impl ClipboardObserver for MockClipboardObserver { + fn start(&self) -> Result<(), DomainError> { + Ok(()) + } + fn stop(&self) -> Result<(), DomainError> { + Ok(()) + } + fn get_urls(&self) -> Result, DomainError> { + Ok(vec![]) + } + } + + fn make_bus(plugin_loader: Arc) -> CommandBus { + CommandBus::new( + Arc::new(MockDownloadRepo), + Arc::new(MockDownloadEngine), + Arc::new(MockEventBus), + Arc::new(MockFileStorage), + Arc::new(MockHttpClient), + plugin_loader, + Arc::new(MockConfigStore), + Arc::new(MockCredentialStore), + Arc::new(MockClipboardObserver), + ) + } + + #[tokio::test] + async fn test_enable_plugin_returns_ok_when_plugin_loaded() { + let bus = make_bus(Arc::new(MockPluginLoader::with_plugin("my-plugin"))); + let cmd = EnablePluginCommand { + name: "my-plugin".to_string(), + }; + assert!(bus.handle_enable_plugin(cmd).await.is_ok()); + } + + #[tokio::test] + async fn test_enable_plugin_returns_not_found_when_plugin_absent() { + let bus = make_bus(Arc::new(MockPluginLoader::empty())); + let cmd = EnablePluginCommand { + name: "missing-plugin".to_string(), + }; + let result = bus.handle_enable_plugin(cmd).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!( + err, + crate::application::error::AppError::Domain( + crate::domain::error::DomainError::NotFound(_) + ) + ), + "expected NotFound, got {err:?}" + ); + } + + #[tokio::test] + async fn test_disable_plugin_returns_ok_when_plugin_loaded() { + let bus = make_bus(Arc::new(MockPluginLoader::with_plugin("my-plugin"))); + let cmd = DisablePluginCommand { + name: "my-plugin".to_string(), + }; + assert!(bus.handle_disable_plugin(cmd).await.is_ok()); + } + + #[tokio::test] + async fn test_disable_plugin_returns_not_found_when_plugin_absent() { + let bus = make_bus(Arc::new(MockPluginLoader::empty())); + let cmd = DisablePluginCommand { + name: "missing-plugin".to_string(), + }; + let result = bus.handle_disable_plugin(cmd).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!( + err, + crate::application::error::AppError::Domain( + crate::domain::error::DomainError::NotFound(_) + ) + ), + "expected NotFound, got {err:?}" + ); + } +} diff --git a/src-tauri/src/application/commands/uninstall_plugin.rs b/src-tauri/src/application/commands/uninstall_plugin.rs new file mode 100644 index 0000000..96ffac9 --- /dev/null +++ b/src-tauri/src/application/commands/uninstall_plugin.rs @@ -0,0 +1,276 @@ +//! Handler for `UninstallPluginCommand`. +//! +//! Unloads a plugin by name via the PluginLoader port +//! and emits a `PluginUnloaded` domain event on success. + +use crate::application::command_bus::CommandBus; +use crate::application::error::AppError; +use crate::domain::event::DomainEvent; + +impl CommandBus { + pub async fn handle_uninstall_plugin( + &self, + cmd: super::UninstallPluginCommand, + ) -> Result<(), AppError> { + self.plugin_loader().unload(&cmd.name)?; + + self.event_bus() + .publish(DomainEvent::PluginUnloaded { name: cmd.name }); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::Path; + use std::sync::{Arc, Mutex}; + + use crate::application::command_bus::CommandBus; + use crate::application::commands::UninstallPluginCommand; + use crate::domain::error::DomainError; + use crate::domain::event::DomainEvent; + use crate::domain::model::config::{AppConfig, ConfigPatch}; + use crate::domain::model::credential::Credential; + use crate::domain::model::download::{Download, DownloadId, DownloadState}; + use crate::domain::model::http::HttpResponse; + use crate::domain::model::meta::DownloadMeta; + use crate::domain::model::plugin::{PluginInfo, PluginManifest}; + use crate::domain::ports::driven::{ + ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, DownloadRepository, + EventBus, FileStorage, HttpClient, PluginLoader, + }; + + struct MockDownloadRepo; + impl DownloadRepository for MockDownloadRepo { + fn find_by_id(&self, _id: DownloadId) -> Result, DomainError> { + Ok(None) + } + fn save(&self, _d: &Download) -> Result<(), DomainError> { + Ok(()) + } + fn delete(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn find_by_state(&self, _s: DownloadState) -> Result, DomainError> { + Ok(vec![]) + } + } + + struct MockDownloadEngine; + impl DownloadEngine for MockDownloadEngine { + fn start(&self, _download: &Download) -> Result<(), DomainError> { + Ok(()) + } + fn pause(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn resume(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + fn cancel(&self, _id: DownloadId) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockEventBus { + events: Mutex>, + } + + impl MockEventBus { + fn new() -> Self { + Self { + events: Mutex::new(Vec::new()), + } + } + } + + impl EventBus for MockEventBus { + fn publish(&self, event: DomainEvent) { + self.events.lock().unwrap().push(event); + } + fn subscribe(&self, _handler: Box) {} + } + + struct MockFileStorage; + impl FileStorage for MockFileStorage { + fn create_file(&self, _path: &Path, _size: u64) -> Result<(), DomainError> { + Ok(()) + } + fn write_segment( + &self, + _path: &Path, + _offset: u64, + _data: &[u8], + ) -> Result<(), DomainError> { + Ok(()) + } + fn read_meta(&self, _path: &Path) -> Result, DomainError> { + Ok(None) + } + fn write_meta(&self, _path: &Path, _meta: &DownloadMeta) -> Result<(), DomainError> { + Ok(()) + } + fn delete_meta(&self, _path: &Path) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockHttpClient; + impl HttpClient for MockHttpClient { + fn head(&self, _url: &str) -> Result { + Ok(HttpResponse { + status_code: 200, + headers: HashMap::new(), + body: vec![], + }) + } + fn get_range(&self, _url: &str, start: u64, end: u64) -> Result, DomainError> { + Ok(vec![ + 0u8; + end.saturating_sub(start).saturating_add(1) as usize + ]) + } + fn supports_range(&self, _url: &str) -> Result { + Ok(true) + } + } + + struct MockPluginLoader { + unloaded: Mutex>, + should_fail: bool, + } + + impl MockPluginLoader { + fn new() -> Self { + Self { + unloaded: Mutex::new(Vec::new()), + should_fail: false, + } + } + + fn failing() -> Self { + Self { + unloaded: Mutex::new(Vec::new()), + should_fail: true, + } + } + } + + impl PluginLoader for MockPluginLoader { + fn load(&self, _manifest: &PluginManifest) -> Result<(), DomainError> { + Ok(()) + } + fn unload(&self, name: &str) -> Result<(), DomainError> { + if self.should_fail { + return Err(DomainError::PluginError("unload failed".to_string())); + } + self.unloaded.lock().unwrap().push(name.to_string()); + Ok(()) + } + fn resolve_url(&self, _url: &str) -> Result, DomainError> { + Ok(None) + } + fn list_loaded(&self) -> Result, DomainError> { + Ok(vec![]) + } + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockConfigStore; + impl ConfigStore for MockConfigStore { + fn get_config(&self) -> Result { + Ok(AppConfig::default()) + } + fn update_config(&self, _patch: ConfigPatch) -> Result { + Ok(AppConfig::default()) + } + } + + struct MockCredentialStore; + impl CredentialStore for MockCredentialStore { + fn get(&self, _service: &str) -> Result, DomainError> { + Ok(None) + } + fn store(&self, _service: &str, _credential: &Credential) -> Result<(), DomainError> { + Ok(()) + } + fn delete(&self, _service: &str) -> Result<(), DomainError> { + Ok(()) + } + } + + struct MockClipboardObserver; + impl ClipboardObserver for MockClipboardObserver { + fn start(&self) -> Result<(), DomainError> { + Ok(()) + } + fn stop(&self) -> Result<(), DomainError> { + Ok(()) + } + fn get_urls(&self) -> Result, DomainError> { + Ok(vec![]) + } + } + + fn make_bus(plugin_loader: Arc, event_bus: Arc) -> CommandBus { + CommandBus::new( + Arc::new(MockDownloadRepo), + Arc::new(MockDownloadEngine), + event_bus, + Arc::new(MockFileStorage), + Arc::new(MockHttpClient), + plugin_loader, + Arc::new(MockConfigStore), + Arc::new(MockCredentialStore), + Arc::new(MockClipboardObserver), + ) + } + + #[tokio::test] + async fn test_uninstall_plugin_unloads_and_emits_event() { + let loader = Arc::new(MockPluginLoader::new()); + let event_bus = Arc::new(MockEventBus::new()); + let bus = make_bus(loader.clone(), event_bus.clone()); + + let cmd = UninstallPluginCommand { + name: "my-plugin".to_string(), + }; + + let result = bus.handle_uninstall_plugin(cmd).await; + assert!(result.is_ok()); + + let unloaded = loader.unloaded.lock().unwrap(); + assert_eq!(unloaded.len(), 1); + assert_eq!(unloaded[0], "my-plugin"); + + let events = event_bus.events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0], + DomainEvent::PluginUnloaded { + name: "my-plugin".to_string(), + } + ); + } + + #[tokio::test] + async fn test_uninstall_plugin_loader_error_returns_err() { + let loader = Arc::new(MockPluginLoader::failing()); + let event_bus = Arc::new(MockEventBus::new()); + let bus = make_bus(loader, event_bus.clone()); + + let cmd = UninstallPluginCommand { + name: "bad-plugin".to_string(), + }; + + let result = bus.handle_uninstall_plugin(cmd).await; + assert!(result.is_err()); + + let events = event_bus.events.lock().unwrap(); + assert!(events.is_empty(), "no event should be emitted on failure"); + } +} diff --git a/src-tauri/src/application/queries/list_plugins.rs b/src-tauri/src/application/queries/list_plugins.rs new file mode 100644 index 0000000..3f22849 --- /dev/null +++ b/src-tauri/src/application/queries/list_plugins.rs @@ -0,0 +1,167 @@ +//! Handler for `ListPluginsQuery`. +//! +//! Returns all loaded plugins as `PluginViewDto` read models. + +use crate::application::error::AppError; +use crate::application::query_bus::QueryBus; +use crate::application::read_models::plugin_view::PluginViewDto; + +impl QueryBus { + pub async fn handle_list_plugins( + &self, + _query: super::ListPluginsQuery, + ) -> Result, AppError> { + let plugins = self.plugin_read_repo().list_loaded()?; + Ok(plugins.into_iter().map(PluginViewDto::from).collect()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::application::queries::ListPluginsQuery; + use crate::application::query_bus::QueryBus; + use crate::domain::error::DomainError; + use crate::domain::model::download::DownloadId; + use crate::domain::model::plugin::{PluginCategory, PluginInfo}; + use crate::domain::model::views::{ + DownloadDetailView, DownloadFilter, DownloadView, HistoryEntry, SortOrder, StateCountMap, + StatsView, + }; + use crate::domain::ports::driven::{ + DownloadReadRepository, HistoryRepository, PluginReadRepository, StatsRepository, + }; + use std::collections::HashMap; + + struct MockDownloadReadRepo; + impl DownloadReadRepository for MockDownloadReadRepo { + fn find_downloads( + &self, + _filter: Option, + _sort: Option, + _limit: Option, + _offset: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + fn find_download_detail( + &self, + _id: DownloadId, + ) -> Result, DomainError> { + Ok(None) + } + fn count_by_state(&self) -> Result { + Ok(HashMap::new()) + } + } + + struct MockHistoryRepo; + impl HistoryRepository for MockHistoryRepo { + fn record(&self, _entry: &HistoryEntry) -> Result<(), DomainError> { + Ok(()) + } + fn find_recent(&self, _limit: usize) -> Result, DomainError> { + Ok(vec![]) + } + fn find_by_download(&self, _id: DownloadId) -> Result, DomainError> { + Ok(vec![]) + } + fn delete_older_than(&self, _before: u64) -> Result { + Ok(0) + } + } + + struct MockStatsRepo; + impl StatsRepository for MockStatsRepo { + fn record_completed(&self, _bytes: u64, _avg_speed: u64) -> Result<(), DomainError> { + Ok(()) + } + fn get_stats(&self) -> Result { + Ok(StatsView { + total_downloaded_bytes: 0, + total_files: 0, + avg_speed: 0, + peak_speed: 0, + success_rate: 0.0, + daily_volumes: vec![], + top_hosts: vec![], + }) + } + } + + struct MockPluginReadRepo { + plugins: Vec, + } + + impl MockPluginReadRepo { + fn empty() -> Self { + Self { plugins: vec![] } + } + + fn with_plugins(plugins: Vec) -> Self { + Self { plugins } + } + } + + impl PluginReadRepository for MockPluginReadRepo { + fn list_loaded(&self) -> Result, DomainError> { + Ok(self.plugins.clone()) + } + } + + fn make_query_bus(plugin_repo: MockPluginReadRepo) -> QueryBus { + QueryBus::new( + Arc::new(MockDownloadReadRepo), + Arc::new(MockHistoryRepo), + Arc::new(MockStatsRepo), + Arc::new(plugin_repo), + ) + } + + fn make_plugin(name: &str, version: &str) -> PluginInfo { + PluginInfo::new( + name.to_string(), + version.to_string(), + "A plugin".to_string(), + "author".to_string(), + PluginCategory::Hoster, + ) + } + + #[tokio::test] + async fn test_list_plugins_empty_returns_empty_vec() { + let bus = make_query_bus(MockPluginReadRepo::empty()); + let result = bus.handle_list_plugins(ListPluginsQuery).await.unwrap(); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn test_list_plugins_maps_plugin_info_to_dto() { + let plugins = vec![ + make_plugin("plugin-a", "1.0.0"), + make_plugin("plugin-b", "2.0.0"), + ]; + let bus = make_query_bus(MockPluginReadRepo::with_plugins(plugins)); + let result = bus.handle_list_plugins(ListPluginsQuery).await.unwrap(); + assert_eq!(result.len(), 2); + let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"plugin-a")); + assert!(names.contains(&"plugin-b")); + } + + #[tokio::test] + async fn test_list_plugins_dto_fields_match_plugin_info() { + let bus = make_query_bus(MockPluginReadRepo::with_plugins(vec![make_plugin( + "my-plugin", + "3.1.0", + )])); + let result = bus.handle_list_plugins(ListPluginsQuery).await.unwrap(); + assert_eq!(result.len(), 1); + let dto = &result[0]; + assert_eq!(dto.name, "my-plugin"); + assert_eq!(dto.version, "3.1.0"); + assert_eq!(dto.category, "Hoster"); + assert!(dto.enabled); + } +} diff --git a/src-tauri/src/application/queries/mod.rs b/src-tauri/src/application/queries/mod.rs index 420f059..c319695 100644 --- a/src-tauri/src/application/queries/mod.rs +++ b/src-tauri/src/application/queries/mod.rs @@ -6,6 +6,7 @@ mod count_by_state; mod get_download_detail; mod get_downloads; +mod list_plugins; use crate::domain::model::download::DownloadId; use crate::domain::model::views::{DownloadFilter, SortOrder}; @@ -28,7 +29,7 @@ impl Query for GetDownloadDetailQuery {} // Handler: task 23 (history view) #[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] +#[expect(dead_code)] pub struct GetHistoryQuery { pub limit: usize, pub offset: Option, @@ -37,13 +38,11 @@ impl Query for GetHistoryQuery {} // Handler: task 23 (statistics view) #[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] +#[expect(dead_code)] pub struct GetStatsQuery; impl Query for GetStatsQuery {} -// Handler: task 13 (plugin infrastructure) #[derive(Debug)] -#[cfg_attr(not(test), allow(dead_code))] pub struct ListPluginsQuery; impl Query for ListPluginsQuery {} diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs index 3d90f90..2cf02cb 100644 --- a/src-tauri/src/domain/error.rs +++ b/src-tauri/src/domain/error.rs @@ -21,6 +21,7 @@ pub enum DomainError { StorageError(String), NetworkError(String), ValidationError(String), + PluginError(String), } impl std::fmt::Display for DomainError { @@ -56,6 +57,9 @@ impl std::fmt::Display for DomainError { DomainError::ValidationError(msg) => { write!(f, "Validation error: {msg}") } + DomainError::PluginError(msg) => { + write!(f, "Plugin error: {msg}") + } } } } @@ -129,6 +133,12 @@ mod tests { assert_send_sync::(); } + #[test] + fn test_display_plugin_error() { + let err = DomainError::PluginError("wasm load failed".to_string()); + assert_eq!(err.to_string(), "Plugin error: wasm load failed"); + } + #[test] fn test_domain_error_implements_error_trait() { let err: Box = Box::new(DomainError::NotFound("x".to_string())); diff --git a/src-tauri/src/domain/model/plugin.rs b/src-tauri/src/domain/model/plugin.rs index 6e20760..fe0179c 100644 --- a/src-tauri/src/domain/model/plugin.rs +++ b/src-tauri/src/domain/model/plugin.rs @@ -5,8 +5,10 @@ pub enum PluginCategory { Crawler, Hoster, Debrid, + Container, Captcha, Extractor, + Notifier, Utility, } @@ -16,8 +18,10 @@ impl fmt::Display for PluginCategory { PluginCategory::Crawler => "Crawler", PluginCategory::Hoster => "Hoster", PluginCategory::Debrid => "Debrid", + PluginCategory::Container => "Container", PluginCategory::Captcha => "Captcha", PluginCategory::Extractor => "Extractor", + PluginCategory::Notifier => "Notifier", PluginCategory::Utility => "Utility", }; write!(f, "{name}") @@ -190,8 +194,10 @@ mod tests { assert_eq!(PluginCategory::Crawler.to_string(), "Crawler"); assert_eq!(PluginCategory::Hoster.to_string(), "Hoster"); assert_eq!(PluginCategory::Debrid.to_string(), "Debrid"); + assert_eq!(PluginCategory::Container.to_string(), "Container"); assert_eq!(PluginCategory::Captcha.to_string(), "Captcha"); assert_eq!(PluginCategory::Extractor.to_string(), "Extractor"); + assert_eq!(PluginCategory::Notifier.to_string(), "Notifier"); assert_eq!(PluginCategory::Utility.to_string(), "Utility"); } } diff --git a/src-tauri/src/domain/ports/driven/plugin_loader.rs b/src-tauri/src/domain/ports/driven/plugin_loader.rs index 4f53f6f..ba66612 100644 --- a/src-tauri/src/domain/ports/driven/plugin_loader.rs +++ b/src-tauri/src/domain/ports/driven/plugin_loader.rs @@ -26,4 +26,7 @@ pub trait PluginLoader: Send + Sync { /// List all currently loaded plugins. fn list_loaded(&self) -> Result, DomainError>; + + /// Enable or disable a loaded plugin by name. + fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), DomainError>; } diff --git a/src-tauri/src/domain/ports/driven/tests.rs b/src-tauri/src/domain/ports/driven/tests.rs index 1840fb7..c166ede 100644 --- a/src-tauri/src/domain/ports/driven/tests.rs +++ b/src-tauri/src/domain/ports/driven/tests.rs @@ -437,6 +437,10 @@ impl PluginLoader for FakePluginLoader { fn list_loaded(&self) -> Result, DomainError> { Ok(self.plugins.lock().unwrap().values().cloned().collect()) } + + fn set_enabled(&self, _name: &str, _enabled: bool) -> Result<(), DomainError> { + Ok(()) + } } // ── FakeDownloadEngine ────────────────────────────────────────── diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d41f66..90e556f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,10 +24,12 @@ pub use application::read_models::{ }; pub use application::services::QueueManager; +pub use adapters::driven::plugin::{ExtismPluginLoader, PluginRegistry, PluginWatcher}; pub use adapters::driving::tauri_ipc::{ self, AppState, download_cancel, download_count_by_state, download_detail, download_list, download_pause, download_pause_all, download_remove, download_resume, download_resume_all, - download_retry, download_set_priority, download_start, + download_retry, download_set_priority, download_start, plugin_disable, plugin_enable, + plugin_install, plugin_list, plugin_uninstall, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -52,6 +54,11 @@ pub fn run() { download_list, download_detail, download_count_by_state, + plugin_install, + plugin_uninstall, + plugin_enable, + plugin_disable, + plugin_list, ]) .run(tauri::generate_context!()) // Tauri's run() has no meaningful recovery path — panic is intentional here