diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3bfe2b6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,506 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cc" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2678b2e3449475e95b0aa6f9b506a28e61b3dc8996592b983695e8ebb58a8b41" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "embed-manifest" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cd446c890d6bed1d8b53acef5f240069ebef91d6fae7c5f52efe61fe8b5eae" + +[[package]] +name = "embed-resource" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6985554d0688b687c5cb73898a34fbe3ad6c24c58c238a4d91d5e840670ee9d" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.8.12", + "vswhom", + "winreg", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "libm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc7aa29613bd6a620df431842069224d8bc9011086b1db4c0e0cd47fa03ec9a" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "muldiv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0419348c027fa7be448d2ae7ea0e4e04c2334c31dc4e74ab29f00a2a7ca69204" + +[[package]] +name = "native-windows-derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76134ae81020d89d154f619fd2495a2cecad204276b1dc21174b55e4d0975edd" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "native-windows-gui" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e049bccae62e28782c5eff82e0c8a4dace50b9783fe03b95447fef3172bb89" +dependencies = [ + "bitflags", + "lazy_static", + "muldiv", + "stretch", + "winapi", + "winapi-build", +] + +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "semver" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + +[[package]] +name = "serde_json" +version = "1.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12dc5c46daa8e9fdf4f5e71b6cf9a53f2487da0e86e55808e2d35539666497dd" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + +[[package]] +name = "stretch" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0dc6d20ce137f302edf90f9cd3d278866fd7fb139efca6f246161222ad6d87" +dependencies = [ + "lazy_static", + "libm", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" + +[[package]] +name = "winnow" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dffa400e67ed5a4dd237983829e66475f0a4a26938c4b04c21baede6262215b8" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wsl-usb-manager" +version = "1.0.0" +dependencies = [ + "embed-manifest", + "embed-resource", + "native-windows-derive", + "native-windows-gui", + "serde", + "serde_json", + "windows-sys 0.52.0", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7aa2ef9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "wsl-usb-manager" +version = "1.0.0" +authors = ["Niccolò Betto "] +edition = "2021" + +[dependencies] +windows-sys = { version = "0.52.0", features = [ + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Usb", + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Security", + "Win32_System_Diagnostics_Debug", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_UI_Controls", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } +native-windows-derive = "1.0.5" +native-windows-gui = { version = "=1.0.12", default-features = false, features = [ + "cursor", + "embed-resource", + "flexbox", + "frame", + "high-dpi", + "list-view", + "menu", + "message-window", + "notice", + "rich-textbox", + "tabs", + "textbox", + "tray-notification", +] } +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.115" + +[build-dependencies] +embed-resource = "2.4" +embed-manifest = "1.4" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a9d02d7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,26 @@ +The MIT License (MIT) +===================== + +Copyright © `2024` `Niccolò Betto` + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbb7d04 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +
+ +
+ + +# WSL USB Manager + +A fast and light GUI for [`usbipd-win`](https://github.com/dorssel/usbipd-win). +Manage connecting USB devices to WSL with an intuitive UI. + +
+ +
+ + +## Features + +- Bind and unbind USB devices +- Attach and detach USB devices to WSL +- Manage persisted devices + + +## Installation + +> [!IMPORTANT] +> `usbipd-win` version **4.0.0** or newer is strongly recommended for this software to work properly. +> Older versions have not been tested and may not work. + +This software requires Microsoft Windows 10 (64-bit only), version 1809 or newer. + +Download the latest release from the [releases page](https://github.com/lynxnb/wsl-usb-manager/releases). +Run the executable to start the application. + + +## Roadmap + +- Auto-attach profiles + - UI dialog for creating and editing profiles + - Background service for auto-attaching devices +- Provide an installer and add to `winget` + - Option to setup logon startup to tray icon + + +## Support + +If you encounter any issues, please open a [GitHub issue](https://github.com/lynxnb/wsl-usb-manager/issues). + + +## Screenshots + +
+ + +
diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..1c0cbf6 --- /dev/null +++ b/build.rs @@ -0,0 +1,12 @@ +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + embed_manifest::embed_manifest(embed_manifest::new_manifest( + "resources/wsl-usb-manager.exe.manifest", + )) + .expect("unable to embed manifest file"); + println!("cargo:rerun-if-changed=resources/wsl-usb-manager.exe.manifest"); + + embed_resource::compile("resources/resources.rc", embed_resource::NONE); + println!("cargo:rerun-if-changed=resources/resources.rc"); +} diff --git a/img/connected_devices.png b/img/connected_devices.png new file mode 100644 index 0000000..cf8bb56 Binary files /dev/null and b/img/connected_devices.png differ diff --git a/img/device_context_menu.png b/img/device_context_menu.png new file mode 100644 index 0000000..0c3c1b7 Binary files /dev/null and b/img/device_context_menu.png differ diff --git a/img/persisted_devices.png b/img/persisted_devices.png new file mode 100644 index 0000000..882b1f8 Binary files /dev/null and b/img/persisted_devices.png differ diff --git a/resources/resources.rc b/resources/resources.rc new file mode 100644 index 0000000..b2fd01c --- /dev/null +++ b/resources/resources.rc @@ -0,0 +1 @@ +MAINICON ICON "wsl-usb-manager.ico" diff --git a/resources/wsl-usb-manager.exe.manifest b/resources/wsl-usb-manager.exe.manifest new file mode 100644 index 0000000..a79245c --- /dev/null +++ b/resources/wsl-usb-manager.exe.manifest @@ -0,0 +1,38 @@ + + + + + + + + + + + + A GUI interface to manage USB device passthrough to WSL. + + + + + + + + + + + + + + diff --git a/resources/wsl-usb-manager.ico b/resources/wsl-usb-manager.ico new file mode 100644 index 0000000..bcaee0e Binary files /dev/null and b/resources/wsl-usb-manager.ico differ diff --git a/resources/wsl-usb-manager.png b/resources/wsl-usb-manager.png new file mode 100644 index 0000000..df1baf0 Binary files /dev/null and b/resources/wsl-usb-manager.png differ diff --git a/src/gui/connected_tab/device_info.rs b/src/gui/connected_tab/device_info.rs new file mode 100644 index 0000000..03f19a5 --- /dev/null +++ b/src/gui/connected_tab/device_info.rs @@ -0,0 +1,110 @@ +use native_windows_derive::NwgPartial; +use native_windows_gui as nwg; + +use nwg::stretch::{ + geometry::{Rect, Size}, + style::{Dimension as D, Dimension::Points as Pt, FlexDirection}, +}; + +use crate::usbipd::{UsbDevice, UsbipState}; + +/// The connected device info tab. +/// It displays detailed information about a connected device. +/// +/// Call the `update` method to update the information displayed. +/// +/// # Remarks +/// +/// The `ES_MULTILINE` flag used to make the `Description` label multi-line +/// sends a `WM_CLOSE` message when the `ESC` key is pressed while the control +/// has focus. It is suggested to inhibit the `OnWindowClose` event on the +/// parent window (e.g. the parent `nwg::Frame`) to prevent it from closing. +#[derive(Default, NwgPartial)] +pub struct DeviceInfo { + #[nwg_resource(family: "Segoe UI Semibold", size: 16, weight: 400)] + font_bold: nwg::Font, + + #[nwg_resource(family: "Segoe UI Semibold", size: 20, weight: 400)] + font_bold_big: nwg::Font, + + #[nwg_layout(flex_direction: FlexDirection::Column, auto_spacing: None)] + device_info_layout: nwg::FlexboxLayout, + + #[nwg_control(text: "Device Info", font: Some(&data.font_bold_big))] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + device_info: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(1.0) }, + margin: Rect { start: Pt(0.0), end: Pt(0.0), top: Pt(5.0), bottom: Pt(0.0)} + )] + separator: nwg::Frame, + + #[nwg_control(text: "Bus ID:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0)}, + margin: Rect { start: Pt(0.0), end: Pt(0.0), top: Pt(6.0), bottom: Pt(0.0)} + )] + bus_id: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + bus_id_content: nwg::RichLabel, + + #[nwg_control(text: "VID:PID:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + vid_pid: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + vid_pid_content: nwg::RichLabel, + + #[nwg_control(text: "Serial number:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + serial: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + serial_content: nwg::RichLabel, + + #[nwg_control(text: "State:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + state: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + state_content: nwg::RichLabel, + + #[nwg_control(text: "Description:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + description: nwg::Label, + + #[nwg_control(flags: "VISIBLE|MULTI_LINE")] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: D::Auto }, flex_grow: 1.0)] + description_content: nwg::RichLabel, +} + +impl DeviceInfo { + pub fn update(&self, device: Option<&UsbDevice>) { + if let Some(device) = device { + self.bus_id_content + .set_text(device.bus_id.as_deref().unwrap_or("-")); + self.vid_pid_content + .set_text(device.vid_pid().as_deref().unwrap_or("-")); + self.serial_content + .set_text(device.serial().as_deref().unwrap_or("-")); + self.state_content.set_text(&device.state().to_string()); + self.description_content.set_text( + device + .description + .as_deref() + .unwrap_or("No description available"), + ); + } else { + self.bus_id_content.set_text("-"); + self.vid_pid_content.set_text("-"); + self.serial_content.set_text("-"); + self.state_content.set_text(&UsbipState::None.to_string()); + self.description_content.set_text("No device selected"); + } + } +} diff --git a/src/gui/connected_tab/mod.rs b/src/gui/connected_tab/mod.rs new file mode 100644 index 0000000..ef8632c --- /dev/null +++ b/src/gui/connected_tab/mod.rs @@ -0,0 +1,365 @@ +mod device_info; + +use std::cell::{Cell, RefCell}; + +use native_windows_derive::NwgPartial; +use native_windows_gui as nwg; +use nwg::stretch::{ + geometry::{Rect, Size}, + style::{Dimension as D, FlexDirection}, +}; +use windows_sys::Win32::UI::Controls::LVSCW_AUTOSIZE_USEHEADER; +use windows_sys::Win32::UI::Shell::SIID_SHIELD; + +use self::device_info::DeviceInfo; +use crate::gui::nwg_ext::{BitmapEx, MenuItemEx}; +use crate::gui::usbipd_gui::GuiTab; +use crate::usbipd::{self, UsbDevice}; + +const PADDING_LEFT: Rect = Rect { + start: D::Points(8.0), + end: D::Points(0.0), + top: D::Points(0.0), + bottom: D::Points(0.0), +}; + +const DETAILS_PANEL_WIDTH: f32 = 260.0; +const DETAILS_PANEL_PADDING: u32 = 4; + +#[derive(Default, NwgPartial)] +pub struct ConnectedTab { + window: Cell, + shield_bitmap: Cell, + + connected_devices: RefCell>, + + #[nwg_layout(flex_direction: FlexDirection::Row)] + connected_tab_layout: nwg::FlexboxLayout, + + #[nwg_control(list_style: nwg::ListViewStyle::Detailed, focus: true, + flags: "VISIBLE|SINGLE_SELECTION|TAB_STOP", + ex_flags: nwg::ListViewExFlags::FULL_ROW_SELECT, + )] + #[nwg_events(OnListViewRightClick: [ConnectedTab::show_menu], + OnListViewItemChanged: [ConnectedTab::update_device_details] + )] + #[nwg_layout_item(layout: connected_tab_layout, flex_grow: 1.0)] + list_view: nwg::ListView, + + // Device info + #[nwg_control] + #[nwg_layout_item(layout: connected_tab_layout, margin: PADDING_LEFT, + size: Size { width: D::Points(DETAILS_PANEL_WIDTH), height: D::Auto }, + )] + details_frame: nwg::Frame, + + #[nwg_layout(parent: details_frame, flex_direction: FlexDirection::Column, + auto_spacing: Some(DETAILS_PANEL_PADDING))] + details_layout: nwg::FlexboxLayout, + + #[nwg_control(parent: details_frame, flags: "VISIBLE")] + #[nwg_layout_item(layout: details_layout, flex_grow: 1.0)] + // Multi-line RichLabels send a WM_CLOSE message when the ESC key is pressed + #[nwg_events(OnWindowClose: [ConnectedTab::inhibit_close(EVT_DATA)])] + device_info_frame: nwg::Frame, + + #[nwg_partial(parent: device_info_frame)] + device_info: DeviceInfo, + + // Buttons + #[nwg_control(parent: details_frame, flags: "VISIBLE")] + #[nwg_layout_item(layout: details_layout, size: Size { width: D::Auto, height: D::Points(25.0) })] + buttons_frame: nwg::Frame, + + #[nwg_layout(parent: buttons_frame, flex_direction: FlexDirection::RowReverse, auto_spacing: None)] + buttons_layout: nwg::FlexboxLayout, + + #[nwg_control(parent: buttons_frame, text: "Attach")] + #[nwg_layout_item(layout: buttons_layout, flex_grow: 0.33)] + #[nwg_events(OnButtonClick: [ConnectedTab::attach_detach_device])] + pub attach_detach_button: nwg::Button, + + #[nwg_control(parent: buttons_frame, text: "Bind")] + #[nwg_layout_item(layout: buttons_layout, flex_grow: 0.33)] + #[nwg_events(OnButtonClick: [ConnectedTab::bind_unbind_device])] + pub bind_unbind_button: nwg::Button, + + // Device context menu + #[nwg_control(text: "Device", popup: true)] + menu: nwg::Menu, + + #[nwg_control(parent: menu, text: "Attach")] + #[nwg_events(OnMenuItemSelected: [ConnectedTab::attach_device])] + menu_attach: nwg::MenuItem, + + #[nwg_control(parent: menu, text: "Detach")] + #[nwg_events(OnMenuItemSelected: [ConnectedTab::detach_device])] + menu_detach: nwg::MenuItem, + + #[nwg_control(parent: menu)] + menu_sep: nwg::MenuSeparator, + + #[nwg_control(parent: menu, text: "Bind")] + #[nwg_events(OnMenuItemSelected: [ConnectedTab::bind_device])] + menu_bind: nwg::MenuItem, + + #[nwg_control(parent: menu, text: "Bind (force)")] + #[nwg_events(OnMenuItemSelected: [ConnectedTab::bind_device_force])] + menu_bind_force: nwg::MenuItem, + + #[nwg_control(parent: menu, text: "Unbind")] + #[nwg_events(OnMenuItemSelected: [ConnectedTab::unbind_device])] + menu_unbind: nwg::MenuItem, +} + +impl ConnectedTab { + fn init_device_list(&self) { + let dv = &self.list_view; + dv.clear(); + dv.insert_column("Bus ID"); + dv.insert_column("Device"); + dv.insert_column("State"); + dv.set_headers_enabled(true); + + dv.set_column_width(0, LVSCW_AUTOSIZE_USEHEADER as isize); + dv.set_column_width(1, 440); + dv.set_column_width(2, LVSCW_AUTOSIZE_USEHEADER as isize); + } + + /// Clears the device list and reloads it with the currently connected devices. + fn refresh_device_list(&self) { + self.update_devices(); + + self.list_view.clear(); + for device in self.connected_devices.borrow().iter() { + self.list_view.insert_items_row( + None, + &[ + device.bus_id.as_deref().unwrap_or("-"), + device.description.as_deref().unwrap_or("Unknown device"), + &device.state().to_string(), + ], + ); + } + } + + /// Updates the device details panel with the currently selected device. + fn update_device_details(&self) { + let devices = self.connected_devices.borrow(); + let device = self.list_view.selected_item().and_then(|i| devices.get(i)); + + self.device_info.update(device); + + // Update buttons + if let Some(device) = device { + if device.is_bound() { + self.bind_unbind_button.set_text("Unbind"); + + // Attaching a bound device doesn't require admin privileges, hide the UAC shield icon + self.attach_detach_button.set_bitmap(None); + } else { + self.bind_unbind_button.set_text("Bind"); + + // Attaching an unbound device requires admin privileges, show the UAC shield icon + let shield_bitmap = self.shield_bitmap.take(); + self.attach_detach_button.set_bitmap(Some(&shield_bitmap)); + self.shield_bitmap.set(shield_bitmap); + } + + if device.is_attached() { + self.attach_detach_button.set_text("Detach"); + } else { + self.attach_detach_button.set_text("Attach"); + } + + self.bind_unbind_button.set_enabled(true); + self.attach_detach_button.set_enabled(true); + } else { + self.attach_detach_button.set_text("Attach"); + self.bind_unbind_button.set_text("Bind"); + self.attach_detach_button.set_bitmap(None); + + self.bind_unbind_button.set_enabled(false); + self.attach_detach_button.set_enabled(false); + } + } + + fn show_menu(&self) { + let selected_index = match self.list_view.selected_item() { + Some(index) => index, + None => return, + }; + let devices = self.connected_devices.borrow(); + let device = devices.get(selected_index).unwrap(); + + if device.is_attached() { + self.menu_detach.set_enabled(true); + self.menu_attach.set_enabled(false); + } else { + self.menu_detach.set_enabled(false); + self.menu_attach.set_enabled(true); + } + + if device.is_bound() { + self.menu_bind.set_enabled(false); + self.menu_bind_force.set_enabled(false); + self.menu_unbind.set_enabled(true); + + // Attaching a bound device doesn't require admin privileges, hide the UAC shield icon + self.menu_attach.set_bitmap(None); + } else { + self.menu_bind.set_enabled(true); + self.menu_bind_force.set_enabled(true); + self.menu_unbind.set_enabled(false); + + // Attaching an unbound device requires admin privileges, show the UAC shield icon + let shield_bitmap = self.shield_bitmap.take(); + self.menu_attach.set_bitmap(Some(&shield_bitmap)); + self.shield_bitmap.set(shield_bitmap); + } + + let (x, y) = nwg::GlobalCursor::position(); + // Disable menu animations because they cause incorrect rendering of the bitmaps + self.menu + .popup_with_flags(x, y, nwg::PopupMenuFlags::ANIMATE_NONE); + } + + fn bind_device(&self) { + self.run_command(|device| { + device.bind(false)?; + device.wait(|d| d.is_some_and(|d| d.is_bound())) + }); + } + + fn bind_device_force(&self) { + self.run_command(|device| { + device.bind(true)?; + device.wait(|d| d.is_some_and(|d| d.is_bound() && d.is_forced)) + }); + } + + fn unbind_device(&self) { + self.run_command(|device| { + device.unbind()?; + device.wait(|d| d.is_some_and(|d| !d.is_bound())) + }); + } + + fn attach_device(&self) { + self.run_command(|device| { + device.attach()?; + device.wait(|d| d.is_some_and(|d| d.is_attached())) + }); + } + + fn detach_device(&self) { + self.run_command(|device| { + device.detach()?; + device.wait(|d| d.is_some_and(|d| d.is_attached())) + }); + } + + fn attach_detach_device(&self) { + self.run_command(|device| { + if !device.is_attached() { + device.attach()?; + device.wait(|d| d.is_some_and(|d| d.is_attached())) + } else { + device.detach()?; + device.wait(|d| d.is_some_and(|d| !d.is_attached())) + } + }); + } + + fn bind_unbind_device(&self) { + self.run_command(|device| { + if !device.is_bound() { + device.bind(false)?; + device.wait(|d| d.is_some_and(|d| d.is_bound())) + } else { + device.unbind()?; + device.wait(|d| d.is_some_and(|d| !d.is_bound())) + } + }); + } + + /// Runs a `command` function on the currently selected device. + /// No-op if no device is selected. + /// + /// If the command completes successfully, the view is reloaded. + /// + /// If an error occurs, an error dialog is shown. + fn run_command(&self, command: fn(&UsbDevice) -> Result<(), String>) { + let window = self.window.get(); + + let wait_cursor = nwg::Cursor::from_system(nwg::OemCursor::Wait); + let cursor_event = + nwg::full_bind_event_handler(&window, move |event, _event_data, _handle| match event { + nwg::Event::OnMousePress(_) | nwg::Event::OnMouseMove => { + nwg::GlobalCursor::set(&wait_cursor) + } + _ => {} + }); + + let result = { + let selected_index = match self.list_view.selected_item() { + Some(index) => index, + None => return, + }; + // Borrow devices in a scoped block so that the ref is released as soon as possible + let devices = self.connected_devices.borrow(); + let device = match devices.get(selected_index) { + Some(device) => device, + None => return, + }; + + command(device) + }; + + if let Err(err) = result { + nwg::modal_error_message(window, "WSL USB Manager", &err); + } + + self.window.set(window); + self.refresh(); + nwg::unbind_event_handler(&cursor_event); + } + + fn update_devices(&self) { + *self.connected_devices.borrow_mut() = usbipd::list_devices() + .into_iter() + .filter(|d| d.is_connected()) + .collect(); + } + + /// Inhibits the window close event. + fn inhibit_close(data: &nwg::EventData) { + if let nwg::EventData::OnWindowClose(close_data) = data { + close_data.close(false); + } + } +} + +impl GuiTab for ConnectedTab { + fn init(&self, window: &nwg::Window) { + self.window.replace(window.handle); + + let shield_bitmap = nwg::Bitmap::from_system_icon(SIID_SHIELD); + + // Set the UAC shield icon for menu items and buttons that always require admin privileges + self.menu_bind.set_bitmap(Some(&shield_bitmap)); + self.menu_bind_force.set_bitmap(Some(&shield_bitmap)); + self.menu_unbind.set_bitmap(Some(&shield_bitmap)); + self.bind_unbind_button.set_bitmap(Some(&shield_bitmap)); + + self.shield_bitmap.set(shield_bitmap); + + self.init_device_list(); + self.refresh(); + } + + fn refresh(&self) { + self.refresh_device_list(); + self.update_device_details(); + } +} diff --git a/src/gui/device_info.rs b/src/gui/device_info.rs new file mode 100644 index 0000000..a3b0dde --- /dev/null +++ b/src/gui/device_info.rs @@ -0,0 +1,114 @@ +use native_windows_derive::NwgPartial; +use native_windows_gui as nwg; + +use nwg::stretch::{ + geometry::{Rect, Size}, + style::{Dimension as D, Dimension::Points as Pt, FlexDirection}, +}; + +use crate::usbipd::{UsbDevice, UsbipState}; + +/// The device info tab. It displays detailed information about a device. +/// +/// Call the `update` method to update the information displayed. +/// +/// # Remarks +/// +/// The `ES_MULTILINE` flag used to make the `Description` label multi-line +/// sends a `WM_CLOSE` message when the `ESC` key is pressed while the control +/// has focus. It is suggested to inhibit the `OnWindowClose` event on the +/// parent window (e.g. the parent `nwg::Frame`) to prevent it from closing. +#[derive(Default, NwgPartial)] +pub struct DeviceInfo { + #[nwg_resource(family: "Segoe UI Semibold", size: 16, weight: 400)] + font_bold: nwg::Font, + + #[nwg_resource(family: "Segoe UI Semibold", size: 20, weight: 400)] + font_bold_big: nwg::Font, + + #[nwg_layout(flex_direction: FlexDirection::Column, auto_spacing: None)] + device_info_layout: nwg::FlexboxLayout, + + #[nwg_control(text: "Device Info", font: Some(&data.font_bold_big))] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + device_info: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(1.0) }, + margin: Rect { start: Pt(0.0), end: Pt(0.0), top: Pt(5.0), bottom: Pt(0.0)} + )] + separator: nwg::Frame, + + #[nwg_control(text: "Bus ID:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0)}, + margin: Rect { start: Pt(0.0), end: Pt(0.0), top: Pt(6.0), bottom: Pt(0.0)} + )] + bus_id: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + bus_id_content: nwg::RichLabel, + + #[nwg_control(text: "VID:PID:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + vid_pid: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + vid_pid_content: nwg::RichLabel, + + #[nwg_control(text: "Serial number:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + serial: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + serial_content: nwg::RichLabel, + + #[nwg_control(text: "State:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + state: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + state_content: nwg::RichLabel, + + #[nwg_control(text: "Description:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + description: nwg::Label, + + #[nwg_control(flags: "VISIBLE|MULTI_LINE")] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: D::Auto }, flex_grow: 1.0)] + #[nwg_events(OnWindowClose: [DeviceInfo::debug])] + description_content: nwg::RichLabel, +} + +impl DeviceInfo { + pub fn update(&self, device: Option<&UsbDevice>) { + if let Some(device) = device { + self.bus_id_content + .set_text(device.bus_id.as_deref().unwrap_or("-")); + self.vid_pid_content + .set_text(device.vid_pid().as_deref().unwrap_or("-")); + self.serial_content + .set_text(device.serial().as_deref().unwrap_or("-")); + self.state_content.set_text(&device.state().to_string()); + self.description_content.set_text( + device + .description + .as_deref() + .unwrap_or("No description available"), + ); + } else { + self.bus_id_content.set_text("-"); + self.vid_pid_content.set_text("-"); + self.serial_content.set_text("-"); + self.state_content.set_text(&UsbipState::None.to_string()); + self.description_content.set_text("No device selected"); + } + } + + fn debug(&self) { + println!("Debug stop"); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..633817f --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,68 @@ +mod connected_tab; +mod nwg_ext; +mod persisted_tab; +mod usbipd_gui; + +use native_windows_gui as nwg; +use nwg::NativeUi; +use usbipd_gui::UsbipdGui; + +/// Starts the GUI and runs the event loop. +/// +/// This function will not return until the app is closed. +pub fn start() -> Result<(), nwg::NwgError> { + nwg::init()?; + + let mut font = nwg::Font::default(); + nwg::Font::builder() + .family("Segoe UI") + .size(16) + .weight(400) + .build(&mut font)?; + + nwg::Font::set_global_default(Some(font)); + + let _gui = UsbipdGui::build_ui(Default::default())?; + + // Run the event loop + nwg::dispatch_thread_events(); + Ok(()) +} + +/// Shows a warning message telling the user that another instance is already running. +/// +/// This function is called when the app fails to obtain the instance lock because one is already held. +pub fn show_multiple_instance_message() { + nwg::message(&nwg::MessageParams { + title: "WSL USB Manager", + content: concat!( + "Another instance of the app is already running.\n", + "Please check the system tray." + ), + buttons: nwg::MessageButtons::Ok, + icons: nwg::MessageIcons::Warning, + }); +} + +/// Shows an error message telling the user that the app failed to start. +/// The passed message should contain details about the error that occurred. +/// +/// This function is called when the app fails to start the GUI. +pub fn show_start_failure_message(error: &str) { + let content = format!( + concat!( + "An error occurred while starting the app.\n", + "Try opening the app again or reboot the system if the issue persists.\n\n", + "Error:\n", + "{}" + ), + error + ); + + nwg::message(&nwg::MessageParams { + title: "WSL USB Manager", + content: &content, + buttons: nwg::MessageButtons::Ok, + icons: nwg::MessageIcons::Error, + }); +} diff --git a/src/gui/nwg_ext.rs b/src/gui/nwg_ext.rs new file mode 100644 index 0000000..7feeaf0 --- /dev/null +++ b/src/gui/nwg_ext.rs @@ -0,0 +1,129 @@ +use native_windows_gui as nwg; + +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Graphics::Gdi::DeleteObject; +use windows_sys::Win32::UI::Shell::{ + SHGetStockIconInfo, SHGSI_ICON, SHGSI_SMALLICON, SHSTOCKICONID, SHSTOCKICONINFO, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CopyImage, DestroyIcon, GetIconInfoExW, SetMenuItemInfoW, HMENU, ICONINFOEXW, IMAGE_BITMAP, + LR_CREATEDIBSECTION, MENUITEMINFOW, MF_BYCOMMAND, MIIM_BITMAP, +}; + +/// Extends [`nwg::Bitmap`] with additional functionality. +pub trait BitmapEx { + fn from_system_icon(icon: SHSTOCKICONID) -> nwg::Bitmap; +} + +impl BitmapEx for nwg::Bitmap { + /// Creates a bitmap from a [`SHSTOCKICONID`] system icon ID. + fn from_system_icon(icon: SHSTOCKICONID) -> nwg::Bitmap { + // Retrieve the icon + let mut stock_icon_info = SHSTOCKICONINFO { + cbSize: std::mem::size_of::() as u32, + hIcon: 0, + iSysImageIndex: 0, + iIcon: 0, + szPath: [0; 260], + }; + unsafe { + SHGetStockIconInfo( + icon, + SHGSI_ICON | SHGSI_SMALLICON, + &mut stock_icon_info as *mut _, + ); + } + + // Retrieve the bitmap for the icon + let mut icon_info = ICONINFOEXW { + cbSize: std::mem::size_of::() as u32, + fIcon: 0, + xHotspot: 0, + yHotspot: 0, + hbmMask: 0, + hbmColor: 0, + wResID: 0, + szModName: [0; 260], + szResName: [0; 260], + }; + unsafe { + GetIconInfoExW(stock_icon_info.hIcon, &mut icon_info as *mut _); + } + + // Create a copy of the bitmap with transparent background from the icon bitmap + let hbitmap = unsafe { + CopyImage( + icon_info.hbmColor as HANDLE, + IMAGE_BITMAP, + 0, + 0, + LR_CREATEDIBSECTION, + ) + }; + + // Delete the unused icon and bitmaps + unsafe { + DeleteObject(icon_info.hbmMask); + DeleteObject(icon_info.hbmColor); + DestroyIcon(stock_icon_info.hIcon); + }; + + if hbitmap == 0 { + panic!("Failed to create bitmap from system icon"); + } else { + #[allow(unused)] + struct Bitmap { + handle: HANDLE, + owned: bool, + } + + let bitmap = Bitmap { + handle: hbitmap as HANDLE, + owned: true, + }; + + // Ugly hack to set the private `owned` field inside nwg::Bitmap to true + unsafe { std::mem::transmute(bitmap) } + } + } +} + +/// Extends [`nwg::MenuItem`] with additional functionality. +pub trait MenuItemEx { + fn set_bitmap(&self, bitmap: Option<&nwg::Bitmap>); +} + +impl MenuItemEx for nwg::MenuItem { + /// Sets a bitmap to be displayed on a menu item. Pass `None` to remove the bitmap. + fn set_bitmap(&self, bitmap: Option<&nwg::Bitmap>) { + let (hmenu, item_id) = self.handle.hmenu_item().unwrap(); + let hbitmap = match bitmap { + Some(b) => b.handle as HANDLE, + None => 0, + }; + + let menu_item_info = MENUITEMINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: MIIM_BITMAP, + fType: 0, + fState: 0, + wID: 0, + hSubMenu: 0, + hbmpChecked: 0, + hbmpUnchecked: 0, + dwItemData: 0, + dwTypeData: std::ptr::null_mut(), + cch: 0, + hbmpItem: hbitmap, + }; + + unsafe { + SetMenuItemInfoW( + hmenu as HMENU, + item_id, + MF_BYCOMMAND as i32, + &menu_item_info as *const _, + ); + } + } +} diff --git a/src/gui/persisted_tab/mod.rs b/src/gui/persisted_tab/mod.rs new file mode 100644 index 0000000..b6fbee8 --- /dev/null +++ b/src/gui/persisted_tab/mod.rs @@ -0,0 +1,223 @@ +mod profile_info; + +use std::cell::{Cell, RefCell}; + +use native_windows_derive::NwgPartial; +use native_windows_gui as nwg; +use nwg::stretch::{ + geometry::{Rect, Size}, + style::{Dimension as D, FlexDirection}, +}; +use windows_sys::Win32::UI::{Controls::LVSCW_AUTOSIZE_USEHEADER, Shell::SIID_SHIELD}; + +use self::profile_info::ProfileInfo; +use crate::gui::nwg_ext::BitmapEx; +use crate::gui::usbipd_gui::GuiTab; +use crate::usbipd::{self, UsbDevice}; + +use super::nwg_ext::MenuItemEx; + +const PADDING_LEFT: Rect = Rect { + start: D::Points(8.0), + end: D::Points(0.0), + top: D::Points(0.0), + bottom: D::Points(0.0), +}; + +const DETAILS_PANEL_WIDTH: f32 = 260.0; +const DETAILS_PANEL_PADDING: u32 = 4; + +#[derive(Default, NwgPartial)] +pub struct PersistedTab { + window: Cell, + shield_bitmap: Cell, + + persisted_devices: RefCell>, + + #[nwg_layout(flex_direction: FlexDirection::Row)] + persisted_tab_layout: nwg::FlexboxLayout, + + #[nwg_control(list_style: nwg::ListViewStyle::Detailed, focus: true, + flags: "VISIBLE|SINGLE_SELECTION|TAB_STOP", + ex_flags: nwg::ListViewExFlags::FULL_ROW_SELECT, + )] + #[nwg_events(OnListViewRightClick: [PersistedTab::show_menu], + OnListViewItemChanged: [PersistedTab::update_profile_details] + )] + #[nwg_layout_item(layout: persisted_tab_layout, flex_grow: 1.0)] + list_view: nwg::ListView, + + // Profile info + #[nwg_control] + #[nwg_layout_item(layout: persisted_tab_layout, margin: PADDING_LEFT, + size: Size { width: D::Points(DETAILS_PANEL_WIDTH), height: D::Auto }, + )] + details_frame: nwg::Frame, + + #[nwg_layout(parent: details_frame, flex_direction: FlexDirection::Column, + auto_spacing: Some(DETAILS_PANEL_PADDING))] + details_layout: nwg::FlexboxLayout, + + #[nwg_control(parent: details_frame, flags: "VISIBLE")] + #[nwg_layout_item(layout: details_layout, flex_grow: 1.0)] + // Multi-line RichLabels send a WM_CLOSE message when the ESC key is pressed + #[nwg_events(OnWindowClose: [PersistedTab::inhibit_close(EVT_DATA)])] + profile_info_frame: nwg::Frame, + + #[nwg_partial(parent: profile_info_frame)] + profile_info: ProfileInfo, + + // Buttons + #[nwg_control(parent: details_frame, flags: "VISIBLE")] + #[nwg_layout_item(layout: details_layout, size: Size { width: D::Auto, height: D::Points(25.0) })] + buttons_frame: nwg::Frame, + + #[nwg_layout(parent: buttons_frame, flex_direction: FlexDirection::RowReverse, auto_spacing: None)] + buttons_layout: nwg::FlexboxLayout, + + #[nwg_control(parent: buttons_frame, text: "Delete")] + #[nwg_layout_item(layout: buttons_layout, flex_grow: 0.33)] + #[nwg_events(OnButtonClick: [PersistedTab::delete])] + pub delete_button: nwg::Button, + + // Device context menu + #[nwg_control(text: "Device", popup: true)] + menu: nwg::Menu, + + #[nwg_control(parent: menu, text: "Delete")] + #[nwg_events(OnMenuItemSelected: [PersistedTab::delete])] + menu_delete: nwg::MenuItem, +} + +impl PersistedTab { + fn init_device_list(&self) { + let dv = &self.list_view; + dv.clear(); + dv.insert_column("Profile"); + dv.set_headers_enabled(true); + + // Auto-size before adding items to ensure we don't overflow the list view + dv.set_column_width(0, LVSCW_AUTOSIZE_USEHEADER as isize); + } + + /// Clears the device list and reloads it with the currently persisted devices. + fn refresh_device_list(&self) { + self.update_devices(); + + self.list_view.clear(); + for device in self.persisted_devices.borrow().iter() { + self.list_view.insert_items_row( + None, + &[device.description.as_deref().unwrap_or("Unknown device")], + ); + } + } + + /// Updates the profile details panel with the currently selected device. + fn update_profile_details(&self) { + let devices = self.persisted_devices.borrow(); + let device = self.list_view.selected_item().and_then(|i| devices.get(i)); + + if device.is_some() { + self.delete_button.set_enabled(true); + } else { + self.delete_button.set_enabled(false); + } + + self.profile_info.update(device); + } + + fn show_menu(&self) { + if self.list_view.selected_item().is_none() { + return; + } + + let (x, y) = nwg::GlobalCursor::position(); + // Disable menu animations because they cause incorrect rendering of the bitmaps + self.menu + .popup_with_flags(x, y, nwg::PopupMenuFlags::ANIMATE_NONE); + } + + fn delete(&self) { + self.run_command(|device| { + device.unbind()?; + device.wait(|d| d.is_none()) + }); + } + + /// Runs a `command` function on the currently selected device. + /// No-op if no device is selected. + /// + /// If the command completes successfully, the view is reloaded. + /// + /// If an error occurs, an error dialog is shown. + fn run_command(&self, command: fn(&UsbDevice) -> Result<(), String>) { + let window = self.window.get(); + + let wait_cursor = nwg::Cursor::from_system(nwg::OemCursor::Wait); + let cursor_event = + nwg::full_bind_event_handler(&window, move |event, _event_data, _handle| match event { + nwg::Event::OnMousePress(_) | nwg::Event::OnMouseMove => { + nwg::GlobalCursor::set(&wait_cursor) + } + _ => {} + }); + + let result = { + let selected_index = match self.list_view.selected_item() { + Some(index) => index, + None => return, + }; + // Borrow devices in a scoped block so that the ref is released as soon as possible + let devices = self.persisted_devices.borrow(); + let device = match devices.get(selected_index) { + Some(device) => device, + None => return, + }; + + command(device) + }; + + if let Err(err) = result { + nwg::modal_error_message(window, "WSL USB Manager", &err); + } + + self.window.set(window); + self.refresh(); + nwg::unbind_event_handler(&cursor_event); + } + + fn update_devices(&self) { + *self.persisted_devices.borrow_mut() = usbipd::list_devices() + .into_iter() + .filter(|d| !d.is_connected()) + .collect(); + } + + /// Inhibits the window close event. + fn inhibit_close(data: &nwg::EventData) { + if let nwg::EventData::OnWindowClose(close_data) = data { + close_data.close(false); + } + } +} + +impl GuiTab for PersistedTab { + fn init(&self, window: &nwg::Window) { + self.window.replace(window.handle); + + let shield_bitmap = nwg::Bitmap::from_system_icon(SIID_SHIELD); + self.delete_button.set_bitmap(Some(&shield_bitmap)); + self.menu_delete.set_bitmap(Some(&shield_bitmap)); + + self.shield_bitmap.set(shield_bitmap); + + self.init_device_list(); + self.refresh(); + } + + fn refresh(&self) { + self.refresh_device_list(); + self.update_profile_details(); + } +} diff --git a/src/gui/persisted_tab/profile_info.rs b/src/gui/persisted_tab/profile_info.rs new file mode 100644 index 0000000..3ec187c --- /dev/null +++ b/src/gui/persisted_tab/profile_info.rs @@ -0,0 +1,100 @@ +use native_windows_derive::NwgPartial; +use native_windows_gui as nwg; + +use nwg::stretch::{ + geometry::{Rect, Size}, + style::{Dimension as D, Dimension::Points as Pt, FlexDirection}, +}; + +use crate::usbipd::UsbDevice; + +/// The persisted device info tab. +/// It displays detailed information about a persistent device profile. +/// +/// Call the `update` method to update the information displayed. +/// +/// # Remarks +/// +/// The `ES_MULTILINE` flag used to make the `Description` label multi-line +/// sends a `WM_CLOSE` message when the `ESC` key is pressed while the control +/// has focus. It is suggested to inhibit the `OnWindowClose` event on the +/// parent window (e.g. the parent `nwg::Frame`) to prevent it from closing. +#[derive(Default, NwgPartial)] +pub struct ProfileInfo { + #[nwg_resource(family: "Segoe UI Semibold", size: 16, weight: 400)] + font_bold: nwg::Font, + + #[nwg_resource(family: "Segoe UI Semibold", size: 20, weight: 400)] + font_bold_big: nwg::Font, + + #[nwg_layout(flex_direction: FlexDirection::Column, auto_spacing: None)] + device_info_layout: nwg::FlexboxLayout, + + #[nwg_control(text: "Profile Info", font: Some(&data.font_bold_big))] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + profile_info: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(1.0) }, + margin: Rect { start: Pt(0.0), end: Pt(0.0), top: Pt(5.0), bottom: Pt(0.0)} + )] + separator: nwg::Frame, + + #[nwg_control(text: "VID:PID:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0)}, + margin: Rect { start: Pt(0.0), end: Pt(0.0), top: Pt(6.0), bottom: Pt(0.0)} + )] + vid_pid: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + vid_pid_content: nwg::RichLabel, + + #[nwg_control(text: "Serial number:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + serial: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + serial_content: nwg::RichLabel, + + #[nwg_control(text: "Persisted ID:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + persisted: nwg::Label, + + #[nwg_control] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + persisted_content: nwg::RichLabel, + + #[nwg_control(text: "Description:", font: Some(&data.font_bold), v_align: nwg::VTextAlign::Bottom)] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: Pt(20.0) })] + description: nwg::Label, + + #[nwg_control(flags: "VISIBLE|MULTI_LINE")] + #[nwg_layout_item(layout: device_info_layout, size: Size { width: D::Auto, height: D::Auto }, flex_grow: 1.0)] + description_content: nwg::RichLabel, +} + +impl ProfileInfo { + pub fn update(&self, device: Option<&UsbDevice>) { + if let Some(device) = device { + self.vid_pid_content + .set_text(device.vid_pid().as_deref().unwrap_or("-")); + self.serial_content + .set_text(device.serial().as_deref().unwrap_or("-")); + self.persisted_content + .set_text(device.persisted_guid.as_deref().unwrap_or("-")); + self.description_content.set_text( + device + .description + .as_deref() + .unwrap_or("No description available"), + ); + } else { + self.vid_pid_content.set_text("-"); + self.serial_content.set_text("-"); + self.persisted_content.set_text("-"); + self.description_content.set_text("No profile selected"); + } + } +} diff --git a/src/gui/usbipd_gui.rs b/src/gui/usbipd_gui.rs new file mode 100644 index 0000000..107c4ed --- /dev/null +++ b/src/gui/usbipd_gui.rs @@ -0,0 +1,143 @@ +use std::cell::Cell; + +use native_windows_derive::NwgUi; +use native_windows_gui as nwg; + +use super::connected_tab::ConnectedTab; +use super::persisted_tab::PersistedTab; +use crate::win_utils::{self, DeviceNotification}; + +pub(super) trait GuiTab { + /// Initializes the tab. The root window handle is provided. + fn init(&self, window: &nwg::Window); + + /// Refreshes the data displayed in the tab. + fn refresh(&self); +} + +#[derive(Default, NwgUi)] +pub struct UsbipdGui { + device_notification: Cell, + + #[nwg_resource] + embed: nwg::EmbedResource, + + #[nwg_resource(source_embed: Some(&data.embed), source_embed_str: Some("MAINICON"))] + app_icon: nwg::Icon, + + // Window + #[nwg_control(size: (780, 430), center: true, title: "WSL USB Manager", icon: Some(&data.app_icon))] + #[nwg_events( + OnInit: [UsbipdGui::init], + OnMinMaxInfo: [UsbipdGui::min_max_info(EVT_DATA)], + OnWindowClose: [UsbipdGui::hide(SELF, EVT_DATA)] + )] + window: nwg::Window, + + #[nwg_layout(parent: window, auto_spacing: Some(2))] + window_layout: nwg::FlexboxLayout, + + #[nwg_control(parent: window)] + #[nwg_events(OnNotice: [UsbipdGui::refresh])] + refresh_notice: nwg::Notice, + + // Tabs + #[nwg_control(parent: window)] + #[nwg_layout_item(layout: window_layout)] + tabs_container: nwg::TabsContainer, + + // Connected devices tab + #[nwg_control(parent: tabs_container, text: "Connected")] + connected_tab: nwg::Tab, + + #[nwg_partial(parent: connected_tab)] + connected_tab_content: ConnectedTab, + + // Persisted devices tab + #[nwg_control(parent: tabs_container, text: "Persisted")] + persisted_tab: nwg::Tab, + + #[nwg_partial(parent: persisted_tab)] + persisted_tab_content: PersistedTab, + + // Tray icon + #[nwg_control(icon: Some(&data.app_icon), tip: Some("WSL USB Manager"))] + #[nwg_events(OnContextMenu: [UsbipdGui::show_tray_menu], MousePressLeftUp: [UsbipdGui::show])] + tray: nwg::TrayNotification, + + // Tray menu + #[nwg_control(parent: window, popup: true)] + menu_tray: nwg::Menu, + + #[nwg_control(parent: menu_tray, text: "Open")] + #[nwg_events(OnMenuItemSelected: [UsbipdGui::show])] + menu_tray_open: nwg::MenuItem, + + #[nwg_control(parent: menu_tray)] + menu_tray_sep: nwg::MenuSeparator, + + #[nwg_control(parent: menu_tray, text: "Exit")] + #[nwg_events(OnMenuItemSelected: [UsbipdGui::exit])] + menu_tray_exit: nwg::MenuItem, + + // File menu + #[nwg_control(parent: window, text: "File", popup: false)] + menu_file: nwg::Menu, + + #[nwg_control(parent: menu_file, text: "Refresh")] + #[nwg_events(OnMenuItemSelected: [UsbipdGui::refresh])] + menu_file_refresh: nwg::MenuItem, + + #[nwg_control(parent: menu_file)] + menu_file_sep1: nwg::MenuSeparator, + + #[nwg_control(parent: menu_file, text: "Exit")] + #[nwg_events(OnMenuItemSelected: [UsbipdGui::exit])] + menu_file_exit: nwg::MenuItem, +} + +impl UsbipdGui { + fn init(&self) { + self.connected_tab_content.init(&self.window); + self.persisted_tab_content.init(&self.window); + + let sender = self.refresh_notice.sender(); + self.device_notification.set( + win_utils::register_usb_device_notifications(move || { + sender.notice(); + }) + .expect("Failed to register USB device notifications"), + ); + } + + fn min_max_info(data: &nwg::EventData) { + if let nwg::EventData::OnMinMaxInfo(info) = data { + info.set_min_size(600, 410); + } + } + + fn hide(&self, data: &nwg::EventData) { + if let nwg::EventData::OnWindowClose(close_data) = data { + close_data.close(false); + } + self.window.set_visible(false); + } + + fn show(&self) { + self.window.set_visible(true); + } + + fn show_tray_menu(&self) { + let (x, y) = nwg::GlobalCursor::position(); + self.menu_tray.popup(x, y); + } + + fn refresh(&self) { + self.connected_tab_content.refresh(); + self.persisted_tab_content.refresh(); + } + + fn exit(&self) { + nwg::stop_thread_dispatch(); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a756297 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,20 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#![cfg(target_os = "windows")] + +mod gui; +mod usbipd; +mod win_utils; + +fn main() { + // Ensure that only one instance of the application is running + if !win_utils::acquire_single_instance_lock() { + gui::show_multiple_instance_message(); + return; + } + + let start = gui::start(); + + if let Err(err) = start { + gui::show_start_failure_message(&err.to_string()); + } +} diff --git a/src/usbipd.rs b/src/usbipd.rs new file mode 100644 index 0000000..9e3be3b --- /dev/null +++ b/src/usbipd.rs @@ -0,0 +1,358 @@ +//! This module provides objects and functions for interacting with the `usbipd` +//! executable and the USB devices it manages. + +use std::fmt::Display; +use std::os::windows::process::CommandExt; +use std::process::Command; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; +use windows_sys::Win32::UI::Shell::{ShellExecuteExW, SHELLEXECUTEINFOW, SHELLEXECUTEINFOW_0}; +use windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE; + +use crate::win_utils::get_last_error_string; + +/// The `usbipd` executable name. +const USBIPD_EXE: &str = "usbipd"; + +/// An enum representing the state of a USB device in `usbipd`. +pub enum UsbipState { + None, + Persisted, + Shared(bool), + Attached(bool), +} + +impl Display for UsbipState { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + UsbipState::None => write!(fmt, "Not shared")?, + UsbipState::Persisted => write!(fmt, "Persisted")?, + UsbipState::Shared(_) => write!(fmt, "Shared")?, + UsbipState::Attached(_) => write!(fmt, "Attached")?, + } + + match self { + UsbipState::None | UsbipState::Persisted => Ok(()), + UsbipState::Shared(forced) | UsbipState::Attached(forced) => { + if *forced { + write!(fmt, " (forced)") + } else { + Ok(()) + } + } + } + } +} + +/// A struct representing a USB device as returned by `usbipd`. +#[derive(Debug, Deserialize)] +pub struct UsbDevice { + #[serde(rename = "BusId")] + pub bus_id: Option, + #[serde(rename = "ClientIPAddress")] + pub client_ip_address: Option, + #[serde(rename = "Description")] + pub description: Option, + #[serde(rename = "InstanceId")] + pub instance_id: Option, + #[serde(rename = "IsForced")] + pub is_forced: bool, + #[serde(rename = "PersistedGuid")] + pub persisted_guid: Option, + #[serde(rename = "StubInstanceGuid")] + pub stub_instance_id: Option, +} + +impl UsbDevice { + /// Returns whether the device is connected to the system. + pub fn is_connected(&self) -> bool { + self.bus_id.is_some() + } + + /// Returns whether the device is shared by usbipd. + pub fn is_bound(&self) -> bool { + self.is_connected() && self.persisted_guid.is_some() + } + + /// Returns whether the device is attached to a usbip client. + pub fn is_attached(&self) -> bool { + self.is_connected() && self.client_ip_address.is_some() + } + + /// Returns the VID:PID of the device if available. + pub fn vid_pid(&self) -> Option { + // USB\VID_XXXX&PID_XXXX\XXXX + let instance_id = self.instance_id.as_deref()?; + // VID_XXXX&PID_XXXX + let vid_pid = instance_id.split('\\').nth(1)?; + // VVVV:PPPP + let vid_pid = vid_pid.replace("VID_", "").replace("&PID_", ":"); + + Some(vid_pid) + } + + /// Returns the serial number of the device if available. + pub fn serial(&self) -> Option { + // USB\VID_XXXX&PID_XXXX\XXXX + let instance_id = self.instance_id.as_deref()?; + // XXXX + let serial = instance_id.split('\\').nth(2)?; + + // Windows generates instance IDs for devices that do not provide a + // serial number. Instance IDs are not persistent across disconnections, + // therefore they do cannot be used to uniquely identify devices + // They can be recognized by the presence of ampersands + if serial.contains('&') { + None + } else { + Some(serial.to_owned()) + } + } + + /// Returns the state of the USB device as a `UsbipState` enum. + pub fn state(&self) -> UsbipState { + if self.bus_id.is_none() { + UsbipState::Persisted + } else if self.is_attached() { + UsbipState::Attached(self.is_forced) + } else if self.is_bound() { + UsbipState::Shared(self.is_forced) + } else { + UsbipState::None + } + } + + /// Binds the device. Asks for admin privileges if necessary. + pub fn bind(&self, force: bool) -> Result<(), String> { + let bus_id = self + .bus_id + .as_deref() + .ok_or("The device does not have a bus ID.".to_owned())?; + + let args = if force { + ["bind", "--force", "--busid", bus_id].to_vec() + } else { + ["bind", "--busid", bus_id].to_vec() + }; + + usbipd(&args).or_else(|err| { + if err.contains("administrator") { + usbipd_admin(&args) + } else { + Err(err) + } + }) + } + + /// Unbinds the device. Asks for admin privileges if necessary. + pub fn unbind(&self) -> Result<(), String> { + let guid = self + .persisted_guid + .as_deref() + .ok_or("The device is already unbound.".to_owned())?; + + let args = ["unbind", "--guid", guid].to_vec(); + + usbipd(&args).or_else(|err| { + if err.contains("administrator") { + usbipd_admin(&args) + } else { + Err(err) + } + }) + } + + /// Attaches the device. Binds the device if necessary. + pub fn attach(&self) -> Result<(), String> { + let bus_id = self + .bus_id + .as_deref() + .ok_or("The device does not have a bus ID.".to_owned())?; + + if !self.is_bound() { + self.bind(false)?; + } + + let args = if version().major < 4 { + ["wsl", "attach", "--busid", bus_id].to_vec() + } else { + ["attach", "--wsl", "--busid", bus_id].to_vec() + }; + + usbipd(&args) + } + + /// Detaches the device. + pub fn detach(&self) -> Result<(), String> { + let bus_id = self + .bus_id + .as_deref() + .ok_or("The device does not have a bus ID.".to_owned())?; + + let args = if version().major < 4 { + ["wsl", "detach", "--busid", bus_id].to_vec() + } else { + ["detach", "--busid", bus_id].to_vec() + }; + + usbipd(&args) + } + + /// Waits until `wait_cond` is satisfied for the device. + /// + /// `wait_cond` receives an optional reference to the updated device. + /// A value of `None` might mean that either the device was disconnected or + /// that it was temporarily removed as part of a `usbipd` operation. + /// Users of this function should take this into account when implementing `wait_cond`. + /// `wait_cond` should return `true` when the device reaches the desired state + /// and waiting should stop. + /// + /// The maximum wait time is 5 seconds, which takes into account the worst-case + /// scenario of Windows remounting the USB device after a `usbipd` operation. + /// If the wait times out, the device is assumed to be lost. + pub fn wait(&self, wait_cond: fn(Option<&UsbDevice>) -> bool) -> Result<(), String> { + let start = Instant::now(); + + // Wait for the device to be in the desired state with a timeout + while start.elapsed() < Duration::from_secs(5) { + let devices = list_devices(); + let device = devices.iter().find(|d| d.instance_id == self.instance_id); + // Pass Option as we might want to check for the device being removed + if wait_cond(device) { + return Ok(()); + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + // Assume the device was disconnected if the maximum wait time was reached + Err("The device was lost while waiting for the operation to complete.".to_owned()) + } +} + +/// Retrieves the list of USB devices from `usbipd`. +pub fn list_devices() -> Vec { + let state_str = { + let cmd = Command::new(USBIPD_EXE) + .arg("state") + .creation_flags(CREATE_NO_WINDOW) + .output() + .unwrap(); + + String::from_utf8(cmd.stdout).unwrap() + }; + + #[derive(Deserialize)] + struct StateResult { + #[serde(rename = "Devices")] + devices: Vec, + } + + let state_res: StateResult = serde_json::from_str(&state_str).unwrap(); + state_res.devices +} + +/// Executes `usbipd` with the given arguments. +fn usbipd<'a, I>(args: I) -> Result<(), String> +where + I: IntoIterator, +{ + match Command::new(USBIPD_EXE) + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .output() + { + Ok(output) => { + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8(output.stderr).unwrap()) + } + } + Err(err) => Err(err.to_string()), + } +} + +/// Executes `usbipd` as administrator with the given arguments. +fn usbipd_admin<'a, I>(args: I) -> Result<(), String> +where + I: IntoIterator, +{ + // Build a space-separated string of arguments + let mut args_str: String = String::new(); + for arg in args { + args_str.push_str(&format!("{arg} ")); + } + // Remove the trailing comma + args_str.pop(); + // Insert a null terminator + args_str.push('\0'); + + // Prepare u16 strings + let verb = "runas\0".encode_utf16().collect::>(); + let file = (USBIPD_EXE.to_owned() + "\0") + .encode_utf16() + .collect::>(); + let params = args_str.encode_utf16().collect::>(); + + let mut shell_exec_info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: 0, + hwnd: 0, + lpVerb: verb.as_ptr(), + lpFile: file.as_ptr(), + lpParameters: params.as_ptr(), + lpDirectory: std::ptr::null(), + nShow: SW_HIDE, + hInstApp: 0, + lpIDList: std::ptr::null_mut(), + lpClass: std::ptr::null(), + hkeyClass: 0, + dwHotKey: 0, + Anonymous: SHELLEXECUTEINFOW_0 { hMonitor: 0 }, + hProcess: 0, + }; + + if unsafe { ShellExecuteExW(&mut shell_exec_info as *mut _) } == 0 { + Err(get_last_error_string()) + } else { + Ok(()) + } +} + +/// A `ubpidp` version struct with major, minor, and patch fields. +#[allow(unused)] +struct Version { + major: u32, + minor: u32, + patch: u32, +} + +/// Returns the version of `usbipd`, split into major, minor, and patch fields. +fn version() -> Version { + let cmd = Command::new(USBIPD_EXE) + .arg("--version") + .creation_flags(CREATE_NO_WINDOW) + .output() + .unwrap(); + let version_string = String::from_utf8(cmd.stdout).unwrap(); + + let version_split: Vec<_> = version_string.split('+').collect(); + let version_parts: Vec<_> = version_split.first().unwrap().split('.').collect(); + + let parse = |i| -> u32 { + version_parts + .get(i) + .unwrap_or(&"0") + .parse::() + .unwrap_or(0) + }; + + Version { + major: parse(0), + minor: parse(1), + patch: parse(2), + } +} diff --git a/src/win_utils.rs b/src/win_utils.rs new file mode 100644 index 0000000..9787427 --- /dev/null +++ b/src/win_utils.rs @@ -0,0 +1,145 @@ +//! Various Windows utilities. + +use std::ptr::null_mut; + +use windows_sys::Win32::{ + Devices::{ + DeviceAndDriverInstallation::{ + CM_Register_Notification, CM_Unregister_Notification, CM_NOTIFY_ACTION, + CM_NOTIFY_ACTION_DEVICEINTERFACEARRIVAL, CM_NOTIFY_ACTION_DEVICEINTERFACEREMOVAL, + CM_NOTIFY_EVENT_DATA, CM_NOTIFY_FILTER, CM_NOTIFY_FILTER_0, CM_NOTIFY_FILTER_0_2, + CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE, CR_SUCCESS, HCMNOTIFICATION, + }, + Usb::GUID_DEVINTERFACE_USB_DEVICE, + }, + Foundation::{GetLastError, ERROR_ALREADY_EXISTS, ERROR_SUCCESS}, + System::{ + Diagnostics::Debug::{FormatMessageW, FORMAT_MESSAGE_FROM_SYSTEM}, + Threading::CreateMutexW, + }, +}; + +/// Acquires a single instance lock for the application. Returns `true` if the lock was acquired. +pub fn acquire_single_instance_lock() -> bool { + // Convert to null-terminated UTF-16 string + let mutex_name: Vec = "WSL_USB_MANAGER_SINGLE_INSTANCE_LOCK\0" + .encode_utf16() + .collect(); + + let mutex_handle = unsafe { CreateMutexW(null_mut(), 1, mutex_name.as_ptr()) }; + if mutex_handle == 0 { + return false; + } + + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + return false; + } + + true +} + +/// Retrieves the last error message from the system. +pub fn get_last_error_string() -> String { + let mut buffer = [0u16; 256]; + + let error_code = unsafe { GetLastError() }; + let msg_slice = unsafe { + let len = FormatMessageW( + FORMAT_MESSAGE_FROM_SYSTEM, + null_mut(), + error_code, + 0x0409_u32, // en-US language ID + buffer.as_mut_ptr(), + buffer.len() as u32, + null_mut(), + ); + &buffer[..len as usize] + }; + + String::from_utf16_lossy(msg_slice).trim_end().to_owned() +} + +/// Registers a closure to be called when a USB device is connected or disconnected. +pub fn register_usb_device_notifications( + callback: impl Fn() + 'static, +) -> Result { + extern "system" fn callback_impl( + _hnotify: HCMNOTIFICATION, + context: *const std::ffi::c_void, + action: CM_NOTIFY_ACTION, + _eventdata: *const CM_NOTIFY_EVENT_DATA, + _eventdatasize: u32, + ) -> u32 { + match action { + // We only care about device arrival and removal events + CM_NOTIFY_ACTION_DEVICEINTERFACEARRIVAL | CM_NOTIFY_ACTION_DEVICEINTERFACEREMOVAL => { + let user_callback = unsafe { &*(context as *const Box) }; + user_callback(); + } + _ => {} + } + + ERROR_SUCCESS + } + + let mut notif = DeviceNotification { + handle: 0, + closure: Box::new(Box::new(callback)), + }; + + // A filter that matches all device instances of the USB device interface class + let filter = CM_NOTIFY_FILTER { + cbSize: std::mem::size_of::() as u32, + Flags: 0, + FilterType: CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE, + Reserved: 0, + u: CM_NOTIFY_FILTER_0 { + DeviceInterface: CM_NOTIFY_FILTER_0_2 { + ClassGuid: GUID_DEVINTERFACE_USB_DEVICE, + }, + }, + }; + + // A pointer to the closure that can be cast to void + let closure_ptr = notif.closure.as_ref() as *const _; + + let error = unsafe { + CM_Register_Notification( + &filter as *const _, + closure_ptr as *const _, + Some(callback_impl), + &mut notif.handle as *mut _, + ) + }; + + if error != CR_SUCCESS { + Err(error) + } else { + Ok(notif) + } +} + +/// A device notification registration handle. +/// +/// The notification is automatically unregistered when the handle is dropped. +pub struct DeviceNotification { + pub handle: HCMNOTIFICATION, + closure: Box>, +} + +impl Default for DeviceNotification { + fn default() -> Self { + Self { + handle: 0, + closure: Box::new(Box::new(|| {})), + } + } +} + +impl Drop for DeviceNotification { + fn drop(&mut self) { + if self.handle != 0 { + unsafe { CM_Unregister_Notification(self.handle) }; + } + } +}