From bf12ecfd9d8aaf723e8763fe79c19382a7846b20 Mon Sep 17 00:00:00 2001 From: Obisidan Jackal Date: Tue, 28 Jul 2026 20:21:35 -0400 Subject: [PATCH 1/5] feat: add iced Ext2Mgr port Add a Rust+Iced Ext2 volume manager (ext2mgr_iced) alongside the MFC app, with persistence modes, dead-letter cleanup, native Win32 chrome, and docs under ext2mgr_iced/. Drop the January .gitignore entries for docs/ and Ext4Fsd.sln so project docs can be tracked. --- ext2mgr_iced/.gitignore | 3 + ext2mgr_iced/Cargo.lock | 2622 +++++++++++++++++++++++ ext2mgr_iced/Cargo.toml | 44 + ext2mgr_iced/PORT_IMPROVEMENTS.md | 136 ++ ext2mgr_iced/README.md | 39 + ext2mgr_iced/app.manifest | 20 + ext2mgr_iced/assets/Ext2Mgr.ico | Bin 0 -> 8478 bytes ext2mgr_iced/assets/FAQ.txt | 176 ++ ext2mgr_iced/assets/icons16/cdrom.ico | Bin 0 -> 1406 bytes ext2mgr_iced/assets/icons16/disk.ico | Bin 0 -> 1406 bytes ext2mgr_iced/assets/icons16/dvd.ico | Bin 0 -> 1406 bytes ext2mgr_iced/assets/icons16/dynamic.ico | Bin 0 -> 1406 bytes ext2mgr_iced/assets/icons16/floppy.ico | Bin 0 -> 1406 bytes ext2mgr_iced/build.rs | 8 + ext2mgr_iced/docs/SOURCE_LAYOUT.md | 80 + ext2mgr_iced/src/disk/enum_disk.rs | 1763 +++++++++++++++ ext2mgr_iced/src/disk/fs_probe.rs | 165 ++ ext2mgr_iced/src/disk/mod.rs | 4 + ext2mgr_iced/src/main.rs | 41 + ext2mgr_iced/src/mount/dead_letters.rs | 293 +++ ext2mgr_iced/src/mount/mod.rs | 10 + ext2mgr_iced/src/mount/ops.rs | 548 +++++ ext2mgr_iced/src/mount/persist.rs | 282 +++ ext2mgr_iced/src/mount/pipe.rs | 363 ++++ ext2mgr_iced/src/service/mgr.rs | 485 +++++ ext2mgr_iced/src/service/mod.rs | 4 + ext2mgr_iced/src/ui/gating_probe.rs | 98 + ext2mgr_iced/src/ui/helpers.rs | 1445 +++++++++++++ ext2mgr_iced/src/ui/mod.rs | 184 ++ ext2mgr_iced/src/ui/types.rs | 227 ++ ext2mgr_iced/src/ui/update.rs | 1238 +++++++++++ ext2mgr_iced/src/ui/view.rs | 1282 +++++++++++ ext2mgr_iced/src/win/accent.rs | 117 + ext2mgr_iced/src/win/autorun.rs | 135 ++ ext2mgr_iced/src/win/chrome.rs | 678 ++++++ ext2mgr_iced/src/win/clipboard.rs | 52 + ext2mgr_iced/src/win/mod.rs | 12 + ext2mgr_iced/src/win/msgbox.rs | 52 + 38 files changed, 12606 insertions(+) create mode 100644 ext2mgr_iced/.gitignore create mode 100644 ext2mgr_iced/Cargo.lock create mode 100644 ext2mgr_iced/Cargo.toml create mode 100644 ext2mgr_iced/PORT_IMPROVEMENTS.md create mode 100644 ext2mgr_iced/README.md create mode 100644 ext2mgr_iced/app.manifest create mode 100644 ext2mgr_iced/assets/Ext2Mgr.ico create mode 100644 ext2mgr_iced/assets/FAQ.txt create mode 100644 ext2mgr_iced/assets/icons16/cdrom.ico create mode 100644 ext2mgr_iced/assets/icons16/disk.ico create mode 100644 ext2mgr_iced/assets/icons16/dvd.ico create mode 100644 ext2mgr_iced/assets/icons16/dynamic.ico create mode 100644 ext2mgr_iced/assets/icons16/floppy.ico create mode 100644 ext2mgr_iced/build.rs create mode 100644 ext2mgr_iced/docs/SOURCE_LAYOUT.md create mode 100644 ext2mgr_iced/src/disk/enum_disk.rs create mode 100644 ext2mgr_iced/src/disk/fs_probe.rs create mode 100644 ext2mgr_iced/src/disk/mod.rs create mode 100644 ext2mgr_iced/src/main.rs create mode 100644 ext2mgr_iced/src/mount/dead_letters.rs create mode 100644 ext2mgr_iced/src/mount/mod.rs create mode 100644 ext2mgr_iced/src/mount/ops.rs create mode 100644 ext2mgr_iced/src/mount/persist.rs create mode 100644 ext2mgr_iced/src/mount/pipe.rs create mode 100644 ext2mgr_iced/src/service/mgr.rs create mode 100644 ext2mgr_iced/src/service/mod.rs create mode 100644 ext2mgr_iced/src/ui/gating_probe.rs create mode 100644 ext2mgr_iced/src/ui/helpers.rs create mode 100644 ext2mgr_iced/src/ui/mod.rs create mode 100644 ext2mgr_iced/src/ui/types.rs create mode 100644 ext2mgr_iced/src/ui/update.rs create mode 100644 ext2mgr_iced/src/ui/view.rs create mode 100644 ext2mgr_iced/src/win/accent.rs create mode 100644 ext2mgr_iced/src/win/autorun.rs create mode 100644 ext2mgr_iced/src/win/chrome.rs create mode 100644 ext2mgr_iced/src/win/clipboard.rs create mode 100644 ext2mgr_iced/src/win/mod.rs create mode 100644 ext2mgr_iced/src/win/msgbox.rs diff --git a/ext2mgr_iced/.gitignore b/ext2mgr_iced/.gitignore new file mode 100644 index 0000000..5f970b5 --- /dev/null +++ b/ext2mgr_iced/.gitignore @@ -0,0 +1,3 @@ +/target/ +/_recovery_backup_*/ +*.log diff --git a/ext2mgr_iced/Cargo.lock b/ext2mgr_iced/Cargo.lock new file mode 100644 index 0000000..881dfd2 --- /dev/null +++ b/ext2mgr_iced/Cargo.lock @@ -0,0 +1,2622 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.10.0", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.10.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.10.0", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ext2mgr_iced" +version = "0.1.0" +dependencies = [ + "iced", + "image", + "tokio", + "windows-sys 0.52.0", + "winres", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "image", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 2.0.18", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "image", + "kamadak-exif", + "log", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.18", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.18", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.18", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "lilt" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67562e5eff6b20553fa9be1c503356768420994e28f67e3eafe6f41910e57ad" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys 0.3.0", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.0", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "orbclient" +version = "0.3.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47cf772000d7aea67a6ea51de8a21b77674aaa538f3b523d89ae6514bf45bb4" +dependencies = [ + "bytemuck", + "font-types 0.12.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb" +dependencies = [ + "bytemuck", + "read-fonts 0.40.1", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.43.2", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libloading", + "log", + "naga", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "thiserror 2.0.18", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "raw-window-handle", + "thiserror 2.0.18", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.10.0", + "block2", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.10.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/ext2mgr_iced/Cargo.toml b/ext2mgr_iced/Cargo.toml new file mode 100644 index 0000000..e2ea89c --- /dev/null +++ b/ext2mgr_iced/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "ext2mgr_iced" +version = "0.1.0" +edition = "2021" +description = "Ext2/Ext4 volume manager GUI (Iced port)" + +[dependencies] +# Software renderer only (no wgpu). Slim image codecs: we only load .ico list icons. +iced = { version = "0.14", default-features = false, features = [ + "tiny-skia", + "tokio", + "image-without-codecs", + "crisp", + "web-colors", + "thread-pool", +] } +image = { version = "0.25", default-features = false, features = ["ico"] } +tokio = { version = "1", features = ["rt"] } +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_DataExchange", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_Threading", + "Win32_System_Registry", + "Win32_System_Services", + "Win32_System_SystemInformation", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +[build-dependencies] +winres = "0.1" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/ext2mgr_iced/PORT_IMPROVEMENTS.md b/ext2mgr_iced/PORT_IMPROVEMENTS.md new file mode 100644 index 0000000..9a1a9ca --- /dev/null +++ b/ext2mgr_iced/PORT_IMPROVEMENTS.md @@ -0,0 +1,136 @@ +# Ext2Mgr Iced port — what is better than the original + +Compares the Rust + [Iced](https://iced.rs/) manager in `ext2mgr_iced/` with classic MFC **Ext2 Volume Manager** (`Ext2Mgr/`). Manager/GUI only. + +Shared service / classic-manager fixes that are **not** iced-only live in [`../Ext2Mgr/IMPROVEMENTS.md`](../Ext2Mgr/IMPROVEMENTS.md). + +Upstream tracker for context: [bobranten/Ext4Fsd issues](https://github.com/bobranten/Ext4Fsd/issues). + +--- + +## UX improvements + +### Windowing and dialogs + +| Behavior | Original (MFC) | Iced port | +|----------|----------------|-----------| +| Internal dialogs | `DoModal()` disables the parent | In-window overlays; main window stays interactive | +| Move main window while a dialog is open | Blocked | Works | +| Minimize | Hide to tray (`ShowWindow(SW_HIDE)` + notify icon) | Same tray-hide path (`win_chrome` subclass on `SC_MINIMIZE`) | +| Stability | More frequent GUI hangs/crashes in practice | Crashes much less often in day-to-day use | + +### Disk / volume information + +| Feature | Original | Iced port | +|---------|----------|-----------| +| Vendor / Product / Serial / Bus | On `IDD_PROPERTY_DIALOG` when storage inquiry succeeds | Same fields; inquiry stored on `DiskEntry` | +| Free space | Properties uses `FILE_FS_SIZE_INFORMATION` (`FssInfo`); often `0` when that query fails | Free space in the **properties pane and tables** from enumerated capacity/used and, when lettered, `GetDiskFreeSpaceExW` | +| Disk identity in the list | Generic disk strip; [#42](https://github.com/bobranten/Ext4Fsd/issues/42) asked for model/name | Disk header + Properties vendor/product; accent-colored DISK rule | +| Narrow columns | Text can clip awkwardly | Cell text truncates with an ellipsis (`…`) when the column is skinnier than the content | + +### Theming (Windows accent) + +Live Settings / DWM accent via `win_accent` applied to: + +- Selection highlight +- DISK header underline +- Primary buttons + +Original uses classic Win32 highlight colors. + +### View menu + +- Show / hide the **properties pane** +- Capacity labels: **SI** vs **binary** +- Display sizes as **bytes** or **bits** + +### Mount / letters + +| Feature | Original | Iced port | +|---------|----------|-----------| +| Assign Drive Letter | Shown for many unlettered volumes; mount still goes through Ext2 stack | **Grayed / omitted** unless the selection is EXT2/3/4 and has no letter (Tools, context menu, Properties Mount) — avoids a long Ext2Srv wait on NTFS/etc. | +| Device path for letter assign | `Volume->Name` = `\Device\HarddiskVolumeN` | Same — uses `physical_object` when present | +| Unmount | Letter-based remove | Tools / context / Properties when any letter is present; removes **all** letters on the selection | +| Change Drive Letters UI | Modal list + Add/Change/Remove on the right | Same layout; in-window dialog | +| Multi-letter volumes | Bitmask `DrvLetters` | Merges DefineDosDevice letters that share the same `\Device\HarddiskVolumeN` onto one row | +| Persistence modes | (1) DefineDosDevice temporary (2) Mount Manager (3) Session Manager DOS Devices registry; plus Ext2Fsd automount elsewhere | Same four options via `MountMode` enum. On startup/refresh, remounts Ext2Fsd `MountPoint=` and dormant Session Manager letters only when not already lettered / MountMgr-present (Ext2Mgr `Ext2ProcessExt2Volumes` pattern) | + +**Modes are not interchangeable:** Mount Manager is `SetVolumeMountPoint`. Session Manager is the registry option. Temporary is DefineDosDevice-only. Classic Session Manager also does an immediate DefineDosDevice bind after writing the key; that bind is not the Temporary option. Device path for Temporary / Session Manager is `\Device\HarddiskVolumeN`. + +### Remove Dead Letters + +| | Original | Iced port | +|--|----------|-----------| +| Detection | Only letters with **no extent** (`drvLetter->Extent == NULL`) after subtracting live volume/part masks | (1) `QueryDosDevice` letters **not claimed** by an enumerated volume; (2) **claimed but Explorer-dead** non–Mount Manager letters (`X:\` inaccessible) — catches Session Manager / `DefineDosDevice` leftovers merged onto the same NTFS volume as a working MountMgr letter | +| Practical gap | Can **miss** real dead letters (e.g. `J:` → `\??\D:`) because that redirect still has usable geometry/extents | Lists those redirects; also lists inaccessible DosDevice siblings that volume merge would otherwise hide | +| Remove path | `DefineDosDevice` + optional MountMgr | Local `DefineDosDevice` + clear Session Manager value (no Ext2Srv round-trip); in-app Yes/No confirm (no freezing Win32 MessageBox on the iced thread) | + +### Ext2Srv / errors + +Temporary **EXT** mounts go through the Ext2Srv named pipe (`\\.\pipe\EXT2MGR_PSRV`). + +| Behavior | Original | Iced port | +|----------|----------|-----------| +| Pipe busy / missing | `WaitNamedPipe` up to **20s**; can also try to start Ext2Srv | Busy wait capped at **2s**, then fail; MessageBox + status bar | +| Dead-letter remove | Local DOS device APIs | Local DOS device APIs (does **not** block on Ext2Srv) | +| Op failures | Mostly `AfxMessageBox` | Status bar **and** Win32 MessageBox (stop/warning), with iced confirms where modality would freeze the UI | + +Pipe-server speedups (dual idle listeners, softer create retries, no `FILE_FLAG_WRITE_THROUGH`) are in **Ext2Srv** and benefit both GUIs — see [`../Ext2Mgr/IMPROVEMENTS.md`](../Ext2Mgr/IMPROVEMENTS.md). + +--- + +## Ext2Srv / IPC — speed levers + +`DefineDosDevice` inside Ext2Srv is cheap. The slow path was **getting onto the pipe on the UI thread**, then connecting again for the next op. + +### Why two idle listeners (not one) + +Historically Ext2Srv did: create one pipe → `ConnectNamedPipe` → hand off to a client thread → **then** create the next listener. Between accept and the next `CreateNamedPipe`, a second client (or a reconnect) could hit `ERROR_PIPE_BUSY` and wait. + +| Listeners | Behavior | +|-----------|----------| +| **1** (old) | Fine for a single quiet client. Overlap → `PIPE_BUSY` / wait. | +| **2** (current) | One instance accepting while another stays pre-created; covers the handoff gap. | +| **3+** | Diminishing returns unless several clients talk at once. | + +### Lever list + +| # | Where | Lever | Status | +|---|--------|--------|------| +| 1 | Ext2Srv | **Dual idle listeners** | Done — second `Ext2PipeEngine` thread in `Ext2StartPipeSrv` | +| 2 | Ext2Srv | **Fail soft on create** | Done — create retries capped (3×50ms) instead of long sleep storms | +| 3 | iced | **Keep-alive client** | Done — `pipe::with_shared_client` reuses one handle | +| 4 | iced | **Off UI thread** | Partial — mount/unmount + dead-letter scan via `spawn_blocking`; still sync on UI: disk refresh/`enumerate_all`, Ext2 attr save, service start/stop, and modal `MessageBox` | +| 5 | iced / policy | **Local `DefineDosDevice` when elevated** | Done — try local first when elevated, else Ext2Srv | +| 6 | iced | **Letter assign device path = `\Device\HarddiskVolumeN`** | Done — use `physical_object` like Ext2Mgr `Volume->Name` for Temporary DefineDosDevice and Session Manager registry values | +| 7 | iced + Ext2Srv | **Health ping** | Done — `pipe::health_check` / Refresh status uses reused connection | +| 8 | Ext2Srv | **Drop `FILE_FLAG_WRITE_THROUGH`** | Done — removed on create; **re-measured after rebuild/reinstall** (elevated pipe probe, 2026-07-28): cold connect median **0.7 ms** (first ~21 ms), burst reconnect median **0.2 ms**, overlap-while-held median **0.2 ms**, `QUERY_DRV C:` median **0.1 ms** (first ~22 ms). Query-only probe (no letter assign/remove). | + +Already done earlier: **2s** busy-pipe cap (vs original **20s**), EXT-only Assign gating, dead-letter remove without Ext2Srv. + +### Other polish + +- Native Win32 menu chrome with selection-synced enable/disable +- Clipboard helpers +- Persistence modes documented as distinct options (Temporary DefineDosDevice vs Session Manager registry vs Ext2Fsd automount; shared device path `\Device\HarddiskVolumeN`) +- HKCU Run autorun for the iced binary (optional; separate from original service `-service -hide`) + +### Intentionally not ported + +- **Format** — original dialog is largely a stub / destructive; left out on purpose + +--- + +## Upstream issues this port may help (manager-side) + +| Issue | Topic | Notes | +|-------|--------|--------| +| [#42](https://github.com/bobranten/Ext4Fsd/issues/42) | Drive model/name in Ext2Mgr | Vendor/product/serial/bus and clearer disk identity | +| [#46](https://github.com/bobranten/Ext4Fsd/issues/46) | Dead drive letter after reformat/uninstall | Dead-letter UI lists more candidates than stock Ext2Mgr (see above) | +| [#79](https://github.com/bobranten/Ext4Fsd/issues/79) | Free space display | Manager display can be more useful when Win32 size APIs work; Explorer still depends on the driver | + +--- + +## Summary + +The iced port’s main wins are **non-blocking dialogs**, **Windows accent theming**, **View menu options**, **more reliable free-space / disk-inquiry display**, **broader dead-letter detection** (including inaccessible non–Mount Manager leftovers), **EXT-only Assign gating**, **Ext2Mgr-compatible `\Device\HarddiskVolumeN` letter targets**, **startup/refresh automount with already-mounted checks**, faster fail when the Ext2Srv pipe is unavailable, and **generally better stability**. diff --git a/ext2mgr_iced/README.md b/ext2mgr_iced/README.md new file mode 100644 index 0000000..0d33caf --- /dev/null +++ b/ext2mgr_iced/README.md @@ -0,0 +1,39 @@ +# Ext2Mgr (Iced port) + +Rust + [Iced](https://iced.rs/) GUI for managing Ext2/Ext4 volumes on Windows. Modeled on classic **Ext2 Volume Manager** (`Ext2Mgr/`). + +## Features + +- Dual volume and disk/partition lists with native Win32 menu, tray, and context menus +- Assign / change / remove drive letters with four persistence modes (below) +- Ext2 volume attributes, service management, properties dialog/pane +- Remove Dead Letters (orphans, Explorer-dead DOS siblings, dormant Session Manager registry letters) +- Startup and refresh automount for Ext2Fsd `MountPoint=` and dormant Session Manager letters +- Windows accent theming; View menu for properties pane, SI/binary units, bytes/bits + +Source layout: [`docs/SOURCE_LAYOUT.md`](docs/SOURCE_LAYOUT.md) (`disk` / `mount` / `service` / `win` / `ui`). + +Compared to classic Ext2Mgr: [`PORT_IMPROVEMENTS.md`](PORT_IMPROVEMENTS.md). Classic Ext2Mgr / Ext2Srv fixes: [`../Ext2Mgr/IMPROVEMENTS.md`](../Ext2Mgr/IMPROVEMENTS.md). + +## Persistence (important) + +Four distinct options (do not conflate): + +| Mode | Survives reboot? | What it is | +|------|------------------|------------| +| Temporary | **No** | `DefineDosDevice` only (Ext2Srv, or local when elevated) | +| Mount Manager | **Yes** | `SetVolumeMountPoint` — same mechanism Windows Disk Management uses. Needs a Win32 volume GUID. Needs Administrator. | +| Session Manager DOS Devices | **Yes** | Registry `HKLM\...\Session Manager\DOS Devices` (`X:` → `\Device\HarddiskVolumeN`). Immediate `DefineDosDevice` bind afterward so the letter appears now — that bind is not the Temporary option. Needs Administrator. | +| Ext2Fsd automount | **Config yes** | `HKLM\...\Ext2Fsd\Volumes\{UUID}` with `MountPoint=X:;`. Remounted on load/refresh. Needs EXT UUID + Administrator. | + +Device paths match Ext2Mgr (`Volume->Name` = `\Device\HarddiskVolumeN`). Mount Manager is disabled in the letter picker when the volume has no Win32 GUID. + +## Build & run + +```bash +cargo build --release +cargo run +cargo run --release +``` + +From `ext2mgr_iced/`. Run **elevated** for permanent registry / Ext2Fsd Volumes / service parameter writes. Ext2Srv must be running for temporary letter ops (unless elevated local `DefineDosDevice` succeeds). diff --git a/ext2mgr_iced/app.manifest b/ext2mgr_iced/app.manifest new file mode 100644 index 0000000..a5e16fe --- /dev/null +++ b/ext2mgr_iced/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ext2mgr_iced/assets/Ext2Mgr.ico b/ext2mgr_iced/assets/Ext2Mgr.ico new file mode 100644 index 0000000000000000000000000000000000000000..e3778e0c312d3685f534acda47565ac069b64017 GIT binary patch literal 8478 zcmeI1Pi$OQ6~<3uI~k1W#1#sgNOosMsEI1Q4Pj#|irAnivq`sM5k0Z3kjMt%DO5=` zUJwZ>%9BuCi9Z6Dsv=_xiJ@XhB^Klnvp~thv70thgF-r);L4cP_2u{7``-ME?WVK{ zA^axayYHTJ?m6e4`#f{+7qdM!WCso$FkN5VWA;34*0paAnSB@8_;}F&b+6gG`_z}U z+0P4R*FR(S+;ds~DYI`sjUT!iF%I_FAa}EdzxUppb2hZPVSCRw+gDh&mkNKkpW4^# zFPPnXb+3K?*JCz%#%yTfdHd=M7p>7~Sfx_2si`UNi19twoO+L5cXN8+Hs|rk&ACT& zuOm~J5A7pVp%ZLm@AdMaN3PLP-?)ckp>9=iWL|Os*;F-G33^QjmZcL_mREiX0>$1`)u}T!I`VAO;a&u(=pH zNI(oCfJ1W;a*%)+M1UZsPYMzcg9s3q&?#Y3T&4_9F-;L;nIZ=Xh(QDpkPmW@fEYvo zBl1BG5)gw3FpzwZg9OAN0yrZway|)&K?DduK1e_UVh{m>k`Ho_fEYx8L{K3I35Y=i z=#UR`kboFO00Z(t4iXT92r!6zkOO^U5CI&K57~hP#2^AVCm-Y>0WpXG0m%nBNI(oC zKoSf=4iXT92=G8Y$Uy>P5CN3rgWP9=oKFN8Kt5CoBp?P6zzO*v2MLHl1aM3~$Uy>P z5CMXb4|0%z7({>saD^NsAO;cOMn1@WRglx_gMxer1N2(z71Zm7G5H_|dOh?iXxVX~ zrPQ)%iL@LlJsspsPbE~jR2r2*Q`Yn}6{W2dm5xFyj9hBCOeHpJf@M$#bKn9v3r>OK zpae!gZ8K{&o3^~XZ1sBG_ztoQ7cSV@vuEwpsZ)0R_;D+hN;W!5T(0c_b3*&$a3d4h;=;shsQU z_6soI!d^A_WZ$#n)Nu;rzeg2*%la?!{1kqVimuo*;Z~!k>-Lrwy9KQDZ@UJJ+$xpc zxRs`vXk_JY^jGHI$i_U?e5X|U^77oXnSQZ}ej;5eWkE)o?_Ir`rq@anS&X+f8@%-C zL>6?@HQ#b>BrTN|<)OIPY~s0`kU$fwtMwA8eQ?PH%YdW9{!ufk*@xS&F1};#XK6O zl)!cM-RGJQHu&}JTz^{bR(Isi{bSm>bp$Qi#+Y8&X@2f=O!7R5Y;1FX;|uTazdiZ> z(A#VWMyEIJ$Jp*;%pS(tw7ZR+(8y6i_hN<*Xus~1SAG7PpuN_}zxRwz0w6kera`xq^ zNw0US4qK1e;E&A;UFUuP-UJW92KW(>{P)_Yh%)*HXzx>o>1=sb%=icK6B_P3dM>DO zt6{A_{>%mk2dz*j*vQC;KQkRXc+d_VI^@q#hYuh2XQ~q?PS~+y$L#3QqgMHjvvS$l z5ABAz`>Flm#sj-?_pV+1#cKr8sZggwof0`M`m-Z9f5t}Uxd?V~GV$kM9z0Kgtq!vq z=FiK>J+FXV2VEL}DAf;)=OGa25bU`D28l<(B%G3US{9!mnfMsw+;q+t_aHt*9F0$j z9K;KVs}O%8PAAY4$UT>U+;c9-38^!_cn5JI;%CGei4PD*A>Kq>j`o9odPEOQg7^*X zk&u&%&iLXT#D$2T5oZ*r3FJJ8I}xAb%R(*0X^3YL_ai<^XM6Dt;$*}ViMvwi#7&65 z5vL@!M|_VjA#uV-Y>UdP>WKePpM9)KO=99h#NCKb5|<_ZLY#_t9&u0N!^ABFz5_Y& z#Z`$v(-Mgv5jP_qN!*t9$CwjCE1?RA@8M1j#CK@VBwk2dmH0Eiw911Th(`+C7IKmn zpCXP&ypy;v@pFXs{EIjt@l@i@#OLu3;D>lGshaZTdC#L0;##I@&v#7&7W6Ne{WF>pr67%09^e4)53ofpJQ ziAxmE^$d*d>C>k@cX96AIlFY}l3lxY&8}RzVmEKz^c=^XJ9q5fy?gfX;X_+rU$@o= z&VKUBEBqZ-sILA7)ZoweUy$73iH+cs&|c8TL_)C`T4aj@$lMA zZe6X`hU+sIgLds=HCQcFtJ%oVuXoI=_2F8zI-|TW@7ULZWwl%{LI z7XH0nH(zKBsuZD`EmOaJXLUm%WWM0*$rS1fHR?4}t98~G{mjCAtwt6zEQA(7rIDL? zKNBAGoRzD8KG+A9X7L2}!}WZ%XIS@z+PvNns?mPu`sb6!&D(x`e?Pa&+kQR$YFL0; zb*3FPxAp~~%$JY&3%%^ySI?WCjj2mm_U7g64Q|_a|ABQvc>BwFe_^4n`m-hr-7j{o z4sGGck?oberlH3KE^jfrtxU5B0r z`tz=lwYR6e{r=m&Um0i=eShG4gEo!7f&H-CvM*LbJNr^-oeO?KTY=U-$Ny`qy`Ht3 olbqq8t~bZB`Qge>^ZQwQC$yU{WG&;Q-{k+)d+c1+{|~hP0??f|;{X5v literal 0 HcmV?d00001 diff --git a/ext2mgr_iced/assets/FAQ.txt b/ext2mgr_iced/assets/FAQ.txt new file mode 100644 index 0000000..4ccc0c2 --- /dev/null +++ b/ext2mgr_iced/assets/FAQ.txt @@ -0,0 +1,176 @@ +================ + FAQ - Ext2Fsd +================ + +------------------------------------------------------------------------- +Q) What systems could Ext2Fsd 0.53 support + +A) Windows 2000, xp, 2003, Vista, Win7 (X86, AMD64) + Windows NT 4.0 is no longer supported. + + My stress test systems: + Win7 AMD64/X86, Vista AMD64, XP pro X86, Win2k SP4 + +------------------------------------------------------------------------- +Q) How to install ext2fsd version 0.53 + +A) Just click the file of ext2fsd-0.53.exe. It will guide you for all. + and writing on ext3 partition: + + Senior users can try the setup.bat or registry manually editing. + +------------------------------------------------------------------------- +Q) How to uninstall + +A) If you created group "Ext2Fsd" in your "Start Menu", just click + the link of "Uninstall Ext2Fsd". + + Or you need "Add/Remove Programs" in "Control Panel" to remove + the program. + +------------------------------------------------------------------------- +Q) Vista always reports "The parameter is not right" when executing any + applications from my linux partition + +A) The exact reason is unknown yet. But it's related to UAC. This issue + can be cured by disabling UAC. + +------------------------------------------------------------------------- +Q) How to mount an ext2fsd partition or a volume ? + +A) It's becoming easy with the help of the program "Ext2 Volume Manager". + Just "right click" on the dialog list, and select "Change Drive Letter". + Then you'll see the mount point dialog, you can add, change or remove + any driver letters. + +------------------------------------------------------------------------- +Q) Windows explorers shows up my mounted Ext3 partition as unformatted ? +A) 1, Make sure ExtsFsd service is started: + Ext2Mgr: Tools Menu->Service Management->Start Button + 2, If ExtsFsd is started already, then try to remove the drive letter + and re-assign + +------------------------------------------------------------------------- +Q) How could I change the volume's codepage and other attributes ? + +A) Right click the Ext2/Ext3 partition or volume in the list with your mouse, + you could see the menu item of "Ext2 Management", or double click and then + click the "Properties" button in Properties dialog. + + Do not forget to apply your changes. These settings won't be kept after + booting, I'll implement it in next version. + + Make sure the ext2fsd service is already started, or you won't get the + chance of changing ext2 volume information. + +------------------------------------------------------------------------- +Q) What's a permanent mountpoint ? + +A) Windows system's mount manager will automatically mount volumes and + assign driver letters during booting. For xp and later systems, windows + only create unique volume ids for all the recognized volume/partitions, + such as FAT32, NTFS. The ext2/ext3 volumes could not be mounted by the + windows mount manager. + + Ext2Mgr provides 3 methods to mount a Linux volume: + + 1, Using Windows API DefineDosDevice + Driver letter won't be kept and be lost after reboot. But it's convenient + for a temporary usage. + + 2, Using Windows MountMgr + Ext2Mgr + This method provides automatic mounting when Ext2Mgr detects new + disk is plugged and removal of driver letters after disk is removed. + If you are heavily using removable disks, this is the best choice. + + 3, Using registry to store the driver letter as a DosDevice + This method always works and it's better for fixed harddisks. Under + some conditions, you need a reboot to see the driver letter in explorer. + + You could also modify the registry manually, see below: + + [HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\DOS Devices] + "X:"="\\Device\\HarddiskVolume1" + +------------------------------------------------------------------------- +Q) Windows Explorer always says the directory is not empty when deleting +A) There are several possible reasons: + 1, the directory or any file under it is being used + 2, there are hidden files inside the directory, try to make explorer + show all files or do "dir/a" in a cmd shell + 3, remove all hiding patterens with ext2mgr: "." prefixed names + +------------------------------------------------------------------------- +Q) How to start Ext2Fsd automatically during system booting + +A) Open program "Ext2 Volume Manager" and click menu "Service Management" + in submenu "Tools". Select "SERVICE_SYSTEM_START" and apply the changes. + + You could click the "Start" button to start ext2fsd service. But it's + only start the service, not enabling auto-starting. + +------------------------------------------------------------------------- +Q) The system configuration changed, but Ext2Mgr doesn't refresh ? + +A) Current version of "Ext2 Volume Manager" does not monitor system changes. + You must refresh it manually: click "Tools" -> "Reload and Refresh". Or + just simply close and reopen the program :) + +------------------------------------------------------------------------- +Q) Ext2Mgr hotkeys + +A) F1: Help (this FAQ.txt) + F2: About Ext2Mgr + F3: Ext2 Volume Management + F4: Mountpoint Management (Drive Letter) + F5: Reload and Refresh + F6: Show Properties + F7: Ext2Fsd Service Management + F8: Ext2Fsd Statistics + F9: Remove Dead Letters + F10: Copy Everything to Clipboard + F11: Flush Cache to Disk + F12: Change Partition Type + +------------------------------------------------------------------------- +Q) Why is Ext2Mgr only listing CD/DVD devices or nothing on Vista ? + +A) Vista's User Account Control (UAC) prohibits Ext2Mgr to query system + SECRET of harddisk/volume configurations. + + You need run Ext2Mgr as administrator or disable UAC to stop Vista's + endless natter forever :) + +------------------------------------------------------------------------- +Q) How to turn off "Driver signature check" on Vista + +A) Normally you can disable it by pressing F8 when booting and select + "Disable Driver Signature Enforcement" + + If you want to disable it forever, do as the followings guide: + + 1, Run cmd.exe as administrator + 2. Execute the followings in the cmd shell: + + bcdedit -set loadoptions DDISABLE_INTEGRITY_CHECKS + bcdedit -set nointegritychecks ON + +WARNING: Turning off "siging check" might put you in danger of other + evil softwares like virus/trojan horse. + +------------------------------------------------------------------------- +Q) I don't like Ext2Mgr showing the splash screen + +A) Add a parameter "/quiet" in command when executing Ext2Mgr, like + "Ext2Mgr /quiet" or ""Ext2Mgr -quiet"" + +------------------------------------------------------------------------- +Q) How to build Ext2Fsd package ? + +A) You need downlaod WDK (provided by Microsoft), then select a build + environment (such as Win7 X64 free), then execute "build" under + the root directory of Ext3Fsd source code. + + +Matt +http://www.ext2fsd.com diff --git a/ext2mgr_iced/assets/icons16/cdrom.ico b/ext2mgr_iced/assets/icons16/cdrom.ico new file mode 100644 index 0000000000000000000000000000000000000000..74f95db98450dd1a3ce14ab9fe622035234d32ab GIT binary patch literal 1406 zcmeHH-Aj{U6#to&M#Z}ugB7VEq@mTtZX_7nF0{0v$O3s$8V=$OG@CDix||M89XisB zVTgnxUgU*=IK{7xX?|onKW1u5=u`~9E<_!sP)}#NBJfYB=jA--Ip;Yazw_ha1p?@k zNN}>Aod>ScDT>7B3MUCbFztXc;6$C-*fB%SiyW}cA7Eb&!4?nUa()6^*atf}g7du% zY}X66v<9}miArNJ*y=a1na^P3<6sjLV3U(z)6-yXHx}C4vCz>0Ha7=0KTq`*;U)i2 zOAFZYGFFF%z}D7KEQ-iTO~qtyFWANgYRzWCu!+|tCCK1;uVbu6?_V;1T%Ok7@D4~1i^hL5j z4Ik`wu%jcW6BEIXk5Nu_UltT#u)iNojg5F)UyrYZ`Qyk48XFq$rnVNAswz}k&3OEj za4`li^*Eq@dwYA>+1bI?))v;+*Ris)0*}XoWtwwoX$f=0!R+iTW@cvKCJv^Fqp7JW zOb}--mkVQKV;CJB#Sn2aFfah8(+LM*w-d&`zCQF4H$6Q)=<4c12XSg6K3ZE_(cIjO z2Et~wT2WnH4U5HsD#BkRyPosY-fbSgO2?R|30w;?8m8Kx_`^8DxCmvrc K$!-|sQSL8>K}kCR literal 0 HcmV?d00001 diff --git a/ext2mgr_iced/assets/icons16/disk.ico b/ext2mgr_iced/assets/icons16/disk.ico new file mode 100644 index 0000000000000000000000000000000000000000..abcf0430b71b1d89c87e30658ed28933fc5b671a GIT binary patch literal 1406 zcmZQzU<5(|0R|w+!H~hqz#zuJz@P!dKp_SNAO?x!1KVfJVCX68HT==7KX5Z00s|dXNEu@ABN)GTn0O9Ylf2idTM5o;`cU@bKY7hWq#LGu*p( zkKx9R8w{5(UuL*?@gl>83l|tpojS#E^5jW|Ee zX=x0JiHQs`F)<9Gp`i?cz;N*O^=0t%^kgtLHpUl>qjIAmFyunOhdf|rW`?BE*oA}J z%gq5QVr6ft%g4*bCgcthF*jGyx3&=VbhP1N=Jc>N(=oEp(^NFEbFy^xW@K_UFw|C+ zkyesdR1*{6XJNI`RFG4Zk(Csel9do)7Z&vKQP;3@(gO39)zu+3s6zmf1ITCo|9|8N s4F9vAwd)7}f+G)@j~r=WIC6vmh8cl;<|Ex7Y{9qiK~*y14^whv$n`(Ou0 zu(ephcHLlK*1*;`QD({qTm1^=`Uo~L0X8`aHZ=t{GXv&yVxg@K3+?S-b8}$x^K{-K z+{D|Pn!uKpuxhu1t*v3Gr3Lv01Crz8G1b!pwy}XKvzc^k;$>kWk`ofZwzn~CwW6}P z7=5j+uoM;HMQ$z(si}Ap6$R(W2r4}}`};5n0_ihPwf2A@g$`_WO<)z;#5 zRTV7da5ykNK8~@mG1$qI!NEb;Y&P_h?mp7j+uMsC@};}G8=akFG!%zfw|Ckd%~!n3x!- z)oQ3zsx$Ayf8w8WK%md&&k*uL?}nRxF*njtG7P@&S?$>DXpHZC?!r_)7+1galr@o~}7s<3;CN4!SE3t14!WLg;~36^Wq zB6!0|$uLgLNtDXxd8bfzQ^NWADV4z{FTN(Ze9`BESR8PQ_5Nb1?-e;G2|2~MC0CyF zyCmj9y$#5sm2# zUauFTD8l3M!0mR!<#NI4bi(0qz;3t0X0yR+wZcMlFq_Q~1OYmo?wWRYz?}lOPyv=@ z=UD5umL3al4gV0mfz>QOdGtUqP7#(}*Km4HFmTHYMkB|*)ao@1#d5rU%cQu+Yqc+E zm5NeRv{t!tf01J-HLX&wK3h|-u05O6X6JR9zN|oB+FS!SPtVVREB{OS{j&o6 VTma|GQUjE&x^srWpVL literal 0 HcmV?d00001 diff --git a/ext2mgr_iced/assets/icons16/floppy.ico b/ext2mgr_iced/assets/icons16/floppy.ico new file mode 100644 index 0000000000000000000000000000000000000000..20185005ca8c81aac9482715d73efb5f8508a519 GIT binary patch literal 1406 zcmeHHUr19?82{~>DJWCRM59b5mL(<0`h$o~*vovlcu~hR=9P<+R9175hS0pBNICz6 zNCc(?;!rU#HYby*v2>wK6mcLd310*srV|wQAlT&6q3+(N;t~PVJRuWN3|Nxx;kw2_oGXvLttnKpBoymH9QQf zD8k;<1pmkg{G+4z-rWtq)e8UE7=BP(!0CioSqXD(ExhD+4-8O;hCOB91Zq!c^P)MANAQttCt1{9gf5DW&fx3`Dg-CgYL>|lF) z8=IS(*x1;>`uaN7*4E(j`LMdW3a{6Tm6a7NQ@^F9B`hv3Vqswc9*+m}^YfUSn}gf! z#_a4YW@cvKqPtH`O~FaCPfSd};c#Gld>nSW9X6W{qlCi{VKX>52#dvnzP>(mbacRM zHlvm1Z)s_PNVwM1z3T{fqtOTh&7s%pQC(e)O2SGI1n@i$jYfmQ!a@`j6d*r8A9;Cs zkV>Ve-tx?FhQR+L!1Fc2aTYIBC0}`3Bk&;;uhVMex8jqorRuA+0IyLhSt0Xw`ZHO= zRf=I%ET>Xw@5wWSj7yhwYK~Rr+)*n_c_H)qgZo-GH#<8!oMAbQs+5=A1vsT5CxVHJ z%S}EnC-soHXeLVfFp1Is(Gkt0B&iaQ^%zN5WciSL ulBxPz4>ySv=Z@6hZ9{VZ5fF#~zS7?)p0LxW2{DX(#KC{?KII%70KWif_WN}J literal 0 HcmV?d00001 diff --git a/ext2mgr_iced/build.rs b/ext2mgr_iced/build.rs new file mode 100644 index 0000000..3cd1549 --- /dev/null +++ b/ext2mgr_iced/build.rs @@ -0,0 +1,8 @@ +fn main() { + let mut resource = winres::WindowsResource::new(); + resource.set_icon("assets/Ext2Mgr.ico"); + resource.set_manifest_file("app.manifest"); + if let Err(err) = resource.compile() { + println!("cargo:warning=winres failed: {err}"); + } +} diff --git a/ext2mgr_iced/docs/SOURCE_LAYOUT.md b/ext2mgr_iced/docs/SOURCE_LAYOUT.md new file mode 100644 index 0000000..62914c0 --- /dev/null +++ b/ext2mgr_iced/docs/SOURCE_LAYOUT.md @@ -0,0 +1,80 @@ +# Source layout + +How `ext2mgr_iced/src` is organized. Entry point is thin; code is grouped by concern. + +## Top level + +```text +src/ + main.rs Binary entry only (iced::application + window icon) + disk/ Volume / disk enumeration and FS probes + mount/ Letters, Ext2Srv pipe, persistence, dead letters + service/ Ext2Fsd service + global properties + win/ Win32 chrome helpers (menu, accent, clipboard, …) + ui/ Iced GUI state, tables, dialogs, update loop +``` + +`main.rs` wires modules and starts the app. It does not own UI or mount logic. + +| Module | Crate path | Role | +|--------|------------|------| +| `disk` | `crate::disk` | List volumes/disks/partitions; probe EXT and other FS signatures | +| `mount` | `crate::mount` | Assign/remove letters, pipe I/O, registry persist, dead-letter scan | +| `service` | `crate::service` | Start/stop Ext2Fsd; read/write global driver settings | +| `win` | `crate::win` | Native menu/tray/context menu, accent color, autorun, clipboard, MessageBox | +| `ui` | `crate::ui` | `Ext2MgrApp` — widgets, dialogs, keyboard/menu → `Message` handling | + +Windows-only modules (`mount`, `service`, `win`) are `#[cfg(windows)]` from `main.rs`. + +## `disk/` + +| File | Purpose | +|------|---------| +| `enum_disk.rs` | Find volumes, disk layout, partition↔volume join, list row model | +| `fs_probe.rs` | Raw superblock / signature probes (EXT, XFS, …) | + +## `mount/` + +| File | Purpose | +|------|---------| +| `pipe.rs` | Ext2Srv named-pipe client (DefineDosDevice via service) | +| `ops.rs` | `MountMode`, mount/unmount, automount-on-refresh | +| `persist.rs` | Session Manager DOS Devices + Ext2Fsd `Volumes\{UUID}` registry | +| `dead_letters.rs` | Detect/remove orphan or inaccessible drive letters | + +**Mount modes** (do not conflate): Temporary (`DefineDosDevice`), Mount Manager (`SetVolumeMountPoint`), Session Manager (registry DOS Devices), Ext2Fsd automount (`Volumes\{UUID}`). See root [`README.md`](../README.md). + +## `service/` + +| File | Purpose | +|------|---------| +| `mgr.rs` | Query/start/stop Ext2Fsd; global readonly / automount / codepage / hiding | + +## `win/` + +| File | Purpose | +|------|---------| +| `chrome.rs` | Native menu bar, tray, `TrackPopupMenu`, HWND helpers | +| `accent.rs` | Windows accent color for selection / primary UI | +| `autorun.rs` | HKCU Run autostart for this binary | +| `clipboard.rs` | Copy list text to clipboard | +| `msgbox.rs` | Stop/warning/confirm MessageBox (owner = main HWND) | + +## `ui/` + +Iced front-end. Files are pulled into **one** Rust module with `include!` so `Dialog` / `Message` fields and `impl Ext2MgrApp` methods stay visible across files (separate `mod` subcrates would force `pub` on every dialog field). + +| File | Purpose | +|------|---------| +| `mod.rs` | `Ext2MgrApp` struct, `new` / title / theme / subscription; `include!`s below | +| `types.rs` | `Selection`, `Dialog`, `Message`, `PropertiesSnapshot` | +| `helpers.rs` | Reload, letter picker gating, detail text, column autofit, mount helpers | +| `view.rs` | Volume/disk tables + dialog overlays + shell layout | +| `update.rs` | `update` / message handling (keyboard, dialogs, native menu commands) | +| `gating_probe.rs` | Windows-only test probe for automount gating | + +## Related docs + +- [`../README.md`](../README.md) — build, persistence modes, status +- [`../PORT_IMPROVEMENTS.md`](../PORT_IMPROVEMENTS.md) — iced vs classic Ext2Mgr +- [`../../Ext2Mgr/IMPROVEMENTS.md`](../../Ext2Mgr/IMPROVEMENTS.md) — classic manager / Ext2Srv fixes diff --git a/ext2mgr_iced/src/disk/enum_disk.rs b/ext2mgr_iced/src/disk/enum_disk.rs new file mode 100644 index 0000000..24ab4fc --- /dev/null +++ b/ext2mgr_iced/src/disk/enum_disk.rs @@ -0,0 +1,1763 @@ +//! Disk / partition / volume enumeration for the Ext2Mgr UI. + +mod win_impl { + use crate::disk::fs_probe::{ + BTRFS_SUPER_BLOCK_OFFSET, RAID_SUPER_BLOCK_OFFSET, + }; + use std::os::windows::ffi::OsStrExt; + + const IOCTL_DISK_GET_DRIVE_GEOMETRY_EX: u32 = 0x0007_00A0; + const IOCTL_DISK_GET_DRIVE_LAYOUT_EX: u32 = 0x0007_0050; + const IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS: u32 = 0x0056_0000; + const IOCTL_STORAGE_QUERY_PROPERTY: u32 = 0x002D_1400; + + const PARTITION_STYLE_MBR: u32 = 0; + const PARTITION_STYLE_GPT: u32 = 1; + const PARTITION_ENTRY_UNUSED: u8 = 0x00; + const PARTITION_EXTENDED: u8 = 0x05; + const PARTITION_XINT13_EXTENDED: u8 = 0x0F; + + /// DRIVE_LAYOUT_INFORMATION_EX before PartitionEntry[]: + /// PartitionStyle(4) + PartitionCount(4) + union{Mbr(8),Gpt(40)} = 48 bytes. + /// Using a shorter header misaligns PARTITION_INFORMATION_EX entries. + const DRIVE_LAYOUT_HEADER_SIZE: usize = 48; + /// sizeof(PARTITION_INFORMATION_EX) on Windows x64. + const PARTITION_INFORMATION_EX_SIZE: usize = 144; + + #[derive(Debug, Clone)] + pub struct DiskExtent { + pub disk_number: u32, + pub starting_offset: u64, + pub extent_length: u64, + } + + #[derive(Debug, Clone)] + pub struct VolumeEntry { + pub letters: Vec, + pub volume_kind: String, + pub filesystem: String, + pub total_bytes: Option, + pub used_bytes: Option, + pub codepage: String, + pub physical_object: String, + pub symlink: String, + pub win32_volume_name: String, + pub extents: Vec, + pub uuid: Option<[u8; 16]>, + } + + #[derive(Debug, Clone)] + pub struct PartitionEntry { + pub number: u32, + pub style: String, + pub partition_type: String, + pub starting_offset: u64, + pub length: u64, + pub letters: Vec, + pub filesystem: String, + pub codepage: String, + pub total_bytes: Option, + pub used_bytes: Option, + pub volume_index: Option, + pub symlink: String, + pub win32_volume_name: String, + } + + #[derive(Debug, Clone)] + pub struct DiskEntry { + pub index: u32, + pub display_name: String, + pub style: String, + pub total_bytes: Option, + pub partitions: Vec, + pub device_path: String, + /// From STORAGE_DEVICE_DESCRIPTOR (Properties dialog). + pub vendor: String, + pub product: String, + pub serial: String, + pub bus_type: String, + pub media_type: String, + pub removable: bool, + } + + /// Flattened disk-list rows for the UI (headers, partitions, spacers). + #[derive(Debug, Clone)] + pub enum DiskRow { + DiskHeader { + disk_index: usize, + }, + Partition { + disk_index: usize, + partition_index: usize, + }, + Spacer, + } + + pub fn enumerate_all() -> (Vec, Vec, Vec) { + let volumes = enumerate_volumes(); + let mut disks = enumerate_disks(); + link_partitions_to_volumes(&mut disks, &volumes); + let rows = build_disk_rows(&disks); + (disks, volumes, rows) + } + + pub fn format_letters(letters: &[char]) -> String { + if letters.is_empty() { + String::new() + } else { + let joined = letters + .iter() + .map(|letter| format!("{letter}:")) + .collect::>() + .join(","); + format!("({joined})") + } + } + + /// Ext2Mgr-style UUID: `AA-BB-CC-...-FF` (no braces). + pub fn format_ext2_style_uuid(uuid: &[u8; 16]) -> String { + let mut out = String::new(); + for (index, byte) in uuid.iter().enumerate() { + if index == 0 { + out.push_str(&format!("{byte:02X}")); + } else { + out.push_str(&format!("-{byte:02X}")); + } + } + out + } + + /// Prefer superblock Ext2Mgr UUID; else bare `guid` from `\\?\Volume{guid}\` (no braces, no `\\?\Volume` prefix). + pub fn display_volume_guid(win32_volume_name: &str, uuid: Option<[u8; 16]>) -> String { + if let Some(bytes) = uuid { + return format_ext2_style_uuid(&bytes); + } + let trimmed = win32_volume_name.trim_end_matches('\\'); + if let Some(rest) = trimmed.strip_prefix(r"\\?\Volume{") { + if let Some(guid) = rest.strip_suffix('}') { + return guid.to_string(); + } + } + String::new() + } + + /// Decimal (SI) vs binary IEC unit labels and bases for capacity display. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] + pub enum SizeUnitStyle { + /// 1000-based units (KB/MB/… or Kb/Mb/… when showing bits). + #[default] + Si, + /// 1024-based units (KiB/MiB/… or Kib/Mib/… when showing bits). + Binary, + } + + /// Format a byte count for list/properties Capacity/Used/Free. + /// + /// Small values use a `> 10×` next-unit threshold (same idea as Ext2Mgr). + /// Larger values step up when the whole-number value would be ≥ 1000 + /// (1000 GB → TB, 1000 TB → PB, 1000 PB → EB). Escalation stops at EB/EiB. + /// When `as_bits` is true, the value is shown as bits (×8) with bit labels. + pub fn format_size(bytes: Option, style: SizeUnitStyle, as_bits: bool) -> String { + let Some(total_bytes) = bytes else { + return String::new(); + }; + let total = if as_bits { + total_bytes.saturating_mul(8) + } else { + total_bytes + }; + match (style, as_bits) { + (SizeUnitStyle::Si, false) => { + format_size_scale(total, 1000, &["KB", "MB", "GB", "TB", "PB", "EB"], "B") + } + (SizeUnitStyle::Si, true) => { + format_size_scale(total, 1000, &["Kb", "Mb", "Gb", "Tb", "Pb", "Eb"], "b") + } + (SizeUnitStyle::Binary, false) => { + format_size_scale(total, 1024, &["KiB", "MiB", "GiB", "TiB", "PiB", "EiB"], "B") + } + (SizeUnitStyle::Binary, true) => { + format_size_scale(total, 1024, &["Kib", "Mib", "Gib", "Tib", "Pib", "Eib"], "b") + } + } + } + + fn format_size_scale(total: u64, base: u64, labels: &[&str; 6], atom: &str) -> String { + let kb = base; + let mb = kb * base; + let gb = mb * base; + let tb = gb * base; + let pb = tb * base; + let eb = pb * base; + if total / pb >= 1000 { + format!("{} {}", total / eb, labels[5]) + } else if total / tb >= 1000 { + format!("{} {}", total / pb, labels[4]) + } else if total / gb >= 1000 { + format!("{} {}", total / tb, labels[3]) + } else if total > 10 * gb { + format!("{} {}", total / gb, labels[2]) + } else if total > 10 * mb { + format!("{} {}", total / mb, labels[1]) + } else if total > 10 * kb { + format!("{} {}", total / kb, labels[0]) + } else { + format!("{total} {atom}") + } + } + + pub fn mbr_partition_type_name(type_id: u8) -> &'static str { + // Mirrors Ext2Mgr enumDisk.cpp PartitionList / PartitionString. + match type_id { + 0x00 => "Empty", + 0x01 => "FAT12", + 0x02 => "Xenix-1", + 0x03 => "Xenix-2", + 0x04 => "FAT16", + 0x05 => "Extended", + 0x06 => "FAT16 HUGE", + 0x07 => "HPFS/NTFS", + 0x0A => "OS/2", + 0x0B => "FAT32", + 0x0C => "FAT32X", + 0x0E => "XINT13", + 0x0F => "EXINT13", + 0x11 => "Hidden FAT12", + 0x14 | 0x16 => "Hidden FAT16", + 0x17 => "Hidden HPFS/NTFS", + 0x1B => "Hidden FAT32", + 0x1C => "Hidden FAT32X", + 0x41 => "OS/2", + 0x42 => "LDM", + 0x52 => "CP/M", + 0x63 => "UNIX", + 0x80 => "NTFT", + 0x81 => "Minix", + 0x82 => "Linux swap", + 0x83 => "Linux", + 0x85 => "Linux extend", + 0x8E => "Linux LVM", + 0xA5 => "FreeBSD", + 0xA6 => "OpenBSD", + 0xA8 => "Darwin UFS", + 0xA9 => "NetBSD", + 0xBE => "Solaris Boot", + 0xBF => "Solaris", + 0xC0 => "VNTFT", + _ => "UNKNOWN", + } + } + + /// Map GPT PartitionType GUID (16 bytes, Windows GUID memory layout) to ASCII. + /// Used when Gpt.Name is empty (common) so the column is still readable. + fn gpt_partition_type_name(type_guid: &[u8]) -> Option<&'static str> { + if type_guid.len() < 16 { + return None; + } + // Compare as little-endian GUID byte pattern (same layout as on-disk / IOCTL). + match type_guid { + // {c12a7328-f81f-11d2-ba4b-00a0c93ec93b} + [0x28, 0x73, 0x2a, 0xc1, 0x1f, 0xf8, 0xd2, 0x11, 0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b] => { + Some("EFI System") + } + // {e3c9e316-0b5c-4db8-817d-f92df00215ae} + [0x16, 0xe3, 0xc9, 0xe3, 0x5c, 0x0b, 0xb8, 0x4d, 0x81, 0x7d, 0xf9, 0x2d, 0xf0, 0x02, 0x15, 0xae] => { + Some("MSR") + } + // {ebd0a0a2-b9e5-4433-87c0-68b6b72699c7} + [0xa2, 0xa0, 0xd0, 0xeb, 0xe5, 0xb9, 0x33, 0x44, 0x87, 0xc0, 0x68, 0xb6, 0xb7, 0x26, 0x99, 0xc7] => { + Some("Basic data") + } + // {5808c8aa-7e8f-42e0-85d2-e1e90434cfb3} + [0xaa, 0xc8, 0x08, 0x58, 0x8f, 0x7e, 0xe0, 0x42, 0x85, 0xd2, 0xe1, 0xe9, 0x04, 0x34, 0xcf, 0xb3] => { + Some("LDM metadata") + } + // {af9b60a0-1431-4f62-bc68-3311714a69ad} + [0xa0, 0x60, 0x9b, 0xaf, 0x31, 0x14, 0x62, 0x4f, 0xbc, 0x68, 0x33, 0x11, 0x71, 0x4a, 0x69, 0xad] => { + Some("LDM data") + } + // {de94bba4-06d1-4d40-a16a-bfd50179d6ac} + [0xa4, 0xbb, 0x94, 0xde, 0xd1, 0x06, 0x40, 0x4d, 0xa1, 0x6a, 0xbf, 0xd5, 0x01, 0x79, 0xd6, 0xac] => { + Some("Windows recovery") + } + // {0fc63daf-8483-4772-8e79-3d69d8477de4} + [0xaf, 0x3d, 0xc6, 0x0f, 0x83, 0x84, 0x72, 0x47, 0x8e, 0x79, 0x3d, 0x69, 0xd8, 0x47, 0x7d, 0xe4] => { + Some("Linux filesystem") + } + // {0657fd6d-a4ab-43c4-84e5-0933c84b4f4f} + [0x6d, 0xfd, 0x57, 0x06, 0xab, 0xa4, 0xc4, 0x43, 0x84, 0xe5, 0x09, 0x33, 0xc8, 0x4b, 0x4f, 0x4f] => { + Some("Linux swap") + } + // {e6d6d379-f507-44c2-a23c-238f2a3df928} + [0x79, 0xd3, 0xd6, 0xe6, 0x07, 0xf5, 0xc2, 0x44, 0xa2, 0x3c, 0x23, 0x8f, 0x2a, 0x3d, 0xf9, 0x28] => { + Some("Linux LVM") + } + _ => None, + } + } + + fn enumerate_volumes() -> Vec { + let mut volumes = Vec::new(); + let mut name_buf = vec![0u16; 256]; + let find_handle = unsafe { + windows_sys::Win32::Storage::FileSystem::FindFirstVolumeW( + name_buf.as_mut_ptr(), + name_buf.len() as u32, + ) + }; + if find_handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return volumes; + } + + loop { + if let Some(entry) = volume_from_guid_name(&name_buf) { + volumes.push(entry); + } + name_buf.fill(0); + let more = unsafe { + windows_sys::Win32::Storage::FileSystem::FindNextVolumeW( + find_handle, + name_buf.as_mut_ptr(), + name_buf.len() as u32, + ) + }; + if more == 0 { + break; + } + } + unsafe { + windows_sys::Win32::Storage::FileSystem::FindVolumeClose(find_handle); + } + + // Ext2Mgr: match letters by mount point + comparing letter extents to volume extents + // (GetVolumePathNames alone misses Ext2Srv/temp mounts and some fixed letters). + attach_letters_like_ext2mgr(&mut volumes); + // Ext2Fsd / DefineDosDevice letters often have no Win32 Volume{GUID} mount + // point, so FindFirstVolume never saw them. Synthesize those orphans. + synthesize_volumes_for_orphan_letters(&mut volumes); + for volume in volumes.iter_mut() { + if !volume.letters.is_empty() { + let probe_name = if volume.win32_volume_name.is_empty() { + format!(r"\\.\{}:", volume.letters[0]) + } else { + volume.win32_volume_name.clone() + }; + let (filesystem, total_bytes, used_bytes, uuid) = + detect_filesystem_and_sizes(&probe_name, &volume.letters); + if !filesystem.is_empty() { + volume.filesystem = filesystem; + } + if total_bytes.is_some() { + volume.total_bytes = total_bytes; + } + if used_bytes.is_some() { + volume.used_bytes = used_bytes; + } + if uuid.is_some() { + volume.uuid = uuid; + } + if volume.physical_object.is_empty() { + if let Some(path) = + nt_device_name_for_volume(&volume.win32_volume_name, &volume.letters) + { + volume.physical_object = path; + } + } + } + // Codepage column: Ext2Mgr `EVP.Codepage` via IOCTL / registry. + volume.codepage = resolve_volume_codepage(volume); + } + + volumes.sort_by(|left, right| { + left.letters + .first() + .cmp(&right.letters.first()) + .then_with(|| left.symlink.cmp(&right.symlink)) + }); + volumes + } + + fn enumerate_disks() -> Vec { + let mut disks = Vec::new(); + let mut consecutive_missing = 0u32; + for disk_index in 0u32..64 { + match probe_disk(disk_index) { + Ok(Some(entry)) => { + consecutive_missing = 0; + disks.push(entry); + } + Ok(None) => { + consecutive_missing += 1; + // PhysicalDrive numbers are usually contiguous; allow a small gap. + if consecutive_missing >= 2 && !disks.is_empty() { + break; + } + if consecutive_missing >= 2 && disks.is_empty() && disk_index > 8 { + break; + } + } + Err(_) => { + // Access denied / busy: keep scanning - do not treat as end-of-list. + consecutive_missing = 0; + } + } + } + disks + } + + fn open_physical_drive( + disk_index: u32, + ) -> Result, std::io::Error> { + let path = format!(r"\\.\PhysicalDrive{disk_index}"); + let wide = to_wide(&path); + // Prefer access=0 for geometry/layout IOCTLs; fall back to GENERIC_READ. + // Elevated FILE_GENERIC_READ can fail on locked drives and skip disks. + const GENERIC_READ: u32 = 0x8000_0000; + for access in [0u32, GENERIC_READ] { + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + access, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle != windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return Ok(Some(handle)); + } + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + if code == 2 || code == 3 { + return Ok(None); + } + // try next access mode + if access == GENERIC_READ { + return Err(std::io::Error::from_raw_os_error(code as i32)); + } + } + Ok(None) + } + + fn probe_disk(disk_index: u32) -> Result, std::io::Error> { + let Some(handle) = open_physical_drive(disk_index)? else { + return Ok(None); + }; + + let total_bytes = ioctl_disk_size(handle); + let media_type = ioctl_media_type(handle).unwrap_or_else(|| "Unknown".to_string()); + let inquiry = ioctl_storage_device(handle).unwrap_or_default(); + let (style, partitions) = ioctl_drive_layout(handle).unwrap_or_else(|| { + ("RAW".to_string(), Vec::new()) + }); + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + + Ok(Some(DiskEntry { + index: disk_index, + display_name: format!("DISK {disk_index}"), + style, + total_bytes, + partitions, + device_path: format!(r"\Device\Harddisk{disk_index}\DR{disk_index}"), + vendor: inquiry.vendor, + product: inquiry.product, + serial: inquiry.serial, + bus_type: inquiry.bus_type, + media_type, + removable: inquiry.removable, + })) + } + + #[derive(Default)] + struct StorageInquiry { + vendor: String, + product: String, + serial: String, + bus_type: String, + removable: bool, + } + + fn cstr_at(buf: &[u8], offset: u32) -> String { + if offset == 0 || offset as usize >= buf.len() { + return String::new(); + } + let slice = &buf[offset as usize..]; + let end = slice.iter().position(|&byte| byte == 0).unwrap_or(slice.len()); + String::from_utf8_lossy(&slice[..end]) + .trim() + .to_string() + } + + fn bus_type_string(bus: u8) -> String { + match bus { + 1 => "SCSI", + 2 => "ATAPI", + 3 => "ATA", + 4 => "1394", + 5 => "SSA", + 6 => "Fibre", + 7 => "USB", + 8 => "RAID", + 9 => "iSCSI", + 10 => "SAS", + 11 => "SATA", + 12 => "SD", + 13 => "MMC", + 14 => "Virtual", + 15 => "FileBackedVirtual", + 16 => "Spaces", + 17 => "NVMe", + _ => "Unknown", + } + .to_string() + } + + fn ioctl_storage_device(handle: windows_sys::Win32::Foundation::HANDLE) -> Option { + // STORAGE_PROPERTY_QUERY: PropertyId=StorageDeviceProperty(0), QueryType=PropertyStandardQuery(0) + let mut query = [0u8; 12]; + let mut buffer = vec![0u8; 1024]; + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_STORAGE_QUERY_PROPERTY, + query.as_mut_ptr() as *mut _, + query.len() as u32, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 || bytes_returned < 28 { + return None; + } + let removable = buffer[10] != 0; + let vendor_off = u32::from_le_bytes(buffer[12..16].try_into().ok()?); + let product_off = u32::from_le_bytes(buffer[16..20].try_into().ok()?); + let serial_off = u32::from_le_bytes(buffer[24..28].try_into().ok()?); + let bus = if bytes_returned as usize > 28 { + buffer[28] + } else { + 0 + }; + Some(StorageInquiry { + vendor: cstr_at(&buffer, vendor_off), + product: cstr_at(&buffer, product_off), + serial: cstr_at(&buffer, serial_off), + bus_type: bus_type_string(bus), + removable, + }) + } + + fn ioctl_media_type(handle: windows_sys::Win32::Foundation::HANDLE) -> Option { + let mut geometry_ex = [0u8; 64]; + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, + std::ptr::null(), + 0, + geometry_ex.as_mut_ptr() as *mut _, + geometry_ex.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 || bytes_returned < 12 { + return None; + } + // DISK_GEOMETRY.MediaType is ULONG at offset 8. + let media = u32::from_le_bytes(geometry_ex[8..12].try_into().ok()?); + Some( + match media { + 11 => "Removable", // RemovableMedia + 12 => "Fixed", // FixedMedia + _ => "Unknown", + } + .to_string(), + ) + } + + fn ioctl_disk_size(handle: windows_sys::Win32::Foundation::HANDLE) -> Option { + let mut geometry_ex = [0u8; 64]; + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, + std::ptr::null(), + 0, + geometry_ex.as_mut_ptr() as *mut _, + geometry_ex.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok != 0 && bytes_returned >= 32 { + Some(u64::from_le_bytes( + geometry_ex[24..32].try_into().unwrap_or([0; 8]), + )) + } else { + None + } + } + + fn ioctl_drive_layout( + handle: windows_sys::Win32::Foundation::HANDLE, + ) -> Option<(String, Vec)> { + let mut capacity = 512usize; + for _ in 0..8 { + let mut buffer = vec![0u8; capacity]; + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_DISK_GET_DRIVE_LAYOUT_EX, + std::ptr::null(), + 0, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + // ERROR_INSUFFICIENT_BUFFER = 122 + if code == 122 { + capacity *= 2; + continue; + } + return None; + } + if bytes_returned < DRIVE_LAYOUT_HEADER_SIZE as u32 { + return None; + } + let partition_style = u32::from_le_bytes(buffer[0..4].try_into().ok()?); + let partition_count = u32::from_le_bytes(buffer[4..8].try_into().ok()?) as usize; + let style = match partition_style { + PARTITION_STYLE_MBR => "Basic", + PARTITION_STYLE_GPT => "GPT", + _ => "RAW", + } + .to_string(); + + let mut partitions = Vec::new(); + let mut data_number = 0u32; + for entry_index in 0..partition_count { + let offset = + DRIVE_LAYOUT_HEADER_SIZE + entry_index * PARTITION_INFORMATION_EX_SIZE; + if offset + PARTITION_INFORMATION_EX_SIZE > bytes_returned as usize { + break; + } + let entry = &buffer[offset..offset + PARTITION_INFORMATION_EX_SIZE]; + let starting_offset = i64::from_le_bytes(entry[8..16].try_into().ok()?) as u64; + let partition_length = i64::from_le_bytes(entry[16..24].try_into().ok()?) as u64; + // Skip empty/extended MBR containers (Ext2Mgr). + if partition_style == PARTITION_STYLE_MBR { + let mbr_type = entry[32]; + if mbr_type == PARTITION_ENTRY_UNUSED + || mbr_type == PARTITION_EXTENDED + || mbr_type == PARTITION_XINT13_EXTENDED + { + continue; + } + } + if partition_length == 0 { + continue; + } + data_number += 1; + let partition_type = if partition_style == PARTITION_STYLE_MBR { + mbr_partition_type_name(entry[32]).to_string() + } else if partition_style == PARTITION_STYLE_GPT { + // Ext2Mgr uses Gpt.Name when set; otherwise "GPT". + // Name is UTF-16; keep printable ASCII only. If empty/unknown, + // fall back to PartitionType GUID English label. + let name_off = 32 + 40; // type GUID 16 + id 16 + attrs 8 + let from_name = ascii_display_name( + &wide_chars_to_string(&entry[name_off..name_off + 72]), + ); + if !from_name.is_empty() { + from_name + } else if let Some(type_name) = gpt_partition_type_name(&entry[32..48]) { + type_name.to_string() + } else { + "GPT".to_string() + } + } else { + "RAW".to_string() + }; + + partitions.push(PartitionEntry { + number: data_number, + style: style.clone(), + partition_type, + starting_offset, + length: partition_length, + letters: Vec::new(), + filesystem: String::new(), + codepage: String::new(), + total_bytes: Some(partition_length), + used_bytes: None, + volume_index: None, + symlink: String::new(), + win32_volume_name: String::new(), + }); + } + return Some((style, partitions)); + } + None + } + + fn link_partitions_to_volumes(disks: &mut [DiskEntry], volumes: &[VolumeEntry]) { + for disk in disks.iter_mut() { + for partition in disk.partitions.iter_mut() { + for (volume_index, volume) in volumes.iter().enumerate() { + if volume.extents.iter().any(|extent| { + extent.disk_number == disk.index + && extent.starting_offset == partition.starting_offset + && extent.extent_length == partition.length + }) { + partition.letters = volume.letters.clone(); + partition.filesystem = volume.filesystem.clone(); + partition.codepage = volume.codepage.clone(); + partition.total_bytes = volume.total_bytes.or(partition.total_bytes); + partition.used_bytes = volume.used_bytes; + partition.volume_index = Some(volume_index); + partition.symlink = volume.symlink.clone(); + partition.win32_volume_name = volume.win32_volume_name.clone(); + break; + } + } + } + } + } + + fn build_disk_rows(disks: &[DiskEntry]) -> Vec { + let mut rows = Vec::new(); + for (disk_index, disk) in disks.iter().enumerate() { + rows.push(DiskRow::DiskHeader { disk_index }); + for partition_index in 0..disk.partitions.len() { + rows.push(DiskRow::Partition { + disk_index, + partition_index, + }); + } + if disk_index + 1 < disks.len() { + rows.push(DiskRow::Spacer); + } + } + rows + } + + fn volume_from_guid_name(name_buf: &[u16]) -> Option { + let win32_volume_name = wide_to_string(name_buf); + if win32_volume_name.is_empty() { + return None; + } + let symlink = win32_volume_name_to_symlink(&win32_volume_name); + let letters = volume_drive_letters(&win32_volume_name); + let extents = volume_extents(&win32_volume_name).unwrap_or_default(); + let (filesystem, total_bytes, used_bytes, uuid) = + detect_filesystem_and_sizes(&win32_volume_name, &letters); + // Physical object column: NT `\Device\HarddiskVolumeN` (Ext2Mgr volume->Name). + // Win32 `\\?\Volume{guid}` paths belong in properties/detail, not this column. + let physical_object = + nt_device_name_for_volume(&win32_volume_name, &letters).unwrap_or_default(); + + Some(VolumeEntry { + letters, + volume_kind: "Basic".to_string(), + filesystem, + total_bytes, + used_bytes, + codepage: String::new(), + physical_object, + symlink, + win32_volume_name, + extents, + uuid, + }) + } + + /// `\Device\HarddiskVolumeN` (or empty). Never returns `\??\Volume{guid}`. + /// Ext2Mgr uses CM_DRP_PHYSICAL_DEVICE_OBJECT_NAME; QueryDosDevice on the + /// Volume{guid} DOS name is the FindFirstVolume-compatible equivalent. + fn nt_device_name_for_volume(win32_volume_name: &str, letters: &[char]) -> Option { + if let Some(letter) = letters.first() { + if let Some(path) = query_dos_device_name(&format!("{letter}:")) { + if path.starts_with(r"\Device\") { + return Some(path); + } + } + } + + // \\?\Volume{GUID}\ -> Volume{GUID} + let trimmed = win32_volume_name.trim_end_matches('\\'); + if let Some(volume_dos) = trimmed + .strip_prefix(r"\\?\") + .or_else(|| trimmed.strip_prefix(r"\\\\?\\")) + { + if let Some(path) = query_dos_device_name(volume_dos) { + if path.starts_with(r"\Device\") { + return Some(path); + } + } + } + + let open_path = trimmed.to_string(); + let wide = to_wide(&open_path); + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + 0, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return None; + } + let mut buffer = vec![0u16; 512]; + let written = unsafe { + windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW( + handle, + buffer.as_mut_ptr(), + buffer.len() as u32, + windows_sys::Win32::Storage::FileSystem::VOLUME_NAME_NT, + ) + }; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if written == 0 || written as usize >= buffer.len() { + return None; + } + let path = wide_to_string(&buffer[..written as usize]); + if path.starts_with(r"\Device\") { + Some(path) + } else { + None + } + } + + fn win32_volume_name_to_symlink(win32_name: &str) -> String { + let trimmed = win32_name.trim_end_matches('\\'); + if let Some(rest) = trimmed.strip_prefix(r"\\?\") { + format!(r"\??\{rest}") + } else { + trimmed.to_string() + } + } + + fn volume_drive_letters(win32_volume_name: &str) -> Vec { + // API requires a trailing backslash on the Win32 volume name. + let mut name = win32_volume_name.to_string(); + if !name.ends_with('\\') { + name.push('\\'); + } + let wide_name = to_wide(&name); + let mut needed = 0u32; + let probe_ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumePathNamesForVolumeNameW( + wide_name.as_ptr(), + std::ptr::null_mut(), + 0, + &mut needed, + ) + }; + // First call normally fails with ERROR_MORE_DATA and sets needed. + if needed == 0 { + let _ = probe_ok; + return Vec::new(); + } + let mut paths = vec![0u16; needed as usize]; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumePathNamesForVolumeNameW( + wide_name.as_ptr(), + paths.as_mut_ptr(), + paths.len() as u32, + &mut needed, + ) + }; + if ok == 0 { + return Vec::new(); + } + let mut letters = Vec::new(); + let mut offset = 0usize; + while offset < paths.len() { + let end = paths[offset..] + .iter() + .position(|&unit| unit == 0) + .map(|index| offset + index) + .unwrap_or(paths.len()); + if end == offset { + break; + } + let path = String::from_utf16_lossy(&paths[offset..end]); + let mut chars = path.chars(); + if let (Some(drive_letter), Some(':')) = (chars.next(), chars.next()) { + if drive_letter.is_ascii_alphabetic() { + letters.push(drive_letter.to_ascii_uppercase()); + } else if drive_letter.is_ascii_digit() { + letters.push(drive_letter); + } + } + offset = end + 1; + } + letters.sort_unstable(); + letters.dedup(); + letters + } + + /// Prefer GetVolumeNameForVolumeMountPoint (reliable for all lettered drives). + /// Fall back to NT path / extent compare like Ext2QueryVolumeDrvLetters. + fn attach_letters_like_ext2mgr(volumes: &mut [VolumeEntry]) { + // Resolve `\Device\HarddiskVolumeN` before letter matching so DefineDosDevice + // letters (no Win32 volume name) can join the FindFirstVolume row. + for volume in volumes.iter_mut() { + if volume.physical_object.is_empty() { + if let Some(path) = + nt_device_name_for_volume(&volume.win32_volume_name, &volume.letters) + { + volume.physical_object = path; + } + } + } + + let logical_mask = + unsafe { windows_sys::Win32::Storage::FileSystem::GetLogicalDrives() }; + + for letter_index in 0u32..26 { + if logical_mask & (1u32 << letter_index) == 0 { + continue; + } + let letter = char::from(b'A' + letter_index as u8); + let root = format!(r"{letter}:\"); + if let Some(mount_volume) = volume_name_for_mount_point(&root) { + let mount_norm = normalize_volume_name(&mount_volume); + for volume in volumes.iter_mut() { + if normalize_volume_name(&volume.win32_volume_name) == mount_norm + && !volume.letters.contains(&letter) + { + volume.letters.push(letter); + } + } + continue; + } + + // Removable / Ext2Srv letters may lack a Win32 volume name mapping. + let dos_name = format!("{letter}:"); + let Some(target) = query_dos_device_name(&dos_name) else { + continue; + }; + if target.is_empty() { + continue; + } + let letter_extents = volume_extents_from_path(&format!(r"\\.\{letter}:")); + for volume in volumes.iter_mut() { + let mut matched = false; + if !volume.physical_object.is_empty() + && target.eq_ignore_ascii_case(&volume.physical_object) + { + matched = true; + } + if !matched { + if let (Some(letter_extents), true) = + (letter_extents.as_ref(), !volume.extents.is_empty()) + { + if extents_equal(letter_extents, &volume.extents) { + matched = true; + } + } + } + if matched && !volume.letters.contains(&letter) { + volume.letters.push(letter); + if volume.physical_object.is_empty() && target.starts_with(r"\Device\") { + volume.physical_object = target.clone(); + } + } + } + } + + // Digit mounts 0-9 (Ext2Mgr). + for digit in '0'..='9' { + let dos_name = format!("{digit}:"); + let Some(target) = query_dos_device_name(&dos_name) else { + continue; + }; + let letter_extents = volume_extents_from_path(&format!(r"\\.\{digit}:")); + for volume in volumes.iter_mut() { + let matched = (!volume.physical_object.is_empty() + && target.eq_ignore_ascii_case(&volume.physical_object)) + || letter_extents + .as_ref() + .zip(Some(&volume.extents)) + .is_some_and(|(left, right)| { + !right.is_empty() && extents_equal(left, right) + }); + if matched && !volume.letters.contains(&digit) { + volume.letters.push(digit); + if volume.physical_object.is_empty() && target.starts_with(r"\Device\") { + volume.physical_object = target.clone(); + } + } + } + } + + for volume in volumes.iter_mut() { + volume.letters.sort_unstable(); + volume.letters.dedup(); + } + } + + /// Create volume rows for drive letters that exist in GetLogicalDrives but + /// were never returned by FindFirstVolume (typical Ext2Fsd mounts). + /// Multiple DefineDosDevice letters for the same `\Device\HarddiskVolumeN` + /// are merged onto one row (Ext2Mgr `DrvLetters` bitmask behavior). + fn synthesize_volumes_for_orphan_letters(volumes: &mut Vec) { + let logical_mask = + unsafe { windows_sys::Win32::Storage::FileSystem::GetLogicalDrives() }; + for letter_index in 0u32..26 { + if logical_mask & (1u32 << letter_index) == 0 { + continue; + } + let letter = char::from(b'A' + letter_index as u8); + if volumes.iter().any(|volume| volume.letters.contains(&letter)) { + continue; + } + let dos_name = format!("{letter}:"); + let Some(physical_object) = query_dos_device_name(&dos_name) else { + continue; + }; + if physical_object.is_empty() || !physical_object.starts_with(r"\Device\") { + continue; + } + + // Same NT device as an existing volume → attach letter there. + if let Some(volume) = volumes.iter_mut().find(|volume| { + !volume.physical_object.is_empty() + && volume.physical_object.eq_ignore_ascii_case(&physical_object) + }) { + if !volume.letters.contains(&letter) { + volume.letters.push(letter); + volume.letters.sort_unstable(); + volume.letters.dedup(); + } + continue; + } + + // Same NT device as another orphan we are about to / already did + // synthesize in this pass — merge instead of a second row. + if let Some(volume) = volumes.iter_mut().find(|volume| { + volume.win32_volume_name.is_empty() + && volume.physical_object.eq_ignore_ascii_case(&physical_object) + }) { + if !volume.letters.contains(&letter) { + volume.letters.push(letter); + volume.letters.sort_unstable(); + volume.letters.dedup(); + } + continue; + } + + let Some(entry) = volume_from_drive_letter(letter) else { + continue; + }; + volumes.push(entry); + } + + merge_volumes_sharing_physical_object(volumes); + } + + /// Collapse rows that share the same `\Device\...` path so multi-letter + /// DefineDosDevice mounts appear as one volume (e.g. F: and K: → HarddiskVolume6). + fn merge_volumes_sharing_physical_object(volumes: &mut Vec) { + let mut index = 0usize; + while index < volumes.len() { + let physical = volumes[index].physical_object.clone(); + if physical.is_empty() || !physical.starts_with(r"\Device\") { + index += 1; + continue; + } + let mut other = index + 1; + while other < volumes.len() { + if volumes[other] + .physical_object + .eq_ignore_ascii_case(&physical) + { + let mut letters = std::mem::take(&mut volumes[other].letters); + volumes[index].letters.append(&mut letters); + volumes[index].letters.sort_unstable(); + volumes[index].letters.dedup(); + if volumes[index].win32_volume_name.is_empty() + && !volumes[other].win32_volume_name.is_empty() + { + volumes[index].win32_volume_name = + volumes[other].win32_volume_name.clone(); + volumes[index].symlink = volumes[other].symlink.clone(); + } + if volumes[index].extents.is_empty() && !volumes[other].extents.is_empty() { + volumes[index].extents = volumes[other].extents.clone(); + } + if volumes[index].filesystem.is_empty() + && !volumes[other].filesystem.is_empty() + { + volumes[index].filesystem = volumes[other].filesystem.clone(); + } + if volumes[index].total_bytes.is_none() { + volumes[index].total_bytes = volumes[other].total_bytes; + } + if volumes[index].used_bytes.is_none() { + volumes[index].used_bytes = volumes[other].used_bytes; + } + if volumes[index].uuid.is_none() { + volumes[index].uuid = volumes[other].uuid; + } + volumes.remove(other); + } else { + other += 1; + } + } + index += 1; + } + } + + /// Build a volume entry for a lettered drive that has no Win32 Volume{GUID} name + /// (common for Ext2Fsd / DefineDosDevice mounts). + fn volume_from_drive_letter(letter: char) -> Option { + let dos_name = format!("{letter}:"); + let physical_object = query_dos_device_name(&dos_name)?; + if physical_object.is_empty() || !physical_object.starts_with(r"\Device\") { + return None; + } + let open_path = format!(r"\\.\{letter}:"); + let extents = volume_extents_from_path(&open_path).unwrap_or_default(); + let (filesystem, total_bytes, used_bytes, uuid) = + detect_filesystem_and_sizes(&open_path, &[letter]); + Some(VolumeEntry { + letters: vec![letter], + volume_kind: "Basic".to_string(), + filesystem, + total_bytes, + used_bytes, + codepage: String::new(), + physical_object, + symlink: format!(r"\??\{}", open_path.trim_start_matches(r"\\.\")), + win32_volume_name: String::new(), + extents, + uuid, + }) + } + + fn normalize_volume_name(name: &str) -> String { + name.trim_end_matches('\\').to_ascii_uppercase() + } + + fn volume_name_for_mount_point(root_path: &str) -> Option { + let wide = to_wide(root_path); + let mut buffer = vec![0u16; 128]; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumeNameForVolumeMountPointW( + wide.as_ptr(), + buffer.as_mut_ptr(), + buffer.len() as u32, + ) + }; + if ok == 0 { + None + } else { + Some(wide_to_string(&buffer)) + } + } + + fn extents_equal(left: &[DiskExtent], right: &[DiskExtent]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter().zip(right.iter()).all(|(left_extent, right_extent)| { + left_extent.disk_number == right_extent.disk_number + && left_extent.starting_offset == right_extent.starting_offset + && left_extent.extent_length == right_extent.extent_length + }) + } + + fn volume_extents(win32_volume_name: &str) -> Option> { + let open_path = win32_volume_name.trim_end_matches('\\').to_string(); + volume_extents_from_path(&open_path) + } + + fn volume_extents_from_path(open_path: &str) -> Option> { + let wide = to_wide(open_path); + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + 0, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return None; + } + + let mut capacity = 256usize; + for _ in 0..6 { + let mut buffer = vec![0u8; capacity]; + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, + std::ptr::null(), + 0, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + if code == 122 { + capacity *= 2; + continue; + } + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return None; + } + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if bytes_returned < 4 { + return None; + } + let count = u32::from_le_bytes(buffer[0..4].try_into().ok()?) as usize; + let mut extents = Vec::new(); + // VOLUME_DISK_EXTENTS: DWORD NumberOfDiskExtents; pad; DISK_EXTENT[n] + for extent_index in 0..count { + let offset = 8 + extent_index * 24; + if offset + 24 > bytes_returned as usize { + break; + } + let disk_number = + u32::from_le_bytes(buffer[offset..offset + 4].try_into().ok()?); + let starting_offset = + i64::from_le_bytes(buffer[offset + 8..offset + 16].try_into().ok()?) as u64; + let extent_length = + i64::from_le_bytes(buffer[offset + 16..offset + 24].try_into().ok()?) as u64; + extents.push(DiskExtent { + disk_number, + starting_offset, + extent_length, + }); + } + return Some(extents); + } + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + None + } + + fn detect_filesystem_and_sizes( + win32_volume_name: &str, + letters: &[char], + ) -> (String, Option, Option, Option<[u8; 16]>) { + let letter_open = letters + .first() + .map(|letter| format!(r"\\.\{letter}:")); + if let Some(letter) = letters.first() { + let root = format!(r"{letter}:\"); + if let Some((name, total, free)) = get_volume_information_and_space(&root) { + if !name.eq_ignore_ascii_case("RAW") { + let used = total.saturating_sub(free); + let used = if name.eq_ignore_ascii_case("SWAP") { + 0 + } else { + used + }; + // Still try raw probe for EXT UUID when Windows already mounted EXT. + let mut uuid = None; + if !win32_volume_name.is_empty() { + uuid = probe_volume_raw(win32_volume_name.trim_end_matches('\\')) + .and_then(|(_, uuid)| uuid); + } + if uuid.is_none() { + if let Some(ref open_path) = letter_open { + uuid = probe_volume_raw(open_path).and_then(|(_, uuid)| uuid); + } + } + return (name, Some(total), Some(used), uuid); + } + } + } + + let open_path = if win32_volume_name.trim_end_matches('\\').is_empty() { + letter_open.clone().unwrap_or_default() + } else { + win32_volume_name.trim_end_matches('\\').to_string() + }; + if !open_path.is_empty() { + if let Some((probed, uuid)) = probe_volume_raw(&open_path) { + let used = if probed == "SWAP" { Some(0) } else { None }; + return (probed, None, used, uuid); + } + } + if let Some(ref open_path) = letter_open { + if open_path != &win32_volume_name.trim_end_matches('\\').to_string() { + if let Some((probed, uuid)) = probe_volume_raw(open_path) { + let used = if probed == "SWAP" { Some(0) } else { None }; + return (probed, None, used, uuid); + } + } + } + + if !open_path.is_empty() { + if let Some((name, total, free)) = + get_volume_information_and_space(&format!(r"{}\", open_path)) + { + let used = total.saturating_sub(free); + return (name, Some(total), Some(used), None); + } + } + + ("RAW".to_string(), None, None, None) + } + + fn get_volume_information_and_space( + root_path: &str, + ) -> Option<(String, u64, u64)> { + let wide = to_wide(root_path); + let mut fs_name = vec![0u16; 64]; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumeInformationW( + wide.as_ptr(), + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + fs_name.as_mut_ptr(), + fs_name.len() as u32, + ) + }; + if ok == 0 { + return None; + } + let name = wide_to_string(&fs_name); + if name.is_empty() { + return None; + } + + let mut free_bytes_available = 0u64; + let mut total_bytes = 0u64; + let mut total_free_bytes = 0u64; + let space_ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut free_bytes_available, + &mut total_bytes, + &mut total_free_bytes, + ) + }; + if space_ok == 0 { + Some((name, 0, 0)) + } else { + Some((name, total_bytes, total_free_bytes)) + } + } + + fn probe_volume_raw(volume_path: &str) -> Option<(String, Option<[u8; 16]>)> { + let wide = to_wide(volume_path); + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return None; + } + + let mut first = vec![0u8; 4096]; + if !read_at(handle, 0, &mut first) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return None; + } + if let Some(result) = crate::disk::fs_probe::probe_first_page(&first) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return Some(result); + } + + let mut btrfs = vec![0u8; 4096]; + if read_at(handle, BTRFS_SUPER_BLOCK_OFFSET, &mut btrfs) { + if let Some(name) = crate::disk::fs_probe::probe_btrfs_page(&btrfs) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return Some((name, None)); + } + } + + let mut raid = vec![0u8; 4096]; + if read_at(handle, RAID_SUPER_BLOCK_OFFSET, &mut raid) { + if let Some(name) = crate::disk::fs_probe::probe_raid_page(&raid) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return Some((name, None)); + } + } + + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + None + } + + fn read_at( + handle: windows_sys::Win32::Foundation::HANDLE, + offset: u64, + buffer: &mut [u8], + ) -> bool { + let mut new_pos = 0i64; + let seek_ok = unsafe { + windows_sys::Win32::Storage::FileSystem::SetFilePointerEx( + handle, + offset as i64, + &mut new_pos, + windows_sys::Win32::Storage::FileSystem::FILE_BEGIN, + ) + }; + if seek_ok == 0 { + return false; + } + let mut bytes_read = 0u32; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::ReadFile( + handle, + buffer.as_mut_ptr(), + buffer.len() as u32, + &mut bytes_read, + std::ptr::null_mut(), + ) + }; + ok != 0 && (bytes_read as usize) > 0 + } + + fn query_dos_device_name(dos_name: &str) -> Option { + let wide = to_wide(dos_name); + let mut target = vec![0u16; 512]; + let written = unsafe { + windows_sys::Win32::Storage::FileSystem::QueryDosDeviceW( + wide.as_ptr(), + target.as_mut_ptr(), + target.len() as u32, + ) + }; + if written == 0 { + None + } else { + Some(wide_to_string(&target)) + } + } + + fn to_wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + fn wide_to_string(wide: &[u16]) -> String { + let end = wide.iter().position(|&unit| unit == 0).unwrap_or(wide.len()); + String::from_utf16_lossy(&wide[..end]) + } + + fn wide_chars_to_string(bytes: &[u8]) -> String { + let mut units = Vec::with_capacity(bytes.len() / 2); + for chunk in bytes.chunks_exact(2) { + let unit = u16::from_le_bytes([chunk[0], chunk[1]]); + if unit == 0 { + break; + } + units.push(unit); + } + String::from_utf16_lossy(&units) + } + + /// Keep list/UI text ASCII printable; drop CJK and other non-ASCII. + fn ascii_display_name(raw: &str) -> String { + raw.chars() + .filter(|ch| { + let code = *ch as u32; + (0x20..=0x7E).contains(&code) + }) + .collect::() + .trim() + .to_string() + } + + /// Ext2Mgr `Ext2RefreshVLVI` / `Ext2RefreshDVPT` column: `EVP.Codepage`. + /// Query via `IOCTL_APP_VOLUME_PROPERTY` (APP_CMD_QUERY_PROPERTY3); fall back to + /// Ext2Fsd Volumes\{UUID} then global Parameters\CodePage for EXT volumes. + fn resolve_volume_codepage(volume: &VolumeEntry) -> String { + if let Some(codepage) = query_ext2_property_codepage(volume) { + if !codepage.is_empty() { + return codepage; + } + } + if let Some(uuid) = volume.uuid { + if let Some(codepage) = registry_volume_codepage(&uuid) { + if !codepage.is_empty() { + return codepage; + } + } + } + let fs_upper = volume.filesystem.to_ascii_uppercase(); + if fs_upper.contains("EXT") { + if let Some(codepage) = registry_global_codepage() { + if !codepage.is_empty() { + return codepage; + } + } + // Ext2Fsd volumes always have a codepage concept; match driver default. + return "default".to_string(); + } + String::new() + } + + fn query_ext2_property_codepage(volume: &VolumeEntry) -> Option { + if !ext2fsd_device_present() { + return None; + } + for open_path in volume_ext2_open_paths(volume) { + if let Some(codepage) = ioctl_query_ext2_codepage(&open_path) { + return Some(codepage); + } + } + None + } + + fn volume_ext2_open_paths(volume: &VolumeEntry) -> Vec { + let mut paths = Vec::new(); + if let Some(letter) = volume.letters.first() { + paths.push(format!(r"\\.\{letter}:")); + } + if !volume.physical_object.is_empty() { + // Ext2Mgr opens \Device\HarddiskVolumeN via ZwCreateFile. + // Win32 equivalent: \\?\GLOBALROOT\Device\HarddiskVolumeN + let trimmed = volume.physical_object.trim_start_matches('\\'); + paths.push(format!(r"\\?\GLOBALROOT\{trimmed}")); + if let Some(rest) = volume.physical_object.strip_prefix(r"\Device\") { + paths.push(format!(r"\\.\{rest}")); + } + } + let win32 = volume.win32_volume_name.trim_end_matches('\\'); + if !win32.is_empty() { + paths.push(win32.to_string()); + } + paths + } + + fn create_file_generic_read(path: &str) -> Option { + const GENERIC_READ: u32 = 0x8000_0000; + let wide = to_wide(path); + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + GENERIC_READ, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + None + } else { + Some(handle) + } + } + + fn ext2fsd_device_present() -> bool { + // Same device Ext2Mgr opens as \DosDevices\Ext2Fsd with GENERIC_READ. + if let Some(handle) = create_file_generic_read(r"\\.\Ext2Fsd") { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + true + } else { + false + } + } + + fn ioctl_query_ext2_codepage(open_path: &str) -> Option { + // Ext4Fsd/include/common.h — QUERY_PROPERTY is enough to fill Codepage. + const IOCTL_APP_VOLUME_PROPERTY: u32 = 0x0022_1F40; + const EXT2_VOLUME_PROPERTY_MAGIC: u32 = 0x4556_504D; // 'EVPM' + const APP_CMD_QUERY_PROPERTY: u32 = 0x0000_0002; + const CODEPAGE_OFF: usize = 16; + const CODEPAGE_MAXLEN: usize = 0x20; + const PROP_SIZE: usize = 48; // sizeof(EXT2_VOLUME_PROPERTY) + + let handle = create_file_generic_read(open_path)?; + let mut buffer = vec![0u8; PROP_SIZE]; + buffer[0..4].copy_from_slice(&EXT2_VOLUME_PROPERTY_MAGIC.to_le_bytes()); + buffer[8..12].copy_from_slice(&APP_CMD_QUERY_PROPERTY.to_le_bytes()); + + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_APP_VOLUME_PROPERTY, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if ok == 0 { + return None; + } + + let codepage = c_string_from_bytes(&buffer[CODEPAGE_OFF..CODEPAGE_OFF + CODEPAGE_MAXLEN]); + // Driver writes "default" when no NLS table; treat empty success as default. + Some(if codepage.is_empty() { + "default".to_string() + } else { + codepage + }) + } + + fn registry_volume_codepage(uuid: &[u8; 16]) -> Option { + let value_name = format_ext2_uuid(uuid); + let key_path = r"SYSTEM\CurrentControlSet\Services\Ext2Fsd\Volumes"; + let blob = reg_query_string(key_path, &value_name)?; + for part in blob.split(';') { + let part = part.trim(); + if let Some(value) = part + .strip_prefix("CodePage=") + .or_else(|| part.strip_prefix("codepage=")) + { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None + } + + fn registry_global_codepage() -> Option { + reg_query_string( + r"SYSTEM\CurrentControlSet\Services\Ext2Fsd\Parameters", + "CodePage", + ) + } + + fn format_ext2_uuid(uuid: &[u8; 16]) -> String { + let mut out = String::from("{"); + for (index, byte) in uuid.iter().enumerate() { + if index == 0 { + out.push_str(&format!("{byte:02X}")); + } else if index == 15 { + out.push_str(&format!("-{byte:02X}}}")); + } else { + out.push_str(&format!("-{byte:02X}")); + } + } + out + } + + fn reg_query_string(key_path: &str, value_name: &str) -> Option { + let wide_path = to_wide(key_path); + let mut key = 0; + let status = unsafe { + windows_sys::Win32::System::Registry::RegOpenKeyExW( + windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE, + wide_path.as_ptr(), + 0, + windows_sys::Win32::System::Registry::KEY_READ, + &mut key, + ) + }; + if status != 0 { + return None; + } + let wide_name = to_wide(value_name); + let mut value_type = 0u32; + let mut data = vec![0u8; 512]; + let mut data_len = data.len() as u32; + let status = unsafe { + windows_sys::Win32::System::Registry::RegQueryValueExW( + key, + wide_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + data.as_mut_ptr(), + &mut data_len, + ) + }; + unsafe { + windows_sys::Win32::System::Registry::RegCloseKey(key); + } + if status != 0 || data_len < 2 { + return None; + } + let units: Vec = data[..data_len as usize] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .take_while(|unit| *unit != 0) + .collect(); + let text = String::from_utf16_lossy(&units); + if text.is_empty() { + None + } else { + Some(text) + } + } + + fn c_string_from_bytes(bytes: &[u8]) -> String { + let end = bytes.iter().position(|&byte| byte == 0).unwrap_or(bytes.len()); + let raw = String::from_utf8_lossy(&bytes[..end]).into_owned(); + ascii_display_name(&raw) + } +} + +pub use win_impl::*; + diff --git a/ext2mgr_iced/src/disk/fs_probe.rs b/ext2mgr_iced/src/disk/fs_probe.rs new file mode 100644 index 0000000..e59f9c3 --- /dev/null +++ b/ext2mgr_iced/src/disk/fs_probe.rs @@ -0,0 +1,165 @@ +//! Raw filesystem probes matching Ext2Mgr `Ext2QueryVolumeFS`. + +pub const SUPER_BLOCK_OFFSET: usize = 0x400; +pub const EXT4_SUPER_MAGIC: u16 = 0xEF53; +pub const EXT4_FEATURE_COMPAT_HAS_JOURNAL: u32 = 0x0004; +pub const EXT4_FEATURE_INCOMPAT_RECOVER: u32 = 0x0004; +pub const EXT4_FEATURE_INCOMPAT_JOURNAL_DEV: u32 = 0x0008; +pub const EXT4_FEATURE_INCOMPAT_EXTENTS: u32 = 0x0040; +pub const EXT4_FEATURE_INCOMPAT_SUPP: u32 = 0x0002 + | 0x0004 + | 0x0010 + | 0x0040 + | 0x0080 + | 0x0200 + | 0x2000 + | 0x20000; + +pub const SWAP_MAGIC_OFFSET: usize = 0x1000 - 10; +pub const SWAP_HEADER_MAGIC_V1: &[u8; 10] = b"SWAP-SPACE"; +pub const SWAP_HEADER_MAGIC_V2: &[u8; 10] = b"SWAPSPACE2"; + +pub const XFS_SB_MAGIC_LE: u32 = 0x4253_4658; +pub const LVM_MAGIC: &[u8; 8] = b"LABELONE"; +pub const BSD_DISKMAGIC: u32 = 0x8256_4557; +pub const BSD_MAGIC_OFFSET: usize = 512; + +pub const BTRFS_SUPER_BLOCK_OFFSET: u64 = 0x1_0000; +pub const BTRFS_MAGIC_OFFSET: usize = 64; +pub const BTRFS_MAGIC: u64 = 0x4D5F_5366_5248_425F; + +pub const RAID_SUPER_BLOCK_OFFSET: u64 = 0x1000; +pub const RAID_MAGIC: u32 = 0xa92b_4efc; + +/// Classify from the first 4096-byte page (EXT/SWAP/XFS/LVM/BSD). +/// Returns (fs name, optional EXT UUID from superblock). +pub fn probe_first_page(buffer: &[u8]) -> Option<(String, Option<[u8; 16]>)> { + if buffer.len() < SUPER_BLOCK_OFFSET + 0x64 { + return None; + } + + let magic = u16::from_le_bytes([ + buffer[SUPER_BLOCK_OFFSET + 0x38], + buffer[SUPER_BLOCK_OFFSET + 0x39], + ]); + if magic == EXT4_SUPER_MAGIC { + let feature_compat = u32::from_le_bytes( + buffer[SUPER_BLOCK_OFFSET + 0x5C..SUPER_BLOCK_OFFSET + 0x60] + .try_into() + .ok()?, + ); + let feature_incompat = u32::from_le_bytes( + buffer[SUPER_BLOCK_OFFSET + 0x60..SUPER_BLOCK_OFFSET + 0x64] + .try_into() + .ok()?, + ); + let mut uuid = [0u8; 16]; + uuid.copy_from_slice(&buffer[SUPER_BLOCK_OFFSET + 0x68..SUPER_BLOCK_OFFSET + 0x78]); + return Some((classify_ext(feature_compat, feature_incompat), Some(uuid))); + } + + if buffer.len() >= SWAP_MAGIC_OFFSET + 10 { + let swap_magic = &buffer[SWAP_MAGIC_OFFSET..SWAP_MAGIC_OFFSET + 10]; + if swap_magic == SWAP_HEADER_MAGIC_V1 || swap_magic == SWAP_HEADER_MAGIC_V2 { + return Some(("SWAP".to_string(), None)); + } + } + + if let Ok(bytes) = buffer[0..4].try_into() { + if u32::from_le_bytes(bytes) == XFS_SB_MAGIC_LE { + return Some(("XFS".to_string(), None)); + } + } + + for sector in 0..4 { + let start = sector * 512; + if buffer.len() >= start + 8 && &buffer[start..start + 8] == LVM_MAGIC { + return Some(("LVM".to_string(), None)); + } + } + + if buffer.len() >= BSD_MAGIC_OFFSET + 4 { + if let Ok(bytes) = buffer[BSD_MAGIC_OFFSET..BSD_MAGIC_OFFSET + 4].try_into() { + if u32::from_le_bytes(bytes) == BSD_DISKMAGIC { + return Some(("BSD".to_string(), None)); + } + } + } + + None +} + +pub fn probe_btrfs_page(buffer: &[u8]) -> Option { + if buffer.len() < BTRFS_MAGIC_OFFSET + 8 { + return None; + } + let magic = u64::from_le_bytes( + buffer[BTRFS_MAGIC_OFFSET..BTRFS_MAGIC_OFFSET + 8] + .try_into() + .ok()?, + ); + if magic == BTRFS_MAGIC { + Some("BTRFS".to_string()) + } else { + None + } +} + +pub fn probe_raid_page(buffer: &[u8]) -> Option { + if buffer.len() < 4 { + return None; + } + let magic = u32::from_le_bytes(buffer[0..4].try_into().ok()?); + if magic == RAID_MAGIC { + Some("RAID".to_string()) + } else { + None + } +} + +fn classify_ext(feature_compat: u32, feature_incompat: u32) -> String { + if feature_incompat & EXT4_FEATURE_INCOMPAT_EXTENTS != 0 { + if feature_incompat & !EXT4_FEATURE_INCOMPAT_SUPP != 0 { + "EXT4+".to_string() + } else { + "EXT4".to_string() + } + } else if feature_incompat + & (EXT4_FEATURE_INCOMPAT_JOURNAL_DEV | EXT4_FEATURE_INCOMPAT_RECOVER) + != 0 + || feature_compat & EXT4_FEATURE_COMPAT_HAS_JOURNAL != 0 + { + "EXT3".to_string() + } else { + "EXT2".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_ext_magic_layout() { + let mut buffer = vec![0u8; 4096]; + buffer[SUPER_BLOCK_OFFSET + 0x38] = 0x53; + buffer[SUPER_BLOCK_OFFSET + 0x39] = 0xEF; + // extents incompat + buffer[SUPER_BLOCK_OFFSET + 0x60] = 0x40; + assert_eq!( + probe_first_page(&buffer).map(|(name, _)| name).as_deref(), + Some("EXT4") + ); + } + + #[test] + fn detects_swap_magic() { + let mut buffer = vec![0u8; 4096]; + buffer[SWAP_MAGIC_OFFSET..SWAP_MAGIC_OFFSET + 10] + .copy_from_slice(SWAP_HEADER_MAGIC_V2); + assert_eq!( + probe_first_page(&buffer).map(|(name, _)| name).as_deref(), + Some("SWAP") + ); + } +} diff --git a/ext2mgr_iced/src/disk/mod.rs b/ext2mgr_iced/src/disk/mod.rs new file mode 100644 index 0000000..44750a5 --- /dev/null +++ b/ext2mgr_iced/src/disk/mod.rs @@ -0,0 +1,4 @@ +//! Disk / volume enumeration and filesystem probes. + +pub mod enum_disk; +pub mod fs_probe; diff --git a/ext2mgr_iced/src/main.rs b/ext2mgr_iced/src/main.rs new file mode 100644 index 0000000..6218b15 --- /dev/null +++ b/ext2mgr_iced/src/main.rs @@ -0,0 +1,41 @@ +//! Ext2Mgr (Iced port) — Ext2/Ext4 volume manager GUI. +//! +//! Layout: +//! - [`ui`] — iced window / tables / dialogs +//! - [`disk`] — volume/disk enumeration + FS probes +//! - [`mount`] — Ext2Srv pipe, mount modes, persist, dead letters +//! - [`service`] — Ext2Fsd service settings +//! - [`win`] — native chrome, accent, autorun, clipboard, msgbox + +mod disk; +#[cfg(windows)] +mod mount; +#[cfg(windows)] +mod service; +#[cfg(windows)] +mod win; +mod ui; + +use iced::window; +use iced::Size; + +fn load_window_icon() -> Option { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/Ext2Mgr.ico"); + let file = ::image::open(&path).ok()?.into_rgba8(); + let width = file.width(); + let height = file.height(); + window::icon::from_rgba(file.into_raw(), width, height).ok() +} + +fn main() -> iced::Result { + iced::application(ui::Ext2MgrApp::new, ui::Ext2MgrApp::update, ui::Ext2MgrApp::view) + .title(ui::Ext2MgrApp::title) + .subscription(ui::Ext2MgrApp::subscription) + .theme(ui::Ext2MgrApp::theme) + .window(window::Settings { + size: Size::new(720.0, 720.0), + icon: load_window_icon(), + ..Default::default() + }) + .run() +} diff --git a/ext2mgr_iced/src/mount/dead_letters.rs b/ext2mgr_iced/src/mount/dead_letters.rs new file mode 100644 index 0000000..441dc45 --- /dev/null +++ b/ext2mgr_iced/src/mount/dead_letters.rs @@ -0,0 +1,293 @@ +//! Dead / orphan drive letter detection and removal. +//! +//! Matches Ext2Mgr `CDelDeadLetter` / `Ext2RemoveMountPoint` / `Ext2RemoveDosSymLink`: +//! remove via `DefineDosDevice` locally — do **not** block on the Ext2Srv pipe +//! (that freezes the GUI when Ext2Srv is slow/absent, and is wrong for redirects +//! like `J:` → `\??\D:`). +//! +//! Mount Manager membership uses `GetVolumePathNamesForVolumeNameW` on the volume +//! GUID — never `GetVolumeNameForVolumeMountPoint` / `GetFileAttributes` on `X:\`, +//! which can hang for minutes on a bad DefineDosDevice bind. +//! +//! Also lists Session Manager DOS Devices letters that are not currently live in +//! `QueryDosDevice` (dormant registry leftovers beside another working letter — +//! e.g. `T:` → `\Device\HarddiskVolume5` while `F:` already serves that device). + +#![cfg(windows)] + +use crate::disk::enum_disk::VolumeEntry; +use std::collections::HashSet; +use std::os::windows::ffi::OsStrExt; + +#[derive(Debug, Clone)] +pub struct DeadLetter { + pub letter: char, + pub symlink: String, + /// True when removal might need a Mount Manager clear (orphan). + /// False for temporary DefineDosDevice leftovers beside a live MountMgr letter, + /// and for Session Manager registry-only dormants (registry is always cleared). + pub may_have_permanent: bool, +} + +/// Letters that QueryDosDevice resolves but are orphans or Explorer-unusable leftovers, +/// plus Session Manager registry letters with no matching live DOS device. +pub fn find_dead_letters(volumes: &[VolumeEntry]) -> Vec { + let mut claimed = HashSet::new(); + for volume in volumes { + for letter in &volume.letters { + claimed.insert(letter.to_ascii_uppercase()); + } + } + + // MountMgr letters for each Win32 volume name (GUID path) — does not open X:\. + let mountmgr_by_volume: Vec> = volumes + .iter() + .map(|volume| { + if volume.win32_volume_name.is_empty() { + HashSet::new() + } else { + mountmgr_letters_for_volume(&volume.win32_volume_name) + } + }) + .collect(); + + let mut all_mountmgr = HashSet::new(); + for set in &mountmgr_by_volume { + all_mountmgr.extend(set.iter().copied()); + } + + let mut dead = Vec::new(); + let mut listed = HashSet::new(); + + for letter_index in 0u8..26 { + let letter = char::from(b'A' + letter_index); + let Some(symlink) = query_dos_device(letter) else { + continue; + }; + + // Volume enum sometimes misses a letter that already maps to a known + // `\Device\HarddiskVolumeN` (multi-letter Ext2 DOS mounts). That is not dead — + // treating it as an orphan falsely listed live letters like Q: beside K:/L:. + let matches_known_device = volumes.iter().any(|volume| { + !volume.physical_object.is_empty() + && volume.physical_object.eq_ignore_ascii_case(&symlink) + }); + if matches_known_device { + claimed.insert(letter); + } + + if !claimed.contains(&letter) { + // Orphan DOS mapping — may still have a stale Mount Manager assignment. + dead.push(DeadLetter { + letter, + symlink, + may_have_permanent: true, + }); + listed.insert(letter); + continue; + } + + if all_mountmgr.contains(&letter) { + continue; + } + + // Claimed via NT-device merge but not a MountMgr path for that volume — + // typically a Temporary/Session DefineDosDevice beside a MountMgr letter. + let Some(volume_index) = volumes.iter().position(|volume| { + volume + .letters + .iter() + .any(|entry| entry.to_ascii_uppercase() == letter) + || (!volume.physical_object.is_empty() + && volume.physical_object.eq_ignore_ascii_case(&symlink)) + }) else { + continue; + }; + let mountmgr = &mountmgr_by_volume[volume_index]; + let has_mountmgr_sibling = volumes[volume_index].letters.iter().any(|entry| { + let other = entry.to_ascii_uppercase(); + other != letter && mountmgr.contains(&other) + }) || mountmgr.iter().any(|other| *other != letter); + if has_mountmgr_sibling { + dead.push(DeadLetter { + letter, + symlink, + may_have_permanent: false, + }); + listed.insert(letter); + } + } + + // Session Manager registry letters with **no** live QueryDosDevice bind + // (dormant leftovers, e.g. T: for HarddiskVolume5 while F: is already live). + // Do **not** flag a registry letter that still has a live DOS bind — even if the + // registry string compare is fussy — those are working mounts (Q:/L: on Vol7). + for (letter, device) in super::persist::registry_dos_device_entries() { + if listed.contains(&letter) { + continue; + } + if query_dos_device(letter).is_some() { + continue; + } + dead.push(DeadLetter { + letter, + symlink: device, + may_have_permanent: false, + }); + listed.insert(letter); + } + + dead.sort_by(|left, right| left.letter.cmp(&right.letter)); + dead +} + +pub fn mountmgr_letters_for_volume(win32_volume_name: &str) -> HashSet { + let mut name = win32_volume_name.to_string(); + if !name.ends_with('\\') { + name.push('\\'); + } + let wide_name = to_wide(&name); + let mut needed = 0u32; + let _ = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumePathNamesForVolumeNameW( + wide_name.as_ptr(), + std::ptr::null_mut(), + 0, + &mut needed, + ) + }; + if needed == 0 { + return HashSet::new(); + } + let mut paths = vec![0u16; needed as usize]; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumePathNamesForVolumeNameW( + wide_name.as_ptr(), + paths.as_mut_ptr(), + paths.len() as u32, + &mut needed, + ) + }; + if ok == 0 { + return HashSet::new(); + } + + let mut letters = HashSet::new(); + let mut offset = 0usize; + while offset < paths.len() { + let end = paths[offset..] + .iter() + .position(|&unit| unit == 0) + .map(|index| offset + index) + .unwrap_or(paths.len()); + if end == offset { + break; + } + let path = String::from_utf16_lossy(&paths[offset..end]); + let mut chars = path.chars(); + if let (Some(drive_letter), Some(':')) = (chars.next(), chars.next()) { + if drive_letter.is_ascii_alphabetic() { + letters.insert(drive_letter.to_ascii_uppercase()); + } + } + offset = end + 1; + } + letters +} + +pub fn remove_dead_letter(letter: char, symlink: &str, delete_mountmgr: bool) -> Result<(), String> { + // Registry-only dormants have no live DOS device — skip DefineDosDevice remove. + if query_dos_device(letter).is_some() { + remove_dos_device(letter, symlink)?; + } + + if delete_mountmgr { + let mount_root = format!(r"{letter}:\"); + let wide = to_wide(&mount_root); + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::DeleteVolumeMountPointW(wide.as_ptr()) + }; + if ok == 0 { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + if code != 2 && code != 3 && code != 87 { + return Err(format!( + "DeleteVolumeMountPoint({letter}:) failed ({code})" + )); + } + } + } + let _ = super::persist::clear_registry_mount_point(letter); + + if query_dos_device(letter).is_some() { + return Err(format!( + "Failed to remove drive letter {letter}: (still present after DefineDosDevice)" + )); + } + Ok(()) +} + +fn remove_dos_device(letter: char, symlink: &str) -> Result<(), String> { + const DDD_RAW_TARGET_PATH: u32 = 0x0000_0001; + const DDD_REMOVE_DEFINITION: u32 = 0x0000_0002; + const DDD_EXACT_MATCH_ON_REMOVE: u32 = 0x0000_0004; + + let dos = format!("{letter}:"); + let dos_w = to_wide(&dos); + let target_w = to_wide(symlink); + + let exact_ok = unsafe { + windows_sys::Win32::Storage::FileSystem::DefineDosDeviceW( + DDD_RAW_TARGET_PATH | DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE, + dos_w.as_ptr(), + target_w.as_ptr(), + ) + }; + if exact_ok != 0 { + return Ok(()); + } + + let loose_ok = unsafe { + windows_sys::Win32::Storage::FileSystem::DefineDosDeviceW( + DDD_REMOVE_DEFINITION, + dos_w.as_ptr(), + std::ptr::null(), + ) + }; + if loose_ok != 0 { + return Ok(()); + } + + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + Err(format!( + "Failed to remove drive letter {letter}:\nDefineDosDevice failed ({code})" + )) +} + +fn query_dos_device(letter: char) -> Option { + let dos = format!("{letter}:"); + let wide = to_wide(&dos); + let mut target = vec![0u16; 512]; + let written = unsafe { + windows_sys::Win32::Storage::FileSystem::QueryDosDeviceW( + wide.as_ptr(), + target.as_mut_ptr(), + target.len() as u32, + ) + }; + if written == 0 { + None + } else { + let end = target + .iter() + .position(|&unit| unit == 0) + .unwrap_or(target.len()); + Some(String::from_utf16_lossy(&target[..end])) + } +} + +fn to_wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} diff --git a/ext2mgr_iced/src/mount/mod.rs b/ext2mgr_iced/src/mount/mod.rs new file mode 100644 index 0000000..5f77a5c --- /dev/null +++ b/ext2mgr_iced/src/mount/mod.rs @@ -0,0 +1,10 @@ +//! Mount / unmount, Ext2Srv pipe, Session Manager persist, dead letters. + +#[cfg(windows)] +pub mod pipe; +#[cfg(windows)] +pub mod ops; +#[cfg(windows)] +pub mod persist; +#[cfg(windows)] +pub mod dead_letters; diff --git a/ext2mgr_iced/src/mount/ops.rs b/ext2mgr_iced/src/mount/ops.rs new file mode 100644 index 0000000..57bc25a --- /dev/null +++ b/ext2mgr_iced/src/mount/ops.rs @@ -0,0 +1,548 @@ +//! Mount / unmount helpers. +//! +//! Persistence modes (same idea as Ext2Mgr’s letter picker): +//! - **Temporary** — `DefineDosDevice` only (Ext2Srv, or local when elevated). Lost after reboot. +//! - **Mount Manager** — `SetVolumeMountPoint` (Windows MountMgr). Survives reboot; Explorer-native. +//! - **Session Manager DOS Devices** — registry under +//! `HKLM\...\Session Manager\DOS Devices`. Survives reboot. Classic also calls +//! `DefineDosDevice` afterward so the letter appears immediately; that assign is +//! not the same option as Temporary. +//! - **Ext2Fsd automount** — `Volumes\{UUID}` registry; remounted on refresh. +//! +//! Device path for registry / DefineDosDevice is `\Device\HarddiskVolumeN` +//! (Ext2Mgr `Volume->Name`). + + +use std::mem::size_of; +use std::os::windows::ffi::OsStrExt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MountMode { + /// Option: Mount via DefineDosDevice only (lost after reboot). + Temporary, + /// Option: Windows Mount Manager (`SetVolumeMountPoint`). + MountManager, + /// Option: Session Manager DOS Devices registry (survives reboot). + PermanentRegistry, + /// Ext2Fsd `Volumes\{UUID}` automount — config persists; remounted by manager/driver. + Ext2Automount, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct MountRequest { + pub letter: char, + pub mode: MountMode, + /// NT path written to Session Manager and/or passed to DefineDosDevice + /// (`\Device\HarddiskVolumeN`). + pub symlink: String, + pub win32_volume_name: String, + /// Required for Ext2Automount. + pub uuid: Option<[u8; 16]>, + pub codepage: String, + pub readonly: bool, +} + +pub fn free_drive_letters() -> Vec { + let mask = unsafe { windows_sys::Win32::Storage::FileSystem::GetLogicalDrives() }; + let mut letters = Vec::new(); + for letter_index in 2u32..26 { + if mask & (1 << letter_index) == 0 { + letters.push(char::from(b'A' + letter_index as u8)); + } + } + letters +} + +pub fn first_free_drive_letter() -> Option { + free_drive_letters().into_iter().next() +} + +/// Prefer `physical_object` when it is `\Device\...` (Ext2Mgr `Volume->Name`). +pub fn dos_device_target(physical_object: &str, symlink: &str) -> String { + if physical_object.starts_with(r"\Device\") { + physical_object.to_string() + } else if symlink.starts_with(r"\Device\") { + symlink.to_string() + } else if !physical_object.is_empty() { + physical_object.to_string() + } else { + symlink.to_string() + } +} + +pub fn mount_volume(request: &MountRequest) -> Result { + match request.mode { + MountMode::Temporary => { + define_temporary(request.letter, &request.symlink)?; + Ok(format!( + "Mounted {}: temporary via DefineDosDevice (lost after reboot)", + request.letter + )) + } + MountMode::MountManager => { + assign_via_mount_manager( + request.letter, + &request.symlink, + &request.win32_volume_name, + )?; + Ok(format!( + "Mounted {}: permanent via Mount Manager (SetVolumeMountPoint)", + request.letter + )) + } + MountMode::PermanentRegistry => { + // Registry is the Session Manager option; DefineDosDevice is only the + // immediate bind (same sequence as Ext2Mgr AddMountPoint + bRegistry). + super::persist::set_registry_mount_point(request.letter, &request.symlink)?; + define_temporary(request.letter, &request.symlink)?; + Ok(format!( + "Mounted {}: Session Manager DOS Devices (registry; immediate DefineDosDevice bind)", + request.letter + )) + } + MountMode::Ext2Automount => { + let uuid = request.uuid.ok_or_else(|| { + "Ext2Fsd automount needs an EXT volume UUID (select an EXT2/3/4 volume)".to_string() + })?; + super::persist::store_ext2_automount( + &uuid, + request.letter, + &request.codepage, + request.readonly, + )?; + define_temporary(request.letter, &request.symlink)?; + Ok(format!( + "Mounted {}: Ext2Fsd automount saved under Volumes\\{} (persists; remounted on refresh)", + request.letter, + super::persist::format_volume_uuid(&uuid) + )) + } + } +} + +pub fn unmount_letter(letter: char, symlink_hint: &str) -> Result<(), String> { + let _ = super::persist::clear_registry_mount_point(letter); + + let mount_root = format!(r"{letter}:\"); + let _ = delete_volume_mount_point(&mount_root); + + if is_process_elevated() { + let symlink = if !symlink_hint.is_empty() { + symlink_hint.to_string() + } else { + query_dos_device(letter).unwrap_or_default() + }; + if !symlink.is_empty() && define_dos_device_local_remove(letter, &symlink) { + if query_dos_device(letter).is_none() { + return Ok(()); + } + } + } + + let symlink = super::pipe::with_shared_client(|client| { + match client.query_drive(letter as u8) { + Ok(query) if query.result && !query.symlink.is_empty() => Ok(query.symlink), + _ if !symlink_hint.is_empty() => Ok(symlink_hint.to_string()), + _ => Ok(query_dos_device(letter).unwrap_or_default()), + } + }) + .map_err(|err| format!("Failed to remove drive letter {letter}:\nExt2Srv pipe: {err}"))?; + if symlink.is_empty() { + return Err(format!("Failed to remove drive letter {letter}:")); + } + let ok = super::pipe::with_shared_client(|client| client.remove_drive(letter as u8, &symlink)) + .map_err(|err| format!("Failed to remove drive letter {letter}:\n{err}"))?; + if ok { + Ok(()) + } else if query_dos_device(letter).is_none() { + Ok(()) + } else { + Err(format!("Failed to remove drive letter {letter}:")) + } +} + +pub fn pipe_status_str() -> String { + match super::pipe::health_check() { + Ok(()) => "Ext2Srv pipe: connected.".to_string(), + Err(err) => format!("Ext2Srv pipe: {err} (is Ext2Srv running?)"), + } +} + +fn define_temporary(letter: char, symlink: &str) -> Result<(), String> { + // Same order as Ext2Mgr `Ext2DefineDosDevice`: local when elevated, else Ext2Srv. + if is_process_elevated() && define_dos_device_local(letter, symlink) { + return Ok(()); + } + let ok = super::pipe::with_shared_client(|client| client.define_drive(letter as u8, symlink)).map_err( + |err| format!("Ext2Fsd service is not started.\n(Ext2Srv pipe: {err})"), + )?; + if ok { + Ok(()) + } else { + Err(format!("Failed to assign new drive letter {letter}:")) + } +} + +/// Ext2Mgr `Ext2AssignDrvLetter(..., bMountMgr=TRUE)` / `Ext2InsertMountPoint`. +fn assign_via_mount_manager( + letter: char, + symlink: &str, + win32_volume_name: &str, +) -> Result<(), String> { + let mount_root = format!(r"{letter}:\"); + let volume_name = if !win32_volume_name.is_empty() { + let mut name = win32_volume_name.to_string(); + if !name.ends_with('\\') { + name.push('\\'); + } + name + } else { + // Classic: temporary DOS bind → query GUID name → remove DOS → SetVolumeMountPoint. + define_temporary(letter, symlink)?; + let probed = match volume_name_for_mount_point(&mount_root) { + Ok(name) => name, + Err(err) => { + let _ = define_dos_device_local_remove(letter, symlink); + return Err(err); + } + }; + let _ = define_dos_device_local_remove(letter, symlink); + if query_dos_device(letter).is_some() { + // Loose remove if exact match failed. + let _ = define_dos_device_local_remove(letter, symlink); + } + probed + }; + set_volume_mount_point(&mount_root, &volume_name) +} + +fn volume_name_for_mount_point(mount_root: &str) -> Result { + let wide = to_wide(mount_root); + let mut name = vec![0u16; 256]; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::GetVolumeNameForVolumeMountPointW( + wide.as_ptr(), + name.as_mut_ptr(), + name.len() as u32, + ) + }; + if ok == 0 { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + return Err(format!( + "GetVolumeNameForVolumeMountPoint({mount_root}) failed ({code})" + )); + } + let end = name + .iter() + .position(|&unit| unit == 0) + .unwrap_or(name.len()); + Ok(String::from_utf16_lossy(&name[..end])) +} + +fn set_volume_mount_point(mount_root: &str, volume_name: &str) -> Result<(), String> { + let root_w = to_wide(mount_root); + let volume_w = to_wide(volume_name); + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::SetVolumeMountPointW( + root_w.as_ptr(), + volume_w.as_ptr(), + ) + }; + if ok == 0 { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + Err(format!( + "SetVolumeMountPoint({mount_root}) failed ({code})" + )) + } else { + Ok(()) + } +} + +fn to_wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn is_process_elevated() -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + unsafe { + let mut token: HANDLE = 0; + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return false; + } + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut returned = 0u32; + let ok = GetTokenInformation( + token, + TokenElevation, + &mut elevation as *mut _ as *mut _, + size_of::() as u32, + &mut returned, + ); + CloseHandle(token); + ok != 0 && elevation.TokenIsElevated != 0 + } +} + +fn define_dos_device_local(letter: char, symlink: &str) -> bool { + let dos = format!("{letter}:"); + let dos_wide = to_wide(&dos); + let target_wide = to_wide(symlink); + unsafe { + windows_sys::Win32::Storage::FileSystem::DefineDosDeviceW( + super::pipe::DDD_RAW_TARGET_PATH, + dos_wide.as_ptr(), + target_wide.as_ptr(), + ) != 0 + } +} + +fn define_dos_device_local_remove(letter: char, symlink: &str) -> bool { + let dos = format!("{letter}:"); + let dos_wide = to_wide(&dos); + let target_wide = to_wide(symlink); + let flags = super::pipe::DDD_RAW_TARGET_PATH + | super::pipe::DDD_REMOVE_DEFINITION + | super::pipe::DDD_EXACT_MATCH_ON_REMOVE; + unsafe { + windows_sys::Win32::Storage::FileSystem::DefineDosDeviceW( + flags, + dos_wide.as_ptr(), + target_wide.as_ptr(), + ) != 0 + } +} + +fn query_dos_device(letter: char) -> Option { + let dos = format!("{letter}:"); + let wide = to_wide(&dos); + let mut target = vec![0u16; 512]; + let written = unsafe { + windows_sys::Win32::Storage::FileSystem::QueryDosDeviceW( + wide.as_ptr(), + target.as_mut_ptr(), + target.len() as u32, + ) + }; + if written == 0 { + None + } else { + let end = target.iter().position(|&unit| unit == 0).unwrap_or(target.len()); + Some(String::from_utf16_lossy(&target[..end])) + } +} + +fn delete_volume_mount_point(mount_root: &str) -> Result<(), String> { + let wide = to_wide(mount_root); + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::DeleteVolumeMountPointW(wide.as_ptr()) + }; + if ok == 0 { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + Err(format!("DeleteVolumeMountPoint failed ({code})")) + } else { + Ok(()) + } +} + +pub fn is_ext_family(filesystem: &str) -> bool { + let upper = filesystem.to_ascii_uppercase(); + upper.starts_with("EXT2") || upper.starts_with("EXT3") || upper.starts_with("EXT4") +} + +#[derive(Debug, Clone, Default)] +pub struct AutomountReport { + pub mounted: Vec, + pub already_mounted: usize, + pub errors: Vec, +} + +impl AutomountReport { + pub fn summary(&self) -> Option { + if self.mounted.is_empty() && self.errors.is_empty() { + return None; + } + let mut parts = Vec::new(); + if !self.mounted.is_empty() { + parts.push(format!( + "Auto-mounted {} volume(s): {}", + self.mounted.len(), + self.mounted.join("; ") + )); + } + if self.already_mounted > 0 { + parts.push(format!( + "{} already mounted (skipped)", + self.already_mounted + )); + } + if !self.errors.is_empty() { + parts.push(format!("Auto-mount errors: {}", self.errors.join("; "))); + } + Some(parts.join(". ")) + } + + pub fn did_mount(&self) -> bool { + !self.mounted.is_empty() + } +} + +/// Ext2Mgr `Ext2ProcessExt2Volumes` (+ dormant Session Manager re-bind). +/// +/// For each volume that *should* have a letter (Ext2Fsd `MountPoint=` or Session +/// Manager DOS Devices), skip when already lettered / MountMgr-present / live DOS, +/// otherwise assign via DefineDosDevice (same as classic `Ext2MountVolumeAs`). +pub fn process_pending_automounts( + volumes: &[crate::disk::enum_disk::VolumeEntry], +) -> AutomountReport { + let mut report = AutomountReport::default(); + let free = free_drive_letters(); + + for volume in volumes { + let device = if volume.physical_object.starts_with(r"\Device\") { + volume.physical_object.as_str() + } else if volume.symlink.starts_with(r"\Device\") { + volume.symlink.as_str() + } else { + "" + }; + let already_live = !volume.letters.is_empty() + || (!volume.win32_volume_name.is_empty() + && !crate::mount::dead_letters::mountmgr_letters_for_volume( + &volume.win32_volume_name, + ) + .is_empty()); + + // Ext2Fsd Volumes\{UUID} MountPoint=X: (Ext2ProcessExt2Property) + if is_ext_family(&volume.filesystem) { + if let Some(uuid) = volume.uuid { + if let Some(preferred) = super::persist::query_ext2_automount_letter(&uuid) { + if already_live { + report.already_mounted += 1; + } else { + let letter = if free.contains(&preferred) + && query_dos_device(preferred).is_none() + { + preferred + } else if let Some(fallback) = free.iter().copied().find(|candidate| { + query_dos_device(*candidate).is_none() + }) { + fallback + } else { + report.errors.push(format!( + "{}: no free letter for Ext2Fsd automount", + super::persist::format_volume_uuid(&uuid) + )); + continue; + }; + let symlink = dos_device_target(&volume.physical_object, &volume.symlink); + match define_temporary(letter, &symlink) { + Ok(()) => report.mounted.push(format!( + "{letter}: (Ext2Fsd automount → {symlink})" + )), + Err(err) => report.errors.push(err), + } + } + continue; + } + } + } + + // Session Manager DOS Devices: activate registry letter if nothing live yet. + if device.is_empty() { + continue; + } + let session_letters = super::persist::registry_letters_for_device(device); + if session_letters.is_empty() { + continue; + } + if already_live { + report.already_mounted += 1; + continue; + } + for letter in session_letters { + if query_dos_device(letter).is_some() { + report.already_mounted += 1; + continue; + } + match define_temporary(letter, device) { + Ok(()) => report + .mounted + .push(format!("{letter}: (Session Manager → {device})")), + Err(err) => report.errors.push(err), + } + } + } + + report +} + +/// Ext2Mgr OnFlush → Ext2FlushVolume: FlushFileBuffers on the volume handle. +pub fn flush_volume( + letters: &[char], + win32_volume_name: &str, + physical_object: &str, +) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + + let mut paths = Vec::new(); + if let Some(letter) = letters.first() { + paths.push(format!(r"\\.\{letter}:")); + } + let trimmed = win32_volume_name.trim_end_matches('\\'); + if !trimmed.is_empty() { + paths.push(trimmed.to_string()); + } + if !physical_object.is_empty() { + let rest = physical_object.trim_start_matches('\\'); + paths.push(format!(r"\\?\GLOBALROOT\{rest}")); + } + if paths.is_empty() { + return Err("No path to flush".to_string()); + } + + let mut last_error = String::from("Flush failed"); + for path in paths { + let wide: Vec = std::ffi::OsStr::new(&path) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + last_error = format!("Open {path} failed ({code})"); + continue; + } + let ok = unsafe { windows_sys::Win32::Storage::FileSystem::FlushFileBuffers(handle) }; + let code = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if ok != 0 { + return Ok(()); + } + last_error = format!("FlushFileBuffers({path}) failed ({code})"); + } + Err(last_error) +} diff --git a/ext2mgr_iced/src/mount/persist.rs b/ext2mgr_iced/src/mount/persist.rs new file mode 100644 index 0000000..28cb5c4 --- /dev/null +++ b/ext2mgr_iced/src/mount/persist.rs @@ -0,0 +1,282 @@ +//! Persistent mount registry helpers (Session Manager DOS Devices + Ext2Fsd Volumes). + + +use std::os::windows::ffi::OsStrExt; + +const DOS_DEVICES_KEY: &str = + r"SYSTEM\CurrentControlSet\Control\Session Manager\DOS Devices"; +const VOLUMES_KEY: &str = r"SYSTEM\CurrentControlSet\Services\Ext2Fsd\Volumes"; + +/// Format Ext2Mgr-style UUID value name: `{AA-BB-...-FF}`. +pub fn format_volume_uuid(uuid: &[u8; 16]) -> String { + let mut out = String::from("{"); + for (index, byte) in uuid.iter().enumerate() { + if index == 0 { + out.push_str(&format!("{byte:02X}")); + } else if index == 15 { + out.push_str(&format!("-{byte:02X}}}")); + } else { + out.push_str(&format!("-{byte:02X}")); + } + } + out +} + +/// Persist letter in Session Manager DOS Devices (survives reboot; may need reboot +/// to appear - Ext2Mgr calls this "fixed mount"). +pub fn set_registry_mount_point(letter: char, device_nt_path: &str) -> Result<(), String> { + let key = open_key(DOS_DEVICES_KEY, true)?; + let value_name = format!("{letter}:"); + let result = set_string(key, &value_name, device_nt_path); + close_key(key); + result +} + +pub fn clear_registry_mount_point(letter: char) -> Result<(), String> { + let key = open_key(DOS_DEVICES_KEY, true)?; + let value_name = format!("{letter}:"); + let wide = to_wide(&value_name); + let status = unsafe { + windows_sys::Win32::System::Registry::RegDeleteValueW(key, wide.as_ptr()) + }; + close_key(key); + // ERROR_FILE_NOT_FOUND = 2 → already gone + if status == 0 || status == 2 { + Ok(()) + } else { + Err(format!("RegDeleteValue({value_name}) failed ({status})")) + } +} + +/// Drive-letter Session Manager DOS Devices entries (`X:` → `\Device\…`). +pub fn registry_dos_device_entries() -> Vec<(char, String)> { + let Ok(key) = open_key(DOS_DEVICES_KEY, false) else { + return Vec::new(); + }; + let mut index = 0u32; + let mut entries = Vec::new(); + loop { + let mut name = vec![0u16; 64]; + let mut name_len = name.len() as u32; + let mut value_type = 0u32; + let mut data = vec![0u8; 512]; + let mut data_len = data.len() as u32; + let status = unsafe { + windows_sys::Win32::System::Registry::RegEnumValueW( + key, + index, + name.as_mut_ptr(), + &mut name_len, + std::ptr::null_mut(), + &mut value_type, + data.as_mut_ptr(), + &mut data_len, + ) + }; + if status != 0 { + break; + } + index += 1; + let name_string = wide_to_string(&name[..name_len as usize]); + let Some(letter) = name_string + .chars() + .next() + .filter(|ch| ch.is_ascii_alphabetic()) + else { + continue; + }; + if !name_string.ends_with(':') { + continue; + } + let data_units: Vec = data[..data_len as usize] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .take_while(|unit| *unit != 0) + .collect(); + let device = String::from_utf16_lossy(&data_units); + if device.is_empty() { + continue; + } + entries.push((letter.to_ascii_uppercase(), device)); + } + close_key(key); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + entries.dedup_by(|left, right| left.0 == right.0); + entries +} + +/// All Session Manager DOS Devices letters that map to `device_nt_path`. +pub fn registry_letters_for_device(device_nt_path: &str) -> Vec { + let mut letters: Vec = registry_dos_device_entries() + .into_iter() + .filter(|(_, device)| device.eq_ignore_ascii_case(device_nt_path)) + .map(|(letter, _)| letter) + .collect(); + letters.sort_unstable(); + letters.dedup(); + letters +} + +pub fn query_registry_letter_for_device(device_nt_path: &str) -> Option { + registry_letters_for_device(device_nt_path).into_iter().next() +} + +/// Store Ext2Fsd per-volume automount blob under Volumes\{UUID}. +pub fn store_ext2_automount( + uuid: &[u8; 16], + letter: char, + codepage: &str, + readonly: bool, +) -> Result<(), String> { + let key = create_key(VOLUMES_KEY)?; + let value_name = format_volume_uuid(uuid); + let mut data = String::new(); + if readonly { + data.push_str("Readonly;"); + } + data.push_str(&format!("MountPoint={letter}:;")); + if !codepage.is_empty() { + data.push_str(&format!("CodePage={codepage};")); + } + let result = set_string(key, &value_name, &data); + close_key(key); + result +} + +/// Preferred drive letter from Ext2Fsd `Volumes\{UUID}` (`MountPoint=X:`), if any. +pub fn query_ext2_automount_letter(uuid: &[u8; 16]) -> Option { + let key = open_key(VOLUMES_KEY, false).ok()?; + let value_name = format_volume_uuid(uuid); + let wide_name = to_wide(&value_name); + let mut value_type = 0u32; + let mut data = vec![0u8; 512]; + let mut data_len = data.len() as u32; + let status = unsafe { + windows_sys::Win32::System::Registry::RegQueryValueExW( + key, + wide_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + data.as_mut_ptr(), + &mut data_len, + ) + }; + close_key(key); + if status != 0 || data_len < 2 { + return None; + } + let data_units: Vec = data[..data_len as usize] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .take_while(|unit| *unit != 0) + .collect(); + let blob = String::from_utf16_lossy(&data_units); + for part in blob.split(';') { + let part = part.trim(); + let Some(value) = part + .strip_prefix("MountPoint=") + .or_else(|| part.strip_prefix("mountpoint=")) + else { + continue; + }; + let value = value.trim(); + let mut chars = value.chars(); + if let (Some(letter), Some(':')) = (chars.next(), chars.next()) { + if letter.is_ascii_alphabetic() { + return Some(letter.to_ascii_uppercase()); + } + } + } + None +} + +type Hkey = windows_sys::Win32::System::Registry::HKEY; + +fn open_key(path: &str, write: bool) -> Result { + let access = if write { + windows_sys::Win32::System::Registry::KEY_ALL_ACCESS + } else { + windows_sys::Win32::System::Registry::KEY_READ + }; + let mut key = 0; + let wide = to_wide(path); + let status = unsafe { + windows_sys::Win32::System::Registry::RegOpenKeyExW( + windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE, + wide.as_ptr(), + 0, + access, + &mut key, + ) + }; + if status != 0 { + Err(format!("RegOpenKeyEx({path}) failed ({status}) - run elevated?")) + } else { + Ok(key) + } +} + +fn create_key(path: &str) -> Result { + let mut key = 0; + let mut disposition = 0u32; + let wide = to_wide(path); + let status = unsafe { + windows_sys::Win32::System::Registry::RegCreateKeyExW( + windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE, + wide.as_ptr(), + 0, + std::ptr::null(), + 0, + windows_sys::Win32::System::Registry::KEY_ALL_ACCESS, + std::ptr::null(), + &mut key, + &mut disposition, + ) + }; + if status != 0 { + Err(format!("RegCreateKeyEx({path}) failed ({status}) - run elevated?")) + } else { + Ok(key) + } +} + +fn close_key(key: Hkey) { + unsafe { + windows_sys::Win32::System::Registry::RegCloseKey(key); + } +} + +fn set_string(key: Hkey, name: &str, value: &str) -> Result<(), String> { + let wide_name = to_wide(name); + let wide_value = to_wide(value); + let bytes = unsafe { + std::slice::from_raw_parts(wide_value.as_ptr() as *const u8, wide_value.len() * 2) + }; + let status = unsafe { + windows_sys::Win32::System::Registry::RegSetValueExW( + key, + wide_name.as_ptr(), + 0, + windows_sys::Win32::System::Registry::REG_SZ, + bytes.as_ptr(), + bytes.len() as u32, + ) + }; + if status == 0 { + Ok(()) + } else { + Err(format!("RegSetValueEx({name}) failed ({status})")) + } +} + +fn to_wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn wide_to_string(wide: &[u16]) -> String { + let end = wide.iter().position(|&unit| unit == 0).unwrap_or(wide.len()); + String::from_utf16_lossy(&wide[..end]) +} diff --git a/ext2mgr_iced/src/mount/pipe.rs b/ext2mgr_iced/src/mount/pipe.rs new file mode 100644 index 0000000..5d9dfa8 --- /dev/null +++ b/ext2mgr_iced/src/mount/pipe.rs @@ -0,0 +1,363 @@ +//! Ext2Srv named-pipe protocol (client side). +//! +//! Matches `Ext2Srv/Ext2Pipe.h` and the client usage in `Ext2Mgr/Ext2Pipe.cpp`. +//! Pipe name: `\\.\pipe\EXT2MGR_PSRV`. + + +use std::io; +use std::mem::size_of; +use std::sync::Mutex; + +pub const PIPE_NAME: &str = r"\\.\pipe\EXT2MGR_PSRV"; +pub const PIPE_REQ_MAGIC: u32 = 0xBAD0BAD8; +#[allow(dead_code)] +pub const REQ_BODY_SIZE: usize = 4096; + +pub const CMD_QUERY_DRV: u32 = 0xBAD0_0001; +pub const CMD_DEFINE_DRV: u32 = 0xBAD0_0002; +pub const CMD_REMOVE_DRV: u32 = 0xBAD0_0003; + +/// Windows DDD_* for DefineDosDevice (used in define/remove) +pub const DDD_RAW_TARGET_PATH: u32 = 0x1; +pub const DDD_REMOVE_DEFINITION: u32 = 0x2; +pub const DDD_EXACT_MATCH_ON_REMOVE: u32 = 0x4; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PipeReqHeader { + pub magic: u32, + pub flag: u32, + pub cmd: u32, + pub len: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ReqQueryDrv { + pub type_: u32, + pub drive: u8, + pub result: u8, + pub symlink: u16, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ReqDefineDrv { + pub pid: u32, + pub flags: u32, + pub drive: u8, + pub result: u8, + pub symlink: u16, +} + +// REQ_REMOVE_DRV has same layout as REQ_DEFINE_DRV +pub type ReqRemoveDrv = ReqDefineDrv; + +const HEADER_SIZE: usize = size_of::(); + +/// Result of querying a drive letter via the pipe. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct QueryDriveResult { + pub drive: u8, + pub type_: u32, + pub result: bool, + pub symlink: String, +} + +/// Keep-alive pipe handle (Ext2Mgr-style `g_hPipe`), shared across mount ops. +static SHARED_PIPE: Mutex> = Mutex::new(None); + +/// Run `op` on a reused Ext2Srv connection; drop and reconnect on I/O failure. +pub fn with_shared_client(op: F) -> io::Result +where + F: FnOnce(&PipeClient) -> io::Result, +{ + let mut guard = SHARED_PIPE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.is_none() { + *guard = Some(PipeClient::open()?); + } + let result = op(guard.as_ref().expect("pipe just opened")); + if result.is_err() { + *guard = None; + } + result +} + +/// Drop the cached connection (e.g. after Ext2Srv restart). +#[allow(dead_code)] +pub fn invalidate_shared_client() { + let mut guard = SHARED_PIPE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = None; +} + +/// Cheap health check: reuse or open the pipe once. +pub fn health_check() -> io::Result<()> { + with_shared_client(|_client| Ok(())) +} + +/// Client handle to the Ext2Srv pipe. +pub struct PipeClient { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +impl PipeClient { + /// Connect to `\\.\pipe\EXT2MGR_PSRV`. + /// + /// Ext2Mgr waits up to 20s when the pipe is busy. That freezes a GUI on the + /// UI thread, so this client uses a short busy-wait (2s) and fails fast when + /// Ext2Srv is down or saturated — callers surface a MessageBox instead of hanging. + pub fn open() -> io::Result { + use std::os::windows::ffi::OsStrExt; + let wide: Vec = std::ffi::OsStr::new(PIPE_NAME) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + const OPEN_EXISTING: u32 = 3; + // Busy-pipe wait; keep low so Assign/Unmount stay responsive. + const PIPE_BUSY_WAIT_MS: u32 = 2_000; + + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + 0, + std::ptr::null(), + OPEN_EXISTING, + 0, + 0, + ) + }; + + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + const ERROR_PIPE_BUSY: u32 = 231; + let code: u32 = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + if code == ERROR_PIPE_BUSY { + let wait_wide: Vec = std::ffi::OsStr::new(PIPE_NAME) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let ok = unsafe { + windows_sys::Win32::System::Pipes::WaitNamedPipeW( + wait_wide.as_ptr(), + PIPE_BUSY_WAIT_MS, + ) + }; + if ok != 0 { + return Self::open(); + } + } + return Err(io::Error::from_raw_os_error(code as i32)); + } + + Ok(Self { handle }) + } + + /// Low-level send request and read response. Used by query/define/remove. + fn roundtrip(&self, req: &[u8]) -> io::Result> { + let mut written: u32 = 0; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::WriteFile( + self.handle, + req.as_ptr(), + req.len() as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 || written as usize != req.len() { + return Err(io::Error::last_os_error()); + } + + let mut header = [0u8; HEADER_SIZE]; + let mut total = 0usize; + while total < HEADER_SIZE { + let mut n: u32 = 0; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::ReadFile( + self.handle, + header[total..].as_mut_ptr(), + (HEADER_SIZE - total) as u32, + &mut n, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + total += n as usize; + } + let h = unsafe { &*(header.as_ptr() as *const PipeReqHeader) }; + if h.len < HEADER_SIZE as u32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "pipe response header len too small", + )); + } + let mut out = Vec::with_capacity(h.len as usize); + out.extend_from_slice(&header); + out.resize(h.len as usize, 0); + let mut total = HEADER_SIZE; + while total < h.len as usize { + let mut n: u32 = 0; + let ok = unsafe { + windows_sys::Win32::Storage::FileSystem::ReadFile( + self.handle, + out[total..].as_mut_ptr(), + (h.len as usize - total) as u32, + &mut n, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + total += n as usize; + } + Ok(out) + } + + /// Query what a drive letter is mapped to. `drive` is 'A'..='Z' as u8. + pub fn query_drive(&self, drive: u8) -> io::Result { + let drive = drive.to_ascii_uppercase(); + let body_size = size_of::(); + let mut buf = vec![0u8; HEADER_SIZE + body_size]; + let h = unsafe { &mut *(buf.as_mut_ptr() as *mut PipeReqHeader) }; + h.magic = PIPE_REQ_MAGIC; + h.flag = 0; + h.cmd = CMD_QUERY_DRV; + h.len = buf.len() as u32; + let q = unsafe { &mut *(buf[HEADER_SIZE..].as_mut_ptr() as *mut ReqQueryDrv) }; + q.drive = drive; + + let resp = self.roundtrip(&buf)?; + if resp.len() < HEADER_SIZE + size_of::() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "query_drive response too short", + )); + } + let q = unsafe { &*(resp[HEADER_SIZE..].as_ptr() as *const ReqQueryDrv) }; + let symlink = if resp.len() > HEADER_SIZE + size_of::() { + let name_start = HEADER_SIZE + size_of::(); + let name_slice = &resp[name_start..]; + let end = name_slice.iter().position(|&b| b == 0).unwrap_or(name_slice.len()); + String::from_utf8_lossy(&name_slice[..end]).into_owned() + } else { + String::new() + }; + Ok(QueryDriveResult { + drive: q.drive, + type_: q.type_, + result: q.result != 0, + symlink, + }) + } + + /// Assign a drive letter to a symlink (e.g. `\??\Volume{...}`). + pub fn define_drive(&self, drive: u8, symlink: &str) -> io::Result { + let drive = drive.to_ascii_uppercase(); + let body_fixed = size_of::(); + let name_bytes = symlink.as_bytes(); + let name_len = name_bytes.len() + 1; + let total = HEADER_SIZE + body_fixed + name_len; + let mut buf = vec![0u8; total]; + let h = unsafe { &mut *(buf.as_mut_ptr() as *mut PipeReqHeader) }; + h.magic = PIPE_REQ_MAGIC; + h.flag = 0; + h.cmd = CMD_DEFINE_DRV; + h.len = total as u32; + let q = unsafe { &mut *(buf[HEADER_SIZE..].as_mut_ptr() as *mut ReqDefineDrv) }; + q.pid = unsafe { windows_sys::Win32::System::Threading::GetCurrentProcessId() }; + // Ext2Mgr uses DDD_RAW_TARGET_PATH with NT/`\??\` volume paths. + q.flags = DDD_RAW_TARGET_PATH; + q.drive = drive; + let name_dst = &mut buf[HEADER_SIZE + body_fixed..]; + name_dst[..name_bytes.len()].copy_from_slice(name_bytes); + name_dst[name_bytes.len()] = 0; + + let resp = self.roundtrip(&buf)?; + if resp.len() < HEADER_SIZE + size_of::() { + return Ok(false); + } + let q = unsafe { &*(resp[HEADER_SIZE..].as_ptr() as *const ReqDefineDrv) }; + Ok(q.result != 0) + } + + /// Remove a drive letter mapping. `symlink` should match what was used when defining. + pub fn remove_drive(&self, drive: u8, symlink: &str) -> io::Result { + let drive = drive.to_ascii_uppercase(); + let body_fixed = size_of::(); + let name_bytes = symlink.as_bytes(); + let name_len = name_bytes.len() + 1; + let total = HEADER_SIZE + body_fixed + name_len; + let mut buf = vec![0u8; total]; + let h = unsafe { &mut *(buf.as_mut_ptr() as *mut PipeReqHeader) }; + h.magic = PIPE_REQ_MAGIC; + h.flag = 0; + h.cmd = CMD_REMOVE_DRV; + h.len = total as u32; + let q = unsafe { &mut *(buf[HEADER_SIZE..].as_mut_ptr() as *mut ReqRemoveDrv) }; + q.pid = unsafe { windows_sys::Win32::System::Threading::GetCurrentProcessId() }; + q.flags = DDD_RAW_TARGET_PATH | DDD_REMOVE_DEFINITION | DDD_EXACT_MATCH_ON_REMOVE; + q.drive = drive; + let name_dst = &mut buf[HEADER_SIZE + body_fixed..]; + name_dst[..name_bytes.len()].copy_from_slice(name_bytes); + name_dst[name_bytes.len()] = 0; + + let resp = self.roundtrip(&buf)?; + if resp.len() < HEADER_SIZE + size_of::() { + return Ok(false); + } + let q = unsafe { &*(resp[HEADER_SIZE..].as_ptr() as *const ReqRemoveDrv) }; + Ok(q.result != 0) + } +} + +impl Drop for PipeClient { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(self.handle); + } + } +} + +// PipeClient is only used behind a Mutex; HANDLE is Send on Windows for this use. +unsafe impl Send for PipeClient {} + +trait ToAsciiUpper { + fn to_ascii_uppercase(self) -> u8; +} +impl ToAsciiUpper for u8 { + fn to_ascii_uppercase(self) -> u8 { + if (b'a'..=b'z').contains(&self) { + self - 32 + } else { + self + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn header_size() { + assert_eq!(size_of::(), 16); + } + #[test] + fn query_drv_size() { + assert_eq!(size_of::(), 8); + } + #[test] + fn define_drv_size() { + assert_eq!(size_of::(), 12); + } +} diff --git a/ext2mgr_iced/src/service/mgr.rs b/ext2mgr_iced/src/service/mgr.rs new file mode 100644 index 0000000..ba68f0c --- /dev/null +++ b/ext2mgr_iced/src/service/mgr.rs @@ -0,0 +1,485 @@ +//! Ext2Fsd service / global Parameters registry (Service Management). + + +use std::os::windows::ffi::OsStrExt; + +#[allow(dead_code)] +pub const CODEPAGES: &[&str] = &[ + "default", "utf8", "cp936", "gb2312", "cp1251", "cp437", "cp850", "cp852", + "cp866", "cp932", "cp949", "cp950", "big5", "euc-jp", "euc-kr", "iso8859-1", + "iso8859-15", "koi8-r", +]; + +pub const START_MODE_LABELS: &[&str] = &[ + "SERVICE_BOOT_START", + "SERVICE_SYSTEM_START", + "SERVICE_AUTO_START", + "SERVICE_DEMAND_START", + "SERVICE_DISABLED", +]; + +#[derive(Debug, Clone)] +pub struct GlobalProperty { + pub startup: u32, + pub readonly: bool, + pub ext3_writable: bool, + pub automount: bool, + pub codepage: String, + pub hiding_prefix: String, + pub hiding_suffix: String, +} + +impl Default for GlobalProperty { + fn default() -> Self { + Self { + startup: 3, // demand start + readonly: true, + ext3_writable: false, + automount: false, + codepage: "utf8".to_string(), + hiding_prefix: String::new(), + hiding_suffix: String::new(), + } + } +} + +pub fn is_driver_started() -> bool { + open_ext2fsd().is_some() +} + +fn open_ext2fsd() -> Option { + let wide = to_wide(r"\\.\Ext2Fsd"); + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ + | windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + None + } else { + Some(handle) + } +} + +/// Ext2QueryDrvVersion — About dialog driver line. +pub fn query_driver_version() -> Result<(String, String, String), String> { + const IOCTL_APP_VOLUME_PROPERTY: u32 = 0x0022_1F40; + const EXT2_VOLUME_PROPERTY_MAGIC: u32 = 0x4556_504D; + const APP_CMD_QUERY_VERSION: u32 = 0; + const EXT2_FLAG_VP_SET_GLOBAL: u32 = 1; + // EXT2_VOLUME_PROPERTY_VERSION layout + const SIZE: usize = 4 + 4 + 4 + 0x1C + 0x20 + 0x20; // 108 + + let handle = open_ext2fsd().ok_or_else(|| "Ext2Fsd: NOT started".to_string())?; + let mut buffer = vec![0u8; SIZE]; + buffer[0..4].copy_from_slice(&EXT2_VOLUME_PROPERTY_MAGIC.to_le_bytes()); + buffer[4..8].copy_from_slice(&EXT2_FLAG_VP_SET_GLOBAL.to_le_bytes()); + buffer[8..12].copy_from_slice(&APP_CMD_QUERY_VERSION.to_le_bytes()); + + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_APP_VOLUME_PROPERTY, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if ok == 0 { + return Err(format!( + "Query version failed ({})", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + let version = c_string_field(&buffer[12..12 + 0x1C]); + let time = c_string_field(&buffer[12 + 0x1C..12 + 0x1C + 0x20]); + let date = c_string_field(&buffer[12 + 0x1C + 0x20..12 + 0x1C + 0x40]); + Ok((version, date, time)) +} + +/// Ext2Fsd IRP statistics for the PerfStat dialog. +pub fn query_perfstat_rows() -> Result, String> { + const IOCTL_APP_QUERY_PERFSTAT: u32 = 0x0022_1F44; + const EXT2_QUERY_PERFSTAT_MAGIC: u32 = 0x4556_504D; + // IRP_MJ_MAXIMUM_FUNCTION = 0x1B → 28 slots * 8 = 224; 4 * 64 = 256; header 12 → 492 + const SZ_V1: usize = 492; + const IRP_COUNT: usize = 28; + + let handle = open_ext2fsd().ok_or_else(|| "Ext2Fsd: NOT started".to_string())?; + let mut buffer = vec![0u8; SZ_V1]; + buffer[0..4].copy_from_slice(&EXT2_QUERY_PERFSTAT_MAGIC.to_le_bytes()); + buffer[8..12].copy_from_slice(&IOCTL_APP_QUERY_PERFSTAT.to_le_bytes()); + + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_APP_QUERY_PERFSTAT, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if ok == 0 { + return Err(format!( + "Query PerfStat failed ({})", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + + let irp_names = [ + "CREATE", "CREATE_NAMED_PIPE", "CLOSE", "READ", "WRITE", "QUERY_INFO", + "SET_INFO", "QUERY_EA", "SET_EA", "FLUSH", "QUERY_VOLUME", "SET_VOLUME", + "DIRECTORY_CONTROL", "FILE_SYSTEM_CONTROL", "DEVICE_CONTROL", + "INTERNAL_DEVICE_CONTROL", "SHUTDOWN", "LOCK_CONTROL", "CLEANUP", + "CREATE_MAILSLOT", "QUERY_SECURITY", "SET_SECURITY", "POWER", + "SYSTEM_CONTROL", "DEVICE_CHANGE", "QUERY_QUOTA", "SET_QUOTA", "PNP", + ]; + let mut rows = Vec::with_capacity(IRP_COUNT); + for (index, name) in irp_names.iter().enumerate().take(IRP_COUNT) { + let offset = 12 + index * 8; + if offset + 8 > buffer.len() { + break; + } + let processed = u32::from_le_bytes(buffer[offset..offset + 4].try_into().unwrap()); + let current = u32::from_le_bytes(buffer[offset + 4..offset + 8].try_into().unwrap()); + rows.push(((*name).to_string(), current, processed)); + } + Ok(rows) +} + +/// Compact Ext2Fsd statistics text retained for callers that need plain text. +pub fn query_perfstat_text() -> Result { + let rows = query_perfstat_rows()?; + let mut output = String::from("Ext2Fsd IRP statistics\n\n"); + output.push_str("Name Current Processed\n"); + output.push_str("------------------------------------------------\n"); + for (name, current, processed) in rows { + output.push_str(&format!("{name:<28} {current:>8} {processed:>10}\n")); + } + Ok(output) +} + +fn c_string_field(bytes: &[u8]) -> String { + let end = bytes.iter().position(|&byte| byte == 0).unwrap_or(bytes.len()); + String::from_utf8_lossy(&bytes[..end]).trim().to_string() +} + +pub fn start_driver() -> Result<(), String> { + control_driver(true) +} + +pub fn stop_driver() -> Result<(), String> { + control_driver(false) +} + +pub fn restart_driver() -> Result<(), String> { + let _ = stop_driver(); + // Brief pause so the service can leave RUNNING. + std::thread::sleep(std::time::Duration::from_millis(400)); + start_driver() +} + +fn control_driver(start: bool) -> Result<(), String> { + if start && is_driver_started() { + return Ok(()); + } + if !start && !is_driver_started() { + return Ok(()); + } + unsafe { + let manager = windows_sys::Win32::System::Services::OpenSCManagerW( + std::ptr::null(), + std::ptr::null(), + windows_sys::Win32::System::Services::SC_MANAGER_CONNECT, + ); + if manager == 0 { + return Err(format!( + "OpenSCManager failed ({})", + windows_sys::Win32::Foundation::GetLastError() + )); + } + let service_name = to_wide("ext2fsd"); + let access = if start { + windows_sys::Win32::System::Services::SERVICE_START + | windows_sys::Win32::System::Services::SERVICE_QUERY_STATUS + } else { + windows_sys::Win32::System::Services::SERVICE_STOP + | windows_sys::Win32::System::Services::SERVICE_QUERY_STATUS + }; + let service = windows_sys::Win32::System::Services::OpenServiceW( + manager, + service_name.as_ptr(), + access, + ); + if service == 0 { + let code = windows_sys::Win32::Foundation::GetLastError(); + windows_sys::Win32::System::Services::CloseServiceHandle(manager); + return Err(format!("OpenService(ext2fsd) failed ({code})")); + } + let result = if start { + let ok = windows_sys::Win32::System::Services::StartServiceW( + service, + 0, + std::ptr::null(), + ); + let code = windows_sys::Win32::Foundation::GetLastError(); + // ERROR_SERVICE_ALREADY_RUNNING = 1056 + if ok == 0 && code != 1056 { + Err(format!("StartService failed ({code})")) + } else { + Ok(()) + } + } else { + let mut status = std::mem::zeroed::(); + let ok = windows_sys::Win32::System::Services::ControlService( + service, + windows_sys::Win32::System::Services::SERVICE_CONTROL_STOP, + &mut status, + ); + let code = windows_sys::Win32::Foundation::GetLastError(); + // ERROR_SERVICE_NOT_ACTIVE = 1062 + if ok == 0 && code != 1062 { + Err(format!("ControlService(STOP) failed ({code})")) + } else { + Ok(()) + } + }; + windows_sys::Win32::System::Services::CloseServiceHandle(service); + windows_sys::Win32::System::Services::CloseServiceHandle(manager); + result?; + } + if start { + if is_driver_started() { + Ok(()) + } else { + Err("Driver start requested but \\\\.\\Ext2Fsd is still unavailable".to_string()) + } + } else { + Ok(()) + } +} + +pub fn query_global_property() -> Result { + let mut props = GlobalProperty::default(); + let service_key = open_key( + r"SYSTEM\CurrentControlSet\Services\Ext2Fsd", + windows_sys::Win32::System::Registry::KEY_READ, + )?; + if let Some(value) = query_dword(service_key, "Start") { + props.startup = value; + } + close_key(service_key); + + let params_key = open_key( + r"SYSTEM\CurrentControlSet\Services\Ext2Fsd\Parameters", + windows_sys::Win32::System::Registry::KEY_READ, + )?; + if let Some(value) = query_dword(params_key, "WritingSupport") { + props.readonly = value == 0; + } + if let Some(value) = query_dword(params_key, "Ext3ForceWriting") { + props.ext3_writable = value != 0; + } + if let Some(value) = query_dword(params_key, "AutoMount") { + props.automount = value != 0; + } + if let Some(value) = query_string(params_key, "CodePage") { + props.codepage = value; + } + if let Some(value) = query_string(params_key, "HidingPrefix") { + props.hiding_prefix = value; + } + if let Some(value) = query_string(params_key, "HidingSuffix") { + props.hiding_suffix = value; + } + close_key(params_key); + Ok(props) +} + +pub fn set_global_property(props: &GlobalProperty) -> Result<(), String> { + let service_key = open_key( + r"SYSTEM\CurrentControlSet\Services\Ext2Fsd", + windows_sys::Win32::System::Registry::KEY_SET_VALUE, + )?; + set_dword(service_key, "Start", props.startup)?; + close_key(service_key); + + let params_key = open_key( + r"SYSTEM\CurrentControlSet\Services\Ext2Fsd\Parameters", + windows_sys::Win32::System::Registry::KEY_SET_VALUE, + )?; + set_dword(params_key, "WritingSupport", u32::from(!props.readonly))?; + set_dword( + params_key, + "Ext3ForceWriting", + u32::from(props.ext3_writable && !props.readonly), + )?; + set_dword(params_key, "AutoMount", u32::from(props.automount))?; + set_string(params_key, "CodePage", &props.codepage)?; + set_string(params_key, "HidingPrefix", &props.hiding_prefix)?; + set_string(params_key, "HidingSuffix", &props.hiding_suffix)?; + close_key(params_key); + Ok(()) +} + +type Hkey = windows_sys::Win32::System::Registry::HKEY; + +fn open_key(path: &str, access: u32) -> Result { + let mut key = 0; + let wide = to_wide(path); + let status = unsafe { + windows_sys::Win32::System::Registry::RegOpenKeyExW( + windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE, + wide.as_ptr(), + 0, + access, + &mut key, + ) + }; + if status != 0 { + Err(format!("RegOpenKeyEx({path}) failed ({status}) - run elevated?")) + } else { + Ok(key) + } +} + +fn close_key(key: Hkey) { + unsafe { + windows_sys::Win32::System::Registry::RegCloseKey(key); + } +} + +fn query_dword(key: Hkey, name: &str) -> Option { + let wide = to_wide(name); + let mut data = 0u32; + let mut data_size = std::mem::size_of::() as u32; + let mut value_type = 0u32; + let status = unsafe { + windows_sys::Win32::System::Registry::RegQueryValueExW( + key, + wide.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + (&mut data as *mut u32).cast(), + &mut data_size, + ) + }; + if status == 0 { + Some(data) + } else { + None + } +} + +fn query_string(key: Hkey, name: &str) -> Option { + let wide = to_wide(name); + let mut data_size = 0u32; + let mut value_type = 0u32; + unsafe { + windows_sys::Win32::System::Registry::RegQueryValueExW( + key, + wide.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + std::ptr::null_mut(), + &mut data_size, + ); + } + if data_size == 0 { + return None; + } + let mut buffer = vec![0u8; data_size as usize]; + let status = unsafe { + windows_sys::Win32::System::Registry::RegQueryValueExW( + key, + wide.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + buffer.as_mut_ptr(), + &mut data_size, + ) + }; + if status != 0 { + return None; + } + let units: Vec = buffer + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .take_while(|unit| *unit != 0) + .collect(); + Some(String::from_utf16_lossy(&units)) +} + +fn set_dword(key: Hkey, name: &str, value: u32) -> Result<(), String> { + let wide = to_wide(name); + let status = unsafe { + windows_sys::Win32::System::Registry::RegSetValueExW( + key, + wide.as_ptr(), + 0, + windows_sys::Win32::System::Registry::REG_DWORD, + (&value as *const u32).cast(), + std::mem::size_of::() as u32, + ) + }; + if status == 0 { + Ok(()) + } else { + Err(format!("RegSetValueEx({name}) failed ({status})")) + } +} + +fn set_string(key: Hkey, name: &str, value: &str) -> Result<(), String> { + let wide_name = to_wide(name); + let wide_value = to_wide(value); + let bytes = unsafe { + std::slice::from_raw_parts( + wide_value.as_ptr() as *const u8, + wide_value.len() * 2, + ) + }; + let status = unsafe { + windows_sys::Win32::System::Registry::RegSetValueExW( + key, + wide_name.as_ptr(), + 0, + windows_sys::Win32::System::Registry::REG_SZ, + bytes.as_ptr(), + bytes.len() as u32, + ) + }; + if status == 0 { + Ok(()) + } else { + Err(format!("RegSetValueEx({name}) failed ({status})")) + } +} + +fn to_wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} diff --git a/ext2mgr_iced/src/service/mod.rs b/ext2mgr_iced/src/service/mod.rs new file mode 100644 index 0000000..8327bd4 --- /dev/null +++ b/ext2mgr_iced/src/service/mod.rs @@ -0,0 +1,4 @@ +//! Ext2Fsd service / global property management. + +#[cfg(windows)] +pub mod mgr; diff --git a/ext2mgr_iced/src/ui/gating_probe.rs b/ext2mgr_iced/src/ui/gating_probe.rs new file mode 100644 index 0000000..e3fee85 --- /dev/null +++ b/ext2mgr_iced/src/ui/gating_probe.rs @@ -0,0 +1,98 @@ +//! Disk/partition automount gating probe (Windows tests only). + + use std::io::Write; + + #[test] + fn dump_disk_partition_automount_gating() { + let out_path = std::env::temp_dir().join("ext2_gate_probe_out.txt"); + let mut out = std::fs::File::create(&out_path).expect("create probe out"); + macro_rules! gate_log { + ($($arg:tt)*) => {{ + let line = format!($($arg)*); + eprintln!("{line}"); + let _ = writeln!(out, "{line}"); + }}; + } + + let (disks, volumes, _rows) = crate::disk::enum_disk::enumerate_all(); + gate_log!("disk_count={} volume_count={}", disks.len(), volumes.len()); + + for disk in &disks { + gate_log!( + "disk.index={} display_name={} part_count={}", + disk.index, + disk.display_name, + disk.partitions.len() + ); + for (partition_index, partition) in disk.partitions.iter().enumerate() { + let linked = partition + .volume_index + .and_then(|volume_index| volumes.get(volume_index)); + let linked_fs = linked.map(|volume| volume.filesystem.as_str()).unwrap_or(""); + let linked_uuid = linked.and_then(|volume| volume.uuid); + let uuid_hex = match linked_uuid { + Some(bytes) => bytes + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join("-"), + None => "None".to_string(), + }; + let letters = crate::disk::enum_disk::format_letters(&partition.letters); + let is_ext_part = crate::mount::ops::is_ext_family(&partition.filesystem); + let is_ext_linked = crate::mount::ops::is_ext_family(linked_fs); + let can_assign = partition.letters.is_empty() && is_ext_part; + let can_automount = linked_uuid.is_some(); + gate_log!( + "GATE disk.index={} part_idx={} part.number={} part.fs={:?} letters={} volume_index={:?} linked_fs={:?} linked_uuid={} is_ext_part={} is_ext_linked={} can_assign={} can_automount={} symlink={:?} win32={:?}", + disk.index, + partition_index, + partition.number, + partition.filesystem, + letters, + partition.volume_index, + linked_fs, + uuid_hex, + is_ext_part, + is_ext_linked, + can_assign, + can_automount, + partition.symlink, + partition.win32_volume_name, + ); + } + } + + let target = disks.iter().find_map(|disk| { + if disk.index != 2 { + return None; + } + disk.partitions + .iter() + .find(|partition| partition.number == 2) + .map(|partition| (disk, partition)) + }); + match target { + Some((disk, partition)) => { + let linked = partition + .volume_index + .and_then(|volume_index| volumes.get(volume_index)); + gate_log!( + "TARGET_FOUND=1 disk.index={} display={} part.number={} part.fs={:?} volume_index={:?} linked_fs={:?} linked_uuid_is_some={} can_automount={} out={}", + disk.index, + disk.display_name, + partition.number, + partition.filesystem, + partition.volume_index, + linked.map(|volume| volume.filesystem.as_str()).unwrap_or(""), + linked.and_then(|volume| volume.uuid).is_some(), + linked.and_then(|volume| volume.uuid).is_some(), + out_path.display(), + ); + } + None => gate_log!( + "TARGET_FOUND=0 disk.index=2 part.number=2 out={}", + out_path.display() + ), + } + } diff --git a/ext2mgr_iced/src/ui/helpers.rs b/ext2mgr_iced/src/ui/helpers.rs new file mode 100644 index 0000000..186585d --- /dev/null +++ b/ext2mgr_iced/src/ui/helpers.rs @@ -0,0 +1,1445 @@ +impl Ext2MgrApp { + /// Extra space after the last column so its `|` resize grip stays clickable. + const TABLE_TRAILING_PAD: f32 = 7.0; + /// `Direction::Both` floats the H-scrollbar over content; reserve its height + /// (iced default scroller 10px) plus a little so the last row clears it. + const TABLE_H_SCROLLBAR_PAD: f32 = 14.0; + + fn reload_lists(&mut self) { + let (disks, volumes, disk_rows) = crate::disk::enum_disk::enumerate_all(); + self.disks = disks; + self.volumes = volumes; + self.disk_rows = disk_rows; + self.autofit_column_widths(); + self.recompute_detail(); + } + + fn reload(&mut self) -> Task { + self.reload_lists(); + self.automount_task() + } + + /// Ext2Mgr `Ext2ProcessExt2Volumes` after load/refresh. + fn automount_task(&self) -> Task { + let volumes = self.volumes.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + crate::mount::ops::process_pending_automounts(&volumes) + }) + .await + .unwrap_or_default() + }, + Message::AutomountFinished, + ) + } + + /// Status bar + modal MessageBox (Ext2Mgr AfxMessageBox STOP). + #[cfg(windows)] + fn report_error(&mut self, message: impl Into) { + let message = message.into(); + self.status = message.clone(); + crate::win::msgbox::error(&message); + } + + /// Status bar + modal MessageBox (WARNING). + #[cfg(windows)] + fn report_warning(&mut self, message: impl Into) { + let message = message.into(); + self.status = message.clone(); + crate::win::msgbox::warning(&message); + } + + fn selected_volume_index(&self) -> Option { + match self.selection { + Selection::Volume(index) => Some(index), + Selection::Partition { disk, part } => self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .and_then(|partition| partition.volume_index), + _ => None, + } + } + + /// Drive letters for the current volume/partition selection (supports multi-mount). + fn selection_letters(&self) -> Vec { + match self.selection { + Selection::Volume(index) => self + .volumes + .get(index) + .map(|volume| volume.letters.clone()) + .unwrap_or_default(), + Selection::Partition { disk, part } => self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| partition.letters.clone()) + .unwrap_or_default(), + Selection::Disk(_) | Selection::None => Vec::new(), + } + } + + fn open_mount_points_dialog(&mut self) { + match self.selection { + Selection::Volume(index) => { + let selected = self + .volumes + .get(index) + .and_then(|volume| { + if volume.letters.is_empty() { + None + } else { + Some(0) + } + }); + self.dialog = Some(Dialog::MountPoints { + volume_index: Some(index), + disk_part: None, + selected, + }); + } + Selection::Partition { disk, part } => { + let partition = self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)); + let Some(partition) = partition else { + self.status = "Select a volume or partition first.".to_string(); + return; + }; + let selected = if partition.letters.is_empty() { + None + } else { + Some(0) + }; + self.dialog = Some(Dialog::MountPoints { + volume_index: partition.volume_index, + disk_part: Some((disk, part)), + selected, + }); + } + Selection::Disk(_) | Selection::None => { + self.status = "Select a volume or partition first.".to_string(); + } + } + } + + /// True when Tools → Assign Drive Letter should be available. + /// Ext2Mgr mounts via Ext2Fsd/Ext2Srv — only EXT volumes, and only when unlettered. + fn selection_can_assign_letter(&self) -> bool { + match self.selection { + Selection::Volume(index) => self.volumes.get(index).is_some_and(|volume| { + volume.letters.is_empty() && crate::mount::ops::is_ext_family(&volume.filesystem) + }), + Selection::Partition { disk, part } => self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .is_some_and(|partition| { + partition.letters.is_empty() + && crate::mount::ops::is_ext_family(&partition.filesystem) + }), + Selection::Disk(_) | Selection::None => false, + } + } + + /// True when Unmount should be available (selection has at least one letter). + fn selection_can_unmount(&self) -> bool { + match self.selection { + Selection::Volume(index) => self + .volumes + .get(index) + .is_some_and(|volume| !volume.letters.is_empty()), + Selection::Partition { disk, part } => self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .is_some_and(|partition| !partition.letters.is_empty()), + Selection::Disk(_) | Selection::None => false, + } + } + + fn disk_row_index_for_selection(&self) -> Option { + match self.selection { + Selection::Disk(disk_index) => self.disk_rows.iter().position(|row| { + matches!(row, DiskRow::DiskHeader { disk_index: candidate } if *candidate == disk_index) + }), + Selection::Partition { disk, part } => self.disk_rows.iter().position(|row| { + matches!( + row, + DiskRow::Partition { + disk_index, + partition_index + } if *disk_index == disk && *partition_index == part + ) + }), + _ => None, + } + } + + fn apply_disk_row_selection(&mut self, row_index: usize) { + if let Some(row) = self.disk_rows.get(row_index) { + self.selection = match row { + DiskRow::DiskHeader { disk_index } => Selection::Disk(*disk_index), + DiskRow::Partition { + disk_index, + partition_index, + } => Selection::Partition { + disk: *disk_index, + part: *partition_index, + }, + DiskRow::Spacer => return, + }; + self.recompute_detail(); + } + } + + fn select_prev_entry(&mut self) { + if self.dialog.is_some() { + return; + } + match self.selection { + Selection::Volume(index) => { + if index > 0 { + self.selection = Selection::Volume(index - 1); + self.recompute_detail(); + } + } + Selection::Disk(_) | Selection::Partition { .. } => { + let Some(current) = self.disk_row_index_for_selection() else { + return; + }; + let mut candidate = current; + while candidate > 0 { + candidate -= 1; + if !matches!(self.disk_rows.get(candidate), Some(DiskRow::Spacer)) { + self.apply_disk_row_selection(candidate); + return; + } + } + } + Selection::None => { + if let Some(last) = self.volumes.len().checked_sub(1) { + self.selection = Selection::Volume(last); + self.recompute_detail(); + } + } + } + } + + fn select_next_entry(&mut self) { + if self.dialog.is_some() { + return; + } + match self.selection { + Selection::Volume(index) => { + if index + 1 < self.volumes.len() { + self.selection = Selection::Volume(index + 1); + self.recompute_detail(); + } + } + Selection::Disk(_) | Selection::Partition { .. } => { + let Some(current) = self.disk_row_index_for_selection() else { + return; + }; + let mut candidate = current + 1; + while candidate < self.disk_rows.len() { + if !matches!(self.disk_rows.get(candidate), Some(DiskRow::Spacer)) { + self.apply_disk_row_selection(candidate); + return; + } + candidate += 1; + } + } + Selection::None => { + if !self.volumes.is_empty() { + self.selection = Selection::Volume(0); + self.recompute_detail(); + } else if !self.disk_rows.is_empty() { + let first = self + .disk_rows + .iter() + .position(|row| !matches!(row, DiskRow::Spacer)); + if let Some(row_index) = first { + self.apply_disk_row_selection(row_index); + } + } + } + } + } + + fn format_size(&self, bytes: Option) -> String { + crate::disk::enum_disk::format_size(bytes, self.size_units, self.display_bits) + } + + fn format_free_bytes(&self, total: Option, used: Option) -> String { + match (total, used) { + (Some(total_bytes), Some(used_bytes)) => { + self.format_size(Some(total_bytes.saturating_sub(used_bytes))) + } + _ => String::new(), + } + } + + fn refresh_size_display(&mut self) { + #[cfg(windows)] + { + crate::win::chrome::sync_view_menu( + self.show_properties_pane, + matches!(self.size_units, SizeUnitStyle::Binary), + self.display_bits, + ); + } + self.autofit_column_widths(); + self.recompute_detail(); + } + + fn set_size_units(&mut self, style: SizeUnitStyle) { + if self.size_units == style { + return; + } + self.size_units = style; + self.refresh_size_display(); + } + + fn set_display_bits(&mut self, display_bits: bool) { + if self.display_bits == display_bits { + return; + } + self.display_bits = display_bits; + self.refresh_size_display(); + } + + /// Approximate rendered width of a list cell label (size-12 text + padding). + fn measure_text_width(label: &str) -> f32 { + // Segoe UI ~12px is closer to ~5.5–6px average glyph width than monospace. + const CHAR_PX: f32 = 5.6; + const PAD_X: f32 = 8.0; + PAD_X + label.chars().count() as f32 * CHAR_PX + } + + fn grow_col(width: &mut f32, label: &str, min: f32) { + *width = (*width) + .max(min) + .max(Self::measure_text_width(label)); + } + + /// Size each column to the widest header or row value (no Fill stretch). + /// Header `|` grips sit at the right edge of each cell; reserve grip width once + /// so autofit does not fight the handle. + fn autofit_column_widths(&mut self) { + const GRIP: f32 = 10.0; + let mut volume = [24.0_f32, 36.0, 36.0, 56.0, 48.0, 48.0, 48.0, 64.0]; + Self::grow_col(&mut volume[1], "Volume", 36.0); + Self::grow_col(&mut volume[2], "Type", 36.0); + Self::grow_col(&mut volume[3], "File system", 56.0); + Self::grow_col(&mut volume[4], "Total size", 48.0); + // Used size / Codepage: size to cell values only; header may ellipsize. + Self::grow_col(&mut volume[7], "Physical object", 64.0); + for volume_entry in &self.volumes { + Self::grow_col( + &mut volume[1], + &format_letters(&volume_entry.letters), + 36.0, + ); + Self::grow_col(&mut volume[2], &volume_entry.volume_kind, 36.0); + Self::grow_col(&mut volume[3], &volume_entry.filesystem, 56.0); + Self::grow_col( + &mut volume[4], + &self.format_size(volume_entry.total_bytes), + 48.0, + ); + Self::grow_col( + &mut volume[5], + &self.format_size(volume_entry.used_bytes), + 48.0, + ); + Self::grow_col(&mut volume[6], &volume_entry.codepage, 48.0); + Self::grow_col(&mut volume[7], &volume_entry.physical_object, 64.0); + } + // Icon column stays fixed; other columns reserve room for the `|` grip. + for slot in volume.iter_mut().skip(1) { + *slot += GRIP; + } + self.volume_col_widths = volume; + + let mut disk = [36.0_f32, 36.0, 56.0, 48.0, 48.0, 48.0, 72.0]; + Self::grow_col(&mut disk[1], "Type", 36.0); + Self::grow_col(&mut disk[2], "File system", 56.0); + Self::grow_col(&mut disk[3], "Total size", 48.0); + // Used size / Codepage: size to cell values only; header may ellipsize. + Self::grow_col(&mut disk[6], "Partition type", 72.0); + for disk_entry in &self.disks { + // Col 0: "DISK N" or indented "(X:)" / "(Y:,Z:)" — no oversized floor. + Self::grow_col(&mut disk[0], &disk_entry.display_name, 36.0); + Self::grow_col(&mut disk[1], &disk_entry.style, 36.0); + Self::grow_col( + &mut disk[3], + &self.format_size(disk_entry.total_bytes), + 48.0, + ); + for partition in &disk_entry.partitions { + Self::grow_col( + &mut disk[0], + &format!(" {}", format_letters(&partition.letters)), + 36.0, + ); + Self::grow_col(&mut disk[1], &partition.style, 36.0); + Self::grow_col(&mut disk[2], &partition.filesystem, 56.0); + Self::grow_col( + &mut disk[3], + &self.format_size(partition.total_bytes), + 48.0, + ); + Self::grow_col( + &mut disk[4], + &self.format_size(partition.used_bytes), + 48.0, + ); + Self::grow_col(&mut disk[5], &partition.codepage, 48.0); + Self::grow_col(&mut disk[6], &partition.partition_type, 72.0); + } + } + for slot in &mut disk { + *slot += GRIP; + } + self.disk_col_widths = disk; + } + + fn volume_table_width(&self) -> f32 { + self.volume_col_widths.iter().sum::() + Self::TABLE_TRAILING_PAD + } + + fn disk_table_width(&self) -> f32 { + self.disk_col_widths.iter().sum::() + Self::TABLE_TRAILING_PAD + } + + /// Smallest content width that fits both tables without horizontal clipping. + fn content_min_width(&self) -> f32 { + const MAIN_PAD: f32 = 16.0; + self.volume_table_width() + .max(self.disk_table_width()) + + MAIN_PAD + } + + fn fit_window_task(&self) -> Task { + let size = Size::new(self.content_min_width().max(480.0), 720.0); + window::latest().then(move |window_id| match window_id { + Some(id) => window::resize(id, size), + None => Task::none(), + }) + } + + fn sync_native_menus(&self) { + #[cfg(windows)] + { + if self.chrome_ready { + crate::win::chrome::sync_mount_letter_menus( + self.selection_can_assign_letter(), + self.selection_can_unmount(), + ); + crate::win::chrome::sync_view_menu( + self.show_properties_pane, + matches!(self.size_units, SizeUnitStyle::Binary), + self.display_bits, + ); + } + } + } + + fn uuid_detail_suffix(uuid: &str) -> String { + if uuid.is_empty() { + String::new() + } else { + format!("\nUUID: {uuid}") + } + } + + /// Shared Capacity/Used/Free/UUID/Codepage block for the bottom properties pane. + fn volume_like_detail_body( + &self, + headline: &str, + total_bytes: Option, + used_bytes: Option, + codepage: &str, + uuid: &str, + ) -> String { + format!( + "{headline}\nCapacity: {}\nUsed: {}\nFree: {}{}\nCodepage: {}", + self.format_size(total_bytes), + self.format_size(used_bytes), + self.format_free_bytes(total_bytes, used_bytes), + Self::uuid_detail_suffix(uuid), + codepage, + ) + } + + fn recompute_detail(&mut self) { + let (status_line, detail) = match self.selection { + Selection::None => ("Ready".to_string(), "Ready".to_string()), + Selection::Volume(index) => { + if let Some(volume) = self.volumes.get(index) { + let device = if volume.physical_object.is_empty() { + "(no NT device name)" + } else { + volume.physical_object.as_str() + }; + // Filesystem last on the headline (no separate Filesystem / NT rows). + let status = format!( + "VOLUME: {} {} {}", + format_letters(&volume.letters), + device, + volume.filesystem, + ); + let uuid = display_volume_guid(&volume.win32_volume_name, volume.uuid); + let detail = self.volume_like_detail_body( + &status, + volume.total_bytes, + volume.used_bytes, + &volume.codepage, + &uuid, + ); + (status, detail) + } else { + ("Ready".to_string(), "Ready".to_string()) + } + } + Selection::Disk(index) => { + if let Some(disk) = self.disks.get(index) { + let status = format!("DISK {}: {}", disk.index, disk.device_path); + let detail = format!( + "{status}\nStyle: {}\nCapacity: {}\nPartitions: {}", + disk.style, + self.format_size(disk.total_bytes), + disk.partitions.len(), + ); + (status, detail) + } else { + ("Ready".to_string(), "Ready".to_string()) + } + } + Selection::Partition { disk, part } => { + if let Some(partition) = self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + { + // Filesystem last on the headline (no separate Filesystem / NT rows). + let status = format!( + "DISK {} PARTITION {}: {} {}", + self.disks[disk].index, + partition.number, + format_letters(&partition.letters), + partition.filesystem, + ); + let partition_uuid = partition + .volume_index + .and_then(|volume_index| self.volumes.get(volume_index)) + .and_then(|volume| volume.uuid); + let uuid = display_volume_guid(&partition.win32_volume_name, partition_uuid); + let detail = self.volume_like_detail_body( + &status, + partition.total_bytes, + partition.used_bytes, + &partition.codepage, + &uuid, + ); + (status, detail) + } else { + ("Ready".to_string(), "Ready".to_string()) + } + } + }; + self.status = status_line; + self.detail_text = detail.clone(); + self.detail_editor = text_editor::Content::with_text(&detail); + self.sync_native_menus(); + } + + + fn raw_bytes_string(bytes: Option) -> String { + bytes.map(|value| value.to_string()).unwrap_or_default() + } + + fn raw_free_bytes_string(total: Option, used: Option) -> String { + match (total, used) { + (Some(total_bytes), Some(used_bytes)) => { + total_bytes.saturating_sub(used_bytes).to_string() + } + _ => String::new(), + } + } + + /// Snapshot for the Properties dialog (IDD_PROPERTY_DIALOG field set). + fn properties_snapshot(&self) -> PropertiesSnapshot { + let empty = PropertiesSnapshot::default(); + match self.selection { + Selection::None => empty, + Selection::Volume(index) => { + let Some(volume) = self.volumes.get(index) else { + return empty; + }; + // Match SetVolume: single-extent volumes with a matching partition + // are shown as that partition + disk. + if volume.extents.len() == 1 { + let extent = &volume.extents[0]; + if let Some((disk_index, partition_index)) = + self.find_partition_for_extent(extent.disk_number, extent.starting_offset) + { + return self.properties_for_partition(disk_index, partition_index); + } + } + PropertiesSnapshot { + sdev_title: "Volume".to_string(), + status: Self::volume_status_line(volume), + mount_points: format_letters(&volume.letters), + filesystem: volume.filesystem.clone(), + capacity_bytes: Self::raw_bytes_string(volume.total_bytes), + free_bytes: Self::raw_free_bytes_string(volume.total_bytes, volume.used_bytes), + can_change_mp: true, + can_mount: volume.letters.is_empty() + && Self::is_ext_fs(&volume.filesystem), + can_unmount: !volume.letters.is_empty(), + can_ext2: Self::is_ext_fs(&volume.filesystem), + ..empty + } + } + Selection::Disk(index) => { + let Some(disk) = self.disks.get(index) else { + return empty; + }; + let mut snap = self.properties_disk_fields(disk); + // Original auto-selects first partition when opening a disk. + if !disk.partitions.is_empty() { + snap.apply_sdev(&self.properties_for_partition(index, 0)); + } + snap + } + Selection::Partition { disk, part } => self.properties_for_partition(disk, part), + } + } + + fn find_partition_for_extent( + &self, + disk_number: u32, + starting_offset: u64, + ) -> Option<(usize, usize)> { + let disk_index = self + .disks + .iter() + .position(|disk| disk.index == disk_number)?; + let partition_index = self.disks[disk_index].partitions.iter().position(|partition| { + partition.starting_offset == starting_offset + })?; + Some((disk_index, partition_index)) + } + + fn is_ext_fs(filesystem: &str) -> bool { + let upper = filesystem.to_ascii_uppercase(); + upper.starts_with("EXT2") || upper.starts_with("EXT3") || upper.starts_with("EXT4") + } + + fn volume_status_line(volume: &VolumeEntry) -> String { + let mut status = String::from("Online"); + if Self::is_ext_fs(&volume.filesystem) && !volume.codepage.is_empty() { + status.push_str(",codepage:"); + status.push_str(&volume.codepage); + } + status + } + + fn properties_disk_fields(&self, disk: &DiskEntry) -> PropertiesSnapshot { + let device_type = if disk.removable { + "Removable".to_string() + } else if disk.style.eq_ignore_ascii_case("GPT") { + "GUID".to_string() + } else if disk.style.is_empty() { + "RAW".to_string() + } else { + disk.style.clone() + }; + PropertiesSnapshot { + disk_title: disk.display_name.clone(), + vendor: disk.vendor.clone(), + product: disk.product.clone(), + serial: disk.serial.clone(), + bus_type: disk.bus_type.clone(), + device_type, + media_type: disk.media_type.clone(), + disk_capacity_bytes: Self::raw_bytes_string(disk.total_bytes), + ..PropertiesSnapshot::default() + } + } + + fn properties_for_partition(&self, disk_index: usize, part_index: usize) -> PropertiesSnapshot { + let Some(disk) = self.disks.get(disk_index) else { + return PropertiesSnapshot::default(); + }; + let Some(partition) = disk.partitions.get(part_index) else { + return PropertiesSnapshot::default(); + }; + let mut snap = self.properties_disk_fields(disk); + snap.sdev_title = format!("PARTITION {}", partition.number); + snap.mount_points = format_letters(&partition.letters); + snap.filesystem = partition.filesystem.clone(); + snap.can_change_mp = true; + snap.can_mount = + partition.letters.is_empty() && Self::is_ext_fs(&partition.filesystem); + snap.can_unmount = !partition.letters.is_empty(); + snap.can_ext2 = Self::is_ext_fs(&partition.filesystem); + + let mut status = String::from("Online"); + if !partition.partition_type.is_empty() { + status.push(','); + status.push_str(&partition.partition_type); + } else if !partition.style.is_empty() { + status.push(','); + status.push_str(&partition.style); + } + if snap.can_ext2 && !partition.codepage.is_empty() { + status.push_str(",codepage:"); + status.push_str(&partition.codepage); + } + snap.status = status; + + if let Some(total) = partition.total_bytes { + snap.capacity_bytes = total.to_string(); + snap.free_bytes = Self::raw_free_bytes_string(Some(total), partition.used_bytes); + if snap.free_bytes.is_empty() { + snap.free_bytes = "0".to_string(); + } + } else { + snap.capacity_bytes = partition.length.to_string(); + snap.free_bytes = "0".to_string(); + } + snap + } + + fn prop_row<'a>(label: &'a str, value: String) -> Element<'a, Message> { + row![ + text(label).size(12).width(Length::Fixed(110.0)), + text(value).size(12).width(Length::Fill), + ] + .spacing(6) + .into() + } + + fn now_clock() -> String { + #[cfg(windows)] + { + let mut local = windows_sys::Win32::Foundation::SYSTEMTIME { + wYear: 0, + wMonth: 0, + wDayOfWeek: 0, + wDay: 0, + wHour: 0, + wMinute: 0, + wSecond: 0, + wMilliseconds: 0, + }; + unsafe { + windows_sys::Win32::System::SystemInformation::GetLocalTime(&mut local); + } + let months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec", + ]; + let month = months + .get(local.wMonth.saturating_sub(1) as usize) + .copied() + .unwrap_or("???"); + format!( + "{} {:02},{:04} {:02}:{:02}:{:02}", + month, local.wDay, local.wYear, local.wHour, local.wMinute, local.wSecond + ) + } + #[cfg(not(windows))] + { + String::new() + } + } + + fn scan_dead_letters_task(&mut self) -> Task { + let volumes = self.volumes.clone(); + self.status = "Scanning for dead letters…".to_string(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || crate::mount::dead_letters::find_dead_letters(&volumes)) + .await + .unwrap_or_default() + }, + Message::DeadLettersLoaded, + ) + } + + fn quick_mount(&mut self) -> Task { + #[cfg(windows)] + { + let Some(volume_index) = self.selected_volume_index() else { + self.status = "Select a volume or partition to mount.".to_string(); + return Task::none(); + }; + let Some(volume) = self.volumes.get(volume_index).cloned() else { + return Task::none(); + }; + if !volume.letters.is_empty() { + self.status = format!( + "Already mounted as {}", + format_letters(&volume.letters) + ); + return Task::none(); + } + if !crate::mount::ops::is_ext_family(&volume.filesystem) { + self.report_warning( + "Assign Drive Letter is only available for EXT2/3/4 volumes.", + ); + return Task::none(); + } + let Some(letter) = crate::mount::ops::first_free_drive_letter() else { + self.status = "No free drive letter (C:-Z:).".to_string(); + return Task::none(); + }; + let request = crate::mount::ops::MountRequest { + letter, + mode: crate::mount::ops::MountMode::Temporary, + symlink: crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ), + win32_volume_name: volume.win32_volume_name, + uuid: volume.uuid, + codepage: if volume.codepage.is_empty() { + "utf8".to_string() + } else { + volume.codepage + }, + readonly: false, + }; + self.status = format!("Mounting {letter}:…"); + return Self::mount_volume_task(request); + } + #[cfg(not(windows))] + { + self.status = "Mount is Windows-only.".to_string(); + Task::none() + } + } + + #[cfg(windows)] + fn mount_volume_task(request: crate::mount::ops::MountRequest) -> Task { + Task::perform( + async move { + match tokio::task::spawn_blocking(move || crate::mount::ops::mount_volume(&request)).await { + Ok(Ok(message)) => (Some(message), None, false), + Ok(Err(err)) => { + let is_error = err.contains("not started"); + (None, Some(err), is_error) + } + Err(join_err) => (None, Some(join_err.to_string()), true), + } + }, + |(ok_message, err_message, is_error)| Message::PipeOpFinished { + ok_message, + err_message, + is_error, + restore_mount_points: None, + }, + ) + } + + #[cfg(windows)] + fn unmount_letters_task(letters: Vec, symlink: String) -> Task { + Task::perform( + async move { + match tokio::task::spawn_blocking(move || { + let mut removed = Vec::new(); + let mut last_error = None; + for letter in letters { + match crate::mount::ops::unmount_letter(letter, &symlink) { + Ok(()) => removed.push(letter), + Err(err) => last_error = Some(err), + } + } + if removed.is_empty() { + Err(last_error.unwrap_or_else(|| "Unmount failed.".to_string())) + } else { + let list = removed + .iter() + .map(|letter| format!("{letter}:")) + .collect::>() + .join(", "); + Ok(format!("Unmounted {list}")) + } + }) + .await + { + Ok(Ok(message)) => (Some(message), None, false), + Ok(Err(err)) => { + let is_error = err.contains("not started"); + (None, Some(err), is_error) + } + Err(join_err) => (None, Some(join_err.to_string()), true), + } + }, + |(ok_message, err_message, is_error)| Message::PipeOpFinished { + ok_message, + err_message, + is_error, + restore_mount_points: None, + }, + ) + } + + fn unmount_selected(&mut self) -> Task { + #[cfg(windows)] + { + let letters = self.selection_letters(); + if letters.is_empty() { + self.status = "Selection has no drive letter.".to_string(); + return Task::none(); + } + let symlink = if let Some(volume_index) = self.selected_volume_index() { + self.volumes + .get(volume_index) + .map(|volume| volume.symlink.clone()) + .unwrap_or_default() + } else if let Selection::Partition { disk, part } = self.selection { + self.disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| partition.symlink.clone()) + .unwrap_or_default() + } else { + String::new() + }; + self.status = "Unmounting…".to_string(); + return Self::unmount_letters_task(letters, symlink); + } + #[cfg(not(windows))] + { + self.status = "Unmount is Windows-only.".to_string(); + Task::none() + } + } + + fn mount_target_for_letter_ops( + &self, + ) -> Option<(String, String, Option<[u8; 16]>, String)> { + // dos_device_target (\Device\...), win32_volume_name, uuid, codepage + if let Some(Dialog::MountPoints { + volume_index, + disk_part, + .. + }) = &self.dialog + { + if let Some(index) = *volume_index { + return self.volumes.get(index).map(|volume| { + ( + crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ), + volume.win32_volume_name.clone(), + volume.uuid, + volume.codepage.clone(), + ) + }); + } + if let Some((disk, part)) = *disk_part { + return self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| { + let (physical, win32, uuid, codepage, volume_symlink) = partition + .volume_index + .and_then(|index| self.volumes.get(index)) + .map(|volume| { + ( + volume.physical_object.clone(), + volume.win32_volume_name.clone(), + volume.uuid, + volume.codepage.clone(), + volume.symlink.clone(), + ) + }) + .unwrap_or_else(|| { + ( + String::new(), + String::new(), + None, + String::new(), + partition.symlink.clone(), + ) + }); + let symlink = crate::mount::ops::dos_device_target( + &physical, + if !volume_symlink.is_empty() { + &volume_symlink + } else { + &partition.symlink + }, + ); + (symlink, win32, uuid, codepage) + }); + } + } + if let Some(index) = self.selected_volume_index() { + return self.volumes.get(index).map(|volume| { + ( + crate::mount::ops::dos_device_target(&volume.physical_object, &volume.symlink), + volume.win32_volume_name.clone(), + volume.uuid, + volume.codepage.clone(), + ) + }); + } + if let Selection::Partition { disk, part } = self.selection { + return self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| { + let (physical, win32, uuid, codepage, volume_symlink) = partition + .volume_index + .and_then(|index| self.volumes.get(index)) + .map(|volume| { + ( + volume.physical_object.clone(), + volume.win32_volume_name.clone(), + volume.uuid, + volume.codepage.clone(), + volume.symlink.clone(), + ) + }) + .unwrap_or_else(|| { + ( + String::new(), + String::new(), + None, + String::new(), + partition.symlink.clone(), + ) + }); + let symlink = crate::mount::ops::dos_device_target( + &physical, + if !volume_symlink.is_empty() { + &volume_symlink + } else { + &partition.symlink + }, + ); + (symlink, win32, uuid, codepage) + }); + } + None + } + + fn open_letter_picker(&mut self, replace_letter: Option) { + #[cfg(windows)] + { + let return_mount_points = match &self.dialog { + Some(Dialog::MountPoints { + volume_index, + disk_part, + .. + }) => Some((*volume_index, *disk_part)), + _ => None, + }; + let Some((mount_symlink, mount_win32, mount_uuid, mount_codepage)) = + self.mount_target_for_letter_ops() + else { + self.status = "Select a volume or partition first.".to_string(); + return; + }; + let mut available = crate::mount::ops::free_drive_letters(); + if let Some(letter) = replace_letter { + if !available.contains(&letter) { + available.insert(0, letter); + } + } + let selected_letter = replace_letter.or_else(|| available.first().copied()); + let existing_session_letters = + crate::mount::persist::registry_letters_for_device(&mount_symlink); + let existing_mountmgr_letters = if mount_win32.is_empty() { + Vec::new() + } else { + let mut letters: Vec = crate::mount::dead_letters::mountmgr_letters_for_volume(&mount_win32) + .into_iter() + .collect(); + letters.sort_unstable(); + letters + }; + let mountmgr_available = !mount_win32.is_empty(); + if replace_letter.is_none() && !existing_mountmgr_letters.is_empty() { + let listed = existing_mountmgr_letters + .iter() + .map(|entry| format!("{entry}:")) + .collect::>() + .join(", "); + self.report_warning(format!( + "This volume already has Mount Manager letter {listed}.\n\ + Windows allows only one drive letter per volume — use Change or Remove." + )); + return; + } + let mountmgr_blocked = !mountmgr_available + || Self::assignment_blocked(&existing_mountmgr_letters, replace_letter); + let session_blocked = + Self::session_manager_blocked(&existing_session_letters, replace_letter); + let persist_mode = if mountmgr_available && !mountmgr_blocked { + crate::mount::ops::MountMode::MountManager + } else if !session_blocked { + crate::mount::ops::MountMode::PermanentRegistry + } else { + crate::mount::ops::MountMode::Temporary + }; + self.dialog = Some(Dialog::LetterPicker { + replace_letter, + selected_letter, + persist_mode, + available, + mount_symlink, + mount_win32, + mount_uuid, + mount_codepage, + existing_session_letters, + existing_mountmgr_letters, + return_mount_points, + }); + } + #[cfg(not(windows))] + { + let _ = replace_letter; + self.status = "Letter picker is Windows-only.".to_string(); + } + } + + fn assignment_blocked(existing_letters: &[char], replace_letter: Option) -> bool { + existing_letters.iter().any(|letter| { + replace_letter.map(|replace| replace != *letter).unwrap_or(true) + }) + } + + fn session_manager_blocked( + existing_session_letters: &[char], + replace_letter: Option, + ) -> bool { + match replace_letter { + None => !existing_session_letters.is_empty(), + Some(letter) if existing_session_letters.contains(&letter) => false, + Some(_) => !existing_session_letters.is_empty(), + } + } + + fn letter_source_label( + letter: char, + win32_volume_name: &str, + device_nt_path: &str, + ) -> String { + let in_mountmgr = !win32_volume_name.is_empty() + && crate::mount::dead_letters::mountmgr_letters_for_volume(win32_volume_name).contains(&letter); + let in_session = crate::mount::persist::registry_letters_for_device(device_nt_path) + .iter() + .any(|entry| *entry == letter); + match (in_mountmgr, in_session) { + (true, _) => format!("{letter}: Mount Manager"), + (false, true) => format!("{letter}: Session Manager (registry)"), + (false, false) => format!("{letter}: Temporary / DOS device"), + } + } + + fn confirm_letter_picker(&mut self) -> Task { + #[cfg(windows)] + { + let Some(Dialog::LetterPicker { + replace_letter, + selected_letter, + persist_mode, + mount_symlink, + mount_win32, + mount_uuid, + mount_codepage, + existing_session_letters, + existing_mountmgr_letters, + return_mount_points, + .. + }) = self.dialog.clone() + else { + return Task::none(); + }; + let Some(letter) = selected_letter else { + self.status = "Choose a drive letter.".to_string(); + return Task::none(); + }; + if replace_letter.is_none() && !existing_mountmgr_letters.is_empty() { + let listed = existing_mountmgr_letters + .iter() + .map(|entry| format!("{entry}:")) + .collect::>() + .join(", "); + self.report_warning(format!( + "This volume already has Mount Manager letter {listed}.\n\ + Windows allows only one drive letter per volume — use Change or Remove." + )); + return Task::none(); + } + if persist_mode == crate::mount::ops::MountMode::Temporary + && !existing_mountmgr_letters.is_empty() + && replace_letter + .map(|letter| !existing_mountmgr_letters.contains(&letter)) + .unwrap_or(true) + { + self.report_warning( + "Temporary DefineDosDevice cannot add another Explorer letter \ + beside an existing Mount Manager letter.\n\ + Windows allows only one drive letter per volume — use Change or Remove.", + ); + return Task::none(); + } + if persist_mode == crate::mount::ops::MountMode::Ext2Automount && mount_uuid.is_none() { + self.report_warning("Ext2Fsd automount needs an EXT volume UUID."); + return Task::none(); + } + if persist_mode == crate::mount::ops::MountMode::MountManager && mount_win32.is_empty() { + self.report_warning( + "Mount Manager needs a Win32 volume name (Volume{GUID}).\n\ + This Ext2 volume is only reachable as a DOS device — use Temporary \ + or Session Manager.", + ); + return Task::none(); + } + if persist_mode == crate::mount::ops::MountMode::MountManager + && Self::assignment_blocked(&existing_mountmgr_letters, replace_letter) + { + let listed = existing_mountmgr_letters + .iter() + .map(|entry| format!("{entry}:")) + .collect::>() + .join(", "); + self.report_warning(format!( + "This volume already has a Mount Manager letter ({listed}).\n\ + Use Change on that letter, or remove it first — only one Mount Manager \ + letter is allowed per volume." + )); + return Task::none(); + } + if persist_mode == crate::mount::ops::MountMode::PermanentRegistry + && Self::session_manager_blocked(&existing_session_letters, replace_letter) + { + let listed = existing_session_letters + .iter() + .map(|entry| format!("{entry}:")) + .collect::>() + .join(", "); + self.report_warning(format!( + "This volume already has a Session Manager letter ({listed}).\n\ + Use Change on that letter, or remove it first — only one Session Manager \ + letter is allowed per volume." + )); + return Task::none(); + } + + let mode = persist_mode; + let request = crate::mount::ops::MountRequest { + letter, + mode, + symlink: mount_symlink.clone(), + win32_volume_name: mount_win32.clone(), + uuid: mount_uuid, + codepage: if mount_codepage.is_empty() { + "utf8".to_string() + } else { + mount_codepage + }, + readonly: false, + }; + let restore = return_mount_points.map(|(volume_index, disk_part)| { + ( + volume_index, + disk_part, + letter, + mount_win32, + mount_uuid, + ) + }); + self.status = format!("Mounting {letter}:…"); + self.dialog = None; + return Task::perform( + async move { + let result = tokio::task::spawn_blocking(move || { + if let Some(old_letter) = replace_letter { + crate::mount::ops::unmount_letter(old_letter, &mount_symlink)?; + } + crate::mount::ops::mount_volume(&request) + }) + .await; + match result { + Ok(Ok(message)) => (Some(message), None, false, restore), + Ok(Err(err)) => { + let is_error = err.contains("not started"); + (None, Some(err), is_error, None) + } + Err(join_err) => (None, Some(join_err.to_string()), true, None), + } + }, + |(ok_message, err_message, is_error, restore_mount_points)| { + Message::PipeOpFinished { + ok_message, + err_message, + is_error, + restore_mount_points, + } + }, + ); + } + #[cfg(not(windows))] + { + self.dialog = None; + Task::none() + } + } + + fn header_cell<'a>( + label: &'a str, + width: Length, + list: ListKind, + column: usize, + resizable: bool, + ) -> Element<'a, Message> { + // `|` grip inside the cell, flush to the right border (column seam). + const GRIP_W: f32 = 10.0; + let display = match width { + Length::Fixed(pixels) => { + let label_budget = if resizable { + (pixels - GRIP_W).max(0.0) + } else { + pixels + }; + Self::ellipsize_for_width(label, label_budget) + } + _ => label.to_string(), + }; + // Intrinsic text height — Length::Fill here collapses the header row to 0. + let label_text = container(text(display).size(12)) + .width(Length::Fill) + .padding([2, 4]); + let body: Element<'_, Message> = if resizable { + let grip = mouse_area( + container(text("|").size(14)) + .width(Length::Fixed(GRIP_W)) + .height(Length::Fixed(18.0)) + .align_x(alignment::Horizontal::Right) + .center_y(Length::Fixed(18.0)) + .style(|_theme| container::Style { + text_color: Some(Color::from_rgb(0.45, 0.45, 0.45)), + ..Default::default() + }), + ) + .interaction(mouse::Interaction::ResizingColumn) + .on_press(Message::StartResize { + list, + column, + cursor_x: 0.0, + }); + row![label_text, grip].spacing(0).into() + } else { + label_text.into() + }; + container(body) + .width(width) + .style(container::bordered_box) + .into() + } + + fn cell<'a>(&self, label: String, width: Length, selected: bool) -> Element<'a, Message> { + let display = match width { + Length::Fixed(pixels) => Self::ellipsize_for_width(&label, pixels), + _ => label, + }; + let content = text(display).size(12); + let mut box_ = container(content).width(width).padding([2, 4]); + if selected { + let background = self.accent; + let text_color = self.accent_text; + box_ = box_.style(move |_theme| container::Style { + text_color: Some(text_color), + background: Some(Background::Color(background)), + border: Border::default(), + shadow: Shadow::default(), + ..Default::default() + }); + } + box_.into() + } + + /// Truncate with an ellipsis when the column is narrower than the text. + /// Uses the same ~5.6px/char estimate as column autofit. + fn ellipsize_for_width(label: &str, column_width: f32) -> String { + const CHAR_PX: f32 = 5.6; + const PAD_PX: f32 = 12.0; // cell horizontal padding + let usable = (column_width - PAD_PX).max(CHAR_PX); + let max_chars = (usable / CHAR_PX).floor() as usize; + if max_chars == 0 { + return String::new(); + } + let char_count = label.chars().count(); + if char_count <= max_chars { + return label.to_string(); + } + if max_chars == 1 { + return "…".to_string(); + } + let keep = max_chars - 1; + let mut out: String = label.chars().take(keep).collect(); + out.push('…'); + out + } + + fn lerp_color(from: Color, to: Color, amount: f32) -> Color { + Color { + r: from.r + (to.r - from.r) * amount, + g: from.g + (to.g - from.g) * amount, + b: from.b + (to.b - from.b) * amount, + a: from.a + (to.a - from.a) * amount, + } + } + + fn accent_gradient_rule(accent: Color, width: f32) -> Element<'static, Message> { + const SEGMENTS: usize = 28; + let dark = Self::lerp_color(accent, Color::BLACK, 0.55); + let mut segments = row![].spacing(0); + for segment_index in 0..SEGMENTS { + let amount = segment_index as f32 / (SEGMENTS - 1) as f32; + let color = Self::lerp_color(dark, accent, amount); + segments = segments.push( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fixed(2.0)) + .style(move |_theme| container::Style { + background: Some(Background::Color(color)), + ..Default::default() + }), + ); + } + segments.width(Length::Fixed(width)).into() + } + + fn volume_icon_path(volume_kind: &str) -> std::path::PathBuf { + let name = if volume_kind.eq_ignore_ascii_case("CDROM") + || volume_kind.eq_ignore_ascii_case("DVD") + { + "cdrom" + } else if volume_kind.eq_ignore_ascii_case("Removable") { + "floppy" + } else if volume_kind.eq_ignore_ascii_case("Dynamic") { + "dynamic" + } else { + "disk" + }; + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("assets/icons16") + .join(format!("{name}.ico")) + } +} diff --git a/ext2mgr_iced/src/ui/mod.rs b/ext2mgr_iced/src/ui/mod.rs new file mode 100644 index 0000000..076f5a6 --- /dev/null +++ b/ext2mgr_iced/src/ui/mod.rs @@ -0,0 +1,184 @@ +//! Iced GUI: window state, tables, dialogs, and message update loop. +//! +//! Split with `include!` (one module) so Dialog fields / `impl Ext2MgrApp` +//! methods stay visible across files: +//! - `types.rs` — Selection, Dialog, Message +//! - `helpers.rs` — mount/letter/detail/column helpers +//! - `view.rs` — tables + dialogs + shell layout +//! - `update.rs` — Message handling + native command Tasks +//! +//! Domain logic lives under `crate::disk`, `crate::mount`, `crate::service`, `crate::win`. + +use crate::disk::enum_disk::{ + display_volume_guid, format_letters, DiskEntry, DiskRow, SizeUnitStyle, VolumeEntry, +}; +use iced::widget::{ + button, checkbox, column, container, image, mouse_area, pick_list, radio, row, scrollable, + stack, text, text_editor, text_input, Column, Space, +}; +use iced::{ + alignment, keyboard, mouse, time, window, Background, Border, Color, Element, Event, Length, + Shadow, Size, Subscription, Task, Theme, +}; +use iced::keyboard::key::Named; + +#[cfg(all(test, windows))] +mod gating_probe; + +include!("types.rs"); + +pub struct Ext2MgrApp { + status: String, + clock: String, + disks: Vec, + volumes: Vec, + disk_rows: Vec, + selection: Selection, + dialog: Option, + detail_text: String, + /// Selectable/copyable properties pane under the disk list. + detail_editor: text_editor::Content, + /// Volume: icon, letters, type, fs, total, used, codepage, physical. + volume_col_widths: [f32; 8], + /// Disk: name, type, fs, total, used, codepage, partition type. + disk_col_widths: [f32; 7], + resizing: Option<(ListKind, usize, f32)>, + chrome_ready: bool, + /// Windows accent (Settings / DWM); drives selection highlight. + accent: Color, + accent_text: Color, + /// View → SI vs binary capacity labels (KB vs KiB, etc.). + size_units: SizeUnitStyle, + /// View → display capacities as bits (×8) instead of bytes. + display_bits: bool, + /// View → show the properties pane under Disks / Partitions. + show_properties_pane: bool, + #[cfg(windows)] + service_draft: crate::service::mgr::GlobalProperty, +} + +include!("helpers.rs"); +include!("view.rs"); +include!("update.rs"); + +impl Ext2MgrApp { + pub fn new() -> (Self, Task) { + let (disks, volumes, disk_rows) = crate::disk::enum_disk::enumerate_all(); + #[cfg(windows)] + let accent = crate::win::accent::read_accent_color(); + #[cfg(not(windows))] + let accent = Color::from_rgb(0.0, 0.45, 0.85); + let accent_text = { + #[cfg(windows)] + { + crate::win::accent::contrasting_text(accent) + } + #[cfg(not(windows))] + { + Color::WHITE + } + }; + let mut app = Self { + status: "Ready".to_string(), + clock: Self::now_clock(), + disks, + volumes, + disk_rows, + selection: Selection::None, + dialog: None, + detail_text: "Ready".to_string(), + detail_editor: text_editor::Content::with_text("Ready"), + // Seed widths; autofit_column_widths replaces these from live data. + volume_col_widths: [24.0, 60.0, 60.0, 80.0, 80.0, 70.0, 70.0, 120.0], + disk_col_widths: [36.0, 60.0, 80.0, 80.0, 70.0, 70.0, 120.0], + resizing: None, + chrome_ready: false, + accent, + accent_text, + size_units: SizeUnitStyle::Si, + display_bits: false, + show_properties_pane: true, + #[cfg(windows)] + service_draft: { + crate::service::mgr::query_global_property().unwrap_or_default() + }, + }; + app.autofit_column_widths(); + app.recompute_detail(); + #[cfg(windows)] + { + crate::win::chrome::sync_autorun_menu(crate::win::autorun::is_autorun_enabled()); + } + // Ext2Mgr processes Ext2Fsd / Session Manager automounts once at startup too, + // not only on refresh. + let startup = Task::batch([app.fit_window_task(), app.automount_task()]); + (app, startup) + } + + pub fn title(&self) -> String { + "Ext2 Volume Manager".to_string() + } + + pub fn theme(&self) -> Theme { + Theme::custom( + "WindowsAccent", + iced::theme::Palette { + background: Color::from_rgb(0.97, 0.97, 0.97), + text: Color::from_rgb(0.1, 0.1, 0.1), + primary: self.accent, + success: Color::from_rgb(0.15, 0.55, 0.25), + danger: Color::from_rgb(0.75, 0.2, 0.2), + warning: Color::from_rgb(0.85, 0.55, 0.1), + }, + ) + } + + pub fn refresh_accent(&mut self) { + #[cfg(windows)] + { + let accent = crate::win::accent::read_accent_color(); + if accent != self.accent { + self.accent = accent; + self.accent_text = crate::win::accent::contrasting_text(accent); + } + } + } + + pub fn subscription(&self) -> Subscription { + let tick = time::every(std::time::Duration::from_secs(1)).map(|_| Message::Tick); + let input = iced::event::listen_with(|event, _status, _id| match event { + Event::Mouse(mouse::Event::CursorMoved { position }) => { + Some(Message::CursorMoved(position.x)) + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + Some(Message::EndResize) + } + Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { + if modifiers.control() || modifiers.alt() || modifiers.logo() { + return None; + } + match key { + keyboard::Key::Named(Named::ArrowUp) => Some(Message::SelectPrev), + keyboard::Key::Named(Named::ArrowDown) => Some(Message::SelectNext), + keyboard::Key::Named(Named::F1) => Some(Message::MenuDocumentation), + keyboard::Key::Named(Named::F2) => Some(Message::MenuAbout), + keyboard::Key::Named(Named::F3) => Some(Message::OpenExt2Attrs), + keyboard::Key::Named(Named::F4) => Some(Message::QuickMount), + keyboard::Key::Named(Named::F5) => Some(Message::Refresh), + keyboard::Key::Named(Named::F6) => Some(Message::ShowProperties), + keyboard::Key::Named(Named::F7) => Some(Message::OpenService), + keyboard::Key::Named(Named::F8) => Some(Message::OpenPerfStat), + keyboard::Key::Named(Named::F9) => Some(Message::OpenDeadLetters), + keyboard::Key::Named(Named::F10) => Some(Message::OpenMountPoints), + keyboard::Key::Named(Named::F11) => Some(Message::FlushSelected), + keyboard::Key::Named(Named::F12) => Some(Message::OpenPartitionType), + _ => None, + } + } + _ => None, + }); + Subscription::batch(vec![tick, input]) + } + +} + diff --git a/ext2mgr_iced/src/ui/types.rs b/ext2mgr_iced/src/ui/types.rs new file mode 100644 index 0000000..3f64ab0 --- /dev/null +++ b/ext2mgr_iced/src/ui/types.rs @@ -0,0 +1,227 @@ +/// Types included into `app` (Selection, Dialog, Message, …). + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Selection { + None, + Volume(usize), + Disk(usize), + Partition { disk: usize, part: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ListKind { + Volume, + Disk, +} + +/// Subset of Ext2Mgr gCodepages[] for combo-like pickers. +const CODE_PAGES: &[&str] = &[ + "default", "utf8", "cp936", "gb2312", "cp437", "cp850", "cp852", "cp866", "cp932", + "euc-jp", "sjis", "cp949", "euc-kr", "cp950", "big5", "iso8859-1", "iso8859-15", +]; + +#[derive(Debug, Clone)] +pub(crate) enum Dialog { + LetterPicker { + replace_letter: Option, + selected_letter: Option, + persist_mode: crate::mount::ops::MountMode, + available: Vec, + mount_symlink: String, + mount_win32: String, + mount_uuid: Option<[u8; 16]>, + mount_codepage: String, + /// Session Manager letters already mapped to this device. + existing_session_letters: Vec, + /// Mount Manager letters already mapped to this volume GUID. + existing_mountmgr_letters: Vec, + /// Re-open Change Drive Letters after OK (volume_index, disk_part). + return_mount_points: Option<(Option, Option<(usize, usize)>)>, + }, + MountPoints { + /// Volume list index when editing via a volume (or linked partition). + volume_index: Option, + /// Disk list partition when there is no linked volume row. + disk_part: Option<(usize, usize)>, + /// Selected row in the mountpoints list (index into `letters`). + selected: Option, + }, + Service, + Ext2Attrs { + volume_index: usize, + readonly: bool, + codepage: String, + fixmount: bool, + automount: bool, + letter: Option, + available: Vec, + hiding_prefix: String, + hiding_suffix: String, + uid: String, + gid: String, + euid: String, + }, + DeadLetters { + entries: Vec, + selected: Option, + /// When set, also clear a permanent Mount Manager assignment on remove. + also_remove_permanent: bool, + pending_remove: Option, + }, + /// Modal properties pane (IDD_PROPERTY_DIALOG fields). + Properties, + About { + driver_line: String, + program_line: String, + }, + Donate, + PerfStat { + rows: Vec<(String, u32, u32)>, + }, + PartitionType { + disk: usize, + part: usize, + selected_type: u8, + note: String, + }, +} + +/// Field set for the Properties dialog (mirrors IDD_PROPERTY_DIALOG). +#[derive(Debug, Clone, Default)] +struct PropertiesSnapshot { + disk_title: String, + vendor: String, + product: String, + serial: String, + bus_type: String, + device_type: String, + media_type: String, + disk_capacity_bytes: String, + sdev_title: String, + status: String, + mount_points: String, + filesystem: String, + capacity_bytes: String, + free_bytes: String, + can_change_mp: bool, + can_mount: bool, + can_unmount: bool, + can_ext2: bool, +} + +impl PropertiesSnapshot { + /// Copy volume/partition (lower) fields from another snapshot onto this disk snapshot. + fn apply_sdev(&mut self, other: &Self) { + self.sdev_title = other.sdev_title.clone(); + self.status = other.status.clone(); + self.mount_points = other.mount_points.clone(); + self.filesystem = other.filesystem.clone(); + self.capacity_bytes = other.capacity_bytes.clone(); + self.free_bytes = other.free_bytes.clone(); + self.can_change_mp = other.can_change_mp; + self.can_mount = other.can_mount; + self.can_unmount = other.can_unmount; + self.can_ext2 = other.can_ext2; + } +} + +#[derive(Debug, Clone)] +pub enum Message { + Tick, + Refresh, + SelectVolume(usize), + SelectDiskRow(usize), + SelectPrev, + SelectNext, + ContextVolume(usize), + ContextDiskRow(usize), + MenuCopyAll, + MenuCopyItem, + MenuAbout, + MenuDonate, + MenuDocumentation, + OpenAboutWebsite, + OpenIcedPortWebsite, + OpenDonateSourceForge, + OpenDonatePayPal, + OpenPerfStat, + OpenPartitionType, + PartitionTypePick(u8), + ApplyPartitionType, + FlushSelected, + EnableAutorun, + DisableAutorun, + QuickMount, + UnmountSelected, + OpenMountPoints, + OpenLetterPicker { replace_letter: Option }, + MountPointsSelect(usize), + CloseDialog, + /// Click sink on the dialog card so backdrop dismiss does not fire underneath. + DialogAbsorbClick, + PickLetter(char), + SetPersistMode(crate::mount::ops::MountMode), + ConfirmLetterPicker, + /// Background mount/unmount finished (keeps UI responsive during Ext2Srv I/O). + PipeOpFinished { + ok_message: Option, + err_message: Option, + is_error: bool, + /// Re-open Change Drive Letters after a successful Assign from that dialog. + restore_mount_points: Option<(Option, Option<(usize, usize)>, char, String, Option<[u8; 16]>)>, + }, + /// Ext2Mgr `Ext2ProcessExt2Volumes` finished (after load/refresh). + AutomountFinished(crate::mount::ops::AutomountReport), + RemoveLetter(char), + OpenService, + StartService, + StopService, + RestartService, + SaveService, + ServiceStartup(u32), + ServiceReadonly(bool), + ServiceExt3Writable(bool), + ServiceAutomount(bool), + ServiceCodepage(String), + ServicePrefix(String), + ServiceSuffix(String), + OpenExt2Attrs, + Ext2Readonly(bool), + Ext2Codepage(String), + Ext2Fixmount(bool), + Ext2Automount(bool), + Ext2Letter(char), + Ext2Prefix(String), + Ext2Suffix(String), + Ext2Uid(String), + Ext2Gid(String), + Ext2Euid(String), + SaveExt2Attrs, + PropertiesChangeMp, + PropertiesQuickMount, + PropertiesUnmount, + PropertiesExt2Info, + OpenDeadLetters, + DeadLettersLoaded(Vec), + SelectDeadLetter(usize), + DeadAlsoRemovePermanent(bool), + RemoveSelectedDeadLetter, + ConfirmDeadLetterRemove, + CancelDeadLetterRemove, + ShowProperties, + CopyPerfStat, + StartResize { + list: ListKind, + column: usize, + cursor_x: f32, + }, + CursorMoved(f32), + EndResize, + DetailEditor(text_editor::Action), + UseSiUnits, + UseBinaryUnits, + DisplayWithBytes, + DisplayWithBits, + TogglePropertiesPane, +} diff --git a/ext2mgr_iced/src/ui/update.rs b/ext2mgr_iced/src/ui/update.rs new file mode 100644 index 0000000..6d526a2 --- /dev/null +++ b/ext2mgr_iced/src/ui/update.rs @@ -0,0 +1,1238 @@ +impl Ext2MgrApp { + fn apply_native_command(&mut self, command_id: u32) -> Task { + #[cfg(windows)] + { + use crate::win::chrome as chrome; + let message = match command_id { + x if x == chrome::ID_EXIT => { + std::process::exit(0); + } + x if x == chrome::ID_REFRESH => Some(Message::Refresh), + x if x == chrome::ID_COPYALL => Some(Message::MenuCopyAll), + x if x == chrome::ID_COPY => Some(Message::MenuCopyItem), + x if x == chrome::ID_ABOUT => Some(Message::MenuAbout), + x if x == chrome::ID_DONATE => Some(Message::MenuDonate), + x if x == chrome::ID_DOCUMENTATION => Some(Message::MenuDocumentation), + x if x == chrome::ID_SERVICE => Some(Message::OpenService), + x if x == chrome::ID_CHANGE => Some(Message::OpenExt2Attrs), + x if x == chrome::ID_DRV_LETTER => Some(Message::OpenMountPoints), + x if x == chrome::ID_DRV_QUICK_MOUNT => Some(Message::QuickMount), + x if x == chrome::ID_DRV_UNMOUNT => Some(Message::UnmountSelected), + x if x == chrome::ID_PROPERTY => Some(Message::ShowProperties), + x if x == chrome::ID_REMOVE_DEAD_LETTER => Some(Message::OpenDeadLetters), + x if x == chrome::ID_ENABLE_AUTOSTART => Some(Message::EnableAutorun), + x if x == chrome::ID_DISABLE_AUTOSTART => Some(Message::DisableAutorun), + x if x == chrome::ID_PERFSTAT => Some(Message::OpenPerfStat), + x if x == chrome::ID_CHANGE_PARTTYPE => Some(Message::OpenPartitionType), + x if x == chrome::ID_FLUSH_BUFFER => Some(Message::FlushSelected), + x if x == chrome::ID_VIEW_SI_UNITS => Some(Message::UseSiUnits), + x if x == chrome::ID_VIEW_BINARY_UNITS => Some(Message::UseBinaryUnits), + x if x == chrome::ID_VIEW_DISPLAY_BYTES => Some(Message::DisplayWithBytes), + x if x == chrome::ID_VIEW_DISPLAY_BITS => Some(Message::DisplayWithBits), + x if x == chrome::ID_VIEW_PROPERTIES_PANE => Some(Message::TogglePropertiesPane), + _ => None, + }; + if let Some(message) = message { + self.update(message) + } else { + Task::none() + } + } + #[cfg(not(windows))] + { + let _ = command_id; + Task::none() + } + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::Tick => { + self.clock = Self::now_clock(); + self.refresh_accent(); + #[cfg(windows)] + { + if !self.chrome_ready { + self.chrome_ready = crate::win::chrome::ensure_attached(); + if self.chrome_ready { + crate::win::chrome::sync_autorun_menu(crate::win::autorun::is_autorun_enabled()); + self.sync_native_menus(); + } + } + let mut tasks = Vec::new(); + for command_id in crate::win::chrome::poll_commands() { + tasks.push(self.apply_native_command(command_id)); + } + return Task::batch(tasks); + } + } + Message::MenuCopyAll => { + let mut text = String::new(); + text.push_str("=== Volumes ===\n"); + for volume in &self.volumes { + text.push_str(&format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\n", + format_letters(&volume.letters), + volume.volume_kind, + volume.filesystem, + self.format_size(volume.total_bytes), + self.format_size(volume.used_bytes), + volume.codepage, + volume.physical_object, + )); + } + text.push_str("\n=== Disks / Partitions ===\n"); + for disk in &self.disks { + text.push_str(&format!( + "{}\t{}\t{}\n", + disk.display_name, + disk.style, + self.format_size(disk.total_bytes) + )); + for partition in &disk.partitions { + text.push_str(&format!( + " {}\t{}\t{}\t{}\t{}\t{}\t{}\n", + format_letters(&partition.letters), + partition.style, + partition.filesystem, + self.format_size(partition.total_bytes), + self.format_size(partition.used_bytes), + partition.codepage, + partition.partition_type, + )); + } + } + #[cfg(windows)] + { + self.status = match crate::win::clipboard::set_text(&text) { + Ok(()) => format!("Copied {} bytes to clipboard.", text.len()), + Err(err) => format!("Clipboard copy failed: {err}"), + }; + } + #[cfg(not(windows))] + { + self.status = "Clipboard copy is Windows-only.".to_string(); + } + } + Message::MenuCopyItem => { + let text = self.detail_text.clone(); + if text.is_empty() || text == "Ready" { + self.status = "Nothing selected to copy.".to_string(); + } else { + #[cfg(windows)] + { + self.status = match crate::win::clipboard::set_text(&text) { + Ok(()) => format!("Copied selected item ({} bytes).", text.len()), + Err(err) => format!("Clipboard copy failed: {err}"), + }; + } + #[cfg(not(windows))] + { + self.status = "Clipboard copy is Windows-only.".to_string(); + } + } + } + Message::MenuAbout => { + #[cfg(windows)] + { + let driver_line = match crate::service::mgr::query_driver_version() { + Ok((version, date, _time)) => { + format!("Ext2Fsd: {version} ({date})") + } + Err(_) => "Ext2Fsd: NOT started !".to_string(), + }; + self.dialog = Some(Dialog::About { + driver_line, + program_line: format!("Ext2Mgr: {}", env!("CARGO_PKG_VERSION")), + }); + } + #[cfg(not(windows))] + { + self.dialog = Some(Dialog::About { + driver_line: "Ext2Fsd: (non-Windows build)".to_string(), + program_line: format!("Ext2Mgr: {}", env!("CARGO_PKG_VERSION")), + }); + } + } + Message::MenuDonate => { + self.dialog = Some(Dialog::Donate); + } + Message::MenuDocumentation => { + #[cfg(windows)] + { + match crate::win::chrome::documentation_path() { + Some(path) => match crate::win::chrome::shell_open(&path.to_string_lossy()) { + Ok(()) => { + self.status = format!("Opened documentation: {}", path.display()); + } + Err(err) => self.report_warning(err), + }, + None => { + self.report_warning( + "FAQ.txt not found (expected under assets/ or Documents/).", + ); + } + } + } + #[cfg(not(windows))] + { + self.status = "Documentation open is Windows-only.".to_string(); + } + } + Message::OpenAboutWebsite => { + #[cfg(windows)] + { + if let Err(err) = + crate::win::chrome::shell_open("https://github.com/bobranten/Ext4Fsd/") + { + self.report_warning(err); + } + } + } + Message::OpenIcedPortWebsite => { + #[cfg(windows)] + { + if let Err(err) = + crate::win::chrome::shell_open("https://github.com/Obsidian-Jackal/Ext4Fsd") + { + self.report_warning(err); + } + } + } + Message::OpenDonateSourceForge => { + #[cfg(windows)] + { + if let Err(err) = crate::win::chrome::shell_open( + "http://sourceforge.net/project/project_donations.php?group_id=43775", + ) { + self.status = err; + } + } + } + Message::OpenDonatePayPal => { + #[cfg(windows)] + { + if let Err(err) = crate::win::chrome::shell_open( + "https://www.paypal.com/cgi-bin/webscr?cmd=_xclick&business=m@ext2fsd.com&item_name=Donation&return=https://sourceforge.net/projects/ext2fsd", + ) { + self.status = err; + } + } + } + Message::FlushSelected => { + #[cfg(windows)] + { + let flush_target = match self.selection { + Selection::Volume(index) => self.volumes.get(index).map(|volume| { + ( + volume.letters.clone(), + volume.win32_volume_name.clone(), + volume.physical_object.clone(), + ) + }), + Selection::Partition { disk, part } => self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| { + ( + partition.letters.clone(), + partition.win32_volume_name.clone(), + if partition.symlink.is_empty() { + String::new() + } else { + partition.symlink.clone() + }, + ) + }), + _ => None, + }; + match flush_target { + Some((letters, win32, physical)) => { + match crate::mount::ops::flush_volume(&letters, &win32, &physical) { + Ok(()) => { + self.status = "Flushed volume cache to disk.".to_string(); + } + Err(err) => { + self.report_warning(format!("Flush failed: {err}")); + } + } + } + None => { + self.status = "Select a volume or partition to flush.".to_string(); + } + } + } + #[cfg(not(windows))] + { + self.status = "Flush is Windows-only.".to_string(); + } + } + Message::EnableAutorun => { + #[cfg(windows)] + { + match crate::win::autorun::set_autorun(true) { + Ok(()) => { + crate::win::chrome::sync_autorun_menu(true); + self.status = + "Autostart enabled (HKCU Run: Ext2MgrIced).".to_string(); + } + Err(err) => self.report_warning(format!("Enable autostart failed: {err}")), + } + } + } + Message::DisableAutorun => { + #[cfg(windows)] + { + match crate::win::autorun::set_autorun(false) { + Ok(()) => { + crate::win::chrome::sync_autorun_menu(false); + self.status = "Autostart disabled.".to_string(); + } + Err(err) => self.report_warning(format!("Disable autostart failed: {err}")), + } + } + } + Message::OpenPerfStat => { + #[cfg(windows)] + { + match crate::service::mgr::query_perfstat_rows() { + Ok(rows) => self.dialog = Some(Dialog::PerfStat { rows }), + Err(err) => self.report_warning(err), + } + } + #[cfg(not(windows))] + { + self.status = "PerfStat is Windows-only.".to_string(); + } + } + Message::CopyPerfStat => { + #[cfg(windows)] + { + match crate::service::mgr::query_perfstat_text() { + Ok(text) => match crate::win::clipboard::set_text(&text) { + Ok(()) => self.status = "Statistics copied to clipboard.".to_string(), + Err(err) => self.report_warning(err), + }, + Err(err) => self.report_warning(err), + } + } + #[cfg(not(windows))] + { + self.status = "Clipboard copy is Windows-only.".to_string(); + } + } + Message::OpenPartitionType => { + match self.selection { + Selection::Partition { disk, part } => { + let Some(partition) = self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + else { + self.status = "Partition not found.".to_string(); + return Task::none(); + }; + if !partition.style.eq_ignore_ascii_case("Basic") { + self.status = + "Change Partition Type is only for MBR (Basic) partitions." + .to_string(); + return Task::none(); + } + // Ext2Mgr PartitionType dialog: start from current type id if known. + let selected_type = 0x83u8; + self.dialog = Some(Dialog::PartitionType { + disk, + part, + selected_type, + note: format!( + "Current type text: {}. Apply writes MBR type via Ext2SetPartitionType (not yet wired to disk IOCTL).", + partition.partition_type + ), + }); + } + _ => { + self.status = "Select an MBR partition first.".to_string(); + } + } + } + Message::PartitionTypePick(type_id) => { + if let Some(Dialog::PartitionType { selected_type, .. }) = self.dialog.as_mut() { + *selected_type = type_id; + } + } + Message::ApplyPartitionType => { + if let Some(Dialog::PartitionType { + disk, + part, + selected_type, + .. + }) = &self.dialog + { + // Ext2Mgr shows success/failure MessageBox after Ext2SetPartitionType. + self.report_warning(format!( + "Failed to set the partition type!\n(IOCTL for disk {disk} part {part} type 0x{selected_type:02X} not wired yet)" + )); + } + self.dialog = None; + } + Message::SelectPrev => { + self.select_prev_entry(); + } + Message::SelectNext => { + self.select_next_entry(); + } + Message::ContextVolume(index) => { + self.selection = Selection::Volume(index); + self.recompute_detail(); + #[cfg(windows)] + { + let volume = self.volumes.get(index); + let has_letter = volume.map(|entry| !entry.letters.is_empty()).unwrap_or(false); + let is_ext = volume + .map(|entry| crate::mount::ops::is_ext_family(&entry.filesystem)) + .unwrap_or(false); + if let Some(command_id) = crate::win::chrome::track_list_context( + crate::win::chrome::ContextKind::Volume { has_letter, is_ext }, + ) { + return self.apply_native_command(command_id); + } + } + } + Message::ContextDiskRow(row_index) => { + if let Some(row) = self.disk_rows.get(row_index) { + self.selection = match row { + DiskRow::DiskHeader { disk_index } => Selection::Disk(*disk_index), + DiskRow::Partition { disk_index, partition_index } => Selection::Partition { + disk: *disk_index, + part: *partition_index, + }, + DiskRow::Spacer => Selection::None, + }; + self.recompute_detail(); + #[cfg(windows)] + { + let (has_letter, is_ext, is_mbr_part) = match self.selection { + Selection::Partition { disk, part } => { + let partition = self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)); + ( + partition.map(|entry| !entry.letters.is_empty()).unwrap_or(false), + partition + .map(|entry| crate::mount::ops::is_ext_family(&entry.filesystem)) + .unwrap_or(false), + partition + .map(|entry| entry.style.eq_ignore_ascii_case("MBR")) + .unwrap_or(false), + ) + } + _ => (false, false, false), + }; + if let Some(command_id) = crate::win::chrome::track_list_context( + crate::win::chrome::ContextKind::Disk { + has_letter, + is_ext, + is_mbr_part, + }, + ) { + return self.apply_native_command(command_id); + } + } + } + } + Message::ShowProperties => { + self.recompute_detail(); + self.dialog = Some(Dialog::Properties); + } + Message::PropertiesQuickMount => { + self.dialog = None; + return self.quick_mount(); + } + Message::PropertiesUnmount => { + self.dialog = None; + return self.unmount_selected(); + } + Message::UnmountSelected => { + return self.unmount_selected(); + } + Message::PropertiesChangeMp => { + self.dialog = None; + return self.update(Message::OpenMountPoints); + } + Message::PropertiesExt2Info => { + self.dialog = None; + return self.update(Message::OpenExt2Attrs); + } + Message::Ext2Prefix(value) => { + if let Some(Dialog::Ext2Attrs { hiding_prefix, .. }) = self.dialog.as_mut() { + *hiding_prefix = value; + } + } + Message::Ext2Suffix(value) => { + if let Some(Dialog::Ext2Attrs { hiding_suffix, .. }) = self.dialog.as_mut() { + *hiding_suffix = value; + } + } + Message::Ext2Uid(value) => { + if let Some(Dialog::Ext2Attrs { uid, .. }) = self.dialog.as_mut() { + *uid = value; + } + } + Message::Ext2Gid(value) => { + if let Some(Dialog::Ext2Attrs { gid, .. }) = self.dialog.as_mut() { + *gid = value; + } + } + Message::Ext2Euid(value) => { + if let Some(Dialog::Ext2Attrs { euid, .. }) = self.dialog.as_mut() { + *euid = value; + } + } + Message::StartResize { list, column, cursor_x } => { + self.resizing = Some((list, column, cursor_x)); + } + Message::CursorMoved(cursor_x) => { + if let Some((list, column, last_x)) = self.resizing { + let delta = if last_x == 0.0 { 0.0 } else { cursor_x - last_x }; + let widths: &mut [f32] = match list { + ListKind::Volume => &mut self.volume_col_widths, + ListKind::Disk => &mut self.disk_col_widths, + }; + let col_count = widths.len(); + let max_width = if column + 1 == col_count { + 1200.0 + } else { + 600.0 + }; + if let Some(width) = widths.get_mut(column) { + *width = (*width + delta).clamp(24.0, max_width); + } + self.resizing = Some((list, column, cursor_x)); + } + } + Message::EndResize => self.resizing = None, + Message::DetailEditor(action) => { + // Selection/copy only; ignore edit actions so content stays read-only. + if !matches!(action, text_editor::Action::Edit(_)) { + self.detail_editor.perform(action); + } + } + Message::UseSiUnits => self.set_size_units(SizeUnitStyle::Si), + Message::UseBinaryUnits => self.set_size_units(SizeUnitStyle::Binary), + Message::DisplayWithBytes => self.set_display_bits(false), + Message::DisplayWithBits => self.set_display_bits(true), + Message::TogglePropertiesPane => { + self.show_properties_pane = !self.show_properties_pane; + self.sync_native_menus(); + }, + Message::Refresh => { + #[cfg(windows)] + { + self.status = crate::mount::ops::pipe_status_str(); + } + #[cfg(not(windows))] + { + self.status = "Refreshed.".to_string(); + } + return self.reload(); + } + Message::SelectVolume(index) => { + self.selection = Selection::Volume(index); + self.recompute_detail(); + } + Message::SelectDiskRow(row_index) => { + if let Some(row) = self.disk_rows.get(row_index) { + self.selection = match row { + DiskRow::DiskHeader { disk_index } => Selection::Disk(*disk_index), + DiskRow::Partition { + disk_index, + partition_index, + } => Selection::Partition { + disk: *disk_index, + part: *partition_index, + }, + DiskRow::Spacer => Selection::None, + }; + self.recompute_detail(); + } + } + Message::QuickMount => return self.quick_mount(), + Message::OpenMountPoints => { + self.open_mount_points_dialog(); + } + Message::MountPointsSelect(index) => { + if let Some(Dialog::MountPoints { selected, .. }) = self.dialog.as_mut() { + *selected = Some(index); + } + } + Message::OpenLetterPicker { replace_letter } => { + self.open_letter_picker(replace_letter); + } + Message::CloseDialog => self.dialog = None, + Message::DialogAbsorbClick => {} + Message::PickLetter(letter) => { + if let Some(Dialog::LetterPicker { + selected_letter, .. + }) = self.dialog.as_mut() + { + *selected_letter = Some(letter); + } + } + Message::SetPersistMode(mode) => { + if let Some(Dialog::LetterPicker { + persist_mode, + mount_uuid, + mount_win32, + replace_letter, + existing_session_letters, + existing_mountmgr_letters, + .. + }) = self.dialog.as_mut() + { + if mode == crate::mount::ops::MountMode::Ext2Automount && mount_uuid.is_none() { + // Ext2Fsd automount requires a volume UUID. + } else if mode == crate::mount::ops::MountMode::MountManager + && (mount_win32.is_empty() + || Self::assignment_blocked( + existing_mountmgr_letters, + *replace_letter, + )) + { + // Needs Volume{GUID}; at most one Mount Manager letter. + } else if mode == crate::mount::ops::MountMode::PermanentRegistry + && Self::session_manager_blocked( + existing_session_letters, + *replace_letter, + ) + { + // One Session Manager letter per volume on Add / convert. + } else if mode == crate::mount::ops::MountMode::Temporary + && !existing_mountmgr_letters.is_empty() + && replace_letter + .map(|letter| !existing_mountmgr_letters.contains(&letter)) + .unwrap_or(true) + { + // Temporary beside MountMgr is not an Explorer letter. + } else { + *persist_mode = mode; + } + } + } + Message::ConfirmLetterPicker => return self.confirm_letter_picker(), + Message::PipeOpFinished { + ok_message, + err_message, + is_error, + restore_mount_points, + } => { + if let Some(err) = err_message { + if is_error { + self.report_error(err); + } else { + self.report_warning(err); + } + } else if let Some(message) = ok_message { + self.status = message; + self.reload_lists(); + if let Some(( + volume_index, + disk_part, + letter, + mount_win32, + mount_uuid, + )) = restore_mount_points + { + let mut new_volume_index = volume_index; + if !mount_win32.is_empty() || mount_uuid.is_some() { + if let Some(found) = self.volumes.iter().position(|volume| { + (!mount_win32.is_empty() + && volume.win32_volume_name == mount_win32) + || (mount_uuid.is_some() && volume.uuid == mount_uuid) + }) { + new_volume_index = Some(found); + } + } + let letters = if let Some(index) = new_volume_index { + self.volumes + .get(index) + .map(|volume| volume.letters.clone()) + .unwrap_or_default() + } else if let Some((disk, part)) = disk_part { + self.disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| partition.letters.clone()) + .unwrap_or_default() + } else { + Vec::new() + }; + let selected = letters + .iter() + .position(|entry| *entry == letter) + .or_else(|| { + if letters.is_empty() { + None + } else { + Some(0) + } + }); + self.dialog = Some(Dialog::MountPoints { + volume_index: new_volume_index, + disk_part, + selected, + }); + } + return self.automount_task(); + } + } + Message::AutomountFinished(report) => { + if report.did_mount() { + self.reload_lists(); + } + if let Some(summary) = report.summary() { + self.status = summary; + } + } + Message::RemoveLetter(letter) => { + #[cfg(windows)] + { + let (symlink, restore) = match &self.dialog { + Some(Dialog::MountPoints { + volume_index, + disk_part, + selected, + }) => { + let symlink = if let Some(index) = volume_index { + self.volumes + .get(*index) + .map(|volume| volume.symlink.clone()) + .unwrap_or_default() + } else if let Some((disk, part)) = disk_part { + self.disks + .get(*disk) + .and_then(|entry| entry.partitions.get(*part)) + .map(|partition| partition.symlink.clone()) + .unwrap_or_default() + } else { + String::new() + }; + ( + symlink, + Some(( + *volume_index, + *disk_part, + *selected, + self.volumes + .get(volume_index.unwrap_or(usize::MAX)) + .map(|volume| { + ( + volume.win32_volume_name.clone(), + volume.uuid, + volume.physical_object.clone(), + ) + }), + )), + ) + } + _ => { + let symlink = self + .selected_volume_index() + .and_then(|index| self.volumes.get(index)) + .map(|volume| volume.symlink.clone()) + .unwrap_or_default(); + (symlink, None) + } + }; + match crate::mount::ops::unmount_letter(letter, &symlink) { + Ok(()) => { + self.status = format!("Removed {letter}:"); + self.reload_lists(); + if let Some((volume_index, disk_part, _selected, mounted_volume)) = + restore + { + let mut new_volume_index = volume_index; + if let Some((win32_name, uuid, physical)) = mounted_volume { + if let Some(found) = self.volumes.iter().position(|volume| { + volume.win32_volume_name == win32_name + || (uuid.is_some() && volume.uuid == uuid) + || (!physical.is_empty() + && volume.physical_object == physical) + }) { + new_volume_index = Some(found); + } + } + let letters = if let Some(index) = new_volume_index { + self.volumes + .get(index) + .map(|volume| volume.letters.clone()) + .unwrap_or_default() + } else if let Some((disk, part)) = disk_part { + self.disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| partition.letters.clone()) + .unwrap_or_default() + } else { + Vec::new() + }; + let selected = if letters.is_empty() { + None + } else { + Some(0) + }; + self.dialog = Some(Dialog::MountPoints { + volume_index: new_volume_index, + disk_part, + selected, + }); + } + return self.automount_task(); + } + Err(err) => self.report_warning(err), + } + } + #[cfg(not(windows))] + { + let _ = letter; + } + } + Message::OpenService => { + #[cfg(windows)] + { + match crate::service::mgr::query_global_property() { + Ok(props) => { + self.service_draft = props; + self.dialog = Some(Dialog::Service); + } + Err(err) => { + self.report_error(format!( + "Cannot query Ext2Fsd service !\n{err}" + )); + } + } + } + #[cfg(not(windows))] + { + self.status = "Service Management is Windows-only.".to_string(); + } + } + Message::StartService => { + #[cfg(windows)] + { + match crate::service::mgr::start_driver() { + Ok(()) => { + self.status = "Ext2Fsd started.".to_string(); + return self.reload(); + } + Err(err) => self.report_error(err), + } + } + } + Message::StopService => { + #[cfg(windows)] + { + match crate::service::mgr::stop_driver() { + Ok(()) => { + self.status = "Ext2Fsd stopped.".to_string(); + return self.reload(); + } + Err(err) => self.report_error(err), + } + } + } + Message::RestartService => { + #[cfg(windows)] + { + match crate::service::mgr::restart_driver() { + Ok(()) => { + self.status = "Ext2Fsd restarted.".to_string(); + return self.reload(); + } + Err(err) => self.report_error(err), + } + } + } + Message::SaveService => { + #[cfg(windows)] + { + if self.service_draft.codepage.trim().is_empty() { + self.report_warning("You must select a codepage type."); + return Task::none(); + } + if !crate::win::msgbox::confirm( + "Current service settings will be overwritten,\ndo you want continue ?", + ) { + return Task::none(); + } + match crate::service::mgr::set_global_property(&self.service_draft) { + Ok(()) => { + self.status = + "Ext2 service settings updated successfully !".to_string(); + self.dialog = None; + } + Err(err) => { + self.report_warning(format!( + "Failed to save the service settings !\n{err}" + )); + } + } + } + } + Message::ServiceStartup(value) => { + #[cfg(windows)] + { + self.service_draft.startup = value; + } + #[cfg(not(windows))] + { + let _ = value; + } + } + Message::ServiceReadonly(value) => { + #[cfg(windows)] + { + self.service_draft.readonly = value; + if value { + self.service_draft.ext3_writable = false; + } + } + #[cfg(not(windows))] + { + let _ = value; + } + } + Message::ServiceExt3Writable(value) => { + #[cfg(windows)] + { + if !self.service_draft.readonly { + self.service_draft.ext3_writable = value; + } + } + #[cfg(not(windows))] + { + let _ = value; + } + } + Message::ServiceAutomount(value) => { + #[cfg(windows)] + { + self.service_draft.automount = value; + } + #[cfg(not(windows))] + { + let _ = value; + } + } + Message::ServiceCodepage(value) => { + #[cfg(windows)] + { + self.service_draft.codepage = value; + } + #[cfg(not(windows))] + { + let _ = value; + } + } + Message::ServicePrefix(value) => { + #[cfg(windows)] + { + self.service_draft.hiding_prefix = value; + } + #[cfg(not(windows))] + { + let _ = value; + } + } + Message::ServiceSuffix(value) => { + #[cfg(windows)] + { + self.service_draft.hiding_suffix = value; + } + #[cfg(not(windows))] + { + let _ = value; + } + } + Message::OpenExt2Attrs => { + #[cfg(windows)] + { + let Some(volume_index) = self.selected_volume_index() else { + self.status = "Select an EXT volume/partition first.".to_string(); + return Task::none(); + }; + let Some(volume) = self.volumes.get(volume_index).cloned() else { + return Task::none(); + }; + if !crate::mount::ops::is_ext_family(&volume.filesystem) { + self.status = "Ext2 Management requires an EXT2/3/4 volume.".to_string(); + return Task::none(); + } + if !crate::service::mgr::is_driver_started() { + self.report_error("Ext2Fsd service isn't started."); + // Still open the dialog so settings can be reviewed offline, + // matching Ext2Mgr which shows the STOP box then continues. + } + let mut available = crate::mount::ops::free_drive_letters(); + for letter in &volume.letters { + if !available.contains(letter) { + available.insert(0, *letter); + } + } + let fixed = crate::mount::persist::query_registry_letter_for_device( + &volume.physical_object, + ); + self.dialog = Some(Dialog::Ext2Attrs { + volume_index, + readonly: false, + codepage: if volume.codepage.is_empty() { + "utf8".to_string() + } else { + volume.codepage.clone() + }, + fixmount: fixed.is_some(), + automount: volume.uuid.is_some() && fixed.is_none(), + letter: fixed.or_else(|| volume.letters.first().copied()) + .or_else(|| available.first().copied()), + available, + hiding_prefix: String::new(), + hiding_suffix: String::new(), + uid: "----".to_string(), + gid: "----".to_string(), + euid: "----".to_string(), + }); + } + #[cfg(not(windows))] + { + self.status = "Ext2 Management is Windows-only.".to_string(); + } + } + Message::Ext2Readonly(value) => { + if let Some(Dialog::Ext2Attrs { readonly, .. }) = self.dialog.as_mut() { + *readonly = value; + } + } + Message::Ext2Codepage(value) => { + if let Some(Dialog::Ext2Attrs { codepage, .. }) = self.dialog.as_mut() { + *codepage = value; + } + } + Message::Ext2Fixmount(value) => { + if let Some(Dialog::Ext2Attrs { + fixmount, + automount, + .. + }) = self.dialog.as_mut() + { + *fixmount = value; + if value { + *automount = false; + } + } + } + Message::Ext2Automount(value) => { + if let Some(Dialog::Ext2Attrs { + fixmount, + automount, + .. + }) = self.dialog.as_mut() + { + *automount = value; + if value { + *fixmount = false; + } + } + } + Message::Ext2Letter(letter) => { + if let Some(Dialog::Ext2Attrs { + letter: selected, .. + }) = self.dialog.as_mut() + { + *selected = Some(letter); + } + } + Message::SaveExt2Attrs => { + #[cfg(windows)] + { + let Some(Dialog::Ext2Attrs { + volume_index, + readonly, + codepage, + fixmount, + automount, + letter, + .. + }) = self.dialog.clone() + else { + return Task::none(); + }; + let Some(volume) = self.volumes.get(volume_index).cloned() else { + return Task::none(); + }; + let Some(letter) = letter else { + self.status = "Choose a drive letter.".to_string(); + return Task::none(); + }; + if fixmount { + let device = crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ); + if let Err(err) = + crate::mount::persist::set_registry_mount_point(letter, &device) + { + self.report_warning(format!( + "Failed to save the Ext2 settings !\n{err}" + )); + return Task::none(); + } + let request = crate::mount::ops::MountRequest { + letter, + mode: crate::mount::ops::MountMode::Temporary, + symlink: device, + win32_volume_name: volume.win32_volume_name, + uuid: volume.uuid, + codepage: codepage.clone(), + readonly, + }; + match crate::mount::ops::mount_volume(&request) { + Ok(message) => { + self.status = format!( + "{message} Fixed mount also written to Session Manager." + ); + self.dialog = None; + return self.reload(); + } + Err(err) => { + self.report_warning(format!( + "Failed to save the Ext2 settings !\n{err}" + )); + } + } + } else if automount { + let request = crate::mount::ops::MountRequest { + letter, + mode: crate::mount::ops::MountMode::Ext2Automount, + symlink: crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ), + win32_volume_name: volume.win32_volume_name, + uuid: volume.uuid, + codepage, + readonly, + }; + match crate::mount::ops::mount_volume(&request) { + Ok(message) => { + self.status = message; + self.dialog = None; + return self.reload(); + } + Err(err) => { + self.report_warning(format!( + "Failed to save the Ext2 settings !\n{err}" + )); + } + } + } else { + self.report_warning( + "Enable Fixed mount or Ext2Fsd automount before Save.", + ); + } + } + } + Message::OpenDeadLetters => { + return self.scan_dead_letters_task(); + } + Message::DeadLettersLoaded(entries) => { + self.dialog = Some(Dialog::DeadLetters { + entries, + selected: None, + also_remove_permanent: false, + pending_remove: None, + }); + self.status = "Ready".to_string(); + } + Message::SelectDeadLetter(index) => { + if let Some(Dialog::DeadLetters { selected, .. }) = self.dialog.as_mut() { + *selected = Some(index); + } + } + Message::DeadAlsoRemovePermanent(value) => { + if let Some(Dialog::DeadLetters { + also_remove_permanent, + .. + }) = self.dialog.as_mut() + { + *also_remove_permanent = value; + } + } + Message::RemoveSelectedDeadLetter => { + let Some(Dialog::DeadLetters { + entries, + selected, + .. + }) = &self.dialog + else { + return Task::none(); + }; + let Some(index) = *selected else { + self.status = "Select a dead letter first.".to_string(); + return Task::none(); + }; + let Some(entry) = entries.get(index).cloned() else { + return Task::none(); + }; + if let Some(Dialog::DeadLetters { + pending_remove, .. + }) = self.dialog.as_mut() + { + *pending_remove = Some(entry); + } + } + Message::CancelDeadLetterRemove => { + if let Some(Dialog::DeadLetters { + pending_remove, .. + }) = self.dialog.as_mut() + { + *pending_remove = None; + } + } + Message::ConfirmDeadLetterRemove => { + let Some(Dialog::DeadLetters { + pending_remove, + also_remove_permanent, + .. + }) = self.dialog.clone() + else { + return Task::none(); + }; + let Some(entry) = pending_remove else { + return Task::none(); + }; + match crate::mount::dead_letters::remove_dead_letter( + entry.letter, + &entry.symlink, + also_remove_permanent, + ) { + Ok(()) => { + self.status = format!("Removed dead letter {}:", entry.letter); + self.reload_lists(); + return self.scan_dead_letters_task(); + } + Err(err) => { + if let Some(Dialog::DeadLetters { + pending_remove, .. + }) = self.dialog.as_mut() + { + *pending_remove = None; + } + self.report_error(err); + } + } + } + } + Task::none() + } +} diff --git a/ext2mgr_iced/src/ui/view.rs b/ext2mgr_iced/src/ui/view.rs new file mode 100644 index 0000000..2d30960 --- /dev/null +++ b/ext2mgr_iced/src/ui/view.rs @@ -0,0 +1,1282 @@ +impl Ext2MgrApp { + fn view_volume_table(&self) -> Element<'_, Message> { + let widths = self.volume_col_widths; + let table_width = self.volume_table_width(); + let header = row![ + Self::header_cell("", Length::Fixed(widths[0]), ListKind::Volume, 0, false), + Self::header_cell( + "Volume", + Length::Fixed(widths[1]), + ListKind::Volume, + 1, + true + ), + Self::header_cell( + "Type", + Length::Fixed(widths[2]), + ListKind::Volume, + 2, + true + ), + Self::header_cell( + "File system", + Length::Fixed(widths[3]), + ListKind::Volume, + 3, + true + ), + Self::header_cell( + "Total size", + Length::Fixed(widths[4]), + ListKind::Volume, + 4, + true + ), + Self::header_cell( + "Used size", + Length::Fixed(widths[5]), + ListKind::Volume, + 5, + true + ), + Self::header_cell( + "Codepage", + Length::Fixed(widths[6]), + ListKind::Volume, + 6, + true + ), + Self::header_cell( + "Physical object", + Length::Fixed(widths[7]), + ListKind::Volume, + 7, + true + ), + Space::new().width(Self::TABLE_TRAILING_PAD), + ] + .spacing(0) + .width(Length::Fixed(table_width)); + + let rows: Element<'_, Message> = if self.volumes.is_empty() { + text("No volumes").size(14).into() + } else { + Column::with_children( + self.volumes + .iter() + .enumerate() + .map(|(index, volume)| { + let selected = self.selection == Selection::Volume(index); + let physical = if volume.physical_object.is_empty() { + String::new() + } else { + volume.physical_object.clone() + }; + let icon = image(image::Handle::from_path(Self::volume_icon_path( + &volume.volume_kind, + ))) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)); + let icon_cell = { + let mut box_ = container(icon) + .width(Length::Fixed(widths[0])) + .padding([2, 2]); + if selected { + let background = self.accent; + let text_color = self.accent_text; + box_ = box_.style(move |_theme| container::Style { + text_color: Some(text_color), + background: Some(Background::Color(background)), + border: Border::default(), + shadow: Shadow::default(), + ..Default::default() + }); + } + box_ + }; + let line = row![ + icon_cell, + self.cell( + format_letters(&volume.letters), + Length::Fixed(widths[1]), + selected + ), + self.cell( + volume.volume_kind.clone(), + Length::Fixed(widths[2]), + selected + ), + self.cell( + volume.filesystem.clone(), + Length::Fixed(widths[3]), + selected + ), + self.cell( + self.format_size(volume.total_bytes), + Length::Fixed(widths[4]), + selected + ), + self.cell( + self.format_size(volume.used_bytes), + Length::Fixed(widths[5]), + selected + ), + self.cell( + volume.codepage.clone(), + Length::Fixed(widths[6]), + selected + ), + self.cell( + physical, + Length::Fixed(widths[7]), + selected + ), + Space::new().width(Self::TABLE_TRAILING_PAD), + ] + .spacing(0) + .width(Length::Fixed(table_width)); + mouse_area( + button(line) + .padding(0) + .style(button::text) + .width(Length::Fixed(table_width)) + .on_press(Message::SelectVolume(index)), + ) + .on_right_press(Message::ContextVolume(index)) + .into() + }) + .collect::>(), + ) + .spacing(0) + .width(Length::Fixed(table_width)) + .into() + }; + + let table_body = column![ + header, + rows, + Space::new().height(Self::TABLE_H_SCROLLBAR_PAD), + ] + .spacing(0) + .width(Length::Fixed(table_width)); + + column![ + text("Volumes").size(14), + scrollable(table_body) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .width(Length::Fill) + .height(Length::Fill), + ] + .spacing(2) + .width(Length::Fill) + .height(Length::FillPortion(3)) + .into() + } + + fn view_disk_table(&self) -> Element<'_, Message> { + let widths = self.disk_col_widths; + let table_width = self.disk_table_width(); + let header = row![ + Self::header_cell("", Length::Fixed(widths[0]), ListKind::Disk, 0, true), + Self::header_cell("Type", Length::Fixed(widths[1]), ListKind::Disk, 1, true), + Self::header_cell( + "File system", + Length::Fixed(widths[2]), + ListKind::Disk, + 2, + true + ), + Self::header_cell( + "Total size", + Length::Fixed(widths[3]), + ListKind::Disk, + 3, + true + ), + Self::header_cell( + "Used size", + Length::Fixed(widths[4]), + ListKind::Disk, + 4, + true + ), + Self::header_cell( + "Codepage", + Length::Fixed(widths[5]), + ListKind::Disk, + 5, + true + ), + Self::header_cell( + "Partition type", + Length::Fixed(widths[6]), + ListKind::Disk, + 6, + true + ), + Space::new().width(Self::TABLE_TRAILING_PAD), + ] + .spacing(0) + .width(Length::Fixed(table_width)); + + let rows: Element<'_, Message> = if self.disk_rows.is_empty() { + text("No disks (open failed - try Refresh as Administrator)") + .size(14) + .into() + } else { + Column::with_children( + self.disk_rows + .iter() + .enumerate() + .map(|(row_index, row)| { + let line: Element<'_, Message> = match row { + DiskRow::Spacer => row![ + self.cell(String::new(), Length::Fixed(table_width), false) + ] + .spacing(0) + .width(Length::Fixed(table_width)) + .into(), + DiskRow::DiskHeader { disk_index } => { + let disk = &self.disks[*disk_index]; + let selected = self.selection == Selection::Disk(*disk_index); + let header = row![ + self.cell( + disk.display_name.clone(), + Length::Fixed(widths[0]), + selected + ), + self.cell( + disk.style.clone(), + Length::Fixed(widths[1]), + selected + ), + self.cell(String::new(), Length::Fixed(widths[2]), selected), + self.cell( + self.format_size(disk.total_bytes), + Length::Fixed(widths[3]), + selected + ), + self.cell(String::new(), Length::Fixed(widths[4]), selected), + self.cell(String::new(), Length::Fixed(widths[5]), selected), + self.cell(String::new(), Length::Fixed(widths[6]), selected), + Space::new().width(Self::TABLE_TRAILING_PAD), + ] + .spacing(0) + .width(Length::Fixed(table_width)); + column![ + header, + Self::accent_gradient_rule(self.accent, table_width), + ] + .spacing(0) + .width(Length::Fixed(table_width)) + .into() + } + DiskRow::Partition { + disk_index, + partition_index, + } => { + let partition = + &self.disks[*disk_index].partitions[*partition_index]; + let selected = self.selection + == Selection::Partition { + disk: *disk_index, + part: *partition_index, + }; + row![ + self.cell( + format!(" {}", format_letters(&partition.letters)), + Length::Fixed(widths[0]), + selected + ), + self.cell( + partition.style.clone(), + Length::Fixed(widths[1]), + selected + ), + self.cell( + partition.filesystem.clone(), + Length::Fixed(widths[2]), + selected + ), + self.cell( + self.format_size(partition.total_bytes), + Length::Fixed(widths[3]), + selected + ), + self.cell( + self.format_size(partition.used_bytes), + Length::Fixed(widths[4]), + selected + ), + self.cell( + partition.codepage.clone(), + Length::Fixed(widths[5]), + selected + ), + self.cell( + partition.partition_type.clone(), + Length::Fixed(widths[6]), + selected + ), + Space::new().width(Self::TABLE_TRAILING_PAD), + ] + .spacing(0) + .width(Length::Fixed(table_width)) + .into() + } + }; + mouse_area( + button(line) + .padding(0) + .style(button::text) + .width(Length::Fixed(table_width)) + .on_press(Message::SelectDiskRow(row_index)), + ) + .on_right_press(Message::ContextDiskRow(row_index)) + .into() + }) + .collect::>(), + ) + .spacing(0) + .width(Length::Fixed(table_width)) + .into() + }; + + let table_body = column![ + header, + rows, + Space::new().height(Self::TABLE_H_SCROLLBAR_PAD), + ] + .spacing(0) + .width(Length::Fixed(table_width)); + + column![ + text("Disks / Partitions").size(14), + scrollable(table_body) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::default(), + horizontal: scrollable::Scrollbar::default(), + }) + .width(Length::Fill) + .height(Length::Fill), + ] + .spacing(2) + .width(Length::Fill) + .height(Length::FillPortion(3)) + .into() + } + + fn view_dialog(&self) -> Option> { + match &self.dialog { + None => None, + Some(Dialog::LetterPicker { + selected_letter, + persist_mode, + available, + replace_letter, + mount_uuid, + mount_win32, + existing_session_letters, + existing_mountmgr_letters, + .. + }) => { + let title = if replace_letter.is_some() { + "Change drive letter" + } else { + "Assign drive letter" + }; + let letter_picker: Element<'_, Message> = if available.is_empty() { + text("No free letters").into() + } else { + let choices = available + .iter() + .map(|letter| format!("{letter}:")) + .collect::>(); + pick_list( + choices, + selected_letter.map(|letter| format!("{letter}:")), + |picked| Message::PickLetter(picked.chars().next().unwrap_or_default()), + ) + .into() + }; + let can_automount = mount_uuid.is_some(); + let automount_row: Element<'_, Message> = if can_automount { + radio( + "Ext2Fsd automount: Ext2Mgr stores Volumes\\{UUID}; remounted when Ext2Mgr or the driver refreshes", + crate::mount::ops::MountMode::Ext2Automount, + Some(*persist_mode), + Message::SetPersistMode, + ) + .into() + } else { + text( + "Ext2Fsd automount (needs EXT UUID) — not available for this volume", + ) + .size(13) + .style(|_| iced::widget::text::Style { + color: Some(Color::from_rgb(0.55, 0.55, 0.55)), + }) + .into() + }; + let mountmgr_available = !mount_win32.is_empty(); + let mountmgr_blocked = !mountmgr_available + || Self::assignment_blocked( + existing_mountmgr_letters, + *replace_letter, + ); + let temporary_blocked = !existing_mountmgr_letters.is_empty() + && replace_letter + .map(|letter| !existing_mountmgr_letters.contains(&letter)) + .unwrap_or(true); + let mountmgr_row: Element<'_, Message> = if !mountmgr_available { + text( + "Automatic mount via Mount Manager — not available \ + (no Win32 Volume{GUID} for this DOS-device volume).", + ) + .size(13) + .style(|_| iced::widget::text::Style { + color: Some(Color::from_rgb(0.55, 0.55, 0.55)), + }) + .into() + } else if mountmgr_blocked { + let listed = existing_mountmgr_letters + .iter() + .map(|entry| format!("{entry}:")) + .collect::>() + .join(", "); + text(format!( + "Automatic mount via Mount Manager — already set as {listed}. \ + Use Change on that letter, or remove it first (one per volume)." + )) + .size(13) + .style(|_| iced::widget::text::Style { + color: Some(Color::from_rgb(0.55, 0.55, 0.55)), + }) + .into() + } else { + radio( + "Automatic mount via Mount Manager (survives reboot; Explorer-native)", + crate::mount::ops::MountMode::MountManager, + Some(*persist_mode), + Message::SetPersistMode, + ) + .into() + }; + let session_blocked = Self::session_manager_blocked( + existing_session_letters, + *replace_letter, + ); + let session_row: Element<'_, Message> = if session_blocked { + let listed = existing_session_letters + .iter() + .map(|entry| format!("{entry}:")) + .collect::>() + .join(", "); + text(format!( + "Permanent Session Manager — already set as {listed}. \ + Use Change on that letter, or remove it first (one per volume)." + )) + .size(13) + .style(|_| iced::widget::text::Style { + color: Some(Color::from_rgb(0.55, 0.55, 0.55)), + }) + .into() + } else { + radio( + "Permanent: Session Manager DOS Devices (survives reboot)", + crate::mount::ops::MountMode::PermanentRegistry, + Some(*persist_mode), + Message::SetPersistMode, + ) + .into() + }; + let temporary_row: Element<'_, Message> = if temporary_blocked { + text( + "Temporary DefineDosDevice — not available beside an existing \ + Mount Manager letter.", + ) + .size(13) + .style(|_| iced::widget::text::Style { + color: Some(Color::from_rgb(0.55, 0.55, 0.55)), + }) + .into() + } else { + radio( + "Temporary via Ext2Srv (lost after reboot)", + crate::mount::ops::MountMode::Temporary, + Some(*persist_mode), + Message::SetPersistMode, + ) + .into() + }; + + Some( + container( + column![ + text(title).size(18), + text("Enter or select a new drive letter").size(13), + letter_picker, + text("Persistence").size(14), + temporary_row, + mountmgr_row, + session_row, + automount_row, + text("Temporary = DefineDosDevice only. Mount Manager = SetVolumeMountPoint (same as Disk Management). Session Manager = registry DOS Devices. Device path \\Device\\HarddiskVolumeN. Permanent modes need Administrator.") + .size(12), + row![ + button("OK").on_press(Message::ConfirmLetterPicker), + button("Cancel").on_press(Message::CloseDialog), + ] + .spacing(10), + ] + .spacing(8) + .padding(16) + .width(Length::Fixed(520.0)), + ) + .style(container::bordered_box) + .into(), + ) + } + Some(Dialog::MountPoints { + volume_index, + disk_part, + selected, + }) => { + let (mut letters, win32_name, device_path) = { + if let Some(index) = *volume_index { + self.volumes + .get(index) + .map(|volume| { + ( + volume.letters.clone(), + volume.win32_volume_name.clone(), + if volume.physical_object.is_empty() { + volume.symlink.clone() + } else { + volume.physical_object.clone() + }, + ) + }) + .unwrap_or_default() + } else if let Some((disk, part)) = *disk_part { + self.disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .map(|partition| { + ( + partition.letters.clone(), + partition.win32_volume_name.clone(), + partition.symlink.clone(), + ) + }) + .unwrap_or_default() + } else { + (Vec::new(), String::new(), String::new()) + } + }; + // Include dormant Session Manager letters (registry only, not live DOS yet). + if !device_path.is_empty() { + for letter in crate::mount::persist::registry_letters_for_device(&device_path) { + if !letters.contains(&letter) { + letters.push(letter); + } + } + letters.sort_unstable(); + } + let selected_index = *selected; + let selected_letter = selected_index.and_then(|index| letters.get(index).copied()); + let list: Element<'_, Message> = if letters.is_empty() { + text("(no mount points)").into() + } else { + Column::with_children( + letters + .iter() + .enumerate() + .map(|(index, letter)| { + let label = Self::letter_source_label( + *letter, + &win32_name, + &device_path, + ); + let row_button = if selected_index == Some(index) { + button(text(label)) + .on_press(Message::MountPointsSelect(index)) + .style(button::primary) + } else { + button(text(label)) + .on_press(Message::MountPointsSelect(index)) + .style(button::secondary) + }; + row_button.width(Length::Fill).into() + }) + .collect::>(), + ) + .spacing(4) + .into() + }; + let mut change_btn = button("Change"); + let mut remove_btn = button("Remove"); + if let Some(letter) = selected_letter { + change_btn = change_btn.on_press(Message::OpenLetterPicker { + replace_letter: Some(letter), + }); + remove_btn = remove_btn.on_press(Message::RemoveLetter(letter)); + } + // Mount Manager volumes: one drive letter only — Add is Change/Remove territory. + let mountmgr_has_letter = !win32_name.is_empty() + && !crate::mount::dead_letters::mountmgr_letters_for_volume(&win32_name).is_empty(); + let add_btn = if mountmgr_has_letter { + button("Add").width(Length::Fixed(88.0)) + } else { + button("Add") + .on_press(Message::OpenLetterPicker { + replace_letter: None, + }) + .width(Length::Fixed(88.0)) + }; + let actions = column![ + add_btn, + change_btn.width(Length::Fixed(88.0)), + remove_btn.width(Length::Fixed(88.0)), + ] + .spacing(8); + Some( + container( + column![ + text("Change Drive Letters").size(18), + text("Mountpoints:").size(13), + row![ + container(scrollable(list).height(Length::Fixed(140.0))) + .width(Length::Fill) + .padding(4) + .style(container::bordered_box), + actions, + ] + .spacing(10) + .align_y(alignment::Vertical::Top), + button("Done").on_press(Message::CloseDialog), + ] + .spacing(10) + .padding(16) + .width(Length::Fixed(360.0)), + ) + .style(container::bordered_box) + .into(), + ) + } + Some(Dialog::Service) => { + #[cfg(windows)] + { + let started = crate::service::mgr::is_driver_started(); + let draft = &self.service_draft; + Some( + container({ + let start_modes = crate::service::mgr::START_MODE_LABELS + .iter() + .map(|label| (*label).to_string()) + .collect::>(); + let startup_and_codepage = row![ + column![ + text("Startup mode").size(13), + pick_list( + start_modes, + crate::service::mgr::START_MODE_LABELS + .get(draft.startup as usize) + .map(|label| (*label).to_string()), + |picked| { + Message::ServiceStartup( + crate::service::mgr::START_MODE_LABELS + .iter() + .position(|label| *label == picked) + .unwrap_or(3) as u32, + ) + }, + ) + ] + .width(Length::FillPortion(1)), + column![ + text("Codepage").size(13), + pick_list( + CODE_PAGES.to_vec(), + Some(draft.codepage.as_str()), + |picked| Message::ServiceCodepage(picked.to_string()), + ) + ] + .width(Length::FillPortion(1)), + ] + .spacing(12); + let service_controls = if started { + row![ + button("Restart Ext2Fsd").on_press(Message::RestartService), + button("Stop Ext2Fsd").on_press(Message::StopService), + ] + .spacing(8) + } else { + row![button("Start Ext2Fsd").on_press(Message::StartService)] + }; + column![ + text("Service Management").size(18), + row![ + text(if started { + "Ext2Fsd: started" + } else { + "Ext2Fsd: NOT started" + }) + .width(Length::Fill), + service_controls, + ] + .align_y(iced::Alignment::Center), + startup_and_codepage, + checkbox(draft.readonly).label("Readonly") + .on_toggle(Message::ServiceReadonly), + checkbox(draft.ext3_writable && !draft.readonly).label("Ext3 writable") + .on_toggle(Message::ServiceExt3Writable), + checkbox(draft.automount).label("Assign drive letter automatically") + .on_toggle(Message::ServiceAutomount), + text_input("Hiding prefix", &draft.hiding_prefix) + .on_input(Message::ServicePrefix), + text_input("Hiding suffix", &draft.hiding_suffix) + .on_input(Message::ServiceSuffix), + row![ + button("Save").on_press(Message::SaveService), + button("Cancel").on_press(Message::CloseDialog), + ] + .spacing(10), + ] + .spacing(8) + .padding(16) + .width(Length::Fixed(560.0)) + }) + .style(container::bordered_box) + .into(), + ) + } + #[cfg(not(windows))] + { + None + } + } + Some(Dialog::Ext2Attrs { + volume_index, + readonly, + codepage, + fixmount, + automount, + letter, + available, + hiding_prefix, + hiding_suffix, + uid, + gid, + euid, + }) => { + let can_automount = self + .volumes + .get(*volume_index) + .is_some_and(|volume| volume.uuid.is_some()); + let letters: Element<'_, Message> = Column::with_children( + available + .iter() + .map(|drive| { + radio( + format!("{drive}:"), + *drive, + *letter, + Message::Ext2Letter, + ) + .into() + }) + .collect::>(), + ) + .spacing(2) + .into(); + let codepage_picks: Element<'_, Message> = Column::with_children( + CODE_PAGES + .iter() + .enumerate() + .map(|(index, page)| { + let selected = CODE_PAGES + .iter() + .position(|candidate| *candidate == codepage.as_str()); + radio( + (*page).to_string(), + index, + selected, + |picked| { + Message::Ext2Codepage(CODE_PAGES[picked].to_string()) + }, + ) + .into() + }) + .collect::>(), + ) + .spacing(2) + .into(); + let automount_label = if can_automount { + "Automatically mount via Ext2Mgr" + } else { + "Automatically mount via Ext2Mgr (needs EXT UUID)" + }; + let automount_box: Element<'_, Message> = { + let box_ = checkbox(*automount && can_automount).label(automount_label); + if can_automount { + box_.on_toggle(Message::Ext2Automount).into() + } else { + box_.into() + } + }; + Some( + container( + column![ + text("Ext2/3 Volume Settings").size(18), + text("Volume attribute").size(14), + checkbox(*readonly).label("Mount volume in readonly mode") + .on_toggle(Message::Ext2Readonly), + text("Codepage").size(13), + scrollable(codepage_picks).height(Length::Fixed(100.0)), + text_input("codepage", codepage).on_input(Message::Ext2Codepage), + text("Mount point and drive letter").size(14), + automount_box, + checkbox(*fixmount).label( + "Mountpoint for fixed disk, need reboot" + ) + .on_toggle(Message::Ext2Fixmount), + text("Letter").size(13), + scrollable(letters).height(Length::Fixed(90.0)), + text("Hiding filter patterns").size(14), + text_input("Hiding files with prefix", hiding_prefix) + .on_input(Message::Ext2Prefix), + text_input("Hiding files with suffix", hiding_suffix) + .on_input(Message::Ext2Suffix), + text("Mounting as User").size(14), + row![ + text("UID:").size(13), + text_input("----", uid).width(Length::Fixed(70.0)) + .on_input(Message::Ext2Uid), + text("GID:").size(13), + text_input("----", gid).width(Length::Fixed(70.0)) + .on_input(Message::Ext2Gid), + text("EUID:").size(13), + text_input("----", euid).width(Length::Fixed(70.0)) + .on_input(Message::Ext2Euid), + ] + .spacing(6), + row![ + button("Apply").on_press(Message::SaveExt2Attrs), + button("Cancel").on_press(Message::CloseDialog), + ] + .spacing(10), + ] + .spacing(6) + .padding(16) + .width(Length::Fixed(560.0)), + ) + .style(container::bordered_box) + .into(), + ) + } + Some(Dialog::DeadLetters { + entries, + selected, + also_remove_permanent, + pending_remove, + }) => { + if let Some(entry) = pending_remove { + Some( + container( + column![ + text("Remove Dead Letters").size(18), + text(format!("{}: {}", entry.letter, entry.symlink)).size(13), + text( + "Warning: this drive letter might still be in use.\n\ + Are you sure it is a real dead drive letter?" + ) + .size(13), + row![ + button("Yes").on_press(Message::ConfirmDeadLetterRemove), + button("No").on_press(Message::CancelDeadLetterRemove), + ] + .spacing(10), + ] + .spacing(10) + .padding(16) + .width(Length::Fixed(560.0)), + ) + .style(container::bordered_box) + .into(), + ) + } else { + let list: Element<'_, Message> = if entries.is_empty() { + text("No dead letters found.").into() + } else { + Column::with_children( + entries + .iter() + .enumerate() + .map(|(index, entry)| { + radio( + format!("{}: {}", entry.letter, entry.symlink), + index, + *selected, + Message::SelectDeadLetter, + ) + .into() + }) + .collect::>(), + ) + .spacing(4) + .into() + }; + let show_permanent = selected + .and_then(|index| entries.get(index)) + .is_some_and(|entry| entry.may_have_permanent); + let mut body = column![ + text("Remove Dead Letters").size(18), + scrollable(list).height(Length::Fixed(200.0)), + ] + .spacing(8); + if show_permanent { + body = body.push( + checkbox(*also_remove_permanent) + .label("Also remove permanent Mount Manager letter") + .on_toggle(Message::DeadAlsoRemovePermanent), + ); + } + body = body.push( + row![ + button("Reload").on_press(Message::OpenDeadLetters), + button("Remove").on_press(Message::RemoveSelectedDeadLetter), + button("Exit").on_press(Message::CloseDialog), + ] + .spacing(10), + ); + Some( + container(body.padding(16).width(Length::Fixed(560.0))) + .style(container::bordered_box) + .into(), + ) + } + } + Some(Dialog::Properties) => { + let snap = self.properties_snapshot(); + let disk_box = container( + column![ + text(if snap.disk_title.is_empty() { + "DISK".to_string() + } else { + snap.disk_title.clone() + }) + .size(13), + Self::prop_row("Vendor:", snap.vendor.clone()), + Self::prop_row("Product:", snap.product.clone()), + Self::prop_row("Serial:", snap.serial.clone()), + Self::prop_row("Bus:", snap.bus_type.clone()), + Self::prop_row("Type:", snap.device_type.clone()), + Self::prop_row("Media:", snap.media_type.clone()), + Self::prop_row("Capacity (bytes):", snap.disk_capacity_bytes.clone()), + ] + .spacing(4) + .padding(8), + ) + .width(Length::Fill) + .style(container::bordered_box); + + let mut mount_row = row![ + text("Mount points:").size(12).width(Length::Fixed(110.0)), + text(snap.mount_points.clone()) + .size(12) + .width(Length::Fill), + ] + .spacing(6) + .align_y(alignment::Vertical::Center); + if snap.can_change_mp { + mount_row = mount_row + .push(button("Change").on_press(Message::PropertiesChangeMp)); + } + if snap.can_mount { + mount_row = + mount_row.push(button("Mount").on_press(Message::PropertiesQuickMount)); + } + if snap.can_unmount { + mount_row = + mount_row.push(button("Unmount").on_press(Message::PropertiesUnmount)); + } + + let mut fs_row = row![ + text("File system:").size(12).width(Length::Fixed(110.0)), + text(snap.filesystem.clone()) + .size(12) + .width(Length::Fill), + ] + .spacing(6) + .align_y(alignment::Vertical::Center); + if snap.can_ext2 { + fs_row = fs_row.push( + button("Ext2 Properties").on_press(Message::PropertiesExt2Info), + ); + } + + let sdev_box = container( + column![ + text(if snap.sdev_title.is_empty() { + "Volume".to_string() + } else { + snap.sdev_title.clone() + }) + .size(13), + Self::prop_row("Status:", snap.status.clone()), + mount_row, + fs_row, + Self::prop_row("Capacity:", snap.capacity_bytes.clone()), + Self::prop_row("Free space:", snap.free_bytes.clone()), + ] + .spacing(4) + .padding(8), + ) + .width(Length::Fill) + .style(container::bordered_box); + + // Overlay already draws one bordered card; keep only the two + // section boxes (disk / volume) inside — no nested outer chrome. + Some( + column![ + text("Properties").size(18), + disk_box, + sdev_box, + button("Exit").on_press(Message::CloseDialog), + ] + .spacing(10) + .padding(16) + .width(Length::Fixed(460.0)) + .into(), + ) + } + Some(Dialog::About { + driver_line, + program_line, + }) => Some( + container( + column![ + text("About Ext2 Volume Manager").size(18), + text(driver_line).size(13), + row![ + text(format!("{program_line} — ")).size(13), + button(text("Iced port by Obsidian Jackal").size(13)) + .padding(0) + .style(button::text) + .on_press(Message::OpenIcedPortWebsite), + ] + .spacing(0) + .align_y(alignment::Vertical::Center), + text("Ext2/Ext4 volume manager for Windows (port of Ext2Mgr).") + .size(12), + row![ + button("Original project website") + .on_press(Message::OpenAboutWebsite), + button("Donate").on_press(Message::MenuDonate), + button("OK").on_press(Message::CloseDialog), + ] + .spacing(8), + ] + .spacing(10) + .padding(16) + .width(Length::Fixed(520.0)), + ) + .style(container::bordered_box) + .into(), + ), + Some(Dialog::Donate) => Some( + container( + column![ + text("Contribute to Ext2Fsd Group").size(18), + text("Donation declaration").size(14), + scrollable( + text( + "Ext2Fsd is an open source software. It acts as a bridge \ +between Windows and Linux, making life easier to access Linux partitions under Windows systems.\n\n\ +Currently there are still lots of jobs left to make a fully functional file system driver, \ +such as complete ext3 support, Linux LVM, and ongoing Windows support.\n\n\ +Any help will be highly appreciated. Thanks and best wishes.\n\n\ +Yours sincerely,\nMatt", + ) + .size(12), + ) + .height(Length::Fixed(220.0)), + text("Click to donate").size(14), + button("Donate via SourceForge.net") + .on_press(Message::OpenDonateSourceForge), + button("Donate via PayPal.com").on_press(Message::OpenDonatePayPal), + button("OK").on_press(Message::CloseDialog), + ] + .spacing(8) + .padding(16) + .width(Length::Fixed(480.0)), + ) + .style(container::bordered_box) + .into(), + ), + Some(Dialog::PerfStat { rows }) => { + let header = row![ + text("Name").width(Length::Fixed(280.0)), + text("Current").width(Length::Fixed(100.0)), + text("Processed").width(Length::Fixed(120.0)), + ]; + let table = Column::with_children( + rows.iter() + .map(|(name, current, processed)| { + row![ + text(name).width(Length::Fixed(280.0)), + text(current.to_string()).width(Length::Fixed(100.0)), + text(processed.to_string()).width(Length::Fixed(120.0)), + ] + .into() + }) + .collect::>(), + ) + .spacing(3); + Some( + container( + column![ + text("Ext2Fsd Statistics").size(18), + header, + scrollable(table).height(Length::Fixed(360.0)), + row![ + button("Copy").on_press(Message::CopyPerfStat), + button("Close").on_press(Message::CloseDialog), + ] + .spacing(8), + ] + .spacing(10) + .padding(16) + .width(Length::Fixed(560.0)), + ) + .style(container::bordered_box) + .into(), + ) + } + Some(Dialog::PartitionType { + selected_type, + note, + .. + }) => { + let type_choices: Element<'_, Message> = Column::with_children( + [ + (0x07u8, "HPFS/NTFS"), + (0x0Bu8, "FAT32"), + (0x0Cu8, "FAT32X"), + (0x82u8, "Linux swap"), + (0x83u8, "Linux"), + (0x8Eu8, "Linux LVM"), + ] + .into_iter() + .map(|(type_id, label)| { + radio( + format!("0x{type_id:02X} {label}"), + type_id, + Some(*selected_type), + Message::PartitionTypePick, + ) + .into() + }) + .collect::>(), + ) + .into(); + Some( + container( + column![ + text("Change Partition Type").size(18), + text(note).size(12), + scrollable(type_choices).height(Length::Fixed(200.0)), + row![ + button("Apply").on_press(Message::ApplyPartitionType), + button("Cancel").on_press(Message::CloseDialog), + ] + .spacing(8), + ] + .spacing(10) + .padding(16) + .width(Length::Fixed(420.0)), + ) + .style(container::bordered_box) + .into(), + ) + } + + } + } + + + pub fn view(&self) -> Element<'_, Message> { + let status_bar = row![ + container(text(&self.status).size(12)) + .width(Length::Fill) + .padding([4, 8]), + container(text(&self.clock).size(12)) + .padding([4, 8]) + .align_x(alignment::Horizontal::Right), + ] + .width(Length::Fill) + .align_y(alignment::Vertical::Center); + + let detail = container( + text_editor(&self.detail_editor) + .height(Length::Fill) + .on_action(Message::DetailEditor), + ) + .width(Length::Fill) + .height(Length::FillPortion(2)) + .padding(8) + .style(container::bordered_box); + + let mut main = column![ + self.view_volume_table(), + self.view_disk_table(), + ] + .spacing(6) + .padding(8) + .width(Length::Fill) + .height(Length::Fill); + if self.show_properties_pane { + main = main.push(text("Properties").size(14)).push(detail); + } + + let scrolled_main = container(main) + .width(Length::Fill) + .height(Length::Fill); + + let body: Element<'_, Message> = if let Some(dialog) = self.view_dialog() { + let dialog_card = mouse_area( + container(dialog) + .padding(16) + .style(container::bordered_box), + ) + .on_press(Message::DialogAbsorbClick); + let overlay = mouse_area( + container(dialog_card) + .width(Length::Fill) + .height(Length::Fill) + .padding(24) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_theme| container::Style { + background: Some(Background::Color(Color::from_rgba( + 0.0, 0.0, 0.0, 0.35, + ))), + ..Default::default() + }), + ) + .on_press(Message::CloseDialog); + stack![scrolled_main, overlay].height(Length::Fill).into() + } else { + scrolled_main.into() + }; + column![body, status_bar] + .spacing(0) + .width(Length::Fill) + .height(Length::Fill) + .into() + } +} diff --git a/ext2mgr_iced/src/win/accent.rs b/ext2mgr_iced/src/win/accent.rs new file mode 100644 index 0000000..57f613c --- /dev/null +++ b/ext2mgr_iced/src/win/accent.rs @@ -0,0 +1,117 @@ +//! Live Windows accent / highlight color for list selection. + + +use iced::Color; + +/// Prefer Settings accent, then DWM colorization, then classic highlight. +pub fn read_accent_color() -> Color { + if let Some(color) = accent_from_registry( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Accent", + "AccentColorMenu", + ColorLayout::Abgr, + ) { + return color; + } + if let Some(color) = accent_from_registry( + r"Software\Microsoft\Windows\DWM", + "AccentColor", + ColorLayout::Abgr, + ) { + return color; + } + if let Some(color) = accent_from_registry( + r"Software\Microsoft\Windows\DWM", + "ColorizationColor", + ColorLayout::Aarrggbb, + ) { + return color; + } + sys_highlight_color() +} + +pub fn contrasting_text(background: Color) -> Color { + let luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b; + if luminance > 0.55 { + Color::from_rgb(0.05, 0.05, 0.05) + } else { + Color::WHITE + } +} + +enum ColorLayout { + /// Windows accent DWORD: 0xAABBGGRR + Abgr, + /// DWM ColorizationColor: 0xAARRGGBB + Aarrggbb, +} + +fn accent_from_registry(subkey: &str, value_name: &str, layout: ColorLayout) -> Option { + use std::os::windows::ffi::OsStrExt; + + let wide_path: Vec = std::ffi::OsStr::new(subkey) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut key = 0; + let status = unsafe { + windows_sys::Win32::System::Registry::RegOpenKeyExW( + windows_sys::Win32::System::Registry::HKEY_CURRENT_USER, + wide_path.as_ptr(), + 0, + windows_sys::Win32::System::Registry::KEY_READ, + &mut key, + ) + }; + if status != 0 { + return None; + } + let wide_name: Vec = std::ffi::OsStr::new(value_name) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut value_type = 0u32; + let mut data = [0u8; 4]; + let mut data_len = data.len() as u32; + let status = unsafe { + windows_sys::Win32::System::Registry::RegQueryValueExW( + key, + wide_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + data.as_mut_ptr(), + &mut data_len, + ) + }; + unsafe { + windows_sys::Win32::System::Registry::RegCloseKey(key); + } + if status != 0 || data_len < 4 { + return None; + } + let dword = u32::from_le_bytes(data); + if dword == 0 { + return None; + } + Some(match layout { + ColorLayout::Abgr => Color::from_rgb8( + (dword & 0xFF) as u8, + ((dword >> 8) & 0xFF) as u8, + ((dword >> 16) & 0xFF) as u8, + ), + ColorLayout::Aarrggbb => Color::from_rgb8( + ((dword >> 16) & 0xFF) as u8, + ((dword >> 8) & 0xFF) as u8, + (dword & 0xFF) as u8, + ), + }) +} + +fn sys_highlight_color() -> Color { + // COLOR_HIGHLIGHT = 13 + let bgr = unsafe { windows_sys::Win32::Graphics::Gdi::GetSysColor(13) }; + Color::from_rgb8( + (bgr & 0xFF) as u8, + ((bgr >> 8) & 0xFF) as u8, + ((bgr >> 16) & 0xFF) as u8, + ) +} diff --git a/ext2mgr_iced/src/win/autorun.rs b/ext2mgr_iced/src/win/autorun.rs new file mode 100644 index 0000000..ab98b85 --- /dev/null +++ b/ext2mgr_iced/src/win/autorun.rs @@ -0,0 +1,135 @@ +//! File / Tools menu: autostart this iced manager at logon (HKCU Run). + + +use std::os::windows::ffi::OsStrExt; + +const RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; +const VALUE_NAME: &str = "Ext2MgrIced"; + +pub fn is_autorun_enabled() -> bool { + reg_query_run_value().is_some() +} + +pub fn set_autorun(enabled: bool) -> Result<(), String> { + if enabled { + let exe = std::env::current_exe() + .map_err(|err| format!("current_exe: {err}"))?; + let path = exe.to_string_lossy(); + // Quote path for Run key (spaces). + let value = format!("\"{path}\""); + reg_set_run_value(&value) + } else { + reg_delete_run_value() + } +} + +fn reg_query_run_value() -> Option { + let key = open_run_key(false)?; + let wide_name = to_wide(VALUE_NAME); + let mut value_type = 0u32; + let mut data = vec![0u8; 1024]; + let mut data_len = data.len() as u32; + let status = unsafe { + windows_sys::Win32::System::Registry::RegQueryValueExW( + key, + wide_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + data.as_mut_ptr(), + &mut data_len, + ) + }; + close_key(key); + if status != 0 || data_len < 2 { + return None; + } + let units: Vec = data[..data_len as usize] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .take_while(|unit| *unit != 0) + .collect(); + let text = String::from_utf16_lossy(&units); + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn reg_set_run_value(value: &str) -> Result<(), String> { + let key = open_run_key(true).ok_or_else(|| "Open Run key failed".to_string())?; + let wide_name = to_wide(VALUE_NAME); + let wide_value = to_wide(value); + let bytes = unsafe { + std::slice::from_raw_parts(wide_value.as_ptr() as *const u8, wide_value.len() * 2) + }; + let status = unsafe { + windows_sys::Win32::System::Registry::RegSetValueExW( + key, + wide_name.as_ptr(), + 0, + windows_sys::Win32::System::Registry::REG_SZ, + bytes.as_ptr(), + bytes.len() as u32, + ) + }; + close_key(key); + if status == 0 { + Ok(()) + } else { + Err(format!("RegSetValueEx(Run) failed ({status})")) + } +} + +fn reg_delete_run_value() -> Result<(), String> { + let key = open_run_key(true).ok_or_else(|| "Open Run key failed".to_string())?; + let wide_name = to_wide(VALUE_NAME); + let status = unsafe { + windows_sys::Win32::System::Registry::RegDeleteValueW(key, wide_name.as_ptr()) + }; + close_key(key); + // ERROR_FILE_NOT_FOUND = 2 + if status == 0 || status == 2 { + Ok(()) + } else { + Err(format!("RegDeleteValue(Run) failed ({status})")) + } +} + +fn open_run_key(write: bool) -> Option { + let access = if write { + windows_sys::Win32::System::Registry::KEY_SET_VALUE + | windows_sys::Win32::System::Registry::KEY_QUERY_VALUE + } else { + windows_sys::Win32::System::Registry::KEY_READ + }; + let wide = to_wide(RUN_KEY); + let mut key = 0; + let status = unsafe { + windows_sys::Win32::System::Registry::RegOpenKeyExW( + windows_sys::Win32::System::Registry::HKEY_CURRENT_USER, + wide.as_ptr(), + 0, + access, + &mut key, + ) + }; + if status == 0 { + Some(key) + } else { + None + } +} + +fn close_key(key: windows_sys::Win32::System::Registry::HKEY) { + unsafe { + windows_sys::Win32::System::Registry::RegCloseKey(key); + } +} + +fn to_wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} diff --git a/ext2mgr_iced/src/win/chrome.rs b/ext2mgr_iced/src/win/chrome.rs new file mode 100644 index 0000000..d8dbaab --- /dev/null +++ b/ext2mgr_iced/src/win/chrome.rs @@ -0,0 +1,678 @@ +//! Win32 chrome fallback: menu bar, tray, TrackPopupMenu, HWND helpers. +//! Matches Ext2Mgr IDR_MENU / IDR_TRAY / Shell_NotifyIcon / TrackPopupMenu. + + +use std::sync::Mutex; + +use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, WPARAM}; +use windows_sys::Win32::UI::Shell::{ + Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NOTIFYICONDATAW, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + AppendMenuW, CheckMenuItem, CreateMenu, CreatePopupMenu, DefWindowProcW, DestroyMenu, + DrawMenuBar, EnableMenuItem, FindWindowW, GetCursorPos, GetSystemMetrics, GetWindowLongPtrW, + IsWindow, LoadImageW, PostMessageW, SetForegroundWindow, SetMenu, SetWindowLongPtrW, + SetWindowPos, ShowWindow, TrackPopupMenu, GWLP_WNDPROC, HICON, HMENU, IMAGE_ICON, + LR_DEFAULTSIZE, LR_LOADFROMFILE, MF_BYCOMMAND, MF_CHECKED, MF_ENABLED, MF_GRAYED, MF_POPUP, + MF_SEPARATOR, MF_STRING, MF_UNCHECKED, SC_MINIMIZE, SM_CXSMICON, SM_CYSMICON, SWP_FRAMECHANGED, + SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, SW_RESTORE, SW_SHOW, TPM_LEFTALIGN, + TPM_RETURNCMD, TPM_RIGHTBUTTON, WM_CLOSE, WM_COMMAND, WM_DESTROY, WM_NULL, WM_SYSCOMMAND, + WM_USER, +}; + +pub const ID_ABOUT: u32 = 32771; +pub const ID_CHANGE: u32 = 32773; +pub const ID_REFRESH: u32 = 32774; +pub const ID_EXIT: u32 = 32775; +pub const ID_SERVICE: u32 = 32776; +pub const ID_PROPERTY: u32 = 32778; +pub const ID_COPY: u32 = 32779; +pub const ID_SHOW_MAIN: u32 = 32780; +pub const ID_DRV_LETTER: u32 = 32781; +pub const ID_DONATE: u32 = 32782; +pub const ID_ENABLE_AUTOSTART: u32 = 32788; +pub const ID_DISABLE_AUTOSTART: u32 = 32789; +pub const ID_PERFSTAT: u32 = 32790; +pub const ID_COPYALL: u32 = 32792; +pub const ID_FLUSH_BUFFER: u32 = 32794; +pub const ID_CHANGE_PARTTYPE: u32 = 32795; +pub const ID_REMOVE_DEAD_LETTER: u32 = 32796; +pub const ID_DRV_QUICK_MOUNT: u32 = 32797; +/// Unmount / remove assigned drive letter(s) for the selection. +pub const ID_DRV_UNMOUNT: u32 = 32799; +/// Help → Documentation (opens FAQ); avoids redundant “Help → Help”. +pub const ID_DOCUMENTATION: u32 = 32800; +pub const ID_VIEW_SI_UNITS: u32 = 32810; +pub const ID_VIEW_BINARY_UNITS: u32 = 32811; +pub const ID_VIEW_DISPLAY_BYTES: u32 = 32812; +pub const ID_VIEW_DISPLAY_BITS: u32 = 32813; +/// View → show/hide the properties pane under Disks / Partitions. +pub const ID_VIEW_PROPERTIES_PANE: u32 = 32814; + +const WM_TRAY_ICON_NOTIFY: u32 = WM_USER + 100; +const TRAY_UID: u32 = 0xE220_1001; + +static PENDING: Mutex> = Mutex::new(Vec::new()); +static STATE: Mutex = Mutex::new(ChromeState::new()); + +struct ChromeState { + hwnd: usize, + old_wnd_proc: Option, + tray_added: bool, + menu: usize, + icon: usize, +} + +unsafe impl Send for ChromeState {} + +impl ChromeState { + const fn new() -> Self { + Self { + hwnd: 0, + old_wnd_proc: None, + tray_added: false, + menu: 0, + icon: 0, + } + } +} + +fn to_wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() +} + +pub fn window_title() -> &'static str { + "Ext2 Volume Manager" +} + +pub fn find_main_hwnd() -> Option { + let title = to_wide(window_title()); + let hwnd = unsafe { FindWindowW(std::ptr::null(), title.as_ptr()) }; + if hwnd == 0 || unsafe { IsWindow(hwnd) } == 0 { + None + } else { + Some(hwnd) + } +} + +fn icon_path() -> Option { + let mut candidates = vec![ + std::path::PathBuf::from("assets/Ext2Mgr.ico"), + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/Ext2Mgr.ico"), + ]; + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + candidates.push(parent.join("assets/Ext2Mgr.ico")); + } + } + candidates.into_iter().find(|path| path.is_file()) +} + +fn load_app_icon() -> HICON { + let Some(path) = icon_path() else { + return 0; + }; + let wide = to_wide(&path.to_string_lossy()); + let cx = unsafe { GetSystemMetrics(SM_CXSMICON) }; + let cy = unsafe { GetSystemMetrics(SM_CYSMICON) }; + unsafe { + LoadImageW( + 0, + wide.as_ptr(), + IMAGE_ICON, + cx, + cy, + LR_LOADFROMFILE | LR_DEFAULTSIZE, + ) as HICON + } +} + +fn append_string(menu: HMENU, id: u32, label: &str) { + let wide = to_wide(label); + unsafe { + AppendMenuW(menu, MF_STRING, id as usize, wide.as_ptr()); + } +} + +fn build_main_menu() -> HMENU { + unsafe { + let menu = CreateMenu(); + + let file = CreatePopupMenu(); + append_string(file, ID_ENABLE_AUTOSTART, "&Enable Ext2Mgr autostart"); + append_string(file, ID_DISABLE_AUTOSTART, "&Disable Ext2Mgr autostart"); + AppendMenuW(file, MF_SEPARATOR, 0, std::ptr::null()); + append_string(file, ID_EXIT, "E&xit"); + let file_label = to_wide("&File"); + AppendMenuW(menu, MF_POPUP, file as usize, file_label.as_ptr()); + + let edit = CreatePopupMenu(); + append_string(edit, ID_COPYALL, "&Copy Everything to Clipboard"); + let edit_label = to_wide("&Edit"); + AppendMenuW(menu, MF_POPUP, edit as usize, edit_label.as_ptr()); + + let view = CreatePopupMenu(); + append_string(view, ID_VIEW_PROPERTIES_PANE, "Show &properties pane"); + AppendMenuW(view, MF_SEPARATOR, 0, std::ptr::null()); + append_string(view, ID_VIEW_SI_UNITS, "Use &SI units"); + append_string(view, ID_VIEW_BINARY_UNITS, "Use &binary units"); + AppendMenuW(view, MF_SEPARATOR, 0, std::ptr::null()); + append_string(view, ID_VIEW_DISPLAY_BYTES, "Display with b&ytes"); + append_string(view, ID_VIEW_DISPLAY_BITS, "Display with b&its"); + let view_label = to_wide("&View"); + AppendMenuW(menu, MF_POPUP, view as usize, view_label.as_ptr()); + + let tools = CreatePopupMenu(); + append_string(tools, ID_REFRESH, "&Reload and Refresh\tF5"); + AppendMenuW(tools, MF_SEPARATOR, 0, std::ptr::null()); + append_string(tools, ID_SERVICE, "&Service Management\tF7"); + append_string(tools, ID_PERFSTAT, "Ext2Fsd S&tatistics\tF8"); + AppendMenuW(tools, MF_SEPARATOR, 0, std::ptr::null()); + append_string(tools, ID_REMOVE_DEAD_LETTER, "Remove &Dead Letters\tF9"); + AppendMenuW(tools, MF_SEPARATOR, 0, std::ptr::null()); + append_string(tools, ID_CHANGE, "&Ext2 Volume Management\tF3"); + append_string(tools, ID_DRV_LETTER, "&Mountpoint Management\tF10"); + AppendMenuW(tools, MF_SEPARATOR, 0, std::ptr::null()); + append_string(tools, ID_FLUSH_BUFFER, "&Flush Cache to Disk\tF11"); + append_string(tools, ID_CHANGE_PARTTYPE, "&Change Partition Type\tF12"); + AppendMenuW(tools, MF_SEPARATOR, 0, std::ptr::null()); + append_string(tools, ID_PROPERTY, "Show &Properties\tF6"); + append_string(tools, ID_DRV_QUICK_MOUNT, "Assign Drive Letter\tF4"); + append_string(tools, ID_DRV_UNMOUNT, "&Unmount"); + let tools_label = to_wide("&Tools"); + AppendMenuW(menu, MF_POPUP, tools as usize, tools_label.as_ptr()); + + let help = CreatePopupMenu(); + append_string(help, ID_DOCUMENTATION, "&Documentation\tF1"); + AppendMenuW(help, MF_SEPARATOR, 0, std::ptr::null()); + append_string(help, ID_ABOUT, "&About\tF2"); + AppendMenuW(help, MF_SEPARATOR, 0, std::ptr::null()); + append_string(help, ID_DONATE, "&Donate"); + let help_label = to_wide("&Help"); + AppendMenuW(menu, MF_POPUP, help as usize, help_label.as_ptr()); + menu + } +} + +fn tray_add(hwnd: HWND, icon: HICON) -> bool { + let mut data: NOTIFYICONDATAW = unsafe { std::mem::zeroed() }; + data.cbSize = std::mem::size_of::() as u32; + data.hWnd = hwnd; + data.uID = TRAY_UID; + data.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + data.uCallbackMessage = WM_TRAY_ICON_NOTIFY; + data.hIcon = icon; + let tip = "Ext2 Volume Manager"; + for (index, unit) in tip.encode_utf16().take(127).enumerate() { + data.szTip[index] = unit; + } + unsafe { Shell_NotifyIconW(NIM_ADD, &data) != 0 } +} + +fn tray_remove(hwnd: HWND) { + let mut data: NOTIFYICONDATAW = unsafe { std::mem::zeroed() }; + data.cbSize = std::mem::size_of::() as u32; + data.hWnd = hwnd; + data.uID = TRAY_UID; + unsafe { + let _ = Shell_NotifyIconW(NIM_DELETE, &data); + } +} + +fn push_command(id: u32) { + if let Ok(mut pending) = PENDING.lock() { + pending.push(id); + } +} + +pub fn poll_commands() -> Vec { + PENDING + .lock() + .map(|mut pending| pending.drain(..).collect()) + .unwrap_or_default() +} + +type WndProc = unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT; + +unsafe extern "system" fn subclass_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_COMMAND => { + let id = (wparam as u32) & 0xFFFF; + if id != 0 { + push_command(id); + } + return 0; + } + WM_SYSCOMMAND => { + let command = (wparam as u32) & 0xFFF0; + if command == SC_MINIMIZE { + ShowWindow(hwnd, SW_HIDE); + return 0; + } + } + // Backup if the shell minimizes without a clean SC_MINIMIZE path. + 0x0005 /* WM_SIZE */ => { + if wparam == 1 /* SIZE_MINIMIZED */ { + ShowWindow(hwnd, SW_HIDE); + return 0; + } + } + x if x == WM_TRAY_ICON_NOTIFY => { + let mouse = (lparam as u32) & 0xFFFF; + // WM_LBUTTONUP / WM_RBUTTONUP + if mouse == 0x0202 { + ShowWindow(hwnd, SW_SHOW); + ShowWindow(hwnd, SW_RESTORE); + SetForegroundWindow(hwnd); + return 0; + } + if mouse == 0x0205 { + if let Some(id) = track_tray_menu(hwnd) { + if id == ID_SHOW_MAIN { + ShowWindow(hwnd, SW_SHOW); + ShowWindow(hwnd, SW_RESTORE); + SetForegroundWindow(hwnd); + } else if id == ID_EXIT { + PostMessageW(hwnd, WM_CLOSE, 0, 0); + } else { + push_command(id); + } + } + return 0; + } + } + WM_DESTROY => { + if let Ok(mut state) = STATE.lock() { + if state.tray_added { + tray_remove(hwnd); + state.tray_added = false; + } + } + } + _ => {} + } + + let old = STATE + .lock() + .ok() + .and_then(|state| state.old_wnd_proc) + .unwrap_or(0); + if old != 0 { + let proc: WndProc = std::mem::transmute(old); + proc(hwnd, msg, wparam, lparam) + } else { + DefWindowProcW(hwnd, msg, wparam, lparam) + } +} + +fn track_tray_menu(hwnd: HWND) -> Option { + unsafe { + let menu = CreatePopupMenu(); + append_string(menu, ID_SHOW_MAIN, "Show Main Window"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + append_string(menu, ID_SERVICE, "Service Management"); + append_string(menu, ID_PERFSTAT, "Ext2Fsd Statistics"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + append_string(menu, ID_ABOUT, "About Ext2Mgr"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + append_string(menu, ID_EXIT, "Exit ..."); + let mut point = POINT { x: 0, y: 0 }; + GetCursorPos(&mut point); + SetForegroundWindow(hwnd); + let id = TrackPopupMenu( + menu, + TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD, + point.x, + point.y, + 0, + hwnd, + std::ptr::null(), + ) as u32; + PostMessageW(hwnd, WM_NULL, 0, 0); + DestroyMenu(menu); + if id == 0 { + None + } else { + Some(id) + } + } +} + +/// Attach menu + tray + subclass once the iced HWND exists. +pub fn ensure_attached() -> bool { + let Some(hwnd) = find_main_hwnd() else { + return false; + }; + + { + let state = match STATE.lock() { + Ok(state) => state, + Err(_) => return false, + }; + if state.hwnd == hwnd as usize && state.old_wnd_proc.is_some() { + return true; + } + } + + { + let mut state = match STATE.lock() { + Ok(state) => state, + Err(_) => return false, + }; + if state.hwnd != 0 && state.hwnd != hwnd as usize && state.tray_added { + tray_remove(state.hwnd as HWND); + state.tray_added = false; + } + } + + let icon = load_app_icon(); + let menu = build_main_menu(); + + // Never hold STATE across these calls: they can synchronously dispatch window + // messages to subclass_proc, which also locks STATE. + let previous_wnd_proc = unsafe { + SetMenu(hwnd, menu); + DrawMenuBar(hwnd); + SetWindowPos( + hwnd, + 0, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED, + ); + GetWindowLongPtrW(hwnd, GWLP_WNDPROC) + }; + + unsafe { + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, subclass_proc as *const () as isize); + } + + let tray_ok = icon != 0 && tray_add(hwnd, icon); + + { + let mut state = match STATE.lock() { + Ok(state) => state, + Err(_) => return false, + }; + state.old_wnd_proc = Some(previous_wnd_proc); + state.hwnd = hwnd as usize; + state.menu = menu as usize; + state.icon = icon as usize; + state.tray_added = tray_ok; + } + true +} + +/// Check View menu items (properties pane, SI/binary, bytes/bits). +pub fn sync_view_menu(show_properties_pane: bool, use_binary: bool, display_bits: bool) { + let Some((menu, hwnd)) = (|| { + let state = STATE.lock().ok()?; + if state.menu == 0 { + return None; + } + Some((state.menu as HMENU, state.hwnd as HWND)) + })() else { + return; + }; + + unsafe { + CheckMenuItem( + menu, + ID_VIEW_PROPERTIES_PANE, + MF_BYCOMMAND + | if show_properties_pane { + MF_CHECKED + } else { + MF_UNCHECKED + }, + ); + CheckMenuItem( + menu, + ID_VIEW_SI_UNITS, + MF_BYCOMMAND + | if use_binary { + MF_UNCHECKED + } else { + MF_CHECKED + }, + ); + CheckMenuItem( + menu, + ID_VIEW_BINARY_UNITS, + MF_BYCOMMAND + | if use_binary { + MF_CHECKED + } else { + MF_UNCHECKED + }, + ); + CheckMenuItem( + menu, + ID_VIEW_DISPLAY_BYTES, + MF_BYCOMMAND + | if display_bits { + MF_UNCHECKED + } else { + MF_CHECKED + }, + ); + CheckMenuItem( + menu, + ID_VIEW_DISPLAY_BITS, + MF_BYCOMMAND + | if display_bits { + MF_CHECKED + } else { + MF_UNCHECKED + }, + ); + if hwnd != 0 { + DrawMenuBar(hwnd); + } + } +} + +/// Open a local file or URL with the shell (FAQ, project site, donate links). +pub fn shell_open(path_or_url: &str) -> Result<(), String> { + let wide = to_wide(path_or_url); + let open = to_wide("open"); + let result = unsafe { + windows_sys::Win32::UI::Shell::ShellExecuteW( + 0, + open.as_ptr(), + wide.as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOW as i32, + ) + }; + // ShellExecute returns > 32 on success (cast as HINSTANCE / isize). + if result as isize > 32 { + Ok(()) + } else { + Err(format!("ShellExecute failed ({result})")) + } +} + +/// Resolve FAQ.txt next to the exe, under assets/, or beside the source tree. +pub fn documentation_path() -> Option { + let mut candidates = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + candidates.push(parent.join("Documents").join("FAQ.txt")); + candidates.push(parent.join("FAQ.txt")); + candidates.push(parent.join("assets").join("FAQ.txt")); + } + } + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/FAQ.txt"), + ); + candidates.push( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../Ext4Fsd/FAQ.txt"), + ); + candidates.into_iter().find(|path| path.is_file()) +} + +/// Enable or gray File → Ext2Mgr autostart menu items. +pub fn sync_autorun_menu(autorun_enabled: bool) { + let Some((menu, hwnd)) = (|| { + let state = STATE.lock().ok()?; + if state.menu == 0 { + return None; + } + Some((state.menu as HMENU, state.hwnd as HWND)) + })() else { + return; + }; + + unsafe { + if autorun_enabled { + EnableMenuItem(menu, ID_ENABLE_AUTOSTART, MF_BYCOMMAND | MF_GRAYED); + EnableMenuItem(menu, ID_DISABLE_AUTOSTART, MF_BYCOMMAND | MF_ENABLED); + } else { + EnableMenuItem(menu, ID_ENABLE_AUTOSTART, MF_BYCOMMAND | MF_ENABLED); + EnableMenuItem(menu, ID_DISABLE_AUTOSTART, MF_BYCOMMAND | MF_GRAYED); + } + if hwnd != 0 { + DrawMenuBar(hwnd); + } + } +} + +/// Tools → Assign Drive Letter / Unmount enable state from selection letters. +pub fn sync_mount_letter_menus(can_assign: bool, can_unmount: bool) { + let Some((menu, hwnd)) = (|| { + let state = STATE.lock().ok()?; + if state.menu == 0 { + return None; + } + Some((state.menu as HMENU, state.hwnd as HWND)) + })() else { + return; + }; + + unsafe { + EnableMenuItem( + menu, + ID_DRV_QUICK_MOUNT, + MF_BYCOMMAND + | if can_assign { + MF_ENABLED + } else { + MF_GRAYED + }, + ); + EnableMenuItem( + menu, + ID_DRV_UNMOUNT, + MF_BYCOMMAND + | if can_unmount { + MF_ENABLED + } else { + MF_GRAYED + }, + ); + if hwnd != 0 { + DrawMenuBar(hwnd); + } + } +} + +pub enum ContextKind { + Volume { has_letter: bool, is_ext: bool }, + Disk { + has_letter: bool, + is_ext: bool, + is_mbr_part: bool, + }, +} + +/// Synchronous TrackPopupMenu at cursor; returns selected command id. +pub fn track_list_context(kind: ContextKind) -> Option { + let _ = ensure_attached(); + let hwnd = find_main_hwnd()?; + unsafe { + let menu = CreatePopupMenu(); + match kind { + ContextKind::Volume { + has_letter, + is_ext, + } => { + if is_ext && !has_letter { + append_string(menu, ID_DRV_QUICK_MOUNT, "Assign Drive Letter\tF4"); + } else if has_letter { + append_string(menu, ID_DRV_UNMOUNT, "Unmount"); + } + append_string(menu, ID_DRV_LETTER, "Change Drive Letter\tF10"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + if is_ext { + append_string(menu, ID_CHANGE, "Ext2 Volume Management\tF3"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + } + append_string(menu, ID_FLUSH_BUFFER, "Flush Cache to Disk\tF11"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + append_string(menu, ID_COPY, "Copy Item to Clipboard"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + } + ContextKind::Disk { + has_letter, + is_ext, + is_mbr_part, + } => { + if is_ext && !has_letter { + append_string(menu, ID_DRV_QUICK_MOUNT, "Assign Drive Letter\tF4"); + } else if has_letter { + append_string(menu, ID_DRV_UNMOUNT, "Unmount"); + } + append_string(menu, ID_DRV_LETTER, "Change Drive Letter\tF10"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + if is_ext { + append_string(menu, ID_CHANGE, "Ext2 Volume Management\tF3"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + } + append_string(menu, ID_FLUSH_BUFFER, "Flush Cache to Disk\tF11"); + if is_mbr_part { + append_string(menu, ID_CHANGE_PARTTYPE, "Change Partition Type\tF12"); + } + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + append_string(menu, ID_COPY, "Copy Item to Clipboard"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + } + } + append_string(menu, ID_REFRESH, "Reload and Refresh\tF5"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + append_string(menu, ID_PROPERTY, "Show Properties\tF6"); + AppendMenuW(menu, MF_SEPARATOR, 0, std::ptr::null()); + append_string(menu, ID_SERVICE, "Service Management\tF7"); + append_string(menu, ID_PERFSTAT, "Ext2Fsd Statistics\tF8"); + append_string(menu, ID_REMOVE_DEAD_LETTER, "Remove Dead Letters\tF9"); + + let mut point = POINT { x: 0, y: 0 }; + GetCursorPos(&mut point); + SetForegroundWindow(hwnd); + let id = TrackPopupMenu( + menu, + TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD, + point.x, + point.y, + 0, + hwnd, + std::ptr::null(), + ) as u32; + PostMessageW(hwnd, WM_NULL, 0, 0); + DestroyMenu(menu); + if id == 0 { + None + } else { + Some(id) + } + } +} diff --git a/ext2mgr_iced/src/win/clipboard.rs b/ext2mgr_iced/src/win/clipboard.rs new file mode 100644 index 0000000..d232ff4 --- /dev/null +++ b/ext2mgr_iced/src/win/clipboard.rs @@ -0,0 +1,52 @@ +//! Win32 clipboard — CF_UNICODETEXT via OpenClipboard. + + +use std::os::windows::ffi::OsStrExt; + +pub fn set_text(text: &str) -> Result<(), String> { + let wide: Vec = std::ffi::OsStr::new(text) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let bytes = wide.len() * 2; + + let opened = unsafe { windows_sys::Win32::System::DataExchange::OpenClipboard(0) }; + if opened == 0 { + return Err(format!( + "OpenClipboard failed ({})", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + unsafe { + windows_sys::Win32::System::DataExchange::EmptyClipboard(); + } + + let handle = unsafe { + windows_sys::Win32::System::Memory::GlobalAlloc( + windows_sys::Win32::System::Memory::GMEM_MOVEABLE, + bytes, + ) + }; + if handle.is_null() { + unsafe { + windows_sys::Win32::System::DataExchange::CloseClipboard(); + } + return Err("GlobalAlloc failed".to_string()); + } + let ptr = unsafe { windows_sys::Win32::System::Memory::GlobalLock(handle) }; + if ptr.is_null() { + unsafe { + windows_sys::Win32::Foundation::GlobalFree(handle); + windows_sys::Win32::System::DataExchange::CloseClipboard(); + } + return Err("GlobalLock failed".to_string()); + } + unsafe { + std::ptr::copy_nonoverlapping(wide.as_ptr() as *const u8, ptr as *mut u8, bytes); + windows_sys::Win32::System::Memory::GlobalUnlock(handle); + // CF_UNICODETEXT = 13 + windows_sys::Win32::System::DataExchange::SetClipboardData(13, handle as isize); + windows_sys::Win32::System::DataExchange::CloseClipboard(); + } + Ok(()) +} diff --git a/ext2mgr_iced/src/win/mod.rs b/ext2mgr_iced/src/win/mod.rs new file mode 100644 index 0000000..a762067 --- /dev/null +++ b/ext2mgr_iced/src/win/mod.rs @@ -0,0 +1,12 @@ +//! Win32 chrome, accent, autorun, clipboard, message boxes. + +#[cfg(windows)] +pub mod chrome; +#[cfg(windows)] +pub mod accent; +#[cfg(windows)] +pub mod autorun; +#[cfg(windows)] +pub mod clipboard; +#[cfg(windows)] +pub mod msgbox; diff --git a/ext2mgr_iced/src/win/msgbox.rs b/ext2mgr_iced/src/win/msgbox.rs new file mode 100644 index 0000000..f593dea --- /dev/null +++ b/ext2mgr_iced/src/win/msgbox.rs @@ -0,0 +1,52 @@ +//! Win32 MessageBox — same user-visible error pattern as Ext2Mgr's AfxMessageBox. + + +use windows_sys::Win32::Foundation::HWND; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + MessageBoxW, SetForegroundWindow, IDYES, MB_ICONQUESTION, MB_ICONSTOP, MB_ICONWARNING, MB_OK, + MB_SETFOREGROUND, MB_TASKMODAL, MB_YESNO, +}; + +fn to_wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn owner() -> HWND { + super::chrome::find_main_hwnd().unwrap_or(0) +} + +fn show(text: &str, caption: &str, flags: u32) -> i32 { + let hwnd = owner(); + if hwnd != 0 { + unsafe { + SetForegroundWindow(hwnd); + } + } + let text_w = to_wide(text); + let caption_w = to_wide(caption); + // TASKMODAL + SETFOREGROUND: avoid the box appearing behind the iced window + // (which makes the main UI look "frozen" until the user finds the MessageBox). + unsafe { + MessageBoxW( + hwnd, + text_w.as_ptr(), + caption_w.as_ptr(), + flags | MB_TASKMODAL | MB_SETFOREGROUND, + ) + } +} + +/// Hard failure (MB_ICONSTOP) — mount when driver/service missing, etc. +pub fn error(text: &str) { + let _ = show(text, "Ext2 Volume Manager", MB_OK | MB_ICONSTOP); +} + +/// Soft / recoverable failure (MB_ICONWARNING). +pub fn warning(text: &str) { + let _ = show(text, "Ext2 Volume Manager", MB_OK | MB_ICONWARNING); +} + +/// Yes/No question; returns true on Yes. +pub fn confirm(text: &str) -> bool { + show(text, "Ext2 Volume Manager", MB_YESNO | MB_ICONQUESTION) == IDYES +} From bcf7a28a1196bad34d5f02afeb919f6a2eb49f92 Mon Sep 17 00:00:00 2001 From: Obisidan Jackal Date: Tue, 28 Jul 2026 20:39:10 -0400 Subject: [PATCH 2/5] docs: lead iced-branch README with Ext2Mgr Iced port Make the fork landing page on ext2mgr-iced state up front that this branch adds the Rust+Iced volume manager under ext2mgr_iced/. --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 7465595..30b7bb7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ +Ext2Mgr Iced port (this branch) +------------------------------- + +This branch (`ext2mgr-iced`) adds an optional **Rust + [Iced](https://iced.rs/) GUI** for managing Ext2/Ext4 volumes on Windows, under **`ext2mgr_iced/`**. It is modeled on classic MFC **Ext2 Volume Manager** (`Ext2Mgr/`) and talks to the same Ext2Fsd driver / Ext2Srv pipe. + +- Build / run: see [`ext2mgr_iced/README.md`](ext2mgr_iced/README.md) (`cargo build --release`, `cargo run`, `cargo run --release`) +- What differs from classic Ext2Mgr: [`ext2mgr_iced/PORT_IMPROVEMENTS.md`](ext2mgr_iced/PORT_IMPROVEMENTS.md) +- Source layout: [`ext2mgr_iced/docs/SOURCE_LAYOUT.md`](ext2mgr_iced/docs/SOURCE_LAYOUT.md) + +Classic Ext2Mgr and Ext2Srv remain available. Driver build/install scripts below are unchanged. + + +Scripts and building from source (this fork) +-------------------------------------------- + +This fork adds PowerShell scripts for building, installing, signing, and diagnosing the driver. All scripts live in **Scripts/**. Run them from the repo root, e.g. `.\Scripts\build.ps1`. + +**Build and install** +- **Scripts\build.ps1** — Build driver, Ext2Srv, Ext2Mgr (requires Visual Studio 2019/2022 and WDK). Optionally signs the driver if `EXT4FSD_CERT_PATH` is set. +- **Scripts\install_driver.ps1** — Copy driver to System32, register kernel driver, install Ext2Srv. Run as Administrator. +- **Scripts\uninstall_driver.ps1** — Remove driver and services. +- **Scripts\register_driver.ps1** — Register the kernel driver service (driver must already be in System32). +- **Scripts\disable_driver.ps1** — Disable the Ext2Fsd service (e.g. to stop boot retries after signature failures). + +**Signing** +- **Scripts\sign_driver.ps1** — Sign the driver with a code-signing certificate. Set `EXT4FSD_CERT_PATH` and optionally `MSIX_CERT_PASSWORD`, or pass `-CertificatePath` / `-CertificatePassword`. +- **Scripts\install_certificate.ps1** — Install the signing certificate into Trusted Publishers (required for loading self-signed drivers). Run as Administrator. + +**Diagnostics** +- **Scripts\diagnose_ext2fsd.ps1** — Unified diagnostic (signature, cert stores, event log, driver status). Use for load failures or error 577. Use `-UseSigntool:$false` to skip signtool (e.g. when SDK is not installed). +- **Scripts\check_driver_load_error.ps1**, **Scripts\diagnose_error_577.ps1**, **Scripts\verify_driver_signature.ps1** — Wrappers that call `diagnose_ext2fsd.ps1`. + +**Other** +- **Scripts\fix_sdk_version.ps1** — Update project files' `WindowsTargetPlatformVersion` to match the installed SDK. + Latest release -------------- From 9097ef649dd165b412333539a0c53f574d17cc27 Mon Sep 17 00:00:00 2001 From: Obisidan Jackal Date: Wed, 29 Jul 2026 12:50:51 -0400 Subject: [PATCH 3/5] docs: update usermode build/release scripts and README --- README.md | 3 +- Scripts/build.ps1 | 306 ++++++++++++++++++++++++ Scripts/release_usermode.ps1 | 446 +++++++++++++++++++++++++++++++++++ 3 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 Scripts/build.ps1 create mode 100644 Scripts/release_usermode.ps1 diff --git a/README.md b/README.md index 30b7bb7..9232f8e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Scripts and building from source (this fork) This fork adds PowerShell scripts for building, installing, signing, and diagnosing the driver. All scripts live in **Scripts/**. Run them from the repo root, e.g. `.\Scripts\build.ps1`. **Build and install** -- **Scripts\build.ps1** — Build driver, Ext2Srv, Ext2Mgr (requires Visual Studio 2019/2022 and WDK). Optionally signs the driver if `EXT4FSD_CERT_PATH` is set. +- **Scripts\build.ps1** — Build driver, Ext2Srv, and/or Ext2Mgr (Visual Studio 2019/2022). Default `-Target All` needs WDK. Use `-Target Ext2Mgr` or `-Target Ext2Srv` for user-mode only (no WDK). Optionally signs the driver if `EXT4FSD_CERT_PATH` is set. Cursor/VS Code: `.vscode/tasks.json` + `launch.json`. +- **Scripts\release_usermode.ps1** — Package Ext2Srv + `ext2mgr_iced` user-mode zips (no driver / no WDK). Default: **host architecture only**. Pass `-Platforms x64,ARM64` for both (requires ARM64 MSVC tools). Writes `dist\usermode-\` with `RELEASE_NOTES.txt` and SHA256 sums. - **Scripts\install_driver.ps1** — Copy driver to System32, register kernel driver, install Ext2Srv. Run as Administrator. - **Scripts\uninstall_driver.ps1** — Remove driver and services. - **Scripts\register_driver.ps1** — Register the kernel driver service (driver must already be in System32). diff --git a/Scripts/build.ps1 b/Scripts/build.ps1 new file mode 100644 index 0000000..6fa1fb8 --- /dev/null +++ b/Scripts/build.ps1 @@ -0,0 +1,306 @@ +<# +.SYNOPSIS + Build script for Ext4Fsd driver and user-mode tools from source + +.DESCRIPTION + Builds the Ext4Fsd driver, Ext2Srv service, and/or Ext2Mgr application. + Full solution builds require Visual Studio 2019/2022 and the Windows Driver Kit (WDK). + Ext2Mgr / Ext2Srv targets need MSVC + MFC only (no WDK). + +.PARAMETER Configuration + Build configuration: Debug or Release (default: Release) + +.PARAMETER Platform + Target platform: x64, x86, ARM, or ARM64 (default: x64) + +.PARAMETER Target + What to build: + All - full solution (driver + Ext2Srv + Ext2Mgr); WDK expected + Ext2Mgr - classic MFC Ext2Mgr.exe only (no WDK) + Ext2Srv - Ext2Srv.exe only (no WDK) + +.PARAMETER Clean + Clean before building + +.EXAMPLE + .\Scripts\build.ps1 + Builds Release x64 full solution (run from repo root) + +.EXAMPLE + .\Scripts\build.ps1 -Target Ext2Mgr + Builds only Ext2Mgr.exe (Release x64) + +.EXAMPLE + .\Scripts\build.ps1 -Target Ext2Mgr -Configuration Debug -Platform x64 + Builds Debug x64 Ext2Mgr.exe + +.EXAMPLE + .\Scripts\build.ps1 -Configuration Debug -Platform x64 + Builds Debug x64 full solution +#> + +[CmdletBinding()] +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + + [ValidateSet("x64", "x86", "ARM", "ARM64")] + [string]$Platform = "x64", + + [ValidateSet("All", "Ext2Mgr", "Ext2Srv")] + [string]$Target = "All", + + [switch]$Clean +) + +$ErrorActionPreference = "Stop" +$script:BuildRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +function Get-MSBuildPlatform([string]$platform) { + # Solution uses x86; vcxproj configs use Win32 for that arch. + if ($platform -eq "x86") { return "Win32" } + return $platform +} + +function Get-OutArchFolder([string]$platform) { + switch ($platform) { + "x64" { return "x64" } + "ARM64" { return "arm64" } + "ARM" { return "arm" } + "x86" { return "x86" } + default { return $platform.ToLowerInvariant() } + } +} + +function Get-SolutionDirProperty { + $solutionDir = $script:BuildRoot + if (-not $solutionDir.EndsWith("\")) { + $solutionDir += "\" + } + return $solutionDir +} + +$header = " Ext4Fsd Build Script" +Write-Host ("=" * ($header.Length + 1)) -ForegroundColor Cyan +Write-Host $header -ForegroundColor Cyan +Write-Host ("=" * ($header.Length + 1)) -ForegroundColor Cyan +Write-Host "" + +# Find Visual Studio installation (including Build Tools) +Write-Host "Searching for Visual Studio or Build Tools..." -ForegroundColor Yellow +$vsInstallPath = $null +$vsVersions = @( + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Community", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Professional", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Enterprise", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\BuildTools", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\Community", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\Professional", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\Enterprise", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\BuildTools", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2019\Community", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2019\Professional", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2019\Enterprise", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2019\BuildTools" +) + +foreach ($path in $vsVersions) { + if (Test-Path $path) { + $vsInstallPath = $path + Write-Host "Found Visual Studio at: $vsInstallPath" -ForegroundColor Green + break + } +} + +if (-not $vsInstallPath) { + Write-Host "ERROR: Visual Studio 2019/2022 or Build Tools not found!" -ForegroundColor Red + Write-Host "Please install Visual Studio 2019/2022 or Build Tools with C++ desktop development workload." -ForegroundColor Yellow + exit 1 +} + +# Find MSBuild +$msbuildPath = Join-Path $vsInstallPath "MSBuild\Current\Bin\MSBuild.exe" +if (-not (Test-Path $msbuildPath)) { + $msbuildPath = Join-Path $vsInstallPath "MSBuild\15.0\Bin\MSBuild.exe" + if (-not (Test-Path $msbuildPath)) { + Write-Host "ERROR: MSBuild not found!" -ForegroundColor Red + exit 1 + } +} + +Write-Host "Using MSBuild: $msbuildPath" -ForegroundColor Green + +$needsWdk = ($Target -eq "All") +if ($needsWdk) { + Write-Host "" + Write-Host "Checking for Windows Driver Kit (WDK)..." -ForegroundColor Yellow + $wdkPaths = @( + "${env:ProgramFiles(x86)}\Windows Kits\10\Include", + "${env:ProgramFiles}\Windows Kits\10\Include" + ) + + $wdkFound = $false + foreach ($path in $wdkPaths) { + if (Test-Path $path) { + $wdkFound = $true + Write-Host "Found WDK at: $path" -ForegroundColor Green + break + } + } + + if (-not $wdkFound) { + Write-Host "WARNING: Windows Driver Kit (WDK) not found!" -ForegroundColor Yellow + Write-Host "The driver project requires WDK to build." -ForegroundColor Yellow + Write-Host "Download from: https://learn.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk" -ForegroundColor Yellow + Write-Host "Tip: use -Target Ext2Mgr or -Target Ext2Srv to build user-mode only (no WDK)." -ForegroundColor Cyan + Write-Host "" + if ([Environment]::UserInteractive -and -not [Console]::IsInputRedirected) { + $continue = Read-Host "Continue anyway? (y/N)" + if ($continue -ne "y" -and $continue -ne "Y") { + exit 1 + } + } else { + Write-Host "Non-interactive session: continuing without WDK (driver build may fail)." -ForegroundColor Yellow + } + } +} else { + Write-Host "Target ${Target}: WDK not required." -ForegroundColor Green +} + +$solutionPath = Join-Path $script:BuildRoot "Ext4Fsd.sln" +$solutionDir = Get-SolutionDirProperty +$msbuildPlatform = if ($Target -eq "All") { $Platform } else { Get-MSBuildPlatform $Platform } +$outArch = Get-OutArchFolder $Platform + +switch ($Target) { + "All" { + $projectPath = $solutionPath + if (-not (Test-Path $projectPath)) { + Write-Host "ERROR: Solution file not found: $projectPath" -ForegroundColor Red + exit 1 + } + $msbuildTarget = "Build" + $outputDirs = @( + "Ext4Fsd\$Configuration\$Platform", + "Ext2Srv\$Configuration\$outArch", + "Ext2Mgr\$Configuration\$outArch" + ) + } + "Ext2Mgr" { + $projectPath = Join-Path $script:BuildRoot "Ext2Mgr\Ext2Mgr.vcxproj" + if (-not (Test-Path $projectPath)) { + Write-Host "ERROR: Ext2Mgr project not found: $projectPath" -ForegroundColor Red + exit 1 + } + $msbuildTarget = "Build" + $outputDirs = @("Ext2Mgr\$Configuration\$outArch") + } + "Ext2Srv" { + $projectPath = Join-Path $script:BuildRoot "Ext2Srv\Ext2Srv.vcxproj" + if (-not (Test-Path $projectPath)) { + Write-Host "ERROR: Ext2Srv project not found: $projectPath" -ForegroundColor Red + exit 1 + } + $msbuildTarget = "Build" + $outputDirs = @("Ext2Srv\$Configuration\$outArch") + } +} + +Write-Host "" +Write-Host "Build Configuration:" -ForegroundColor Cyan +Write-Host " Target: $Target" -ForegroundColor White +Write-Host " Configuration: $Configuration" -ForegroundColor White +Write-Host " Platform: $Platform (MSBuild: $msbuildPlatform)" -ForegroundColor White +Write-Host " Project: $projectPath" -ForegroundColor White +Write-Host "" + +$commonProps = @( + "/p:Configuration=$Configuration" + "/p:Platform=$msbuildPlatform" + "/v:normal" + "/m" + "/nologo" +) +if ($Target -ne "All") { + $commonProps += "/p:SolutionDir=$solutionDir" +} + +if ($Clean) { + Write-Host "Cleaning..." -ForegroundColor Yellow + & $msbuildPath $projectPath (@("/t:Clean") + $commonProps + @("/v:minimal")) + if ($LASTEXITCODE -ne 0) { + Write-Host "WARNING: Clean failed, but continuing..." -ForegroundColor Yellow + } + Write-Host "" +} + +Write-Host "Building $Target..." -ForegroundColor Yellow +Write-Host "" + +$buildArgs = @($projectPath, "/t:$msbuildTarget") + $commonProps +& $msbuildPath @buildArgs + +if ($LASTEXITCODE -eq 0) { + Write-Host "" + $msg = " Build completed successfully!" + Write-Host ("=" * ($msg.Length + 1)) -ForegroundColor Green + Write-Host $msg -ForegroundColor Green + Write-Host ("=" * ($msg.Length + 1)) -ForegroundColor Green + Write-Host "" + + Write-Host "Output files:" -ForegroundColor Cyan + foreach ($dir in $outputDirs) { + $fullPath = Join-Path $script:BuildRoot $dir + if (Test-Path $fullPath) { + Write-Host " $fullPath" -ForegroundColor White + Get-ChildItem $fullPath -File | ForEach-Object { + Write-Host " - $($_.Name)" -ForegroundColor Gray + } + } + } + + if ($Target -eq "All") { + $driverPath = Join-Path $script:BuildRoot "Ext4Fsd\$Configuration\$Platform\Ext2Fsd.sys" + if (Test-Path $driverPath) { + Write-Host "" + $signScript = Join-Path $script:BuildRoot "Scripts\sign_driver.ps1" + if (Test-Path $signScript) { + Write-Host "Running post-build driver signing..." -ForegroundColor Cyan + & $signScript -DriverPath $driverPath -SkipIfMissing + } + } + + Write-Host "" + Write-Host "Next Steps:" -ForegroundColor Cyan + Write-Host " To sign the driver (optional):" -ForegroundColor Yellow + Write-Host " set EXT4FSD_CERT_PATH and MSIX_CERT_PASSWORD, then .\Scripts\sign_driver.ps1" -ForegroundColor White + Write-Host "" + Write-Host " To install the driver:" -ForegroundColor Yellow + Write-Host " .\Scripts\install_driver.ps1" -ForegroundColor White + Write-Host " (Requires administrator privileges)" -ForegroundColor Gray + Write-Host "" + Write-Host " See INSTALLATION.md for detailed instructions" -ForegroundColor Cyan + } elseif ($Target -eq "Ext2Mgr") { + $exePath = Join-Path $script:BuildRoot "Ext2Mgr\$Configuration\$outArch\Ext2Mgr.exe" + Write-Host "" + Write-Host "Run (as Administrator):" -ForegroundColor Cyan + Write-Host " $exePath" -ForegroundColor White + } elseif ($Target -eq "Ext2Srv") { + $exePath = Join-Path $script:BuildRoot "Ext2Srv\$Configuration\$outArch\Ext2Srv.exe" + Write-Host "" + Write-Host "Built:" -ForegroundColor Cyan + Write-Host " $exePath" -ForegroundColor White + } + +} else { + Write-Host "" + $msg = " Build failed!" + Write-Host ("=" * ($msg.Length + 1)) -ForegroundColor Red + Write-Host $msg -ForegroundColor Red + Write-Host ("=" * ($msg.Length + 1)) -ForegroundColor Red + exit 1 +} diff --git a/Scripts/release_usermode.ps1 b/Scripts/release_usermode.ps1 new file mode 100644 index 0000000..b451d73 --- /dev/null +++ b/Scripts/release_usermode.ps1 @@ -0,0 +1,446 @@ +<# +.SYNOPSIS + Build and package user-mode release binaries (Ext2Srv + ext2mgr_iced). + +.DESCRIPTION + Does NOT build or ship the Ext2Fsd kernel driver - leave that to upstream. + Builds only Ext2Srv (MSVC) and the iced GUI (Cargo), then copies them into + dist\usermode-\ with per-arch zips, SHA256 sums, and RELEASE_NOTES.txt. + + With no -Platforms, builds only the host architecture (x64 or ARM64). + With explicit -Platforms, every listed arch must have its toolchain or the + script exits without building and prints install help. + + Requirements: + - Visual Studio 2019/2022 (or Build Tools) with MSVC for the requested arch(es) + - For ARM64 Ext2Srv: MSVC v143 C++ ARM64/ARM64EC build tools + - Rust (rustup) with the matching *-pc-windows-msvc target(s) + - WDK is NOT required for this script + +.PARAMETER Version + Version string for folder/zip names. Default: git describe --tags --always, else date. + +.PARAMETER OutDir + Output root. Default: \dist + +.PARAMETER Platforms + Architectures to build: x64 and/or ARM64. + Default (omit this parameter): host architecture only. + +.PARAMETER SkipExt2Srv + Skip Ext2Srv MSBuild + +.PARAMETER SkipIced + Skip ext2mgr_iced cargo build + +.PARAMETER SkipZip + Do not create .zip archives + +.PARAMETER Clean + Clean MSBuild / cargo before building + +.EXAMPLE + .\Scripts\release_usermode.ps1 + Build Ext2Srv + iced for this machine's architecture only + +.EXAMPLE + .\Scripts\release_usermode.ps1 -Platforms x64,ARM64 + Build both arches (fails early if any toolchain is missing) + +.EXAMPLE + .\Scripts\release_usermode.ps1 -Version 0.1.0-preview1 -SkipZip +#> + +[CmdletBinding()] +param( + [string]$Version = "", + [string]$OutDir = "", + [ValidateSet("x64", "ARM64")] + [string[]]$Platforms = @(), + [switch]$SkipExt2Srv, + [switch]$SkipIced, + [switch]$SkipZip, + [switch]$Clean +) + +$ErrorActionPreference = "Stop" +$script:BuildRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$script:PlatformsExplicit = $PSBoundParameters.ContainsKey("Platforms") + +function Write-Header([string]$text) { + Write-Host "" + Write-Host ("=" * ($text.Length + 1)) -ForegroundColor Cyan + Write-Host $text -ForegroundColor Cyan + Write-Host ("=" * ($text.Length + 1)) -ForegroundColor Cyan +} + +function Get-HostReleasePlatform { + # PROCESSOR_ARCHITECTURE is the native OS arch (not WoW64 process arch). + $arch = [string]$env:PROCESSOR_ARCHITECTURE + switch -Regex ($arch) { + '^(ARM64|aarch64)$' { return "ARM64" } + '^(AMD64|x86_64)$' { return "x64" } + '^(x86)$' { + if ($env:PROCESSOR_ARCHITEW6432 -match '^(AMD64|x86_64)$') { + return "x64" + } + throw "32-bit x86 host is not supported by this release script." + } + default { + throw "Unrecognized PROCESSOR_ARCHITECTURE='$arch' (expected AMD64 or ARM64)." + } + } +} + +function Find-MSBuild { + $vsVersions = @( + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Community", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Professional", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Enterprise", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\BuildTools", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\Community", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\Professional", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\Enterprise", + "${env:ProgramFiles}\Microsoft Visual Studio\2019\BuildTools" + ) + foreach ($path in $vsVersions) { + $msbuild = Join-Path $path "MSBuild\Current\Bin\MSBuild.exe" + if (Test-Path -LiteralPath $msbuild) { + return $msbuild + } + } + return $null +} + +function Get-VSInstallRootFromMSBuild([string]$msbuildPath) { + # ...\MSBuild\Current\Bin\MSBuild.exe -> VS root + return (Split-Path (Split-Path (Split-Path (Split-Path $msbuildPath -Parent) -Parent) -Parent) -Parent) +} + +function Test-MsvcArm64Tools([string]$vsRoot) { + # Need MSBuild ARM64 platform props AND Hostx64\arm64 cl.exe under some MSVC version. + $platformDir = Join-Path $vsRoot "MSBuild\Microsoft\VC\v170\Platforms\ARM64" + if (-not (Test-Path -LiteralPath $platformDir)) { + return $false + } + $msvcRoot = Join-Path $vsRoot "VC\Tools\MSVC" + if (-not (Test-Path -LiteralPath $msvcRoot)) { + return $false + } + foreach ($verDir in Get-ChildItem -LiteralPath $msvcRoot -Directory) { + $cl = Join-Path $verDir.FullName "bin\Hostx64\arm64\cl.exe" + if (Test-Path -LiteralPath $cl) { + return $true + } + } + return $false +} + +function Write-Arm64ToolchainHelp([string]$vsRoot) { + Write-Host "" + Write-Host "ARM64 MSVC build tools are not installed in:" -ForegroundColor Red + Write-Host " $vsRoot" -ForegroundColor Yellow + Write-Host "" + Write-Host "Install component: MSVC v143 - VS 2022 C++ ARM64/ARM64EC build tools" -ForegroundColor Cyan + Write-Host " Visual Studio Installer -> Modify Build Tools -> Individual components -> search ARM64" -ForegroundColor White + Write-Host "" + Write-Host "Or from an elevated prompt (adjust InstallPath if needed):" -ForegroundColor Cyan + $setup = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\setup.exe" + Write-Host " Start-Process -FilePath `"$setup`" -ArgumentList 'modify','--installPath','$vsRoot','--add','Microsoft.VisualStudio.Component.VC.Tools.ARM64','--passive','--norestart' -Verb RunAs -Wait" -ForegroundColor White + Write-Host "" + Write-Host "Host-arch only (no -Platforms):" -ForegroundColor Cyan + Write-Host " .\Scripts\release_usermode.ps1" -ForegroundColor White +} + +function Resolve-ReleasePlatforms { + if (-not $script:PlatformsExplicit -or $Platforms.Count -eq 0) { + $hostPlatform = Get-HostReleasePlatform + Write-Host "Platforms: $hostPlatform (host default; pass -Platforms to select explicitly)" -ForegroundColor Gray + return ,@($hostPlatform) + } + # Dedupe while preserving order + $resolved = [System.Collections.Generic.List[string]]::new() + foreach ($platform in $Platforms) { + if (-not $resolved.Contains($platform)) { + $resolved.Add($platform) + } + } + Write-Host "Platforms: $($resolved -join ', ') (explicit)" -ForegroundColor Gray + return ,$resolved.ToArray() +} + +function Get-Ext2SrvOutDir([string]$configuration, [string]$platform) { + # Matches Ext2Srv.vcxproj OutDir (arm64 is lowercase in the project file) + $folder = switch ($platform) { + "x64" { "x64" } + "ARM64" { "arm64" } + default { $platform.ToLowerInvariant() } + } + return Join-Path $script:BuildRoot "Ext2Srv\$configuration\$folder" +} + +function Get-RustTarget([string]$platform) { + switch ($platform) { + "x64" { return "x86_64-pc-windows-msvc" } + "ARM64" { return "aarch64-pc-windows-msvc" } + default { throw "Unsupported platform for Rust: $platform" } + } +} + +function Resolve-Version { + if ($Version -and $Version.Trim().Length -gt 0) { + return $Version.Trim() + } + Push-Location $script:BuildRoot + try { + $described = & git describe --tags --always 2>$null + if ($LASTEXITCODE -eq 0 -and $described) { + return ($described.Trim() -replace '[\\/:*?"<>|]', '-') + } + } finally { + Pop-Location + } + return (Get-Date -Format "yyyyMMdd") +} + +function Write-Sha256Sums([string]$directory) { + $sumPath = Join-Path $directory "SHA256SUMS.txt" + $lines = @() + Get-ChildItem -LiteralPath $directory -File | + Where-Object { $_.Name -ne "SHA256SUMS.txt" } | + ForEach-Object { + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + $lines += "$hash $($_.Name)" + } + Set-Content -LiteralPath $sumPath -Value $lines -Encoding utf8 + Write-Host " wrote $sumPath" -ForegroundColor Gray +} + +function New-ReleaseNotes([string]$path, [string]$versionLabel) { + $notes = @" +Ext4Fsd fork - user-mode tools $versionLabel +============================================ + +This release packages **user-mode** binaries only: + +- Ext2Srv.exe - mount helper service (named pipe for Ext2Mgr / iced) +- ext2mgr_iced.exe - Rust + Iced Ext2/Ext4 volume manager GUI + +Architectures +------------- +- x64 -> Windows on x86_64 +- arm64 -> Windows on ARM64 (Snapdragon / Windows on Arm) + +Use the folder that matches your Windows install. Mixing arches (e.g. ARM64 GUI +with x64 Ext2Srv on ARM64 Windows) is not supported by this layout. + +What this does NOT include +-------------------------- +- The Ext2Fsd **kernel driver** (.sys) and installer +- Classic MFC Ext2Mgr.exe + +Install the signed driver from **upstream** (or your existing Ext2Fsd install), +then replace/add these user-mode tools on top if you want this fork's Ext2Srv / +iced improvements. + +Upstream driver / classic stack (example): + https://www.accum.se/~bosse/ext2fsd/ + +Requirements +------------ +- Windows 10/11 with Ext2Fsd driver already installed and working +- Matching-architecture Ext2Srv (install as service, usually elevated) +- For permanent mount modes / Ext2Fsd registry writes: run iced elevated +- Temporary letter ops normally need Ext2Srv running + +Install sketch (Administrator) +------------------------------ +1. Stop the existing Ext2Srv service if present: + sc.exe stop Ext2Srv +2. Copy the arch-matching Ext2Srv.exe over your install (often + "C:\Program Files\Ext2Fsd\Ext2Srv.exe"), or install via Ext2Srv's own + /installasservice if you use a fresh path. +3. Start the service: + sc.exe start Ext2Srv +4. Run ext2mgr_iced.exe (elevated when changing permanent mounts / services). + +Limits / caveats +---------------- +- These builds are **not** a full product installer. +- Binaries may be **unsigned**; Windows SmartScreen can warn on first run. +- This fork does not ship a re-signed kernel driver - driver trust stays with + upstream (or your own signing process). +- If the driver on the machine is an older build, some iced/Ext2Srv behaviors + may still depend on what that driver supports. + +Verify +------ +Each arch folder has SHA256SUMS.txt. Example: + + Get-FileHash .\Ext2Srv.exe -Algorithm SHA256 + +"@ + # UTF-8 no BOM preferred for notes consumed on multiple OSes + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($path, $notes.Replace("`n", "`r`n"), $utf8NoBom) + Write-Host " wrote $path" -ForegroundColor Gray +} + +Write-Header " Ext4Fsd user-mode release (Ext2Srv + iced)" + +$versionLabel = Resolve-Version +if (-not $OutDir) { + $OutDir = Join-Path $script:BuildRoot "dist" +} +$Platforms = Resolve-ReleasePlatforms +$stageRoot = Join-Path $OutDir "usermode-$versionLabel" +Write-Host "Repo: $script:BuildRoot" +Write-Host "Version: $versionLabel" +Write-Host "Out: $stageRoot" +Write-Host "" + +$msbuild = $null +$vsRoot = $null +if (-not $SkipExt2Srv) { + $msbuild = Find-MSBuild + if (-not $msbuild) { + Write-Host "ERROR: MSBuild not found (need VS 2019/2022 or Build Tools)." -ForegroundColor Red + exit 1 + } + Write-Host "MSBuild: $msbuild" -ForegroundColor Green + $vsRoot = Get-VSInstallRootFromMSBuild $msbuild + + # Whatever is in $Platforms (host default or explicit) must be buildable. + if (($Platforms -contains "ARM64") -and -not (Test-MsvcArm64Tools $vsRoot)) { + Write-Arm64ToolchainHelp $vsRoot + Write-Host "No build was started (fix the toolchain, then re-run)." -ForegroundColor Yellow + exit 2 + } +} + +if (-not $SkipIced) { + $cargo = Get-Command cargo -ErrorAction SilentlyContinue + if (-not $cargo) { + Write-Host "ERROR: cargo not found on PATH." -ForegroundColor Red + exit 1 + } + Write-Host "Cargo: $($cargo.Source)" -ForegroundColor Green +} + +New-Item -ItemType Directory -Force -Path $stageRoot | Out-Null +$notesPath = Join-Path $stageRoot "RELEASE_NOTES.txt" +New-ReleaseNotes -path $notesPath -versionLabel $versionLabel + +$ext2SrvProject = Join-Path $script:BuildRoot "Ext2Srv\Ext2Srv.vcxproj" +$icedDir = Join-Path $script:BuildRoot "ext2mgr_iced" +$solutionDir = $script:BuildRoot +if (-not $solutionDir.EndsWith("\")) { + $solutionDir += "\" +} + +foreach ($platform in $Platforms) { + Write-Header " Platform $platform" + + $archFolder = switch ($platform) { + "x64" { "x64" } + "ARM64" { "arm64" } + } + $archOut = Join-Path $stageRoot $archFolder + New-Item -ItemType Directory -Force -Path $archOut | Out-Null + + if (-not $SkipExt2Srv) { + Write-Host "Building Ext2Srv ($platform Release)..." -ForegroundColor Yellow + if ($Clean) { + & $msbuild $ext2SrvProject /t:Clean /p:Configuration=Release /p:Platform=$platform /p:SolutionDir=$solutionDir /v:minimal /nologo + } + & $msbuild $ext2SrvProject ` + /t:Build ` + /p:Configuration=Release ` + /p:Platform=$platform ` + /p:SolutionDir=$solutionDir ` + /v:minimal ` + /m ` + /nologo + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Ext2Srv build failed for $platform" -ForegroundColor Red + if ($platform -eq "ARM64") { + $vsRootHint = Get-VSInstallRootFromMSBuild $msbuild + Write-Arm64ToolchainHelp $vsRootHint + } + exit $LASTEXITCODE + } + $builtSrv = Join-Path (Get-Ext2SrvOutDir "Release" $platform) "Ext2Srv.exe" + if (-not (Test-Path -LiteralPath $builtSrv)) { + Write-Host "ERROR: missing $builtSrv" -ForegroundColor Red + exit 1 + } + Copy-Item -LiteralPath $builtSrv -Destination (Join-Path $archOut "Ext2Srv.exe") -Force + Write-Host " staged Ext2Srv.exe" -ForegroundColor Green + } + + if (-not $SkipIced) { + $rustTarget = Get-RustTarget $platform + Write-Host "Ensuring Rust target $rustTarget ..." -ForegroundColor Yellow + & rustup target add $rustTarget + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: rustup target add failed for $rustTarget" -ForegroundColor Red + exit $LASTEXITCODE + } + Write-Host "Building ext2mgr_iced ($rustTarget release)..." -ForegroundColor Yellow + Push-Location $icedDir + try { + $cargoArgs = @("build", "--release", "--target", $rustTarget) + if ($Clean) { + & cargo clean --release --target $rustTarget + } + & cargo @cargoArgs + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: cargo build failed for $rustTarget" -ForegroundColor Red + exit $LASTEXITCODE + } + } finally { + Pop-Location + } + $builtIced = Join-Path $icedDir "target\$rustTarget\release\ext2mgr_iced.exe" + if (-not (Test-Path -LiteralPath $builtIced)) { + Write-Host "ERROR: missing $builtIced" -ForegroundColor Red + exit 1 + } + Copy-Item -LiteralPath $builtIced -Destination (Join-Path $archOut "ext2mgr_iced.exe") -Force + Write-Host " staged ext2mgr_iced.exe" -ForegroundColor Green + } + + Write-Sha256Sums $archOut + + if (-not $SkipZip) { + $zipPath = Join-Path $stageRoot "Ext4Fsd-usermode-$versionLabel-$archFolder.zip" + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + # Zip arch folder contents + top-level RELEASE_NOTES + $zipStage = Join-Path $env:TEMP "ext4fsd-usermode-zip-$archFolder" + if (Test-Path -LiteralPath $zipStage) { + Remove-Item -LiteralPath $zipStage -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $zipStage | Out-Null + Copy-Item -LiteralPath (Join-Path $archOut "*") -Destination $zipStage -Force + Copy-Item -LiteralPath $notesPath -Destination $zipStage -Force + Compress-Archive -Path (Join-Path $zipStage "*") -DestinationPath $zipPath -Force + Remove-Item -LiteralPath $zipStage -Recurse -Force + Write-Host " zip $zipPath" -ForegroundColor Green + } +} + +Write-Header " Done" +Write-Host "Artifacts under: $stageRoot" -ForegroundColor Green +Get-ChildItem -LiteralPath $stageRoot -Recurse -File | ForEach-Object { + $rel = $_.FullName.Substring($stageRoot.Length).TrimStart("\") + Write-Host (" {0,12} {1}" -f $_.Length, $rel) -ForegroundColor Gray +} +Write-Host "" +Write-Host "Attach the per-arch .zip files (and RELEASE_NOTES.txt) to your GitHub/GitLab release." -ForegroundColor Cyan +Write-Host "Driver remains upstream - this package is user-mode only." -ForegroundColor Yellow From df414c847623e584e180a05db16209822d312af3 Mon Sep 17 00:00:00 2001 From: Obisidan Jackal Date: Wed, 29 Jul 2026 12:50:51 -0400 Subject: [PATCH 4/5] fix(ext2mgr_iced): Ext2Srv mounts, no surprise auto-letters, stable volume list Match classic Vista+ DefineDosDevice via Ext2Srv so Temporary letters are visible to Explorer (elevated local DefineDosDevice was session-private). Stop surprise remounts: do not run pending automount on startup/refresh or after Assign/Remove; only persist Volumes\{UUID} for Ext2Automount mode. Preserve unlettered EXT volumes (FS type / HarddiskVolumeN) after Remove, refuse HarddiskN\PartitionM mount targets, scrub bad Session Manager entries. Sort volumes by HarddiskVolume number and re-sort after synthesizing unlettered rows so volumes do not jump to the bottom on Remove. Letter picker: default persistence from the current letter's real source; explorer notify on the UI thread after assign/remove. Bump to 0.2.0; About and winres versions come from CARGO_PKG_VERSION. --- ext2mgr_iced/Cargo.lock | 2 +- ext2mgr_iced/Cargo.toml | 2 +- ext2mgr_iced/PORT_IMPROVEMENTS.md | 4 +- ext2mgr_iced/README.md | 25 +- ext2mgr_iced/build.rs | 7 + ext2mgr_iced/src/disk/enum_disk.rs | 293 ++++++++++++- ext2mgr_iced/src/mount/ops.rs | 680 +++++++++++++++++++++++------ ext2mgr_iced/src/mount/persist.rs | 42 ++ ext2mgr_iced/src/ui/helpers.rs | 184 ++++++-- ext2mgr_iced/src/ui/mod.rs | 4 +- ext2mgr_iced/src/ui/types.rs | 5 + ext2mgr_iced/src/ui/update.rs | 235 ++++++---- ext2mgr_iced/src/ui/view.rs | 34 +- 13 files changed, 1236 insertions(+), 281 deletions(-) diff --git a/ext2mgr_iced/Cargo.lock b/ext2mgr_iced/Cargo.lock index 881dfd2..9f637f8 100644 --- a/ext2mgr_iced/Cargo.lock +++ b/ext2mgr_iced/Cargo.lock @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "ext2mgr_iced" -version = "0.1.0" +version = "0.2.0" dependencies = [ "iced", "image", diff --git a/ext2mgr_iced/Cargo.toml b/ext2mgr_iced/Cargo.toml index e2ea89c..ad23b56 100644 --- a/ext2mgr_iced/Cargo.toml +++ b/ext2mgr_iced/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ext2mgr_iced" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Ext2/Ext4 volume manager GUI (Iced port)" diff --git a/ext2mgr_iced/PORT_IMPROVEMENTS.md b/ext2mgr_iced/PORT_IMPROVEMENTS.md index 9a1a9ca..8365e60 100644 --- a/ext2mgr_iced/PORT_IMPROVEMENTS.md +++ b/ext2mgr_iced/PORT_IMPROVEMENTS.md @@ -57,6 +57,8 @@ Original uses classic Win32 highlight colors. **Modes are not interchangeable:** Mount Manager is `SetVolumeMountPoint`. Session Manager is the registry option. Temporary is DefineDosDevice-only. Classic Session Manager also does an immediate DefineDosDevice bind after writing the key; that bind is not the Temporary option. Device path for Temporary / Session Manager is `\Device\HarddiskVolumeN`. +Iced letter remove/assign extras vs classic: skip `DeleteVolumeMountPoint` unless Mount Manager owns the letter; Exact-match remove uses live `QueryDosDevice` (`\Device\…`); Session Manager Change same-letter is registry-only (no tear-down); automount order is Session Manager → `Volumes\{UUID}` → `EVP.DrvLetter`; Explorer notify via `WM_DEVICECHANGE` + `SHChangeNotify`. + ### Remove Dead Letters | | Original | Iced port | @@ -100,7 +102,7 @@ Historically Ext2Srv did: create one pipe → `ConnectNamedPipe` → hand off to | 1 | Ext2Srv | **Dual idle listeners** | Done — second `Ext2PipeEngine` thread in `Ext2StartPipeSrv` | | 2 | Ext2Srv | **Fail soft on create** | Done — create retries capped (3×50ms) instead of long sleep storms | | 3 | iced | **Keep-alive client** | Done — `pipe::with_shared_client` reuses one handle | -| 4 | iced | **Off UI thread** | Partial — mount/unmount + dead-letter scan via `spawn_blocking`; still sync on UI: disk refresh/`enumerate_all`, Ext2 attr save, service start/stop, and modal `MessageBox` | +| 4 | iced | **Off UI thread** | Partial — mount/unmount (incl. Mount Points **Remove**), dead-letter scan via `spawn_blocking`; still sync on UI: disk refresh/`enumerate_all`, Ext2 attr save, service start/stop, and modal `MessageBox` | | 5 | iced / policy | **Local `DefineDosDevice` when elevated** | Done — try local first when elevated, else Ext2Srv | | 6 | iced | **Letter assign device path = `\Device\HarddiskVolumeN`** | Done — use `physical_object` like Ext2Mgr `Volume->Name` for Temporary DefineDosDevice and Session Manager registry values | | 7 | iced + Ext2Srv | **Health ping** | Done — `pipe::health_check` / Refresh status uses reused connection | diff --git a/ext2mgr_iced/README.md b/ext2mgr_iced/README.md index 0d33caf..cb85fcf 100644 --- a/ext2mgr_iced/README.md +++ b/ext2mgr_iced/README.md @@ -22,8 +22,8 @@ Four distinct options (do not conflate): | Mode | Survives reboot? | What it is | |------|------------------|------------| | Temporary | **No** | `DefineDosDevice` only (Ext2Srv, or local when elevated) | -| Mount Manager | **Yes** | `SetVolumeMountPoint` — same mechanism Windows Disk Management uses. Needs a Win32 volume GUID. Needs Administrator. | -| Session Manager DOS Devices | **Yes** | Registry `HKLM\...\Session Manager\DOS Devices` (`X:` → `\Device\HarddiskVolumeN`). Immediate `DefineDosDevice` bind afterward so the letter appears now — that bind is not the Temporary option. Needs Administrator. | +| Mount Manager | **Yes** | `SetVolumeMountPoint` - same mechanism Windows Disk Management uses. Needs a Win32 volume GUID. Needs Administrator. | +| Session Manager DOS Devices | **Yes** | Registry `HKLM\...\Session Manager\DOS Devices` (`X:` -> `\Device\HarddiskVolumeN`). Immediate `DefineDosDevice` bind afterward so the letter appears now - that bind is not the Temporary option. Needs Administrator. | | Ext2Fsd automount | **Config yes** | `HKLM\...\Ext2Fsd\Volumes\{UUID}` with `MountPoint=X:;`. Remounted on load/refresh. Needs EXT UUID + Administrator. | Device paths match Ext2Mgr (`Volume->Name` = `\Device\HarddiskVolumeN`). Mount Manager is disabled in the letter picker when the volume has no Win32 GUID. @@ -33,7 +33,28 @@ Device paths match Ext2Mgr (`Volume->Name` = `\Device\HarddiskVolumeN`). Mount M ```bash cargo build --release cargo run +``` + +**Or** simply use: + +``` cargo run --release ``` From `ext2mgr_iced/`. Run **elevated** for permanent registry / Ext2Fsd Volumes / service parameter writes. Ext2Srv must be running for temporary letter ops (unless elevated local `DefineDosDevice` succeeds). + +### Release binaries (x64 / ARM64) + +From the repo root, package Ext2Srv + this GUI (user-mode only; driver stays upstream): + +```powershell +.\Scripts\release_usermode.ps1 +``` + +Default builds **this machine's architecture only**. For both arches (needs *MSVC v143 - VS 2022 C++ ARM64/ARM64EC build tools*): + +```powershell +.\Scripts\release_usermode.ps1 -Platforms x64,ARM64 +``` + +Also needs `rustup` targets for each requested arch. Output: `dist\usermode-\` with per-arch zips and `RELEASE_NOTES.txt`. diff --git a/ext2mgr_iced/build.rs b/ext2mgr_iced/build.rs index 3cd1549..d98a823 100644 --- a/ext2mgr_iced/build.rs +++ b/ext2mgr_iced/build.rs @@ -2,6 +2,13 @@ fn main() { let mut resource = winres::WindowsResource::new(); resource.set_icon("assets/Ext2Mgr.ico"); resource.set_manifest_file("app.manifest"); + // Keep FileVersion / ProductVersion in sync with Cargo.toml `[package].version`. + let pkg = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string()); + let file_ver = format!("{}.0", pkg); + resource.set("FileVersion", &file_ver); + resource.set("ProductVersion", &file_ver); + resource.set("ProductName", "Ext2 Volume Manager"); + resource.set("FileDescription", "Ext2/Ext4 volume manager (Iced)"); if let Err(err) = resource.compile() { println!("cargo:warning=winres failed: {err}"); } diff --git a/ext2mgr_iced/src/disk/enum_disk.rs b/ext2mgr_iced/src/disk/enum_disk.rs index 24ab4fc..99c300d 100644 --- a/ext2mgr_iced/src/disk/enum_disk.rs +++ b/ext2mgr_iced/src/disk/enum_disk.rs @@ -94,9 +94,19 @@ mod win_impl { } pub fn enumerate_all() -> (Vec, Vec, Vec) { - let volumes = enumerate_volumes(); + let mut volumes = enumerate_volumes(); let mut disks = enumerate_disks(); link_partitions_to_volumes(&mut disks, &volumes); + // Ext2 DefineDosDevice volumes vanish from FindFirstVolume when unlettered. + // Probe the partition on-disk so FS type / UUID survive Remove-all-letters. + fill_missing_partition_filesystems(&mut disks); + let before = volumes.len(); + append_volumes_for_unlinked_ext_partitions(&disks, &mut volumes); + if volumes.len() != before { + // Synthesized rows are appended; re-sort so Volume7 does not sink to bottom. + sort_volumes_by_harddisk_number(&mut volumes); + link_partitions_to_volumes(&mut disks, &volumes); + } let rows = build_disk_rows(&disks); (disks, volumes, rows) } @@ -336,11 +346,13 @@ mod win_impl { // point, so FindFirstVolume never saw them. Synthesize those orphans. synthesize_volumes_for_orphan_letters(&mut volumes); for volume in volumes.iter_mut() { - if !volume.letters.is_empty() { - let probe_name = if volume.win32_volume_name.is_empty() { - format!(r"\\.\{}:", volume.letters[0]) - } else { + // Letter-less Win32 volumes must still keep FS (after Remove all letters). + let can_probe = !volume.letters.is_empty() || !volume.win32_volume_name.is_empty(); + if can_probe { + let probe_name = if !volume.win32_volume_name.is_empty() { volume.win32_volume_name.clone() + } else { + format!(r"\\.\{}:", volume.letters[0]) }; let (filesystem, total_bytes, used_bytes, uuid) = detect_filesystem_and_sizes(&probe_name, &volume.letters); @@ -368,13 +380,38 @@ mod win_impl { volume.codepage = resolve_volume_codepage(volume); } + sort_volumes_by_harddisk_number(&mut volumes); + volumes + } + + fn sort_volumes_by_harddisk_number(volumes: &mut [VolumeEntry]) { volumes.sort_by(|left, right| { - left.letters - .first() - .cmp(&right.letters.first()) + harddisk_volume_number(&left.physical_object) + .or_else(|| harddisk_volume_number(&left.symlink)) + .cmp( + &harddisk_volume_number(&right.physical_object) + .or_else(|| harddisk_volume_number(&right.symlink)), + ) + .then_with(|| left.letters.first().cmp(&right.letters.first())) .then_with(|| left.symlink.cmp(&right.symlink)) }); - volumes + } + + /// Parse N from `\Device\HarddiskVolumeN` as an integer. + /// String compare would order Volume10 before Volume6. + fn harddisk_volume_number(path: &str) -> Option { + let upper = path.to_ascii_uppercase(); + let marker = "HARDDISKVOLUME"; + let start = upper.find(marker)? + marker.len(); + let digits: String = upper[start..] + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect(); + if digits.is_empty() { + None + } else { + digits.parse().ok() + } } fn enumerate_disks() -> Vec { @@ -739,6 +776,189 @@ mod win_impl { } } + /// Open PhysicalDrive with read access (layout IOCTLs may use access=0). + fn open_physical_drive_readable( + disk_index: u32, + ) -> Option { + let path = format!(r"\\.\PhysicalDrive{disk_index}"); + let wide = to_wide(&path); + const GENERIC_READ: u32 = 0x8000_0000; + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + GENERIC_READ, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + None + } else { + Some(handle) + } + } + + fn probe_filesystem_at_disk_offset( + disk_index: u32, + starting_offset: u64, + ) -> Option<(String, Option<[u8; 16]>)> { + let handle = open_physical_drive_readable(disk_index)?; + let mut first = vec![0u8; 4096]; + let ok = read_at(handle, starting_offset, &mut first); + if !ok { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return None; + } + if let Some(result) = crate::disk::fs_probe::probe_first_page(&first) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return Some(result); + } + let mut btrfs = vec![0u8; 4096]; + if read_at(handle, starting_offset + BTRFS_SUPER_BLOCK_OFFSET, &mut btrfs) { + if let Some(name) = crate::disk::fs_probe::probe_btrfs_page(&btrfs) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return Some((name, None)); + } + } + let mut raid = vec![0u8; 4096]; + if read_at(handle, starting_offset + RAID_SUPER_BLOCK_OFFSET, &mut raid) { + if let Some(name) = crate::disk::fs_probe::probe_raid_page(&raid) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return Some((name, None)); + } + } + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + None + } + + fn fill_missing_partition_filesystems(disks: &mut [DiskEntry]) { + for disk in disks.iter_mut() { + for partition in disk.partitions.iter_mut() { + if !partition.filesystem.is_empty() + && !partition.filesystem.eq_ignore_ascii_case("RAW") + { + continue; + } + let Some((filesystem, uuid)) = + probe_filesystem_at_disk_offset(disk.index, partition.starting_offset) + else { + continue; + }; + partition.filesystem = filesystem; + if let Some(harddisk_volume) = find_harddisk_volume_for_extent( + disk.index, + partition.starting_offset, + partition.length, + ) { + partition.symlink = harddisk_volume; + } else if partition.symlink.is_empty() { + // Display-only fallback — never use for DefineDosDevice / Session Manager. + partition.symlink = format!( + r"\Device\Harddisk{}\Partition{}", + disk.index, partition.number + ); + } + let _ = uuid; // applied when synthesizing the volume row + } + } + } + + /// Map a partition extent to `\Device\HarddiskVolumeN` (required by Ext2Fsd / + /// Ext2Mgr for DefineDosDevice and Session Manager). Never invent + /// `HarddiskN\PartitionM` as a mount target — those "succeed" in registry and + /// leave Explorer-dead letters. + fn find_harddisk_volume_for_extent( + disk_number: u32, + starting_offset: u64, + extent_length: u64, + ) -> Option { + for index in 1u32..128 { + let open = format!(r"\\?\GLOBALROOT\Device\HarddiskVolume{index}"); + let Some(extents) = volume_extents_from_path(&open) else { + continue; + }; + if extents.iter().any(|extent| { + extent.disk_number == disk_number + && extent.starting_offset == starting_offset + && extent.extent_length == extent_length + }) { + return Some(format!(r"\Device\HarddiskVolume{index}")); + } + } + None + } + + /// Restore volume rows for EXT partitions that only existed via orphan letters. + fn append_volumes_for_unlinked_ext_partitions( + disks: &[DiskEntry], + volumes: &mut Vec, + ) { + for disk in disks { + for partition in &disk.partitions { + if partition.volume_index.is_some() { + continue; + } + let fs_upper = partition.filesystem.to_ascii_uppercase(); + if !(fs_upper.contains("EXT2") + || fs_upper.contains("EXT3") + || fs_upper.contains("EXT4")) + { + continue; + } + let already = volumes.iter().any(|volume| { + volume.extents.iter().any(|extent| { + extent.disk_number == disk.index + && extent.starting_offset == partition.starting_offset + && extent.extent_length == partition.length + }) + }); + if already { + continue; + } + let Some(physical) = find_harddisk_volume_for_extent( + disk.index, + partition.starting_offset, + partition.length, + ) else { + continue; + }; + let uuid = probe_filesystem_at_disk_offset(disk.index, partition.starting_offset) + .and_then(|(_, uuid)| uuid); + volumes.push(VolumeEntry { + letters: Vec::new(), + volume_kind: "Basic".to_string(), + filesystem: partition.filesystem.clone(), + total_bytes: partition.total_bytes, + used_bytes: None, + codepage: partition.codepage.clone(), + physical_object: physical.clone(), + symlink: physical, + win32_volume_name: partition.win32_volume_name.clone(), + extents: vec![DiskExtent { + disk_number: disk.index, + starting_offset: partition.starting_offset, + extent_length: partition.length, + }], + uuid, + }); + } + } + } + fn build_disk_rows(disks: &[DiskEntry]) -> Vec { let mut rows = Vec::new(); for (disk_index, disk) in disks.iter().enumerate() { @@ -1664,6 +1884,61 @@ mod win_impl { }) } + /// Ext2Mgr `Ext2ProcessExt2Property` — `EVP.DrvLetter` (low 7 bits), if set. + pub fn query_volume_fixed_drv_letter(volume: &VolumeEntry) -> Option { + if !ext2fsd_device_present() { + return None; + } + for open_path in volume_ext2_open_paths(volume) { + if let Some(letter) = ioctl_query_ext2_drv_letter(&open_path) { + return Some(letter); + } + } + None + } + + fn ioctl_query_ext2_drv_letter(open_path: &str) -> Option { + // EXT2_VOLUME_PROPERTY2: base 48 + UUID[16] + DrvLetter at offset 64. + const IOCTL_APP_VOLUME_PROPERTY: u32 = 0x0022_1F40; + const EXT2_VOLUME_PROPERTY_MAGIC: u32 = 0x4556_504D; // 'EVPM' + const APP_CMD_QUERY_PROPERTY2: u32 = 0x0000_0004; + const DRV_LETTER_OFF: usize = 64; + // sizeof(EXT2_VOLUME_PROPERTY2) ≈ 132; PROPERTY3 is larger — allocate room. + const PROP_SIZE: usize = 256; + + let handle = create_file_generic_read(open_path)?; + let mut buffer = vec![0u8; PROP_SIZE]; + buffer[0..4].copy_from_slice(&EXT2_VOLUME_PROPERTY_MAGIC.to_le_bytes()); + buffer[8..12].copy_from_slice(&APP_CMD_QUERY_PROPERTY2.to_le_bytes()); + + let mut bytes_returned = 0u32; + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_APP_VOLUME_PROPERTY, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if ok == 0 || (bytes_returned as usize) <= DRV_LETTER_OFF { + return None; + } + let raw = buffer[DRV_LETTER_OFF] & 0x7F; + let letter = char::from(raw); + if letter.is_ascii_alphabetic() { + Some(letter.to_ascii_uppercase()) + } else { + None + } + } + fn registry_volume_codepage(uuid: &[u8; 16]) -> Option { let value_name = format_ext2_uuid(uuid); let key_path = r"SYSTEM\CurrentControlSet\Services\Ext2Fsd\Volumes"; diff --git a/ext2mgr_iced/src/mount/ops.rs b/ext2mgr_iced/src/mount/ops.rs index 57bc25a..3987c3e 100644 --- a/ext2mgr_iced/src/mount/ops.rs +++ b/ext2mgr_iced/src/mount/ops.rs @@ -72,13 +72,27 @@ pub fn dos_device_target(physical_object: &str, symlink: &str) -> String { } pub fn mount_volume(request: &MountRequest) -> Result { - match request.mode { + if request.symlink.contains(r"\Partition") + && !request.symlink.to_ascii_uppercase().contains(r"\HARDDISKVOLUME") + { + return Err(format!( + "Refusing drive letter {}: target {} is a partition path, not \ + \\Device\\HarddiskVolumeN. Remount / refresh so Ext2Fsd exposes the volume.", + request.letter, request.symlink + )); + } + // Classic AddMountPoint: try Ext2SetExt2Property before Assign, but still + // DefineDosDevice when QUERY/SET fails — unlettered RAW volumes are not yet + // Ext2Fsd VCBs (QUERY_PROPERTY3 → ERROR_INVALID_PARAMETER / 87). + let _ = set_driver_automount_letter(request); + + let message = match request.mode { MountMode::Temporary => { define_temporary(request.letter, &request.symlink)?; - Ok(format!( + format!( "Mounted {}: temporary via DefineDosDevice (lost after reboot)", request.letter - )) + ) } MountMode::MountManager => { assign_via_mount_manager( @@ -86,20 +100,35 @@ pub fn mount_volume(request: &MountRequest) -> Result { &request.symlink, &request.win32_volume_name, )?; - Ok(format!( + // Explorer notify is done on the iced UI thread (PipeOpFinished). + format!( "Mounted {}: permanent via Mount Manager (SetVolumeMountPoint)", request.letter - )) + ) } MountMode::PermanentRegistry => { // Registry is the Session Manager option; DefineDosDevice is only the // immediate bind (same sequence as Ext2Mgr AddMountPoint + bRegistry). - super::persist::set_registry_mount_point(request.letter, &request.symlink)?; - define_temporary(request.letter, &request.symlink)?; - Ok(format!( - "Mounted {}: Session Manager DOS Devices (registry; immediate DefineDosDevice bind)", - request.letter - )) + // If the letter already targets this device, only write the registry — + // classic still EndDialog after SetRegistry even when Assign is a no-op. + let reg_result = + super::persist::set_registry_mount_point(request.letter, &request.symlink); + reg_result?; + let already_live = query_dos_device(request.letter) + .is_some_and(|live| live.eq_ignore_ascii_case(&request.symlink)); + if !already_live { + define_temporary(request.letter, &request.symlink)?; + } + // Explorer notify is done on the iced UI thread (PipeOpFinished). + format!( + "Mounted {}: Session Manager DOS Devices (registry{})", + request.letter, + if already_live { + "; letter already live" + } else { + "; immediate DefineDosDevice bind" + } + ) } MountMode::Ext2Automount => { let uuid = request.uuid.ok_or_else(|| { @@ -112,54 +141,150 @@ pub fn mount_volume(request: &MountRequest) -> Result { request.readonly, )?; define_temporary(request.letter, &request.symlink)?; - Ok(format!( + format!( "Mounted {}: Ext2Fsd automount saved under Volumes\\{} (persists; remounted on refresh)", request.letter, super::persist::format_volume_uuid(&uuid) - )) + ) } + }; + // Do not call MountMgr VOLUME_ARRIVAL_NOTIFICATION here — classic + // Ext2AssignDrvLetter(Temporary) does not, and ArrivalNotify can invent a + // second Mount Manager letter (G:/H:) beside the one the user chose. + // After DefineDosDevice, Ext2Fsd may have claimed the volume — retry SET_PROPERTY3 + // via \\.\X: (classic path when the volume is already an Ext2 VCB). + let _ = set_driver_automount_letter(request); + let attrs = letter_root_attrs(request.letter); + if attrs.starts_with("fail") { + return Err(format!( + "Drive letter {0}: is bound to {1} but {0}:\\ is not accessible ({attrs}). \ + Ext2Fsd did not mount a usable filesystem (compare with classic Ext2Mgr).", + request.letter, request.symlink + )); } + // Volumes\{UUID} is written only by MountMode::Ext2Automount (and classic + // AddMountPoint registry store) — not for Temporary / Mount Manager / Session. + Ok(message) +} + +/// Remove a drive letter. Only calls `DeleteVolumeMountPoint` when `win32_volume_name` +/// is set and Mount Manager actually lists this letter for that GUID — never open +/// `X:\` for pure Ext2 DefineDosDevice / Session Manager binds (hangs / freezes). +/// +/// Prefer the live `QueryDosDevice` target over `symlink_hint`. Volume rows often +/// carry `\??\Volume{GUID}` while Temporary/Session binds use `\Device\HarddiskVolumeN`; +/// `DDD_EXACT_MATCH_ON_REMOVE` with the GUID path fails instantly with +/// "Failed to remove drive letter". +pub fn unmount_letter( + letter: char, + symlink_hint: &str, + win32_volume_name: &str, +) -> Result<(), String> { + unmount_letter_ex(letter, symlink_hint, win32_volume_name, None) } -pub fn unmount_letter(letter: char, symlink_hint: &str) -> Result<(), String> { +pub fn unmount_letter_ex( + letter: char, + symlink_hint: &str, + win32_volume_name: &str, + volume_uuid: Option<[u8; 16]>, +) -> Result<(), String> { let _ = super::persist::clear_registry_mount_point(letter); + if let Some(uuid) = volume_uuid { + if super::persist::query_ext2_automount_letter(&uuid) == Some(letter.to_ascii_uppercase()) + { + let _ = super::persist::clear_ext2_automount_mount_point(&uuid); + } + } - let mount_root = format!(r"{letter}:\"); - let _ = delete_volume_mount_point(&mount_root); + let letter_upper = letter.to_ascii_uppercase(); + let mountmgr_owns = !win32_volume_name.is_empty() + && super::dead_letters::mountmgr_letters_for_volume(win32_volume_name) + .contains(&letter_upper); + if mountmgr_owns { + let mount_root = format!(r"{letter}:\"); + let _ = delete_volume_mount_point(&mount_root); + } - if is_process_elevated() { - let symlink = if !symlink_hint.is_empty() { - symlink_hint.to_string() - } else { - query_dos_device(letter).unwrap_or_default() - }; - if !symlink.is_empty() && define_dos_device_local_remove(letter, &symlink) { - if query_dos_device(letter).is_none() { - return Ok(()); - } + if query_dos_device(letter).is_none() { + return finish_unmount_ok(letter); + } + + let mut targets = Vec::new(); + if let Some(live) = query_dos_device(letter) { + push_unique_target(&mut targets, live); + } + if !symlink_hint.is_empty() { + push_unique_target(&mut targets, symlink_hint.to_string()); + } + if let Ok(query) = super::pipe::with_shared_client(|client| client.query_drive(letter as u8)) { + if query.result && !query.symlink.is_empty() { + push_unique_target(&mut targets, query.symlink); } } - let symlink = super::pipe::with_shared_client(|client| { - match client.query_drive(letter as u8) { - Ok(query) if query.result && !query.symlink.is_empty() => Ok(query.symlink), - _ if !symlink_hint.is_empty() => Ok(symlink_hint.to_string()), - _ => Ok(query_dos_device(letter).unwrap_or_default()), + // Vista+: classic removes via Ext2Srv first (global DOS devices). Local + // DefineDosDevice only affects the elevated session. + let mut last_pipe_err = None; + for target in &targets { + match super::pipe::with_shared_client(|client| { + client.remove_drive(letter as u8, target) + }) { + Ok(true) | Ok(false) if query_dos_device(letter).is_none() => { + return finish_unmount_ok(letter); + } + Ok(_) => {} + Err(err) => { + if query_dos_device(letter).is_none() { + return finish_unmount_ok(letter); + } + last_pipe_err = Some(err.to_string()); + } } - }) - .map_err(|err| format!("Failed to remove drive letter {letter}:\nExt2Srv pipe: {err}"))?; - if symlink.is_empty() { - return Err(format!("Failed to remove drive letter {letter}:")); } - let ok = super::pipe::with_shared_client(|client| client.remove_drive(letter as u8, &symlink)) - .map_err(|err| format!("Failed to remove drive letter {letter}:\n{err}"))?; - if ok { - Ok(()) - } else if query_dos_device(letter).is_none() { - Ok(()) + for target in &targets { + if define_dos_device_local_remove(letter, target) && query_dos_device(letter).is_none() { + return finish_unmount_ok(letter); + } + } + // Same loose fallback as dead-letter remove (no EXACT_MATCH). + if define_dos_device_local_remove_loose(letter) && query_dos_device(letter).is_none() { + return finish_unmount_ok(letter); + } + + if query_dos_device(letter).is_none() { + finish_unmount_ok(letter) + } else if let Some(err) = last_pipe_err { + Err(format!("Failed to remove drive letter {letter}:\n{err}")) } else { - Err(format!("Failed to remove drive letter {letter}:")) + let tried = if targets.is_empty() { + "(no DOS target)".to_string() + } else { + targets.join(", ") + }; + Err(format!( + "Failed to remove drive letter {letter}:\n(tried DefineDosDevice/Ext2Srv against {tried})" + )) + } +} + +fn finish_unmount_ok(letter: char) -> Result<(), String> { + // Explorer notify is done on the iced UI thread (PipeOpFinished). + let _ = letter; + Ok(()) +} + +fn push_unique_target(targets: &mut Vec, candidate: String) { + if candidate.is_empty() { + return; } + if targets + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&candidate)) + { + return; + } + targets.push(candidate); } pub fn pipe_status_str() -> String { @@ -170,20 +295,264 @@ pub fn pipe_status_str() -> String { } fn define_temporary(letter: char, symlink: &str) -> Result<(), String> { - // Same order as Ext2Mgr `Ext2DefineDosDevice`: local when elevated, else Ext2Srv. - if is_process_elevated() && define_dos_device_local(letter, symlink) { - return Ok(()); - } - let ok = super::pipe::with_shared_client(|client| client.define_drive(letter as u8, symlink)).map_err( - |err| format!("Ext2Fsd service is not started.\n(Ext2Srv pipe: {err})"), - )?; + // Classic `Ext2DefineDosDevice` / `CanDoLocalMount()`: on Vista+ always use + // Ext2Srv. Elevated `DefineDosDevice` without the service is session-private + // and invisible to Explorer (medium IL). + let ok = super::pipe::with_shared_client(|client| client.define_drive(letter as u8, symlink)) + .map_err(|err| format!("Ext2Fsd service is not started.\n(Ext2Srv pipe: {err})"))?; + let query = query_dos_device(letter).unwrap_or_default(); if ok { + if query.is_empty() { + return Err(format!( + "Failed to assign new drive letter {letter}: Ext2Srv reported success but QueryDosDevice is empty" + )); + } + // Explorer notify is done on the iced UI thread (PipeOpFinished). Ok(()) } else { Err(format!("Failed to assign new drive letter {letter}:")) } } +/// Ext2Mgr `Ext2DrvNotify` — call from the **UI thread** after assign/remove. +pub fn notify_explorer_letter(letter: char, arrival: bool) { + drv_notify(letter, arrival); +} + +/// Ext2Mgr `Ext2DrvNotify` — wake Explorer after DefineDosDevice add/remove. +fn drv_notify(letter: char, arrival: bool) { + #[repr(C)] + struct DevBroadcastVolume { + size: u32, + device_type: u32, + reserved: u32, + unitmask: u32, + flags: u16, + } + + const DBT_DEVTYP_VOLUME: u32 = 0x0000_0002; + const DBTF_NET: u16 = 0x0001; + const DBT_DEVICEARRIVAL: usize = 0x8000; + const DBT_DEVICEREMOVECOMPLETE: usize = 0x8004; + const WM_DEVICECHANGE: u32 = 0x0219; + const BSM_APPLICATIONS: u32 = 0x0000_0008; + const BSF_IGNORECURRENTTASK: u32 = 0x0000_0002; + const BSF_FORCEIFHUNG: u32 = 0x0000_0020; + const BSF_NOHANG: u32 = 0x0000_0008; + const BSF_NOTIMEOUTIFNOTHUNG: u32 = 0x0000_0040; + const SHCNE_DRIVEADD: i32 = 0x0000_0100; + const SHCNE_DRIVEREMOVED: i32 = 0x0000_0080; + const SHCNF_PATHW: u32 = 0x0005; + + #[link(name = "user32")] + extern "system" { + fn BroadcastSystemMessageW( + flags: u32, + recipients: *mut u32, + message: u32, + wparam: usize, + lparam: isize, + ) -> i32; + } + #[link(name = "shell32")] + extern "system" { + fn SHChangeNotify(event_id: i32, flags: u32, item1: *const u16, item2: *const u16); + } + + let letter = letter.to_ascii_uppercase(); + if !letter.is_ascii_alphabetic() { + return; + } + let unit = (letter as u8 - b'A') as u32; + let mut dbv = DevBroadcastVolume { + size: size_of::() as u32, + device_type: DBT_DEVTYP_VOLUME, + reserved: 0, + unitmask: 1u32 << unit, + flags: DBTF_NET, + }; + let mut recipients = BSM_APPLICATIONS; + let event = if arrival { + DBT_DEVICEARRIVAL + } else { + DBT_DEVICEREMOVECOMPLETE + }; + unsafe { + BroadcastSystemMessageW( + BSF_IGNORECURRENTTASK | BSF_FORCEIFHUNG | BSF_NOHANG | BSF_NOTIMEOUTIFNOTHUNG, + &mut recipients, + WM_DEVICECHANGE, + event, + &mut dbv as *mut DevBroadcastVolume as isize, + ); + } + // Shell often ignores DBT alone for DefineDosDevice letters — also poke Explorer. + let root = format!(r"{letter}:\"); + let root_w = to_wide(&root); + unsafe { + SHChangeNotify( + if arrival { + SHCNE_DRIVEADD + } else { + SHCNE_DRIVEREMOVED + }, + SHCNF_PATHW, + root_w.as_ptr(), + std::ptr::null(), + ); + } +} + +fn set_driver_automount_letter(request: &MountRequest) -> Result<(), String> { + const IOCTL_APP_VOLUME_PROPERTY: u32 = 0x0022_1F40; + const EXT2_VOLUME_PROPERTY_MAGIC: u32 = 0x4556_504D; + const APP_CMD_QUERY_PROPERTY3: u32 = 0x0000_0006; + const APP_CMD_SET_PROPERTY3: u32 = 0x0000_0007; + const EXT2_VPROP3_AUTOMOUNT: u64 = 1; + const DRV_LETTER_OFF: usize = 64; + const FLAGS2_OFF: usize = 132; + const PROP3_SIZE: usize = 268; + + let mut last_err = String::from("no open path"); + let mut saw_ioctl_err = false; + for open_path in volume_open_paths_for_property(request) { + let handle = match open_volume_for_ioctl(&open_path) { + Some(handle) => handle, + None => { + let open_err = format!("CreateFile({open_path}) failed ({})", + unsafe { windows_sys::Win32::Foundation::GetLastError() }); + if !saw_ioctl_err { + last_err = open_err; + } + continue; + } + }; + let mut buffer = vec![0u8; PROP3_SIZE]; + buffer[0..4].copy_from_slice(&EXT2_VOLUME_PROPERTY_MAGIC.to_le_bytes()); + buffer[8..12].copy_from_slice(&APP_CMD_QUERY_PROPERTY3.to_le_bytes()); + let mut bytes_returned = 0u32; + let query_ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_APP_VOLUME_PROPERTY, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + if query_ok == 0 { + last_err = format!( + "QUERY_PROPERTY3({open_path}) failed ({})", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + ); + saw_ioctl_err = true; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + continue; + } + + let letter = request.letter.to_ascii_uppercase() as u8; + buffer[DRV_LETTER_OFF] = letter | 0x80; + let flags2 = u64::from_le_bytes(buffer[FLAGS2_OFF..FLAGS2_OFF + 8].try_into().unwrap_or([0; 8])); + buffer[FLAGS2_OFF..FLAGS2_OFF + 8] + .copy_from_slice(&(flags2 | EXT2_VPROP3_AUTOMOUNT).to_le_bytes()); + buffer[0..4].copy_from_slice(&EXT2_VOLUME_PROPERTY_MAGIC.to_le_bytes()); + buffer[8..12].copy_from_slice(&APP_CMD_SET_PROPERTY3.to_le_bytes()); + + let set_ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + handle, + IOCTL_APP_VOLUME_PROPERTY, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + buffer.as_mut_ptr() as *mut _, + buffer.len() as u32, + &mut bytes_returned, + std::ptr::null_mut(), + ) + }; + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + if set_ok != 0 { + return Ok(()); + } + last_err = format!( + "SET_PROPERTY3({open_path}) failed ({})", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + ); + saw_ioctl_err = true; + } + Err(last_err) +} + +fn volume_open_paths_for_property(request: &MountRequest) -> Vec { + let mut paths = Vec::new(); + // Prefer live letter once DefineDosDevice has bound it (Ext2Fsd VCB path). + if query_dos_device(request.letter).is_some() { + paths.push(format!(r"\\.\{}:", request.letter)); + } + if request.symlink.starts_with(r"\Device\") { + let trimmed = request.symlink.trim_start_matches('\\'); + paths.push(format!(r"\\?\GLOBALROOT\{trimmed}")); + if let Some(rest) = request.symlink.strip_prefix(r"\Device\") { + paths.push(format!(r"\\.\{rest}")); + } + } + let win32 = request.win32_volume_name.trim_end_matches('\\'); + if !win32.is_empty() { + paths.push(win32.to_string()); + } + if query_dos_device(request.letter).is_none() { + paths.push(format!(r"\\.\{}:", request.letter)); + } + paths +} + +fn open_volume_for_ioctl(path: &str) -> Option { + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + let wide = to_wide(path); + for access in [GENERIC_READ | GENERIC_WRITE, GENERIC_READ, 0u32] { + let handle = unsafe { + windows_sys::Win32::Storage::FileSystem::CreateFileW( + wide.as_ptr(), + access, + windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ + | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + std::ptr::null(), + windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + 0, + 0, + ) + }; + if handle != windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return Some(handle); + } + } + None +} + +fn letter_root_attrs(letter: char) -> String { + let root = format!(r"{letter}:\"); + let wide = to_wide(&root); + let attrs = unsafe { + windows_sys::Win32::Storage::FileSystem::GetFileAttributesW(wide.as_ptr()) + }; + if attrs == u32::MAX { + format!( + "fail_err={}", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + ) + } else { + format!("ok=0x{attrs:x}") + } +} + /// Ext2Mgr `Ext2AssignDrvLetter(..., bMountMgr=TRUE)` / `Ext2InsertMountPoint`. fn assign_via_mount_manager( letter: char, @@ -203,14 +572,19 @@ fn assign_via_mount_manager( let probed = match volume_name_for_mount_point(&mount_root) { Ok(name) => name, Err(err) => { + let _ = super::pipe::with_shared_client(|client| { + client.remove_drive(letter as u8, symlink) + }); let _ = define_dos_device_local_remove(letter, symlink); return Err(err); } }; - let _ = define_dos_device_local_remove(letter, symlink); + let _ = super::pipe::with_shared_client(|client| { + client.remove_drive(letter as u8, symlink) + }); if query_dos_device(letter).is_some() { - // Loose remove if exact match failed. let _ = define_dos_device_local_remove(letter, symlink); + let _ = define_dos_device_local_remove_loose(letter); } probed }; @@ -266,57 +640,30 @@ fn to_wide(value: &str) -> Vec { .collect() } -fn is_process_elevated() -> bool { - use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; - use windows_sys::Win32::Security::{ - GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, - }; - use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; - - unsafe { - let mut token: HANDLE = 0; - if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { - return false; - } - let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; - let mut returned = 0u32; - let ok = GetTokenInformation( - token, - TokenElevation, - &mut elevation as *mut _ as *mut _, - size_of::() as u32, - &mut returned, - ); - CloseHandle(token); - ok != 0 && elevation.TokenIsElevated != 0 - } -} - -fn define_dos_device_local(letter: char, symlink: &str) -> bool { +fn define_dos_device_local_remove(letter: char, symlink: &str) -> bool { let dos = format!("{letter}:"); let dos_wide = to_wide(&dos); let target_wide = to_wide(symlink); + let flags = super::pipe::DDD_RAW_TARGET_PATH + | super::pipe::DDD_REMOVE_DEFINITION + | super::pipe::DDD_EXACT_MATCH_ON_REMOVE; unsafe { windows_sys::Win32::Storage::FileSystem::DefineDosDeviceW( - super::pipe::DDD_RAW_TARGET_PATH, + flags, dos_wide.as_ptr(), target_wide.as_ptr(), ) != 0 } } -fn define_dos_device_local_remove(letter: char, symlink: &str) -> bool { +fn define_dos_device_local_remove_loose(letter: char) -> bool { let dos = format!("{letter}:"); let dos_wide = to_wide(&dos); - let target_wide = to_wide(symlink); - let flags = super::pipe::DDD_RAW_TARGET_PATH - | super::pipe::DDD_REMOVE_DEFINITION - | super::pipe::DDD_EXACT_MATCH_ON_REMOVE; unsafe { windows_sys::Win32::Storage::FileSystem::DefineDosDeviceW( - flags, + super::pipe::DDD_REMOVE_DEFINITION, dos_wide.as_ptr(), - target_wide.as_ptr(), + std::ptr::null(), ) != 0 } } @@ -340,6 +687,11 @@ fn query_dos_device(letter: char) -> Option { } } +/// Public wrapper for UI labeling (Session Manager vs Temporary). +pub fn query_dos_device_public(letter: char) -> Option { + query_dos_device(letter) +} + fn delete_volume_mount_point(mount_root: &str) -> Result<(), String> { let wide = to_wide(mount_root); let ok = unsafe { @@ -397,9 +749,13 @@ impl AutomountReport { /// Ext2Mgr `Ext2ProcessExt2Volumes` (+ dormant Session Manager re-bind). /// -/// For each volume that *should* have a letter (Ext2Fsd `MountPoint=` or Session -/// Manager DOS Devices), skip when already lettered / MountMgr-present / live DOS, -/// otherwise assign via DefineDosDevice (same as classic `Ext2MountVolumeAs`). +/// Priority when nothing is live yet: +/// 1. Session Manager DOS Devices (user's permanent registry choice, e.g. P:) +/// 2. Ext2Fsd `Volumes\{UUID}` MountPoint= +/// 3. Driver `EVP.DrvLetter` (classic Ext2ProcessExt2Property) +/// +/// EVP used to run first and `continue`, so a stale H: overrode a just-written +/// Session Manager P: after Change — Explorer then only saw H: on upstream refresh. pub fn process_pending_automounts( volumes: &[crate::disk::enum_disk::VolumeEntry], ) -> AutomountReport { @@ -407,77 +763,83 @@ pub fn process_pending_automounts( let free = free_drive_letters(); for volume in volumes { - let device = if volume.physical_object.starts_with(r"\Device\") { - volume.physical_object.as_str() - } else if volume.symlink.starts_with(r"\Device\") { - volume.symlink.as_str() - } else { - "" - }; + let device = dos_device_target(&volume.physical_object, &volume.symlink); let already_live = !volume.letters.is_empty() || (!volume.win32_volume_name.is_empty() && !crate::mount::dead_letters::mountmgr_letters_for_volume( &volume.win32_volume_name, ) - .is_empty()); + .is_empty()) + || (!device.is_empty() && device_has_live_dos_letter(&device)); + + if already_live { + report.already_mounted += 1; + continue; + } - // Ext2Fsd Volumes\{UUID} MountPoint=X: (Ext2ProcessExt2Property) - if is_ext_family(&volume.filesystem) { - if let Some(uuid) = volume.uuid { - if let Some(preferred) = super::persist::query_ext2_automount_letter(&uuid) { - if already_live { + // 1) Session Manager registry letter(s) for this NT device. + if !device.is_empty() { + let session_letters = super::persist::registry_letters_for_device(&device); + if !session_letters.is_empty() { + for letter in session_letters { + if query_dos_device(letter).is_some() { report.already_mounted += 1; - } else { - let letter = if free.contains(&preferred) - && query_dos_device(preferred).is_none() - { - preferred - } else if let Some(fallback) = free.iter().copied().find(|candidate| { - query_dos_device(*candidate).is_none() - }) { - fallback - } else { - report.errors.push(format!( - "{}: no free letter for Ext2Fsd automount", - super::persist::format_volume_uuid(&uuid) - )); - continue; - }; - let symlink = dos_device_target(&volume.physical_object, &volume.symlink); - match define_temporary(letter, &symlink) { - Ok(()) => report.mounted.push(format!( - "{letter}: (Ext2Fsd automount → {symlink})" - )), - Err(err) => report.errors.push(err), - } + continue; + } + match define_temporary(letter, &device) { + Ok(()) => report + .mounted + .push(format!("{letter}: (Session Manager → {device})")), + Err(err) => report.errors.push(err), } - continue; } + continue; } } - // Session Manager DOS Devices: activate registry letter if nothing live yet. - if device.is_empty() { - continue; - } - let session_letters = super::persist::registry_letters_for_device(device); - if session_letters.is_empty() { + if !is_ext_family(&volume.filesystem) { continue; } - if already_live { - report.already_mounted += 1; + + let symlink = device.clone(); + if symlink.is_empty() { continue; } - for letter in session_letters { - if query_dos_device(letter).is_some() { - report.already_mounted += 1; + + // 2) Ext2Fsd Volumes\{UUID} MountPoint=X: + if let Some(uuid) = volume.uuid { + if let Some(preferred) = super::persist::query_ext2_automount_letter(&uuid) { + let letter = pick_automount_letter(preferred, &free, &mut report, &format!( + "{} automount", + super::persist::format_volume_uuid(&uuid) + )); + if let Some(letter) = letter { + match define_temporary(letter, &symlink) { + Ok(()) => report.mounted.push(format!( + "{letter}: (Ext2Fsd automount → {symlink})" + )), + Err(err) => report.errors.push(err), + } + } continue; } - match define_temporary(letter, device) { - Ok(()) => report - .mounted - .push(format!("{letter}: (Session Manager → {device})")), - Err(err) => report.errors.push(err), + } + + // 3) Driver EVP.DrvLetter (classic Ext2ProcessExt2Property). + if let Some(preferred) = crate::disk::enum_disk::query_volume_fixed_drv_letter(volume) { + let letter = pick_automount_letter( + preferred, + &free, + &mut report, + &format!("EVP.DrvLetter={preferred}"), + ); + if let Some(letter) = letter { + match define_temporary(letter, &symlink) { + Ok(()) => report.mounted.push(format!( + "{letter}: (Ext2Fsd EVP.DrvLetter → {symlink})" + )), + Err(err) => report.errors.push(err), + } } } } @@ -485,6 +847,40 @@ pub fn process_pending_automounts( report } +fn device_has_live_dos_letter(device_nt_path: &str) -> bool { + for index in 0u8..26 { + let letter = char::from(b'A' + index); + if query_dos_device(letter) + .is_some_and(|target| target.eq_ignore_ascii_case(device_nt_path)) + { + return true; + } + } + false +} + +fn pick_automount_letter( + preferred: char, + free: &[char], + report: &mut AutomountReport, + context: &str, +) -> Option { + if free.contains(&preferred) && query_dos_device(preferred).is_none() { + return Some(preferred); + } + if let Some(fallback) = free + .iter() + .copied() + .find(|candidate| query_dos_device(*candidate).is_none()) + { + return Some(fallback); + } + report + .errors + .push(format!("{context}: no free letter")); + None +} + /// Ext2Mgr OnFlush → Ext2FlushVolume: FlushFileBuffers on the volume handle. pub fn flush_volume( letters: &[char], diff --git a/ext2mgr_iced/src/mount/persist.rs b/ext2mgr_iced/src/mount/persist.rs index 28cb5c4..2326ac5 100644 --- a/ext2mgr_iced/src/mount/persist.rs +++ b/ext2mgr_iced/src/mount/persist.rs @@ -48,6 +48,22 @@ pub fn clear_registry_mount_point(letter: char) -> Result<(), String> { } } +/// Drop Session Manager letters that target `\Device\HarddiskN\PartitionM` +/// (invalid Ext2Fsd mount targets left by a prior iced bug). Real mounts use +/// `\Device\HarddiskVolumeN`. +pub fn scrub_partition_path_session_letters() -> Vec { + let mut removed = Vec::new(); + for (letter, device) in registry_dos_device_entries() { + let upper = device.to_ascii_uppercase(); + if upper.contains(r"\PARTITION") && !upper.contains(r"\HARDDISKVOLUME") { + if clear_registry_mount_point(letter).is_ok() { + removed.push(letter); + } + } + } + removed +} + /// Drive-letter Session Manager DOS Devices entries (`X:` → `\Device\…`). pub fn registry_dos_device_entries() -> Vec<(char, String)> { let Ok(key) = open_key(DOS_DEVICES_KEY, false) else { @@ -105,6 +121,15 @@ pub fn registry_dos_device_entries() -> Vec<(char, String)> { entries } +/// Target device for a Session Manager letter (`X:` → `\Device\…`), if any. +pub fn registry_device_for_letter(letter: char) -> Option { + let want = letter.to_ascii_uppercase(); + registry_dos_device_entries() + .into_iter() + .find(|(entry, _)| *entry == want) + .map(|(_, device)| device) +} + /// All Session Manager DOS Devices letters that map to `device_nt_path`. pub fn registry_letters_for_device(device_nt_path: &str) -> Vec { let mut letters: Vec = registry_dos_device_entries() @@ -143,6 +168,23 @@ pub fn store_ext2_automount( result } +/// Remove Ext2Fsd `Volumes\{UUID}` so refresh/automount does not immediately +/// rebind a letter the user just removed from Mount Points. +pub fn clear_ext2_automount_mount_point(uuid: &[u8; 16]) -> Result<(), String> { + let key = open_key(VOLUMES_KEY, true)?; + let value_name = format_volume_uuid(uuid); + let wide = to_wide(&value_name); + let status = unsafe { + windows_sys::Win32::System::Registry::RegDeleteValueW(key, wide.as_ptr()) + }; + close_key(key); + if status == 0 || status == 2 { + Ok(()) + } else { + Err(format!("RegDeleteValue({value_name}) failed ({status})")) + } +} + /// Preferred drive letter from Ext2Fsd `Volumes\{UUID}` (`MountPoint=X:`), if any. pub fn query_ext2_automount_letter(uuid: &[u8; 16]) -> Option { let key = open_key(VOLUMES_KEY, false).ok()?; diff --git a/ext2mgr_iced/src/ui/helpers.rs b/ext2mgr_iced/src/ui/helpers.rs index 186585d..9a1a4c4 100644 --- a/ext2mgr_iced/src/ui/helpers.rs +++ b/ext2mgr_iced/src/ui/helpers.rs @@ -6,6 +6,10 @@ impl Ext2MgrApp { const TABLE_H_SCROLLBAR_PAD: f32 = 14.0; fn reload_lists(&mut self) { + #[cfg(windows)] + { + let _ = crate::mount::persist::scrub_partition_path_session_letters(); + } let (disks, volumes, disk_rows) = crate::disk::enum_disk::enumerate_all(); self.disks = disks; self.volumes = volumes; @@ -16,7 +20,7 @@ impl Ext2MgrApp { fn reload(&mut self) -> Task { self.reload_lists(); - self.automount_task() + Task::none() } /// Ext2Mgr `Ext2ProcessExt2Volumes` after load/refresh. @@ -828,35 +832,51 @@ impl Ext2MgrApp { #[cfg(windows)] fn mount_volume_task(request: crate::mount::ops::MountRequest) -> Task { + let notify_letter = request.letter; Task::perform( async move { match tokio::task::spawn_blocking(move || crate::mount::ops::mount_volume(&request)).await { - Ok(Ok(message)) => (Some(message), None, false), + Ok(Ok(message)) => (Some(message), None, false, true), Ok(Err(err)) => { let is_error = err.contains("not started"); - (None, Some(err), is_error) + (None, Some(err), is_error, false) } - Err(join_err) => (None, Some(join_err.to_string()), true), + Err(join_err) => (None, Some(join_err.to_string()), true, false), } }, - |(ok_message, err_message, is_error)| Message::PipeOpFinished { + move |(ok_message, err_message, is_error, notify)| Message::PipeOpFinished { ok_message, err_message, is_error, restore_mount_points: None, + explorer_notify: if notify { + vec![(notify_letter, true)] + } else { + Vec::new() + }, + // Never auto-assign other volumes after a user-initiated mount. + run_automount: false, }, ) } #[cfg(windows)] - fn unmount_letters_task(letters: Vec, symlink: String) -> Task { + fn unmount_letters_task( + letters: Vec, + symlink: String, + win32_volume_name: String, + ) -> Task { Task::perform( async move { match tokio::task::spawn_blocking(move || { let mut removed = Vec::new(); let mut last_error = None; for letter in letters { - match crate::mount::ops::unmount_letter(letter, &symlink) { + match crate::mount::ops::unmount_letter( + letter, + &symlink, + &win32_volume_name, + ) { Ok(()) => removed.push(letter), Err(err) => last_error = Some(err), } @@ -869,24 +889,31 @@ impl Ext2MgrApp { .map(|letter| format!("{letter}:")) .collect::>() .join(", "); - Ok(format!("Unmounted {list}")) + Ok((format!("Unmounted {list}"), removed)) } }) .await { - Ok(Ok(message)) => (Some(message), None, false), + Ok(Ok((message, removed))) => ( + Some(message), + None, + false, + removed.into_iter().map(|letter| (letter, false)).collect(), + ), Ok(Err(err)) => { let is_error = err.contains("not started"); - (None, Some(err), is_error) + (None, Some(err), is_error, Vec::new()) } - Err(join_err) => (None, Some(join_err.to_string()), true), + Err(join_err) => (None, Some(join_err.to_string()), true, Vec::new()), } }, - |(ok_message, err_message, is_error)| Message::PipeOpFinished { + |(ok_message, err_message, is_error, explorer_notify)| Message::PipeOpFinished { ok_message, err_message, is_error, restore_mount_points: None, + explorer_notify, + run_automount: false, }, ) } @@ -899,22 +926,48 @@ impl Ext2MgrApp { self.status = "Selection has no drive letter.".to_string(); return Task::none(); } - let symlink = if let Some(volume_index) = self.selected_volume_index() { + let (symlink, win32_volume_name) = if let Some(volume_index) = + self.selected_volume_index() + { self.volumes .get(volume_index) - .map(|volume| volume.symlink.clone()) + .map(|volume| { + ( + crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ), + volume.win32_volume_name.clone(), + ) + }) .unwrap_or_default() } else if let Selection::Partition { disk, part } = self.selection { self.disks .get(disk) .and_then(|entry| entry.partitions.get(part)) - .map(|partition| partition.symlink.clone()) + .map(|partition| { + let linked = partition + .volume_index + .and_then(|index| self.volumes.get(index)); + let physical = linked + .map(|volume| volume.physical_object.as_str()) + .unwrap_or(""); + let volume_symlink = linked + .map(|volume| volume.symlink.as_str()) + .unwrap_or(partition.symlink.as_str()); + ( + crate::mount::ops::dos_device_target(physical, volume_symlink), + linked + .map(|volume| volume.win32_volume_name.clone()) + .unwrap_or_else(|| partition.win32_volume_name.clone()), + ) + }) .unwrap_or_default() } else { - String::new() + (String::new(), String::new()) }; self.status = "Unmounting…".to_string(); - return Self::unmount_letters_task(letters, symlink); + return Self::unmount_letters_task(letters, symlink, win32_volume_name); } #[cfg(not(windows))] { @@ -1088,7 +1141,21 @@ impl Ext2MgrApp { || Self::assignment_blocked(&existing_mountmgr_letters, replace_letter); let session_blocked = Self::session_manager_blocked(&existing_session_letters, replace_letter); - let persist_mode = if mountmgr_available && !mountmgr_blocked { + // Prefer the *current* persistence of the letter being Changed; do not + // default to Mount Manager / Ext2Automount just because they are "preferred". + let persist_mode = if let Some(letter) = replace_letter { + if existing_mountmgr_letters.contains(&letter) { + crate::mount::ops::MountMode::MountManager + } else if existing_session_letters.contains(&letter) { + crate::mount::ops::MountMode::PermanentRegistry + } else if mount_uuid.is_some_and(|uuid| { + crate::mount::persist::query_ext2_automount_letter(&uuid) == Some(letter) + }) { + crate::mount::ops::MountMode::Ext2Automount + } else { + crate::mount::ops::MountMode::Temporary + } + } else if mountmgr_available && !mountmgr_blocked { crate::mount::ops::MountMode::MountManager } else if !session_blocked { crate::mount::ops::MountMode::PermanentRegistry @@ -1137,16 +1204,32 @@ impl Ext2MgrApp { letter: char, win32_volume_name: &str, device_nt_path: &str, + mount_uuid: Option<[u8; 16]>, ) -> String { let in_mountmgr = !win32_volume_name.is_empty() - && crate::mount::dead_letters::mountmgr_letters_for_volume(win32_volume_name).contains(&letter); - let in_session = crate::mount::persist::registry_letters_for_device(device_nt_path) - .iter() - .any(|entry| *entry == letter); - match (in_mountmgr, in_session) { - (true, _) => format!("{letter}: Mount Manager"), - (false, true) => format!("{letter}: Session Manager (registry)"), - (false, false) => format!("{letter}: Temporary / DOS device"), + && crate::mount::dead_letters::mountmgr_letters_for_volume(win32_volume_name) + .contains(&letter); + // Match by letter in Session Manager first — Mount Points device_path used to be + // `\??\Volume{GUID}` while the registry stores `\Device\HarddiskVolumeN`, so + // registry_letters_for_device never matched and every SM letter looked Temporary. + let in_session = crate::mount::persist::registry_device_for_letter(letter).is_some_and( + |reg_device| { + device_nt_path.is_empty() + || reg_device.eq_ignore_ascii_case(device_nt_path) + || crate::mount::ops::query_dos_device_public(letter) + .is_some_and(|live| live.eq_ignore_ascii_case(®_device)) + }, + ); + let in_automount = !in_mountmgr + && !in_session + && mount_uuid.is_some_and(|uuid| { + crate::mount::persist::query_ext2_automount_letter(&uuid) == Some(letter) + }); + match (in_mountmgr, in_session, in_automount) { + (true, _, _) => format!("{letter}: Mount Manager"), + (false, true, _) => format!("{letter}: Session Manager (registry)"), + (false, false, true) => format!("{letter}: Ext2Fsd automount"), + (false, false, false) => format!("{letter}: Temporary / DOS device"), } } @@ -1173,6 +1256,13 @@ impl Ext2MgrApp { self.status = "Choose a drive letter.".to_string(); return Task::none(); }; + if mount_symlink.is_empty() { + self.report_error( + "Cannot assign drive letter: volume has no NT device path.\n\ + Refresh the list, then select the volume or partition again.", + ); + return Task::none(); + } if replace_letter.is_none() && !existing_mountmgr_letters.is_empty() { let listed = existing_mountmgr_letters .iter() @@ -1260,7 +1350,7 @@ impl Ext2MgrApp { volume_index, disk_part, letter, - mount_win32, + mount_win32.clone(), mount_uuid, ) }); @@ -1269,27 +1359,55 @@ impl Ext2MgrApp { return Task::perform( async move { let result = tokio::task::spawn_blocking(move || { + let mut notify = Vec::new(); if let Some(old_letter) = replace_letter { - crate::mount::ops::unmount_letter(old_letter, &mount_symlink)?; + // Session Manager in-place only: Change Temporary → Session Manager + // on the *same* letter writes registry without tear-down. Changing + // M: → G: must still unmount M: first. + let skip_unmount = mode + == crate::mount::ops::MountMode::PermanentRegistry + && old_letter == letter; + if !skip_unmount { + crate::mount::ops::unmount_letter( + old_letter, + &mount_symlink, + &mount_win32, + )?; + notify.push((old_letter, false)); + } } - crate::mount::ops::mount_volume(&request) + crate::mount::ops::mount_volume(&request)?; + notify.push((letter, true)); + Ok::, String>(notify) }) .await; match result { - Ok(Ok(message)) => (Some(message), None, false, restore), + Ok(Ok(explorer_notify)) => ( + Some(format!( + "Mounted {letter}: ({mode:?})" + )), + None, + false, + restore, + explorer_notify, + ), Ok(Err(err)) => { let is_error = err.contains("not started"); - (None, Some(err), is_error, None) + (None, Some(err), is_error, None, Vec::new()) + } + Err(join_err) => { + (None, Some(join_err.to_string()), true, None, Vec::new()) } - Err(join_err) => (None, Some(join_err.to_string()), true, None), } }, - |(ok_message, err_message, is_error, restore_mount_points)| { + |(ok_message, err_message, is_error, restore_mount_points, explorer_notify)| { Message::PipeOpFinished { ok_message, err_message, is_error, restore_mount_points, + explorer_notify, + run_automount: false, } }, ); diff --git a/ext2mgr_iced/src/ui/mod.rs b/ext2mgr_iced/src/ui/mod.rs index 076f5a6..19b1507 100644 --- a/ext2mgr_iced/src/ui/mod.rs +++ b/ext2mgr_iced/src/ui/mod.rs @@ -109,9 +109,7 @@ impl Ext2MgrApp { { crate::win::chrome::sync_autorun_menu(crate::win::autorun::is_autorun_enabled()); } - // Ext2Mgr processes Ext2Fsd / Session Manager automounts once at startup too, - // not only on refresh. - let startup = Task::batch([app.fit_window_task(), app.automount_task()]); + let startup = app.fit_window_task(); (app, startup) } diff --git a/ext2mgr_iced/src/ui/types.rs b/ext2mgr_iced/src/ui/types.rs index 3f64ab0..909b362 100644 --- a/ext2mgr_iced/src/ui/types.rs +++ b/ext2mgr_iced/src/ui/types.rs @@ -170,6 +170,11 @@ pub enum Message { is_error: bool, /// Re-open Change Drive Letters after a successful Assign from that dialog. restore_mount_points: Option<(Option, Option<(usize, usize)>, char, String, Option<[u8; 16]>)>, + /// Explorer shell notify on the UI thread: `(letter, arrival)`. + explorer_notify: Vec<(char, bool)>, + /// When false (Mount Points Remove), skip startup automount so removed + /// letters are not immediately rebound onto this or another volume. + run_automount: bool, }, /// Ext2Mgr `Ext2ProcessExt2Volumes` finished (after load/refresh). AutomountFinished(crate::mount::ops::AutomountReport), diff --git a/ext2mgr_iced/src/ui/update.rs b/ext2mgr_iced/src/ui/update.rs index 6d526a2..8104882 100644 --- a/ext2mgr_iced/src/ui/update.rs +++ b/ext2mgr_iced/src/ui/update.rs @@ -626,7 +626,15 @@ impl Ext2MgrApp { err_message, is_error, restore_mount_points, + explorer_notify, + run_automount, } => { + #[cfg(windows)] + { + for (letter, arrival) in explorer_notify { + crate::mount::ops::notify_explorer_letter(letter, arrival); + } + } if let Some(err) = err_message { if is_error { self.report_error(err); @@ -654,17 +662,19 @@ impl Ext2MgrApp { new_volume_index = Some(found); } } - let letters = if let Some(index) = new_volume_index { - self.volumes - .get(index) - .map(|volume| volume.letters.clone()) - .unwrap_or_default() - } else if let Some((disk, part)) = disk_part { + // Prefer disk/partition identity so we don't jump to another + // volume after reload reorders the volume list. + let letters = if let Some((disk, part)) = disk_part { self.disks .get(disk) .and_then(|entry| entry.partitions.get(part)) .map(|partition| partition.letters.clone()) .unwrap_or_default() + } else if let Some(index) = new_volume_index { + self.volumes + .get(index) + .map(|volume| volume.letters.clone()) + .unwrap_or_default() } else { Vec::new() }; @@ -684,12 +694,40 @@ impl Ext2MgrApp { selected, }); } - return self.automount_task(); + if run_automount { + return self.automount_task(); + } } } Message::AutomountFinished(report) => { if report.did_mount() { + let dialog = self.dialog.clone(); self.reload_lists(); + // Keep Mount Points on the same disk/partition / uuid after + // volume list reorder from remounts. + if let Some(Dialog::MountPoints { + volume_index, + disk_part, + selected, + }) = dialog + { + let mut new_volume_index = volume_index; + if let Some((disk, part)) = disk_part { + if let Some(index) = self + .disks + .get(disk) + .and_then(|entry| entry.partitions.get(part)) + .and_then(|partition| partition.volume_index) + { + new_volume_index = Some(index); + } + } + self.dialog = Some(Dialog::MountPoints { + volume_index: new_volume_index, + disk_part, + selected, + }); + } } if let Some(summary) = report.summary() { self.status = summary; @@ -698,100 +736,137 @@ impl Ext2MgrApp { Message::RemoveLetter(letter) => { #[cfg(windows)] { - let (symlink, restore) = match &self.dialog { + // Never call DeleteVolumeMountPoint / pipe unmount on the UI thread — + // Ext2 DOS-only letters hang on `X:\` and MessageBox then chimes. + let (symlink, win32, uuid, restore) = match &self.dialog { Some(Dialog::MountPoints { volume_index, disk_part, - selected, + .. }) => { - let symlink = if let Some(index) = volume_index { - self.volumes - .get(*index) - .map(|volume| volume.symlink.clone()) - .unwrap_or_default() + let (symlink, win32, uuid) = if let Some(index) = volume_index { + self.volumes.get(*index).map_or_else( + || (String::new(), String::new(), None), + |volume| { + ( + crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ), + volume.win32_volume_name.clone(), + volume.uuid, + ) + }, + ) } else if let Some((disk, part)) = disk_part { self.disks .get(*disk) .and_then(|entry| entry.partitions.get(*part)) - .map(|partition| partition.symlink.clone()) - .unwrap_or_default() + .map_or_else( + || (String::new(), String::new(), None), + |partition| { + let linked = partition + .volume_index + .and_then(|index| self.volumes.get(index)); + let uuid = linked.and_then(|volume| volume.uuid); + let physical = linked + .map(|volume| volume.physical_object.as_str()) + .unwrap_or(""); + let volume_symlink = linked + .map(|volume| volume.symlink.as_str()) + .unwrap_or(partition.symlink.as_str()); + ( + crate::mount::ops::dos_device_target( + physical, + volume_symlink, + ), + linked + .map(|volume| volume.win32_volume_name.clone()) + .unwrap_or_else(|| { + partition.win32_volume_name.clone() + }), + uuid, + ) + }, + ) } else { - String::new() + (String::new(), String::new(), None) }; ( symlink, - Some(( - *volume_index, - *disk_part, - *selected, - self.volumes - .get(volume_index.unwrap_or(usize::MAX)) - .map(|volume| { - ( - volume.win32_volume_name.clone(), - volume.uuid, - volume.physical_object.clone(), - ) - }), - )), + win32.clone(), + uuid, + Some((*volume_index, *disk_part, letter, win32, uuid)), ) } _ => { - let symlink = self + let (symlink, win32, uuid) = self .selected_volume_index() .and_then(|index| self.volumes.get(index)) - .map(|volume| volume.symlink.clone()) - .unwrap_or_default(); - (symlink, None) + .map(|volume| { + ( + crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ), + volume.win32_volume_name.clone(), + volume.uuid, + ) + }) + .unwrap_or_else(|| (String::new(), String::new(), None)); + (symlink, win32, uuid, None) } }; - match crate::mount::ops::unmount_letter(letter, &symlink) { - Ok(()) => { - self.status = format!("Removed {letter}:"); - self.reload_lists(); - if let Some((volume_index, disk_part, _selected, mounted_volume)) = - restore - { - let mut new_volume_index = volume_index; - if let Some((win32_name, uuid, physical)) = mounted_volume { - if let Some(found) = self.volumes.iter().position(|volume| { - volume.win32_volume_name == win32_name - || (uuid.is_some() && volume.uuid == uuid) - || (!physical.is_empty() - && volume.physical_object == physical) - }) { - new_volume_index = Some(found); - } + self.status = format!("Removing {letter}:…"); + self.dialog = None; + return Task::perform( + async move { + let result = tokio::task::spawn_blocking(move || { + crate::mount::ops::unmount_letter_ex( + letter, &symlink, &win32, uuid, + ) + }) + .await; + match result { + Ok(Ok(())) => ( + Some(format!("Removed {letter}:")), + None, + false, + restore, + vec![(letter, false)], + false, + ), + Ok(Err(err)) => { + (None, Some(err), false, None, Vec::new(), false) } - let letters = if let Some(index) = new_volume_index { - self.volumes - .get(index) - .map(|volume| volume.letters.clone()) - .unwrap_or_default() - } else if let Some((disk, part)) = disk_part { - self.disks - .get(disk) - .and_then(|entry| entry.partitions.get(part)) - .map(|partition| partition.letters.clone()) - .unwrap_or_default() - } else { - Vec::new() - }; - let selected = if letters.is_empty() { - None - } else { - Some(0) - }; - self.dialog = Some(Dialog::MountPoints { - volume_index: new_volume_index, - disk_part, - selected, - }); + Err(join_err) => ( + None, + Some(join_err.to_string()), + true, + None, + Vec::new(), + false, + ), } - return self.automount_task(); - } - Err(err) => self.report_warning(err), - } + }, + |( + ok_message, + err_message, + is_error, + restore_mount_points, + explorer_notify, + run_automount, + )| { + Message::PipeOpFinished { + ok_message, + err_message, + is_error, + restore_mount_points, + explorer_notify, + run_automount, + } + }, + ); } #[cfg(not(windows))] { diff --git a/ext2mgr_iced/src/ui/view.rs b/ext2mgr_iced/src/ui/view.rs index 2d30960..cb3c4ad 100644 --- a/ext2mgr_iced/src/ui/view.rs +++ b/ext2mgr_iced/src/ui/view.rs @@ -545,7 +545,7 @@ impl Ext2MgrApp { disk_part, selected, }) => { - let (mut letters, win32_name, device_path) = { + let (mut letters, win32_name, device_path, mount_uuid) = { if let Some(index) = *volume_index { self.volumes .get(index) @@ -553,11 +553,11 @@ impl Ext2MgrApp { ( volume.letters.clone(), volume.win32_volume_name.clone(), - if volume.physical_object.is_empty() { - volume.symlink.clone() - } else { - volume.physical_object.clone() - }, + crate::mount::ops::dos_device_target( + &volume.physical_object, + &volume.symlink, + ), + volume.uuid, ) }) .unwrap_or_default() @@ -566,15 +566,30 @@ impl Ext2MgrApp { .get(disk) .and_then(|entry| entry.partitions.get(part)) .map(|partition| { + let linked = partition + .volume_index + .and_then(|index| self.volumes.get(index)); + let physical = linked + .map(|volume| volume.physical_object.as_str()) + .unwrap_or(""); + let volume_symlink = linked + .map(|volume| volume.symlink.as_str()) + .unwrap_or(partition.symlink.as_str()); ( partition.letters.clone(), - partition.win32_volume_name.clone(), - partition.symlink.clone(), + linked + .map(|volume| volume.win32_volume_name.clone()) + .unwrap_or_else(|| partition.win32_volume_name.clone()), + crate::mount::ops::dos_device_target( + physical, + volume_symlink, + ), + linked.and_then(|volume| volume.uuid), ) }) .unwrap_or_default() } else { - (Vec::new(), String::new(), String::new()) + (Vec::new(), String::new(), String::new(), None) } }; // Include dormant Session Manager letters (registry only, not live DOS yet). @@ -600,6 +615,7 @@ impl Ext2MgrApp { *letter, &win32_name, &device_path, + mount_uuid, ); let row_button = if selected_index == Some(index) { button(text(label)) From f9d7b82339117514af507ac62cbb6462afbb4d73 Mon Sep 17 00:00:00 2001 From: Obisidan Jackal Date: Mon, 3 Aug 2026 13:11:29 -0400 Subject: [PATCH 5/5] chore(Ext2Srv): bump VERSIONINFO to 1.4.71.2 Bump FileVersion/ProductVersion for the usermode release zip. Also sync onto ext2mgr-iced the Ext2Srv changes already on master (interactive service flag removal and named-pipe accept hardening) so this branch's 1.4.71.2 binary matches that work. Fix RELEASE_NOTES CRLF doubling in release_usermode.ps1. --- Ext2Srv/Ext2Pipe.cpp | 17 +++++++++++------ Ext2Srv/Ext2Srv.cpp | 5 +++-- Ext2Srv/Ext2Srv.rc | 8 ++++---- Scripts/release_usermode.ps1 | 5 +++-- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/Ext2Srv/Ext2Pipe.cpp b/Ext2Srv/Ext2Pipe.cpp index 3d3e971..ae99d7c 100644 --- a/Ext2Srv/Ext2Pipe.cpp +++ b/Ext2Srv/Ext2Pipe.cpp @@ -151,8 +151,8 @@ Ext2CreatePipe() sa = Ext2CreateSA(); - ap->p = CreateNamedPipe( _T(EXT2_MGR_SRV), PIPE_ACCESS_DUPLEX | - FILE_FLAG_WRITE_THROUGH /* | ACCESS_SYSTEM_SECURITY */, + ap->p = CreateNamedPipe( _T(EXT2_MGR_SRV), PIPE_ACCESS_DUPLEX, + /* no WRITE_THROUGH: tiny IPC messages; measure if needed */ PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT /* PIPE_REJECT_REMOTE_CLIENTS */ , PIPE_UNLIMITED_INSTANCES, @@ -429,10 +429,11 @@ Ext2PipeEngine(VOID *arg) /* create named pipe */ ap = Ext2CreatePipe(); if (NULL == ap) { - if (times++ < 10) { - Sleep(250 * times); + if (times++ < 3) { + Sleep(50); goto retry; } + Sleep(100); continue; } @@ -440,10 +441,11 @@ Ext2PipeEngine(VOID *arg) if (!ap->p || ap->p == INVALID_HANDLE_VALUE || !ap->e || ap->e == INVALID_HANDLE_VALUE) { Ext2DestroyPipe(ap); - if (times++ < 10) { - Sleep(500); + if (times++ < 3) { + Sleep(50); goto retry; } + Sleep(100); continue; } @@ -498,6 +500,9 @@ DWORD Ext2StartPipeSrv() rc = WaitForSingleObject(g_wait, 1000*1); } while (rc == WAIT_TIMEOUT); + /* Second idle listener so a reconnect/overlap rarely hits PIPE_BUSY. */ + _beginthread(Ext2PipeEngine, 0, NULL); + return 0; } diff --git a/Ext2Srv/Ext2Srv.cpp b/Ext2Srv/Ext2Srv.cpp index 2658d02..ffc18ad 100644 --- a/Ext2Srv/Ext2Srv.cpp +++ b/Ext2Srv/Ext2Srv.cpp @@ -318,13 +318,14 @@ Ext2SetupService(BOOL bInstall) if (bInstall) { // now create service entry for Ext2Mgr + // Note: SERVICE_INTERACTIVE_PROCESS removed - interactive services are deprecated + // and don't work on modern Windows (can't create named pipes accessible to user sessions) hService = CreateService( hManager, // SCManager database _T("Ext2Srv"), // name of service _T("Ext2Fsd Service Manager"), // name to display SERVICE_ALL_ACCESS, // desired access - SERVICE_WIN32_OWN_PROCESS | // service type - SERVICE_INTERACTIVE_PROCESS, + SERVICE_WIN32_OWN_PROCESS, // service type (removed SERVICE_INTERACTIVE_PROCESS) SERVICE_AUTO_START, // start type SERVICE_ERROR_NORMAL, // error control type Target, // service's binary diff --git a/Ext2Srv/Ext2Srv.rc b/Ext2Srv/Ext2Srv.rc index 6ccd216..d640438 100644 --- a/Ext2Srv/Ext2Srv.rc +++ b/Ext2Srv/Ext2Srv.rc @@ -66,8 +66,8 @@ LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US // VS_VERSION_INFO VERSIONINFO - FILEVERSION 1,4,71,1 - PRODUCTVERSION 1,4,71,1 + FILEVERSION 1,4,71,2 + PRODUCTVERSION 1,4,71,2 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -85,12 +85,12 @@ BEGIN VALUE "Comments", "Bo Branten " VALUE "CompanyName", "Ext2Fsd Group (www.ext2fsd.com)" VALUE "FileDescription", "Ext2Fsd Service" - VALUE "FileVersion", "1.4.71.1" + VALUE "FileVersion", "1.4.71.2" VALUE "InternalName", "Ext2Srv" VALUE "LegalCopyright", "Copyright (C) 2002-2017 Matt Wu " VALUE "OriginalFilename", "Ext2Srv.exe" VALUE "ProductName", "Ext2Fsd Service" - VALUE "ProductVersion", "1.4.71.1" + VALUE "ProductVersion", "1.4.71.2" END END BLOCK "VarFileInfo" diff --git a/Scripts/release_usermode.ps1 b/Scripts/release_usermode.ps1 index b451d73..06c5583 100644 --- a/Scripts/release_usermode.ps1 +++ b/Scripts/release_usermode.ps1 @@ -284,9 +284,10 @@ Each arch folder has SHA256SUMS.txt. Example: Get-FileHash .\Ext2Srv.exe -Algorithm SHA256 "@ - # UTF-8 no BOM preferred for notes consumed on multiple OSes + # UTF-8 no BOM; normalize LF/CRLF so a CRLF-saved .ps1 does not become CRCRLF $utf8NoBom = New-Object System.Text.UTF8Encoding $false - [System.IO.File]::WriteAllText($path, $notes.Replace("`n", "`r`n"), $utf8NoBom) + $normalized = (($notes -replace "`r`n", "`n") -replace "`r", "`n") -replace "`n", "`r`n" + [System.IO.File]::WriteAllText($path, $normalized, $utf8NoBom) Write-Host " wrote $path" -ForegroundColor Gray }