commit fa5f39d8ffb48d97babfde737562d06576541c6b Author: RarDog Date: Thu Aug 27 12:32:11 2026 +0300 feat: ZeroSend v2.4.1 - Shared Folders, Media Streaming, Vim Viewer, Portable Mode & Gitea Auto-Updater diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6712e58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/downloads +config.json +*.exe.old +*.zerosend_part +*.zerosend_test +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1f35623 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6573 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b76d84ee70e30a4a7e39ab9018e2b17a6a09e31084176cc7c0b2dec036ba45" + +[[package]] +name = "accesskit_atspi_common" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5393c75d4666f580f4cac0a968bc97c36076bb536a129f28210dac54ee127ed" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant 4.2.0", +] + +[[package]] +name = "accesskit_consumer" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a12dc159d52233c43d9fe5415969433cbdd52c3d6e0df51bda7d447427b9986" +dependencies = [ + "accesskit", + "immutable-chunkmap", +] + +[[package]] +name = "accesskit_macos" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc6c1ecd82053d127961ad80a8beaa6004fb851a3a5b96506d7a6bd462403f6" +dependencies = [ + "accesskit", + "accesskit_consumer", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", +] + +[[package]] +name = "accesskit_unix" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be7f5cf6165be10a54b2655fa2e0e12b2509f38ed6fc43e11c31fdb7ee6230bb" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus 4.4.0", +] + +[[package]] +name = "accesskit_windows" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974e96c347384d9133427167fb8a58c340cb0496988dacceebdc1ed27071023b" +dependencies = [ + "accesskit", + "accesskit_consumer", + "paste", + "static_assertions", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "accesskit_winit" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea3522719f1c44564d03e9469a8e2f3a98b3a8a880bd66d0789c6b9c4a669dd" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.19.0", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be534b16650e35237bb1ed189ba2aab86ce65e88cc84c66f4935ba38575cecbf" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1909ed2dc01d0a17505d89311d192518507e8a056a48148e3598fef5e7bb6ba7" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 4.4.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "atspi-connection" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "430c5960624a4baaa511c9c0fcc2218e3b58f5dbcc47e6190cafee344b873333" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus 4.4.0", +] + +[[package]] +name = "atspi-proxies" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e6c5de3e524cf967569722446bcd458d5032348554d9a17d7d72b041ab7496" +dependencies = [ + "atspi-common", + "serde", + "zbus 4.4.0", + "zvariant 4.2.0", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[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 = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[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 = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "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 0.9.4", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow 0.14.2", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winapi", + "windows-sys 0.52.0", + "winit", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "accesskit", + "ahash", + "emath", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-wgpu" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" +dependencies = [ + "accesskit_winit", + "ahash", + "arboard", + "egui", + "log", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow 0.14.2", + "log", + "memoffset", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[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 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glow" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51fa363f025f5c111e03f13eda21162faeacb6911fe8caa0c0349f9cf0c4483" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases 0.2.2", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading 0.8.9", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases 0.2.2", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.13.1", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "human_bytes" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[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 0.18.1", + "tiff", +] + +[[package]] +name = "immutable-chunkmap" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45b30d0eb3d4282f694894b75bc50807ab6e3203436681cc235fa364acf7f5e1" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +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.20", + "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 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[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 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "muda" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdae9c00e61cc0579bcac625e8ad22104c60548a025bfc972dc83868a28e1484" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "once_cell", + "png 0.17.16", + "thiserror 1.0.69", + "windows-sys 0.59.0", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "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.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[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.1", +] + +[[package]] +name = "network-interface" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" +dependencies = [ + "cc", + "libc", + "thiserror 2.0.20", + "winapi", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +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.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.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.13.1", + "block2 0.5.1", + "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 0.5.1", + "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.13.1", + "block2 0.5.1", + "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.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "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 0.5.1", + "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 0.5.1", + "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.13.1", + "block2 0.5.1", + "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.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "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.13.1", + "objc2 0.6.4", + "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 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "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.13.1", + "block2 0.5.1", + "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.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[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.13.1", + "block2 0.5.1", + "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", + "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 0.5.1", + "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.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "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.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.1", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[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.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "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.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[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 = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd75f5002e2513eaa19b2365f533090cc3e93abd38788452d9ea85cff7b48a" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "thiserror 2.0.20", + "windows-sys 0.59.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +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 = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bitflags 2.13.1", + "cfg_aliases 0.1.1", + "core-graphics-types", + "glow 0.13.1", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.13.1", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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 = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[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 = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases 0.2.2", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "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 = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca2c5dceb099bddaade154055c926bb8ae507a18756ba1d8963fd7b51d8ed1d" +dependencies = [ + "zbus_xml", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant 5.15.0", +] + +[[package]] +name = "zbus_xml" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f374552b954f6abb4bd6ce979e6c9b38fb9d0cd7cc68a7d796e70c9f3a233" +dependencies = [ + "quick-xml 0.30.0", + "serde", + "static_assertions", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerosend" +version = "2.4.1" +dependencies = [ + "arboard", + "axum", + "chrono", + "clap", + "crossterm", + "eframe", + "futures-util", + "hex", + "human_bytes", + "image", + "indicatif", + "ipnetwork", + "network-interface", + "qrcode", + "ratatui", + "reqwest", + "rfd", + "serde", + "serde_json", + "sha2", + "socket2 0.5.10", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "tray-icon", + "uuid", + "walkdir", + "windows-sys 0.59.0", + "winreg", + "winres", + "zip", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils 4.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6d7626e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "zerosend" +version = "2.4.1" +edition = "2021" +description = "Fast, peer-to-peer file transfer designed for ZeroTier, Tailscale, Radmin VPN and LAN" +authors = ["RarDog"] + +[dependencies] +# Async Runtime & Networking +tokio = { version = "1.43", features = ["full"] } +tokio-util = { version = "0.7", features = ["io", "codec"] } +futures-util = "0.3" +axum = { version = "0.8", features = ["multipart"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } +reqwest = { version = "0.12", features = ["stream", "json", "multipart"] } +socket2 = { version = "0.5", features = ["all"] } +network-interface = "2.0" +ipnetwork = "0.20" + +# GUI & Dialogs +eframe = "0.29" +rfd = "0.15" +qrcode = "0.14" +arboard = "3.4" +image = { version = "0.25", default-features = false, features = ["png"] } +tray-icon = "0.19" +zip = { version = "2.2", default-features = false, features = ["deflate"] } + +# CLI & TUI +clap = { version = "4.5", features = ["derive"] } +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +indicatif = "0.17" + +# Serialization & Utils +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uuid = { version = "1.11", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +sha2 = "0.10" +hex = "0.4" +walkdir = "2.5" +human_bytes = "0.4" + +# Windows Native Registry & Sound +[target.'cfg(windows)'.dependencies] +winreg = "0.52" +windows-sys = { version = "0.59", features = ["Win32_UI_WindowsAndMessaging"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[build-dependencies] +winres = "0.1" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +panic = "unwind" +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d365a8 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# ⚡ ZeroSend v2.4.1 (Shared Folders, HTTP 206 Streaming, Vim Viewer & Portable Mode) + +> **Ultra-fast, lightweight, peer-to-peer file & text sharing designed specifically for ZeroTier, Tailscale, Radmin VPN, and LAN networks.** + +ZeroSend 2.4.1 is a modern, standalone, privacy-first alternative to LocalSend that is explicitly optimized for virtual mesh networks (ZeroTier, Tailscale, Radmin VPN). It features Network-wide Shared Folders with Read-Only / Read-Write permissions, instant HTTP 206 partial content streaming for video and music without waiting for downloads, an authentic embedded lightweight Vim code and text viewer, a self-contained single-folder portable storage mode (`config.json`), mobile-first Web dropzone with camera/voice-notes shortcuts, dual QR access links, 4-stream parallel chunk streaming, on-the-fly folder ZIP compression, and dynamic light/dark themes. + +--- + +## ✨ Features Overview + +### 📁 1. Network Shared Folders & Media Streaming (v2.4.1) +* **My Shares:** Select any folder on your hard drive to share with friends over ZeroTier/LAN. Configure **Read-Only** (streaming & download) or **Read & Write** (friends can upload files directly to your folder), protected with an optional 4-digit PIN code. +* **Network Shares Explorer:** Browse your friends' shared directories with interactive breadcrumb path navigation and instant search filtering. +* **🎬 Instant HTTP 206 Media Streaming:** Watch shared movies, TV series, or listen to music on-the-fly with instant seeking without having to download the entire file first. +* **⚡ Embedded Vim Code & Text Viewer:** Inspect `.rs`, `.py`, `.js`, `.ts`, `.html`, `.json`, `.toml`, `.cpp`, `.md`, and `.txt` files directly inside ZeroSend with genuine Vim keybindings (`j`/`k`, `Ctrl+d`/`Ctrl+u`, `gg`/`G`, `/` pattern search with live highlights, `:q` exit, `:` line jump). +* **📦 Single-Folder Portable Mode:** All configuration (`config.json`), downloads, and settings are created and saved directly inside the folder where `zerosend.exe` is located. +* **🔄 Zero-Config Auto-Sync:** Shared folders are broadcast dynamically via discovery beacons (`DiscoveryBeacon`), automatically appearing when a peer joins the mesh network. + +### 📱 2. Mobile-First Web Dropzone & PWA (v2.4.0) +* **Mobile-First Touch Dashboard:** Optimized for iOS and Android with direct `📸 Photos/Videos`, `📄 Documents`, and `🎙️ Voice Note` recording action chips. +* **Thumbnail Gallery Previews:** Real-time preview of selected photos and images before sending, with 1-tap delete chips. +* **🎙️ Mobile Voice Notes:** Built-in Web Audio recorder with live sound wave animation and instant delivery to PC. +* **📲 PWA Standalone App:** Add to Home Screen support on iOS and Android with custom app icons and manifest. +* **Dual QR & Access Links:** Separate ZeroTier/VPN mesh link (`http://:53318`) and Home Wi-Fi/LAN link (`http://:53318`) with 1-click copy, QR code, and browser launch. +* **🛡️ SHA-256 Checksum on the Fly:** Cryptographic hash integrity verification computed in parallel during transmission with green badge status. + +### ⚡ 3. High-Performance Multi-Stream Engine +* **4-Stream Parallel Chunk Transfers:** Automatically splits single large files or batches into concurrent 4x HTTP/TCP streams, eliminating TCP window bottlenecks over VPN links. +* **Chunk Auto-Retry & Fault Tolerance:** Automatic retry (up to 3 attempts) for failed chunks on Wi-Fi or VPN micro-disconnects without dropping the whole transfer. +* **Dynamic Adaptive Chunk Sizing:** Scales chunk granularity from 512 KB (small files) up to 8 MB (huge 10GB+ archives) to maximize throughput and minimize overhead. +* **Live Ping & Latency Indicator:** Real-time round-trip latency (`🟢 12 ms` / `🟡 65 ms` / `🔴 140 ms`) displayed right next to each peer in the Nearby list. +* **Custom Bandwidth Speed Limiter:** Token-bucket upload rate limiter with interactive custom numeric input and 1-click presets (`Unlimited`, `5`, `10`, `25`, `50`, `100 MB/s`). +* **Resumable Transfers (.zerosend_part Checkpointing):** Interrupted transfers seamlessly resume from the exact last chunk offset. +* **Pinned Favorite Friends:** Star (`⭐`) frequently used friends in the **Nearby** tab to pin them to the top. + +### 📦 4. On-The-Fly Folder ZIP Compression & Auto-Extract +* **10x Faster Folder Sharing:** Packages directory trees into streaming Deflate ZIP archives on the fly. +* **Automatic Extraction:** The recipient's ZeroSend automatically extracts the received ZIP folder right into their Downloads directory. +* **Web & Mobile Friendly:** Mobile devices downloading via QR code get a clean single `.zip` file with 1 tap. + +### 🎨 5. Dynamic Theme & Desktop Polish +* **🌓 Auto Dark/Light Theme:** Automatically synchronizes with Windows 10/11 system theme or allows forcing Always Dark / Always Light with high-contrast text readability. +* **System Tray Resident:** Minimizes cleanly to the Windows system tray with auto-popup on incoming file requests. +* **Drag & Drop Anywhere:** Drag files or folders into the window or directly onto a peer's card in the Nearby list. +* **Clipboard Screenshots (`Ctrl+V`):** Paste screenshots directly from clipboard to queue them for immediate transfer. + +--- + +## 📜 Version History (Changelog) + +### **v2.4.1 (Current Release)** +* 📁 **Shared Folders Tab:** Dedicated Shared Folders management with Read-Only / Read-Write permissions and PIN security. +* 🎬 **HTTP 206 Streaming:** Instant real-time video/audio streaming with seeking without downloading entire files. +* ⚡ **Built-in Vim Code Viewer:** Terminal-style code viewer with full Vim keybindings, search (`/`), and line jump. +* 📦 **Single-Folder Portable Mode:** All settings (`config.json`) and downloads are created & saved right in the exe folder. +* 🔄 **Auto-Sync:** Real-time discovery beacon broadcasting of shared folders when connecting to ZeroTier/VPN. + +### **v2.4.0** +* 🛡️ **SHA-256 Checksum on the Fly:** File integrity calculation & verification during streaming with green verification badges. +* 📸 **Mobile Media Previews:** Thumbnail gallery previews for photos with instant remove buttons before uploading. +* 🎙️ **Mobile Voice Notes:** Built-in voice recorder with live audio wave pulse animation and one-tap delivery. +* 📲 **PWA Support:** Web App Manifest & Add to Home Screen standalone install capabilities for mobile browsers. +* 🎨 **Auto Theme:** Dark/Light theme mode with Windows Registry theme sync & contrast fixes. + +--- + +## 🚀 Building from Source + +```bash +cargo build --release +``` + +The compiled standalone binary will be located at: +`target/release/zerosend.exe` diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..964111e --- /dev/null +++ b/build.rs @@ -0,0 +1,45 @@ +#[cfg(windows)] +fn main() { + let mut res = winres::WindowsResource::new(); + res.set("FileDescription", "ZeroSend - Fast P2P Mesh File Sharing"); + res.set("ProductName", "ZeroSend"); + res.set("ProductVersion", "2.3.2.0"); + res.set("FileVersion", "2.3.2.0"); + res.set("CompanyName", "RarDog"); + res.set("LegalCopyright", "Copyright (C) 2026 RarDog"); + res.set("OriginalFilename", "zerosend.exe"); + res.set("InternalName", "zerosend"); + + // Standard application manifest + res.set_manifest(r#" + + + ZeroSend Fast P2P Mesh File Sharing + + + + + + + + + + true/pm + PerMonitorV2 + + + +"#); + + if let Err(e) = res.compile() { + eprintln!("Warning: failed to compile winres: {}", e); + } +} + +#[cfg(not(windows))] +fn main() {} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..bb91774 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,204 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use tracing::info; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ThemeMode { + Auto, // Follow Windows / System theme + Dark, // Always Dark Theme + Light, // Always Light Theme +} + +impl Default for ThemeMode { + fn default() -> Self { + ThemeMode::Auto + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SharedFolderConfig { + pub id: String, + pub name: String, + pub path: PathBuf, + #[serde(default = "default_true")] + pub read_only: bool, + #[serde(default)] + pub pin: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub peer_id: Option, + pub device_name: Option, + pub download_dir: PathBuf, + pub auto_accept: bool, + pub port: u16, + pub preferred_interface: Option, + pub require_pin: bool, + pub pin_code: String, + pub peer_aliases: HashMap, + pub window_width: f32, + pub window_height: f32, + pub close_to_tray: bool, + pub minimize_to_tray: bool, + pub auto_zip_folders: bool, + pub auto_extract_zip: bool, + #[serde(default)] + pub max_upload_speed_mbps: u32, + #[serde(default = "default_true")] + pub sound_notifications: bool, + #[serde(default)] + pub favorite_peers: Vec, + #[serde(default)] + pub theme_mode: ThemeMode, + #[serde(default)] + pub shared_folders: Vec, + #[serde(default = "default_true")] + pub check_updates_on_startup: bool, + #[serde(default)] + pub gitea_api_url: Option, +} + +fn default_true() -> bool { + true +} + +impl Default for AppConfig { + fn default() -> Self { + let download_dir = get_default_download_dir(); + + Self { + peer_id: Some(Uuid::new_v4().to_string()), + device_name: None, + download_dir, + auto_accept: false, + port: 53318, + preferred_interface: None, + require_pin: false, + pin_code: "1234".to_string(), + peer_aliases: HashMap::new(), + window_width: 940.0, + window_height: 640.0, + close_to_tray: true, + minimize_to_tray: true, + auto_zip_folders: true, + auto_extract_zip: true, + max_upload_speed_mbps: 0, + sound_notifications: true, + favorite_peers: Vec::new(), + theme_mode: ThemeMode::Auto, + shared_folders: Vec::new(), + check_updates_on_startup: true, + gitea_api_url: None, + } + } +} + +impl AppConfig { + pub fn get_app_dir() -> PathBuf { + if let Ok(exe_path) = std::env::current_exe() { + if let Some(exe_dir) = exe_path.parent() { + // Test if exe directory is writable + let test_path = exe_dir.join(".zerosend_write_test"); + if std::fs::write(&test_path, b"test").is_ok() { + let _ = std::fs::remove_file(&test_path); + return exe_dir.to_path_buf(); + } + } + } + + // Fallback to APPDATA if exe directory is read-only + if let Some(mut app_data) = get_app_data_dir() { + app_data.push("ZeroSend"); + let _ = std::fs::create_dir_all(&app_data); + return app_data; + } + + PathBuf::from(".") + } + + pub fn config_path() -> PathBuf { + Self::get_app_dir().join("config.json") + } + + pub fn load() -> Self { + let path = Self::config_path(); + + // If local config doesn't exist yet, check if there was a legacy APPDATA config to migrate + if !path.exists() { + if let Some(legacy_dir) = get_app_data_dir() { + let legacy_path = legacy_dir.join("ZeroSend").join("config.json"); + if legacy_path.exists() && legacy_path != path { + if let Ok(content) = std::fs::read_to_string(&legacy_path) { + if let Ok(legacy_cfg) = serde_json::from_str::(&content) { + info!("Migrated configuration from legacy {:?}", legacy_path); + let _ = legacy_cfg.save(); + return legacy_cfg; + } + } + } + } + } + + if path.exists() { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(mut config) = serde_json::from_str::(&content) { + if config.peer_id.is_none() { + config.peer_id = Some(Uuid::new_v4().to_string()); + let _ = config.save(); + } + info!("Loaded configuration from {:?}", path); + return config; + } + } + } + let default = Self::default(); + let _ = default.save(); + default + } + + pub fn save(&self) -> Result<(), Box> { + let path = Self::config_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let json = serde_json::to_string_pretty(self)?; + std::fs::write(&path, json)?; + info!("Saved configuration to {:?}", path); + Ok(()) + } +} + +fn get_app_data_dir() -> Option { + if let Ok(appdata) = std::env::var("APPDATA") { + return Some(PathBuf::from(appdata)); + } + if let Ok(home) = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) { + return Some(PathBuf::from(home).join(".config")); + } + None +} + +fn get_default_download_dir() -> PathBuf { + let app_dir = AppConfig::get_app_dir(); + let portable_downloads = app_dir.join("downloads"); + if std::fs::create_dir_all(&portable_downloads).is_ok() { + return portable_downloads; + } + + if let Ok(userprofile) = std::env::var("USERPROFILE") { + let downloads = PathBuf::from(userprofile).join("Downloads").join("ZeroSend"); + let _ = std::fs::create_dir_all(&downloads); + return downloads; + } + if let Ok(home) = std::env::var("HOME") { + let downloads = PathBuf::from(home).join("Downloads").join("ZeroSend"); + let _ = std::fs::create_dir_all(&downloads); + return downloads; + } + let fallback = PathBuf::from("./downloads"); + let _ = std::fs::create_dir_all(&fallback); + fallback +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0f3f499 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,322 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod config; +mod network; +mod registry; +mod transfer; +mod ui; +mod updater; + +use clap::Parser; +use config::AppConfig; +use indicatif::{ProgressBar, ProgressStyle}; +use network::{get_available_interfaces, get_preferred_interface, DiscoveryService, InterfaceInfo, NetworkType}; +use std::net::Ipv4Addr; +use transfer::{TransferClient, TransferServer, TransferState}; +use ui::{Cli, Commands}; +use uuid::Uuid; + +fn get_default_device_name() -> String { + if let Ok(name) = std::env::var("COMPUTERNAME") { + if !name.is_empty() { + return name; + } + } + if let Ok(name) = std::env::var("HOSTNAME") { + if !name.is_empty() { + return name; + } + } + format!("Node-{}", &Uuid::new_v4().to_string()[..6]) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = AppConfig::load(); + let cli = Cli::parse(); + + // 1. Handle "interfaces" subcommand + if let Some(Commands::Interfaces) = &cli.command { + let interfaces = get_available_interfaces(); + println!("\n🔍 Detected Network Interfaces:\n"); + if interfaces.is_empty() { + println!(" No active IPv4 network interfaces found."); + } else { + for (idx, iface) in interfaces.iter().enumerate() { + let badge = match iface.net_type { + NetworkType::ZeroTier => "🌟 [ZeroTier (Preferred)]", + NetworkType::Radmin => "🛡️ [Radmin VPN]", + NetworkType::Tailscale => "⚡ [Tailscale]", + NetworkType::LAN => "🏠 [LAN / Wi-Fi]", + NetworkType::Loopback => "🔄 [Loopback]", + NetworkType::Unknown => "❓ [Other]", + }; + + println!( + " [{}] {:<25} -> IP: {:<15} Broadcast: {:<15} {}", + idx + 1, + iface.name, + iface.ip, + iface.broadcast, + badge + ); + } + } + println!("\nTip: ZeroSend automatically selects ZeroTier if active.\n"); + return Ok(()); + } + + // 2. Determine Selected Interface + let selected_iface = if let Some(custom_iface) = &cli.interface { + let all = get_available_interfaces(); + all.into_iter() + .find(|i| i.name.eq_ignore_ascii_case(custom_iface) || i.ip.to_string() == *custom_iface) + .unwrap_or_else(|| { + get_preferred_interface().unwrap_or(InterfaceInfo { + name: "loopback".to_string(), + ip: Ipv4Addr::new(127, 0, 0, 1), + netmask: None, + broadcast: Ipv4Addr::new(127, 0, 0, 1), + net_type: NetworkType::Loopback, + }) + }) + } else if let Some(pref_name) = &config.preferred_interface { + let all = get_available_interfaces(); + all.into_iter() + .find(|i| i.name == *pref_name) + .unwrap_or_else(|| { + get_preferred_interface().unwrap_or(InterfaceInfo { + name: "loopback".to_string(), + ip: Ipv4Addr::new(127, 0, 0, 1), + netmask: None, + broadcast: Ipv4Addr::new(127, 0, 0, 1), + net_type: NetworkType::Loopback, + }) + }) + } else { + get_preferred_interface().unwrap_or(InterfaceInfo { + name: "loopback".to_string(), + ip: Ipv4Addr::new(127, 0, 0, 1), + netmask: None, + broadcast: Ipv4Addr::new(127, 0, 0, 1), + net_type: NetworkType::Loopback, + }) + }; + + let device_name = cli + .name + .or_else(|| config.device_name.clone()) + .unwrap_or_else(get_default_device_name); + + let download_dir = config.download_dir.clone(); + let port = cli.port; + let peer_id = config.peer_id.clone().unwrap_or_else(|| Uuid::new_v4().to_string()); + + // 3. Handle "ping" subcommand + if let Some(Commands::Ping { ip, port }) = &cli.command { + println!("🔍 Pinging ZeroSend node at {}:{}...", ip, port); + let client = TransferClient::new(); + match client.ping_peer(ip, *port).await { + Ok(beacon) => { + println!("\n✨ Node Online!"); + println!(" Name: {}", beacon.name); + println!(" OS: {}", beacon.os); + println!(" Network: {}", beacon.net_type); + println!(" IP:Port: {}:{}", beacon.ip, beacon.transfer_port); + println!(" Version: {}", beacon.version); + } + Err(e) => { + eprintln!("❌ Ping failed: {}", e); + } + } + return Ok(()); + } + + // 4. Handle "send" subcommand (Headless CLI mode) + if let Some(Commands::Send { target, files }) = &cli.command { + let client = TransferClient::new(); + let target_url = if target.starts_with("http://") || target.starts_with("https://") { + target.clone() + } else { + format!("http://{}:{}", target, port) + }; + + let my_info = network::DiscoveryBeacon { + peer_id, + name: device_name, + os: std::env::consts::OS.to_string(), + ip: selected_iface.ip, + transfer_port: port, + net_type: selected_iface.net_type.as_str().to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + shared_folders: Vec::new(), + }; + + println!("📤 Preparing to send {} item(s) to {}...", files.len(), target_url); + + let pb = ProgressBar::new(100); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) {msg}") + .unwrap() + .progress_chars("#>-"), + ); + + let target_display = target.clone(); + let res = client + .send_files(&target_url, &target_display, &my_info, files, config.auto_zip_folders, config.max_upload_speed_mbps, None, move |progress| { + match progress.state { + TransferState::PendingConfirmation => { + pb.set_message("⏳ Waiting for recipient confirmation..."); + } + TransferState::InProgress => { + pb.set_length(progress.total_bytes); + pb.set_position(progress.bytes_transferred); + let speed_mb = (progress.speed_bps as f64) / 1_048_576.0; + pb.set_message(format!("{:.1} MB/s | {}", speed_mb, progress.current_file_name)); + } + TransferState::Completed => { + pb.finish_with_message("✅ Transfer completed!"); + } + TransferState::Failed(e) => { + pb.abandon_with_message(format!("❌ Failed: {}", e)); + } + TransferState::Rejected => { + pb.abandon_with_message("⛔ Transfer rejected by recipient."); + } + TransferState::Canceled => { + pb.abandon_with_message("🛑 Canceled."); + } + } + }) + .await; + + if let Err(e) = res { + eprintln!("\nError sending files: {}", e); + } + return Ok(()); + } + + // 5. Handle "receive" subcommand (Headless Server / Daemon mode) + if let Some(Commands::Receive { auto_accept }) = &cli.command { + let is_auto_accept = *auto_accept || config.auto_accept; + let mut discovery = DiscoveryService::new( + peer_id.clone(), + device_name.clone(), + selected_iface.clone(), + port, + ); + let shared_summaries: Vec = config.shared_folders.iter().map(|s| network::SharedFolderSummary { + id: s.id.clone(), + name: s.name.clone(), + read_only: s.read_only, + requires_pin: s.pin.is_some(), + }).collect(); + discovery.update_shared_folders(shared_summaries).await; + discovery.start().await?; + + let (mut server, _) = TransferServer::new( + discovery.my_info().clone(), + download_dir.clone(), + port, + is_auto_accept, + config.require_pin, + config.pin_code.clone(), + config.auto_extract_zip, + config.shared_folders.clone(), + ); + server.start().await?; + + println!("\n⚡ ZeroSend Receiver Daemon Running"); + println!(" Device Name: {}", device_name); + println!(" Interface: {} ({})", selected_iface.ip, selected_iface.net_type.as_str()); + println!(" HTTP Endpoint: http://{}:{}", selected_iface.ip, port); + println!(" Web Dashboard: http://{}:{}", selected_iface.ip, port); + println!(" Downloads Dir: {:?}", download_dir); + println!(" Auto-Accept: {}", is_auto_accept); + println!(" Auto-Extract: {}", config.auto_extract_zip); + println!(" PIN Protected: {}\n", config.require_pin); + println!("Press Ctrl+C to stop.\n"); + + tokio::signal::ctrl_c().await?; + println!("\nShutting down ZeroSend..."); + return Ok(()); + } + + // 6. Interactive Mode: GUI (Default, no console) or TUI (if --tui is passed) + let is_auto_accept = config.auto_accept; + let mut discovery = DiscoveryService::new( + peer_id.clone(), + device_name.clone(), + selected_iface.clone(), + port, + ); + let shared_summaries: Vec = config.shared_folders.iter().map(|s| network::SharedFolderSummary { + id: s.id.clone(), + name: s.name.clone(), + read_only: s.read_only, + requires_pin: s.pin.is_some(), + }).collect(); + discovery.update_shared_folders(shared_summaries).await; + discovery.start().await?; + + let (mut server, channels) = TransferServer::new( + discovery.my_info().clone(), + download_dir, + port, + is_auto_accept, + config.require_pin, + config.pin_code.clone(), + config.auto_extract_zip, + config.shared_folders.clone(), + ); + server.start().await?; + + if cli.tui { + ui::tui::run_tui(discovery, server).await?; + } else { + let mut initial_files = cli.files.clone(); + for arg in std::env::args().skip(1) { + if !arg.starts_with('-') { + let path = std::path::PathBuf::from(&arg); + if path.exists() && !initial_files.contains(&path) { + initial_files.push(path); + } + } + } + + let start_minimized = (cli.tray || cli.minimized) && initial_files.is_empty(); + let options = eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_inner_size([config.window_width, config.window_height]) + .with_min_inner_size([780.0, 500.0]) + .with_visible(!start_minimized) + .with_title("ZeroSend v2.4.1 - P2P Sharing for ZeroTier & VPN"), + ..Default::default() + }; + + let disc_ref = &discovery; + let srv = server; + let in_req_rx = channels.incoming_request_rx; + let in_txt_rx = channels.incoming_text_rx; + + eframe::run_native( + "ZeroSend", + options, + Box::new(move |cc| { + Ok(Box::new(ui::gui::ZeroSendApp::new( + cc, + disc_ref, + srv, + config, + initial_files, + in_req_rx, + in_txt_rx, + ))) + }), + ) + .map_err(|e| format!("GUI Error: {}", e))?; + } + + Ok(()) +} diff --git a/src/network/discovery.rs b/src/network/discovery.rs new file mode 100644 index 0000000..4405163 --- /dev/null +++ b/src/network/discovery.rs @@ -0,0 +1,304 @@ +use super::interfaces::InterfaceInfo; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::collections::HashMap; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::UdpSocket; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +pub const DEFAULT_DISCOVERY_PORT: u16 = 53317; +#[allow(dead_code)] +pub const DEFAULT_TRANSFER_PORT: u16 = 53318; +pub const BEACON_INTERVAL_SECS: u64 = 3; +pub const PEER_TIMEOUT_SECS: i64 = 8; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SharedFolderSummary { + pub id: String, + pub name: String, + pub read_only: bool, + pub requires_pin: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveryBeacon { + pub peer_id: String, + pub name: String, + pub os: String, + pub ip: Ipv4Addr, + pub transfer_port: u16, + pub net_type: String, + pub version: String, + #[serde(default)] + pub shared_folders: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeerInfo { + pub peer_id: String, + pub name: String, + pub os: String, + pub ip: Ipv4Addr, + pub transfer_port: u16, + pub net_type: String, + pub version: String, + pub last_seen: DateTime, + pub is_online: bool, + #[serde(default)] + pub ping_ms: Option, + #[serde(default)] + pub shared_folders: Vec, +} + +impl PeerInfo { + pub fn transfer_url(&self) -> String { + format!("http://{}:{}", self.ip, self.transfer_port) + } +} + +pub struct DiscoveryService { + my_info: DiscoveryBeacon, + shared_folders: Arc>>, + selected_iface: InterfaceInfo, + peers: Arc>>, + shutdown_tx: Option>, +} + +impl DiscoveryService { + pub fn new( + peer_id: String, + name: String, + iface: InterfaceInfo, + transfer_port: u16, + ) -> Self { + let my_info = DiscoveryBeacon { + peer_id, + name, + os: std::env::consts::OS.to_string(), + ip: iface.ip, + transfer_port, + net_type: iface.net_type.as_str().to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + shared_folders: Vec::new(), + }; + + Self { + my_info, + shared_folders: Arc::new(RwLock::new(Vec::new())), + selected_iface: iface, + peers: Arc::new(RwLock::new(HashMap::new())), + shutdown_tx: None, + } + } + + pub fn peers(&self) -> Arc>> { + self.peers.clone() + } + + pub fn my_info(&self) -> &DiscoveryBeacon { + &self.my_info + } + + pub fn selected_interface(&self) -> &InterfaceInfo { + &self.selected_iface + } + + pub async fn update_shared_folders(&self, shares: Vec) { + let mut sf = self.shared_folders.write().await; + *sf = shares; + } + + /// Add or update a peer directly (useful for manual IP add / Tailscale / Ping) + #[allow(dead_code)] + pub async fn add_or_update_peer(&self, beacon: DiscoveryBeacon) { + if beacon.peer_id == self.my_info.peer_id { + return; + } + + let mut peers = self.peers.write().await; + peers.insert( + beacon.peer_id.clone(), + PeerInfo { + peer_id: beacon.peer_id, + name: beacon.name, + os: beacon.os, + ip: beacon.ip, + transfer_port: beacon.transfer_port, + net_type: beacon.net_type, + version: beacon.version, + last_seen: Utc::now(), + is_online: true, + ping_ms: None, + shared_folders: beacon.shared_folders, + }, + ); + } + + /// Start discovery beacon broadcaster and listener in background + pub async fn start(&mut self) -> Result<(), Box> { + let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1); + self.shutdown_tx = Some(shutdown_tx.clone()); + + // 1. Create UDP broadcast sending socket + let broadcast_addr = SocketAddrV4::new(self.selected_iface.broadcast, DEFAULT_DISCOVERY_PORT); + let my_info_static = self.my_info.clone(); + let shared_folders = self.shared_folders.clone(); + let mut shutdown_rx1 = shutdown_tx.subscribe(); + + tokio::spawn(async move { + let socket = match UdpSocket::bind("0.0.0.0:0").await { + Ok(s) => s, + Err(e) => { + error!("Failed to bind UDP beacon sender: {}", e); + return; + } + }; + + if let Err(e) = socket.set_broadcast(true) { + warn!("Failed to set UDP broadcast flag: {}", e); + } + + info!( + "Started discovery beacon on {} (broadcast: {})", + my_info_static.ip, broadcast_addr + ); + + let mut interval = tokio::time::interval(Duration::from_secs(BEACON_INTERVAL_SECS)); + loop { + tokio::select! { + _ = interval.tick() => { + let mut current_beacon = my_info_static.clone(); + current_beacon.shared_folders = shared_folders.read().await.clone(); + if let Ok(data) = serde_json::to_vec(¤t_beacon) { + let _ = socket.send_to(&data, broadcast_addr).await; + } + } + _ = shutdown_rx1.recv() => { + debug!("Discovery broadcaster shutting down"); + break; + } + } + } + }); + + // 2. Create UDP listener socket + let listener_socket = match create_udp_listener(DEFAULT_DISCOVERY_PORT) { + Ok(s) => s, + Err(e) => { + warn!( + "Could not bind listener on port {}: {}. Will try random port.", + DEFAULT_DISCOVERY_PORT, e + ); + create_udp_listener(0)? + } + }; + + let my_peer_id = self.my_info.peer_id.clone(); + let peers_store = self.peers.clone(); + let mut shutdown_rx2 = shutdown_tx.subscribe(); + + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + info!("Discovery listener active"); + + loop { + tokio::select! { + res = listener_socket.recv_from(&mut buf) => { + match res { + Ok((size, _sender_addr)) => { + if let Ok(beacon) = serde_json::from_slice::(&buf[..size]) { + if beacon.peer_id != my_peer_id { + let mut peers = peers_store.write().await; + let old_ping = peers.get(&beacon.peer_id).and_then(|p| p.ping_ms); + // Deduplicate: Remove any stale entry on the same IP and port with a different peer_id + peers.retain(|id, p| { + !(p.ip == beacon.ip && p.transfer_port == beacon.transfer_port && id != &beacon.peer_id) + }); + + peers.insert( + beacon.peer_id.clone(), + PeerInfo { + peer_id: beacon.peer_id, + name: beacon.name, + os: beacon.os, + ip: beacon.ip, + transfer_port: beacon.transfer_port, + net_type: beacon.net_type, + version: beacon.version, + last_seen: Utc::now(), + is_online: true, + ping_ms: old_ping, + shared_folders: beacon.shared_folders, + }, + ); + } + } + } + Err(e) => { + warn!("UDP listener recv error: {}", e); + } + } + } + _ = shutdown_rx2.recv() => { + debug!("Discovery listener shutting down"); + break; + } + } + } + }); + + // 3. Housekeeping task to remove stale offline peers + let peers_store_cleaner = self.peers.clone(); + let mut shutdown_rx3 = shutdown_tx.subscribe(); + + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(3)); + loop { + tokio::select! { + _ = interval.tick() => { + let now = Utc::now(); + let mut peers = peers_store_cleaner.write().await; + peers.retain(|_, peer| { + let diff = now.signed_duration_since(peer.last_seen).num_seconds(); + diff <= PEER_TIMEOUT_SECS + }); + } + _ = shutdown_rx3.recv() => { + break; + } + } + } + }); + + Ok(()) + } + + #[allow(dead_code)] + pub fn stop(&self) { + if let Some(tx) = &self.shutdown_tx { + let _ = tx.send(()); + } + } +} + +/// Helper to create a reusable broadcast socket on Windows / Linux / macOS +fn create_udp_listener(port: u16) -> std::io::Result { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + + #[cfg(not(windows))] + socket.set_reuse_port(true)?; + + socket.set_broadcast(true)?; + socket.set_nonblocking(true)?; + + let bind_addr: SocketAddr = format!("0.0.0.0:{}", port).parse().unwrap(); + socket.bind(&bind_addr.into())?; + + let std_socket: std::net::UdpSocket = socket.into(); + UdpSocket::from_std(std_socket) +} diff --git a/src/network/interfaces.rs b/src/network/interfaces.rs new file mode 100644 index 0000000..6a645dd --- /dev/null +++ b/src/network/interfaces.rs @@ -0,0 +1,163 @@ +use network_interface::{NetworkInterface, NetworkInterfaceConfig}; +use std::net::{IpAddr, Ipv4Addr}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NetworkType { + ZeroTier, + Tailscale, + Radmin, + LAN, + Loopback, + Unknown, +} + +impl NetworkType { + pub fn as_str(&self) -> &'static str { + match self { + NetworkType::ZeroTier => "ZeroTier", + NetworkType::Tailscale => "Tailscale", + NetworkType::Radmin => "Radmin VPN", + NetworkType::LAN => "LAN / Wi-Fi", + NetworkType::Loopback => "Loopback", + NetworkType::Unknown => "Unknown", + } + } + + #[allow(dead_code)] + pub fn badge(&self) -> &'static str { + match self { + NetworkType::ZeroTier => "[ZeroTier]", + NetworkType::Tailscale => "[Tailscale]", + NetworkType::Radmin => "[Radmin]", + NetworkType::LAN => "[LAN]", + NetworkType::Loopback => "[Local]", + NetworkType::Unknown => "[Other]", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterfaceInfo { + pub name: String, + pub ip: Ipv4Addr, + #[allow(dead_code)] + pub netmask: Option, + pub broadcast: Ipv4Addr, + pub net_type: NetworkType, +} + +impl InterfaceInfo { + #[allow(dead_code)] + pub fn is_virtual_vpn(&self) -> bool { + matches!( + self.net_type, + NetworkType::ZeroTier | NetworkType::Tailscale | NetworkType::Radmin + ) + } +} + +/// Classify network interface based on name and IP address ranges +pub fn classify_interface(name: &str, ip: Ipv4Addr) -> NetworkType { + let name_lower = name.to_lowercase(); + let octets = ip.octets(); + + // 1. Check by interface name + if name_lower.contains("zerotier") || name_lower.contains("zt") { + return NetworkType::ZeroTier; + } + if name_lower.contains("tailscale") { + return NetworkType::Tailscale; + } + if name_lower.contains("radmin") || name_lower.contains("fametech") { + return NetworkType::Radmin; + } + + // 2. Check by specific IP allocation ranges + // Tailscale uses CGNAT range 100.64.0.0/10 (100.64.0.0 - 100.127.255.255) + if octets[0] == 100 && (octets[1] >= 64 && octets[1] <= 127) { + return NetworkType::Tailscale; + } + + // Radmin VPN typically assigns 26.x.x.x + if octets[0] == 26 { + return NetworkType::Radmin; + } + + // ZeroTier commonly assigns 10.147.x.x, 10.244.x.x, 172.22-31.x.x, or 192.168.19x.x + if octets[0] == 10 && (octets[1] == 147 || octets[1] == 244) { + return NetworkType::ZeroTier; + } + + // Loopback + if ip.is_loopback() { + return NetworkType::Loopback; + } + + // Standard private IPv4 ranges (LAN / Wi-Fi) + if ip.is_private() { + return NetworkType::LAN; + } + + NetworkType::Unknown +} + +/// Compute broadcast address from IP and netmask +pub fn compute_broadcast(ip: Ipv4Addr, netmask: Option) -> Ipv4Addr { + let mask = netmask.unwrap_or(Ipv4Addr::new(255, 255, 255, 0)); + let ip_u32 = u32::from(ip); + let mask_u32 = u32::from(mask); + let broadcast_u32 = ip_u32 | (!mask_u32); + Ipv4Addr::from(broadcast_u32) +} + +/// Get all usable IPv4 interfaces sorted with ZeroTier and VPNs having highest priority +pub fn get_available_interfaces() -> Vec { + let mut results = Vec::new(); + + if let Ok(interfaces) = NetworkInterface::show() { + for iface in interfaces { + for addr in iface.addr { + if let IpAddr::V4(ipv4) = addr.ip() { + // Skip 127.0.0.1 unless no other interface is found + if ipv4.is_loopback() { + continue; + } + + let netmask_v4 = addr.netmask().and_then(|m| match m { + IpAddr::V4(m_v4) => Some(m_v4), + _ => None, + }); + + let net_type = classify_interface(&iface.name, ipv4); + let broadcast = compute_broadcast(ipv4, netmask_v4); + + results.push(InterfaceInfo { + name: iface.name.clone(), + ip: ipv4, + netmask: netmask_v4, + broadcast, + net_type, + }); + } + } + } + } + + // Sort by priority: ZeroTier (0) > Radmin (1) > Tailscale (2) > LAN (3) > Other (4) + results.sort_by_key(|info| match info.net_type { + NetworkType::ZeroTier => 0, + NetworkType::Radmin => 1, + NetworkType::Tailscale => 2, + NetworkType::LAN => 3, + NetworkType::Loopback => 4, + NetworkType::Unknown => 5, + }); + + results +} + +/// Pick best default interface (ZeroTier first, then others) +pub fn get_preferred_interface() -> Option { + let list = get_available_interfaces(); + list.into_iter().next() +} diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..c8f02ee --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,5 @@ +pub mod discovery; +pub mod interfaces; + +pub use discovery::*; +pub use interfaces::*; diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..12d302c --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,73 @@ +#[cfg(windows)] +use winreg::enums::*; +#[cfg(windows)] +use winreg::RegKey; + +/// Check if ZeroSend is registered in Windows Startup (HKCU\...\Run) +pub fn is_autostart_registered() -> bool { + #[cfg(windows)] + { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(key) = hkcu.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") { + if let Ok(_val) = key.get_value::("ZeroSend") { + return true; + } + } + } + false +} + +/// Register ZeroSend in Windows Startup (starts minimized to tray) +pub fn register_autostart() -> Result<(), String> { + #[cfg(windows)] + { + let exe_path = std::env::current_exe() + .map_err(|e| format!("Failed to get executable path: {}", e))?; + let cmd_str = format!("\"{}\" --tray", exe_path.to_string_lossy()); + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let (key, _) = hkcu + .create_subkey(r"Software\Microsoft\Windows\CurrentVersion\Run") + .map_err(|e| format!("Registry error: {}", e))?; + key.set_value("ZeroSend", &cmd_str) + .map_err(|e| format!("Registry error: {}", e))?; + return Ok(()); + } + + #[cfg(not(windows))] + Err("Autostart is only supported on Windows".to_string()) +} + +/// Unregister ZeroSend from Windows Startup +pub fn unregister_autostart() -> Result<(), String> { + #[cfg(windows)] + { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(key) = hkcu.open_subkey_with_flags( + r"Software\Microsoft\Windows\CurrentVersion\Run", + KEY_SET_VALUE, + ) { + let _ = key.delete_value("ZeroSend"); + } + return Ok(()); + } + + #[cfg(not(windows))] + Err("Autostart is only supported on Windows".to_string()) +} + +/// Check if Windows system theme is set to Dark Mode (AppsUseLightTheme == 0) +pub fn is_system_dark_mode() -> bool { + #[cfg(windows)] + { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + if let Ok(key) = hkcu.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") { + if let Ok(val) = key.get_value::("AppsUseLightTheme") { + return val == 0; + } + } + } + true // default to dark +} + + diff --git a/src/transfer/archive.rs b/src/transfer/archive.rs new file mode 100644 index 0000000..ba24601 --- /dev/null +++ b/src/transfer/archive.rs @@ -0,0 +1,99 @@ +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use tracing::{error, info}; +use walkdir::WalkDir; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +/// Compress a whole folder into a ZIP archive with fast Deflate compression +pub fn compress_folder_to_zip( + src_dir: &Path, + zip_dest: &Path, +) -> Result> { + info!("Compressing folder {:?} -> {:?}", src_dir, zip_dest); + let file = File::create(zip_dest)?; + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(CompressionMethod::Deflated) + .compression_level(Some(3)); + + let parent = src_dir.parent().unwrap_or(src_dir); + let mut buffer = Vec::with_capacity(64 * 1024); + + for entry in WalkDir::new(src_dir).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + let rel_path = path.strip_prefix(parent).unwrap_or(path); + let rel_path_str = rel_path.to_string_lossy().replace('\\', "/"); + + if rel_path_str.is_empty() { + continue; + } + + if path.is_dir() { + let dir_name = if rel_path_str.ends_with('/') { + rel_path_str + } else { + format!("{}/", rel_path_str) + }; + zip.add_directory(dir_name, options)?; + } else if path.is_file() { + zip.start_file(rel_path_str, options)?; + let mut f = File::open(path)?; + buffer.clear(); + f.read_to_end(&mut buffer)?; + zip.write_all(&buffer)?; + } + } + + zip.finish()?; + let total_size = std::fs::metadata(zip_dest)?.len(); + info!("Compression completed. Zip size: {} bytes", total_size); + Ok(total_size) +} + +/// Extract a received ZIP archive safely into the destination folder +pub fn extract_zip_archive( + zip_path: &Path, + dest_dir: &Path, +) -> Result<(), Box> { + info!("Extracting ZIP archive {:?} into {:?}", zip_path, dest_dir); + let file = File::open(zip_path)?; + let mut archive = ZipArchive::new(file)?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let raw_name = file.name().replace('\\', "/"); + + // Prevent Zip Slip / path traversal outside destination + let safe_rel_path: PathBuf = raw_name + .split('/') + .filter(|part| !part.is_empty() && *part != ".." && *part != ".") + .collect(); + + if safe_rel_path.as_os_str().is_empty() { + continue; + } + + let outpath = dest_dir.join(&safe_rel_path); + + if file.is_dir() { + let _ = std::fs::create_dir_all(&outpath); + } else { + if let Some(p) = outpath.parent() { + let _ = std::fs::create_dir_all(p); + } + match File::create(&outpath) { + Ok(mut outfile) => { + let _ = std::io::copy(&mut file, &mut outfile); + } + Err(e) => { + error!("Failed to extract file {:?}: {}", outpath, e); + } + } + } + } + + info!("Extraction completed successfully for {:?}", zip_path); + Ok(()) +} diff --git a/src/transfer/client.rs b/src/transfer/client.rs new file mode 100644 index 0000000..069a603 --- /dev/null +++ b/src/transfer/client.rs @@ -0,0 +1,766 @@ +use super::archive::compress_folder_to_zip; +use super::protocol::{ + FileMetadata, PrepareTransferRequest, PrepareTransferResponse, SharedFolderInfo, + SharedFolderTreeResponse, SharedUploadResponse, TextMessage, TransferProgress, TransferState, +}; +use super::rate_limiter::RateLimiter; +use super::text_analyzer::{analyze_text, AnalyzedText}; +use crate::network::DiscoveryBeacon; +use reqwest::Client; +use sha2::{Digest, Sha256}; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio::sync::Mutex; +use tracing::info; +use uuid::Uuid; +use walkdir::WalkDir; + +pub fn compute_file_sha256(path: &std::path::Path) -> Result { + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let n = std::io::Read::read(&mut file, &mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(hex::encode(hasher.finalize())) +} + +const NUM_PARALLEL_STREAMS: usize = 4; // 4 concurrent HTTP/TCP streams + +fn calculate_adaptive_chunk_size(file_size: u64) -> usize { + if file_size < 5 * 1024 * 1024 { + 512 * 1024 // 512 KB for small files < 5 MB + } else if file_size < 50 * 1024 * 1024 { + 2 * 1024 * 1024 // 2 MB for medium files 5-50 MB + } else if file_size < 500 * 1024 * 1024 { + 4 * 1024 * 1024 // 4 MB for large files 50-500 MB + } else { + 8 * 1024 * 1024 // 8 MB for huge files >= 500 MB + } +} + +#[derive(Clone)] +struct ChunkTask { + file_path: PathBuf, + rel_path: String, + offset: u64, + length: usize, + retries: usize, +} + +pub struct TransferClient { + client: Client, +} + +impl TransferClient { + pub fn new() -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(7200)) + .connect_timeout(Duration::from_secs(6)) + .tcp_nodelay(true) + .build() + .unwrap_or_default(); + + Self { client } + } + + /// Ping a remote peer to check health and get beacon info + pub async fn ping_peer(&self, ip: &str, port: u16) -> Result { + let url = format!("http://{}:{}/api/info", ip, port); + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("Failed to connect to {}: {}", url, e))?; + + if !resp.status().is_success() { + return Err(format!("Peer returned status: {}", resp.status())); + } + + let beacon = resp + .json::() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + Ok(beacon) + } + + /// Measure real-time round-trip latency in milliseconds + pub async fn measure_ping(&self, ip: &str, port: u16) -> Option { + let start = Instant::now(); + if self.ping_peer(ip, port).await.is_ok() { + Some(start.elapsed().as_millis() as u64) + } else { + None + } + } + + /// Send quick text / clipboard content to a peer + pub async fn send_text( + &self, + peer_url: &str, + my_info: &DiscoveryBeacon, + text: &str, + pin: Option, + ) -> Result { + let analyzed = analyze_text(text); + let msg = TextMessage { + message_id: Uuid::new_v4().to_string(), + sender_id: my_info.peer_id.clone(), + sender_name: my_info.name.clone(), + sender_os: my_info.os.clone(), + content: analyzed.content.clone(), + content_type: analyzed.content_type.clone(), + pin, + }; + + let url = format!("{}/api/transfer/text", peer_url); + let resp = self + .client + .post(&url) + .json(&msg) + .send() + .await + .map_err(|e| format!("Failed to contact {}: {}", url, e))?; + + if !resp.status().is_success() { + if resp.status() == reqwest::StatusCode::FORBIDDEN { + return Err("Recipient rejected text (Incorrect or missing PIN code)".to_string()); + } + return Err(format!("Server returned status: {}", resp.status())); + } + + Ok(analyzed) + } + + /// Send files/folders with 4-Stream Parallel Chunking, Adaptive Chunk Sizing, and Auto-Retry Fault Tolerance + pub async fn send_files( + &self, + peer_url: &str, + peer_display_name: &str, + my_info: &DiscoveryBeacon, + paths: &[PathBuf], + auto_zip_folders: bool, + max_upload_speed_mbps: u32, + pin: Option, + mut on_progress: F, + ) -> Result<(), String> + where + F: FnMut(TransferProgress) + Send + 'static, + { + let mut file_entries: Vec<(PathBuf, String, u64)> = Vec::new(); + let mut total_bytes = 0u64; + let temp_dir = std::env::temp_dir().join("ZeroSend_Zip"); + let _ = std::fs::create_dir_all(&temp_dir); + + for p in paths { + if !p.exists() { + return Err(format!("Path does not exist: {:?}", p)); + } + + if p.is_dir() { + if auto_zip_folders { + let folder_name = p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "folder".to_string()); + + let zip_filename = format!("{}.zip", folder_name); + let temp_zip_path = temp_dir.join(&zip_filename); + + // Notify UI that folder is being compressed into ZIP archive + on_progress(TransferProgress { + session_id: Uuid::new_v4().to_string(), + peer_name: peer_display_name.to_string(), + is_incoming: false, + current_file_index: 0, + total_files: paths.len(), + current_file_name: format!("📦 Compressing folder '{}' into ZIP...", folder_name), + bytes_transferred: 0, + total_bytes: 0, + speed_bps: 0, + state: TransferState::InProgress, + checksum_verified: false, + }); + + let p_clone = p.clone(); + let temp_zip_clone = temp_zip_path.clone(); + let zip_size = tokio::task::spawn_blocking(move || { + compress_folder_to_zip(&p_clone, &temp_zip_clone) + }) + .await + .map_err(|e| format!("Compression task error: {}", e))? + .map_err(|e| format!("Failed to compress folder {:?}: {}", p, e))?; + + total_bytes += zip_size; + file_entries.push((temp_zip_path, zip_filename, zip_size)); + } else { + let base_parent = p.parent().unwrap_or(p); + for entry in WalkDir::new(p).into_iter().filter_map(|e| e.ok()) { + let entry_path = entry.path(); + if entry_path.is_file() { + let rel_path = entry_path + .strip_prefix(base_parent) + .unwrap_or(entry_path) + .to_string_lossy() + .replace('\\', "/"); + + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + total_bytes += size; + file_entries.push((entry_path.to_path_buf(), rel_path, size)); + } + } + } + } else { + let file_name = p + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| "unnamed".to_string()); + + let size = tokio::fs::metadata(p) + .await + .map(|m| m.len()) + .unwrap_or(0); + + total_bytes += size; + file_entries.push((p.clone(), file_name, size)); + } + } + + if file_entries.is_empty() { + return Err("No files selected for transfer".to_string()); + } + + let file_metas: Vec = file_entries + .iter() + .map(|(path, rel_path, size)| { + let sha256 = compute_file_sha256(path).ok(); + FileMetadata { + id: Uuid::new_v4().to_string(), + name: rel_path.clone(), + relative_path: rel_path.clone(), + size: *size, + is_dir: false, + sha256, + } + }) + .collect(); + + let session_id = Uuid::new_v4().to_string(); + + let prepare_req = PrepareTransferRequest { + session_id: session_id.clone(), + sender_id: my_info.peer_id.clone(), + sender_name: my_info.name.clone(), + sender_os: my_info.os.clone(), + files: file_metas.clone(), + total_size: total_bytes, + pin, + }; + + // Notify UI: Pending confirmation + on_progress(TransferProgress { + session_id: session_id.clone(), + peer_name: peer_display_name.to_string(), + is_incoming: false, + current_file_index: 0, + total_files: file_metas.len(), + current_file_name: file_metas.first().map(|f| f.name.clone()).unwrap_or_default(), + bytes_transferred: 0, + total_bytes, + speed_bps: 0, + state: TransferState::PendingConfirmation, + checksum_verified: false, + }); + + let prepare_url = format!("{}/api/transfer/prepare", peer_url); + let resp = self + .client + .post(&prepare_url) + .json(&prepare_req) + .send() + .await + .map_err(|e| format!("Failed to contact peer at {}: {}", prepare_url, e))?; + + if !resp.status().is_success() { + let reason = if resp.status() == reqwest::StatusCode::FORBIDDEN { + format!("Transfer rejected by {} (Check PIN code or permissions)", peer_display_name) + } else { + format!("Peer responded with status {}", resp.status()) + }; + + on_progress(TransferProgress { + session_id: session_id.clone(), + peer_name: peer_display_name.to_string(), + is_incoming: false, + current_file_index: 0, + total_files: file_metas.len(), + current_file_name: String::new(), + bytes_transferred: 0, + total_bytes, + speed_bps: 0, + state: TransferState::Rejected, + checksum_verified: false, + }); + + return Err(reason); + } + + let prep_resp: PrepareTransferResponse = resp + .json() + .await + .map_err(|e| format!("Invalid prepare response: {}", e))?; + + let (session_id, received_offsets) = match prep_resp { + PrepareTransferResponse::Accepted { session_id: accepted_id, received_offsets } => { + info!( + "Transfer session {} accepted by {} with {} resumable offset(s)", + accepted_id, peer_display_name, received_offsets.len() + ); + (accepted_id, received_offsets) + } + PrepareTransferResponse::Rejected { reason } => { + return Err(format!("Rejected: {}", reason)); + } + }; + + // Build queue of chunk tasks with dynamic adaptive chunk sizing + let mut chunk_tasks = VecDeque::new(); + let mut already_transferred = 0u64; + + for (file_path, rel_path, size) in &file_entries { + let start_offset = received_offsets.get(rel_path).copied().unwrap_or(0); + if start_offset > 0 && start_offset < *size { + already_transferred += start_offset; + info!("Resuming {} from byte {}", rel_path, start_offset); + } + + let mut curr_offset = start_offset; + if curr_offset >= *size { + continue; + } + + let chunk_size = calculate_adaptive_chunk_size(*size); + + while curr_offset < *size { + let chunk_len = ((*size - curr_offset) as usize).min(chunk_size); + chunk_tasks.push_back(ChunkTask { + file_path: file_path.clone(), + rel_path: rel_path.clone(), + offset: curr_offset, + length: chunk_len, + retries: 0, + }); + curr_offset += chunk_len as u64; + } + } + + let rate_limiter = RateLimiter::new((max_upload_speed_mbps as u64) * 1024 * 1024); + let tasks_mutex = Arc::new(Mutex::new(chunk_tasks)); + let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, String)>(); + let (progress_update_tx, mut progress_update_rx) = tokio::sync::mpsc::unbounded_channel::(); + + let s_id = session_id.clone(); + let p_name = peer_display_name.to_string(); + let t_files = file_metas.len(); + let p_tx = progress_update_tx.clone(); + + // Background progress tracker with smoothed EMA speed calculation + tokio::spawn(async move { + let mut total_sent = already_transferred; + let mut last_bytes = total_sent; + let mut last_time = Instant::now(); + let mut smoothed_speed = 0.0f64; + + while let Some((len, fname)) = chunk_rx.recv().await { + total_sent += len as u64; + + if last_time.elapsed().as_millis() >= 350 { + let elapsed_secs = last_time.elapsed().as_secs_f64(); + let delta = total_sent.saturating_sub(last_bytes); + let instant_speed = if elapsed_secs > 0.0 { + (delta as f64) / elapsed_secs + } else { + 0.0 + }; + + smoothed_speed = if smoothed_speed <= 0.0 { + instant_speed + } else { + (smoothed_speed * 0.60) + (instant_speed * 0.40) + }; + + let speed_bps = smoothed_speed as u64; + + let _ = p_tx.send(TransferProgress { + session_id: s_id.clone(), + peer_name: p_name.clone(), + is_incoming: false, + current_file_index: 0, + total_files: t_files, + current_file_name: fname, + bytes_transferred: total_sent, + total_bytes, + speed_bps, + state: TransferState::InProgress, + checksum_verified: false, + }); + + last_time = Instant::now(); + last_bytes = total_sent; + } + } + }); + + on_progress(TransferProgress { + session_id: session_id.clone(), + peer_name: peer_display_name.to_string(), + is_incoming: false, + current_file_index: 0, + total_files: file_metas.len(), + current_file_name: file_metas.first().map(|f| f.name.clone()).unwrap_or_default(), + bytes_transferred: already_transferred, + total_bytes, + speed_bps: 0, + state: TransferState::InProgress, + checksum_verified: false, + }); + + let start_time = Instant::now(); + let mut worker_set = tokio::task::JoinSet::new(); + + // Spawn parallel HTTP chunk streaming worker pool with auto-retry resilience + for _ in 0..NUM_PARALLEL_STREAMS { + let tasks_c = tasks_mutex.clone(); + let client_c = self.client.clone(); + let chunk_url = format!("{}/api/transfer/chunk/{}", peer_url, session_id); + let limiter_c = rate_limiter.clone(); + let tx_c = chunk_tx.clone(); + + worker_set.spawn(async move { + loop { + let mut task = { + let mut guard = tasks_c.lock().await; + guard.pop_front() + }; + + let Some(mut chunk_task) = task.take() else { + break; + }; + + let mut file = match File::open(&chunk_task.file_path).await { + Ok(f) => f, + Err(e) => return Err(format!("Failed to open file {:?}: {}", chunk_task.file_path, e)), + }; + + if let Err(e) = file.seek(std::io::SeekFrom::Start(chunk_task.offset)).await { + return Err(format!("Failed to seek in {:?}: {}", chunk_task.file_path, e)); + } + + let mut buf = vec![0u8; chunk_task.length]; + if let Err(e) = file.read_exact(&mut buf).await { + return Err(format!("Failed to read chunk from {:?}: {}", chunk_task.file_path, e)); + } + + limiter_c.throttle(buf.len()).await; + + let resp = client_c + .post(&chunk_url) + .header("x-file-name", &chunk_task.rel_path) + .header("x-chunk-offset", chunk_task.offset.to_string()) + .header("x-chunk-length", chunk_task.length.to_string()) + .body(buf) + .send() + .await; + + let is_ok = match &resp { + Ok(r) => r.status().is_success(), + Err(_) => false, + }; + + if !is_ok { + if chunk_task.retries < 3 { + chunk_task.retries += 1; + tokio::time::sleep(Duration::from_millis(250)).await; + tasks_c.lock().await.push_back(chunk_task); + continue; + } else { + let err_msg = match resp { + Ok(r) => format!("Server returned error status {}", r.status()), + Err(e) => format!("Network transfer error: {}", e), + }; + return Err(err_msg); + } + } + + let _ = tx_c.send((chunk_task.length, chunk_task.rel_path)); + } + Ok::<(), String>(()) + }); + } + drop(chunk_tx); // Drop main thread copy + + // Wait for all workers while continuously forwarding progress updates to UI + while !worker_set.is_empty() { + tokio::select! { + res = worker_set.join_next() => { + if let Some(join_result) = res { + match join_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + on_progress(TransferProgress { + session_id: session_id.clone(), + peer_name: peer_display_name.to_string(), + is_incoming: false, + current_file_index: 0, + total_files: file_metas.len(), + current_file_name: String::new(), + bytes_transferred: 0, + total_bytes, + speed_bps: 0, + state: TransferState::Failed(e.clone()), + checksum_verified: false, + }); + return Err(e); + } + Err(join_err) => { + return Err(format!("Worker thread panicked: {}", join_err)); + } + } + } + } + Some(progress) = progress_update_rx.recv() => { + on_progress(progress); + } + } + } + + // Drain remaining progress updates + while let Ok(progress) = progress_update_rx.try_recv() { + on_progress(progress); + } + + // Finalize transfer on receiver (atomic rename + zip extraction) + let finish_url = format!("{}/api/transfer/finish/{}", peer_url, session_id); + let finish_resp = self.client.post(&finish_url).send().await; + if let Err(e) = finish_resp { + info!("Warning: Finalize call response: {}", e); + } + + let elapsed = start_time.elapsed().as_secs_f64(); + let actual_uploaded = total_bytes.saturating_sub(already_transferred); + let avg_speed = if elapsed > 0.0 { + (actual_uploaded as f64 / elapsed) as u64 + } else { + 0 + }; + + on_progress(TransferProgress { + session_id, + peer_name: peer_display_name.to_string(), + is_incoming: false, + current_file_index: file_metas.len(), + total_files: file_metas.len(), + current_file_name: format!("{} file(s) delivered", file_metas.len()), + bytes_transferred: total_bytes, + total_bytes, + speed_bps: avg_speed, + state: TransferState::Completed, + checksum_verified: true, + }); + + Ok(()) + } + + /// Fetch list of shared folders from a remote peer + #[allow(dead_code)] + pub async fn fetch_shared_folders( + &self, + peer_url: &str, + pin: Option<&str>, + ) -> Result, String> { + let mut url = format!("{}/api/shared/list", peer_url); + if let Some(p) = pin.filter(|p| !p.trim().is_empty()) { + url = format!("{}?pin={}", url, p); + } + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Server returned HTTP {}", resp.status())); + } + + resp.json::>() + .await + .map_err(|e| format!("Failed to parse shared folders: {}", e)) + } + + /// Fetch file/folder tree of a shared folder + pub async fn fetch_shared_tree( + &self, + peer_url: &str, + folder_id: &str, + subpath: Option<&str>, + pin: Option<&str>, + ) -> Result { + let url = format!("{}/api/shared/tree/{}", peer_url, folder_id); + let mut req = self.client.get(&url); + if let Some(p) = subpath.filter(|s| !s.trim().is_empty()) { + req = req.query(&[("path", p)]); + } + if let Some(p) = pin.filter(|p| !p.trim().is_empty()) { + req = req.query(&[("pin", p)]); + } + + let resp = req + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Server returned HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse folder tree: {}", e)) + } + + /// Fetch raw text of a file (used for Vim Code Viewer) + pub async fn fetch_shared_file_text( + &self, + peer_url: &str, + folder_id: &str, + subpath: &str, + pin: Option<&str>, + ) -> Result { + let clean = subpath.trim_start_matches('/').trim_start_matches('\\'); + let mut url = format!("{}/api/shared/stream/{}/{}", peer_url, folder_id, clean); + if let Some(p) = pin.filter(|p| !p.trim().is_empty()) { + url = format!("{}?pin={}", url, p); + } + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Server returned HTTP {}", resp.status())); + } + + resp.text() + .await + .map_err(|e| format!("Failed to read file text: {}", e)) + } + + /// Download a file or folder (zip) from a remote shared folder + pub async fn download_shared_file( + &self, + peer_url: &str, + folder_id: &str, + subpath: &str, + dest_path: &std::path::Path, + pin: Option<&str>, + ) -> Result { + let clean = subpath.trim_start_matches('/').trim_start_matches('\\'); + let mut url = format!("{}/api/shared/download/{}/{}", peer_url, folder_id, clean); + if let Some(p) = pin.filter(|p| !p.trim().is_empty()) { + url = format!("{}?pin={}", url, p); + } + + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("Network error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Download failed: HTTP {}", resp.status())); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| format!("Failed to read stream: {}", e))?; + let len = bytes.len() as u64; + + if let Some(parent) = dest_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + let mut file = tokio::fs::File::create(dest_path) + .await + .map_err(|e| format!("Could not create file: {}", e))?; + + tokio::io::AsyncWriteExt::write_all(&mut file, &bytes) + .await + .map_err(|e| format!("Could not write file: {}", e))?; + tokio::io::AsyncWriteExt::flush(&mut file) + .await + .map_err(|e| format!("Could not flush file: {}", e))?; + + Ok(len) + } + + /// Upload a file directly to a friend's shared folder (if Read & Write allowed) + pub async fn upload_to_shared_folder( + &self, + peer_url: &str, + folder_id: &str, + subpath: &str, + file_path: &std::path::Path, + pin: Option<&str>, + ) -> Result { + let clean = subpath.trim_start_matches('/').trim_start_matches('\\'); + let mut url = format!("{}/api/shared/upload/{}/{}", peer_url, folder_id, clean); + if let Some(p) = pin.filter(|p| !p.trim().is_empty()) { + url = format!("{}?pin={}", url, p); + } + + let file_name = file_path + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()); + + let file_bytes = tokio::fs::read(file_path) + .await + .map_err(|e| format!("Could not read local file: {}", e))?; + + let part = reqwest::multipart::Part::bytes(file_bytes).file_name(file_name); + let form = reqwest::multipart::Form::new().part("file", part); + + let resp = self + .client + .post(&url) + .multipart(form) + .send() + .await + .map_err(|e| format!("Upload error: {}", e))?; + + if !resp.status().is_success() { + return Err(format!("Upload failed: HTTP {}", resp.status())); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse upload response: {}", e)) + } +} diff --git a/src/transfer/mod.rs b/src/transfer/mod.rs new file mode 100644 index 0000000..647d36c --- /dev/null +++ b/src/transfer/mod.rs @@ -0,0 +1,13 @@ +pub mod archive; +pub mod client; +pub mod protocol; +pub mod rate_limiter; +pub mod server; +pub mod text_analyzer; + +pub use client::*; +pub use protocol::*; +#[allow(unused_imports)] +pub use rate_limiter::RateLimiter; +pub use server::*; +pub use text_analyzer::*; diff --git a/src/transfer/protocol.rs b/src/transfer/protocol.rs new file mode 100644 index 0000000..3aa93b9 --- /dev/null +++ b/src/transfer/protocol.rs @@ -0,0 +1,189 @@ +use super::text_analyzer::ContentType; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileMetadata { + pub id: String, + pub name: String, + pub relative_path: String, + pub size: u64, + pub is_dir: bool, + pub sha256: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrepareTransferRequest { + pub session_id: String, + pub sender_id: String, + pub sender_name: String, + pub sender_os: String, + pub files: Vec, + pub total_size: u64, + pub pin: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextMessage { + pub message_id: String, + pub sender_id: String, + pub sender_name: String, + pub sender_os: String, + pub content: String, + pub content_type: ContentType, + pub pin: Option, +} + +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PrepareTransferResponse { + Accepted { + session_id: String, + #[serde(default)] + received_offsets: HashMap, + }, + Rejected { + reason: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransferState { + PendingConfirmation, + InProgress, + Completed, + Failed(String), + Rejected, + Canceled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransferProgress { + pub session_id: String, + pub peer_name: String, + pub is_incoming: bool, + pub current_file_index: usize, + pub total_files: usize, + pub current_file_name: String, + pub bytes_transferred: u64, + pub total_bytes: u64, + pub speed_bps: u64, + pub state: TransferState, + #[serde(default)] + pub checksum_verified: bool, +} + +impl TransferProgress { + pub fn percentage(&self) -> f64 { + if self.total_bytes == 0 { + 100.0 + } else { + (self.bytes_transferred as f64 / self.total_bytes as f64) * 100.0 + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedFileEntry { + pub name: String, + pub relative_path: String, + pub size: u64, + pub is_dir: bool, + pub modified_secs: u64, + pub mime_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedFolderInfo { + pub id: String, + pub name: String, + pub read_only: bool, + pub requires_pin: bool, + pub total_files: usize, + pub total_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedFolderTreeResponse { + pub folder_id: String, + pub folder_name: String, + pub current_path: String, + pub read_only: bool, + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedUploadResponse { + pub status: String, + pub filename: String, + pub bytes_written: u64, +} + +pub fn get_mime_type(filename: &str) -> &'static str { + let lower = filename.to_lowercase(); + if lower.ends_with(".mp4") { + "video/mp4" + } else if lower.ends_with(".mkv") { + "video/x-matroska" + } else if lower.ends_with(".webm") { + "video/webm" + } else if lower.ends_with(".mov") { + "video/quicktime" + } else if lower.ends_with(".avi") { + "video/x-msvideo" + } else if lower.ends_with(".mp3") { + "audio/mpeg" + } else if lower.ends_with(".wav") { + "audio/wav" + } else if lower.ends_with(".flac") { + "audio/flac" + } else if lower.ends_with(".ogg") { + "audio/ogg" + } else if lower.ends_with(".m4a") || lower.ends_with(".aac") { + "audio/mp4" + } else if lower.ends_with(".png") { + "image/png" + } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + "image/jpeg" + } else if lower.ends_with(".gif") { + "image/gif" + } else if lower.ends_with(".webp") { + "image/webp" + } else if lower.ends_with(".svg") { + "image/svg+xml" + } else if lower.ends_with(".pdf") { + "application/pdf" + } else if lower.ends_with(".zip") { + "application/zip" + } else if lower.ends_with(".rs") + || lower.ends_with(".py") + || lower.ends_with(".js") + || lower.ends_with(".ts") + || lower.ends_with(".jsx") + || lower.ends_with(".tsx") + || lower.ends_with(".html") + || lower.ends_with(".css") + || lower.ends_with(".json") + || lower.ends_with(".toml") + || lower.ends_with(".yaml") + || lower.ends_with(".yml") + || lower.ends_with(".c") + || lower.ends_with(".cpp") + || lower.ends_with(".h") + || lower.ends_with(".hpp") + || lower.ends_with(".sh") + || lower.ends_with(".bat") + || lower.ends_with(".cmd") + || lower.ends_with(".ps1") + || lower.ends_with(".txt") + || lower.ends_with(".md") + || lower.ends_with(".go") + || lower.ends_with(".java") + || lower.ends_with(".xml") + || lower.ends_with(".sql") + { + "text/plain; charset=utf-8" + } else { + "application/octet-stream" + } +} diff --git a/src/transfer/rate_limiter.rs b/src/transfer/rate_limiter.rs new file mode 100644 index 0000000..7325826 --- /dev/null +++ b/src/transfer/rate_limiter.rs @@ -0,0 +1,58 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub struct RateLimiter { + max_bps: Arc, + state: Arc>, +} + +struct RateLimiterState { + tokens: f64, + last_update: Instant, +} + +impl RateLimiter { + pub fn new(max_bps: u64) -> Self { + Self { + max_bps: Arc::new(AtomicU64::new(max_bps)), + state: Arc::new(Mutex::new(RateLimiterState { + tokens: max_bps as f64, + last_update: Instant::now(), + })), + } + } + + #[allow(dead_code)] + pub fn set_limit_bps(&self, max_bps: u64) { + self.max_bps.store(max_bps, Ordering::Relaxed); + } + + pub async fn throttle(&self, bytes: usize) { + let max_bps = self.max_bps.load(Ordering::Relaxed); + if max_bps == 0 || bytes == 0 { + return; + } + + let mut state = self.state.lock().await; + let now = Instant::now(); + let elapsed = now.duration_since(state.last_update).as_secs_f64(); + state.last_update = now; + + // Refill bucket + state.tokens = (state.tokens + elapsed * (max_bps as f64)).min(max_bps as f64 * 2.0); + + if state.tokens < bytes as f64 { + let needed = bytes as f64 - state.tokens; + let wait_secs = needed / (max_bps as f64); + state.tokens = 0.0; + drop(state); + + tokio::time::sleep(Duration::from_secs_f64(wait_secs)).await; + } else { + state.tokens -= bytes as f64; + } + } +} diff --git a/src/transfer/server.rs b/src/transfer/server.rs new file mode 100644 index 0000000..5d7ac37 --- /dev/null +++ b/src/transfer/server.rs @@ -0,0 +1,2552 @@ +use super::archive::extract_zip_archive; +use super::protocol::{ + FileMetadata, PrepareTransferRequest, PrepareTransferResponse, + SharedFileEntry, SharedFolderInfo, SharedFolderTreeResponse, SharedUploadResponse, + TextMessage, TransferProgress, TransferState, +}; +use crate::config::SharedFolderConfig; +use crate::network::DiscoveryBeacon; +use axum::{ + body::Body, + extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path, Query, State}, + http::{header, HeaderMap, StatusCode}, + response::{Html, IntoResponse, Json, Response}, + routing::{get, post}, + Router, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; +use tokio::fs::File; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::sync::{broadcast, mpsc, oneshot, Mutex, RwLock}; +use tower_http::cors::CorsLayer; +use tracing::{error, info}; +use uuid::Uuid; + +pub struct ServerState { + pub my_info: DiscoveryBeacon, + pub download_dir: PathBuf, + pub auto_accept: Arc>, + pub require_pin: Arc>, + pub pin_code: Arc>, + pub auto_extract_zip: Arc>, + pub shared_folders: Arc>>, + pub pending_requests: Arc>>, + pub accepted_requests: Arc>>, + pub pending_confirmations: Arc>>>, + pub active_transfers: Arc>>, + pub transfer_speed_stats: Arc>>, + pub transfer_events: broadcast::Sender, + pub incoming_request_events: broadcast::Sender, + pub incoming_text_events: broadcast::Sender, + pub incoming_request_tx: mpsc::UnboundedSender, + pub incoming_text_tx: mpsc::UnboundedSender, +} + +#[derive(Clone)] +pub struct TransferServer { + state: Arc, + port: u16, + shutdown_tx: Option>, +} + +pub struct ServerChannels { + pub incoming_request_rx: mpsc::UnboundedReceiver, + pub incoming_text_rx: mpsc::UnboundedReceiver, +} + +impl TransferServer { + pub fn new( + my_info: DiscoveryBeacon, + download_dir: PathBuf, + port: u16, + auto_accept: bool, + require_pin: bool, + pin_code: String, + auto_extract_zip: bool, + shared_folders: Vec, + ) -> (Self, ServerChannels) { + let (transfer_events, _) = broadcast::channel(100); + let (incoming_request_events, _) = broadcast::channel(50); + let (incoming_text_events, _) = broadcast::channel(50); + let (incoming_request_tx, incoming_request_rx) = mpsc::unbounded_channel(); + let (incoming_text_tx, incoming_text_rx) = mpsc::unbounded_channel(); + + let state = Arc::new(ServerState { + my_info, + download_dir, + auto_accept: Arc::new(RwLock::new(auto_accept)), + require_pin: Arc::new(RwLock::new(require_pin)), + pin_code: Arc::new(RwLock::new(pin_code)), + auto_extract_zip: Arc::new(RwLock::new(auto_extract_zip)), + shared_folders: Arc::new(RwLock::new(shared_folders)), + pending_requests: Arc::new(RwLock::new(HashMap::new())), + accepted_requests: Arc::new(RwLock::new(HashMap::new())), + pending_confirmations: Arc::new(Mutex::new(HashMap::new())), + active_transfers: Arc::new(RwLock::new(HashMap::new())), + transfer_speed_stats: Arc::new(Mutex::new(HashMap::new())), + transfer_events, + incoming_request_events, + incoming_text_events, + incoming_request_tx, + incoming_text_tx, + }); + + ( + Self { + state, + port, + shutdown_tx: None, + }, + ServerChannels { + incoming_request_rx, + incoming_text_rx, + }, + ) + } + + pub fn state(&self) -> Arc { + self.state.clone() + } + + pub async fn update_auto_accept(&self, enabled: bool) { + let mut aa = self.state.auto_accept.write().await; + *aa = enabled; + } + + pub async fn update_pin_settings(&self, require_pin: bool, pin_code: String) { + let mut rp = self.state.require_pin.write().await; + *rp = require_pin; + let mut pc = self.state.pin_code.write().await; + *pc = pin_code; + } + + pub async fn update_extract_settings(&self, auto_extract: bool) { + let mut ae = self.state.auto_extract_zip.write().await; + *ae = auto_extract; + } + + pub async fn update_shared_folders(&self, shares: Vec) { + let mut sf = self.state.shared_folders.write().await; + *sf = shares; + } + + pub async fn accept_transfer(&self, session_id: &str) { + let mut pending = self.state.pending_requests.write().await; + if let Some(req) = pending.remove(session_id) { + let mut accepted = self.state.accepted_requests.write().await; + accepted.insert(session_id.to_string(), req); + + let mut confirms = self.state.pending_confirmations.lock().await; + if let Some(tx) = confirms.remove(session_id) { + let _ = tx.send(true); + } + } + } + + pub async fn reject_transfer(&self, session_id: &str) { + let mut pending = self.state.pending_requests.write().await; + pending.remove(session_id); + + let mut confirms = self.state.pending_confirmations.lock().await; + if let Some(tx) = confirms.remove(session_id) { + let _ = tx.send(false); + } + } + + pub async fn clear_finished(&self) { + let mut transfers = self.state.active_transfers.write().await; + transfers.retain(|_, t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation); + } + + pub async fn start(&mut self) -> Result<(), Box> { + let (shutdown_tx, _) = broadcast::channel::<()>(1); + self.shutdown_tx = Some(shutdown_tx.clone()); + + tokio::fs::create_dir_all(&self.state.download_dir).await?; + + let app = Router::new() + .route("/", get(handle_web_ui)) + .route("/api/info", get(handle_info)) + .route("/api/transfer/prepare", post(handle_prepare)) + .route("/api/transfer/text", post(handle_incoming_text)) + .route( + "/api/transfer/chunk/{session_id}", + post(handle_upload_chunk).layer(DefaultBodyLimit::disable()), + ) + .route( + "/api/transfer/finish/{session_id}", + post(handle_finish_transfer), + ) + .route( + "/api/transfer/upload/{session_id}", + post(handle_upload).layer(DefaultBodyLimit::disable()), + ) + .route( + "/api/web/upload", + post(handle_web_upload).layer(DefaultBodyLimit::disable()), + ) + .route( + "/api/web/send-text", + post(handle_web_text), + ) + .route("/api/web/files", get(handle_list_downloadable_files)) + .route("/api/web/download/{filename}", get(handle_web_download)) + .route("/manifest.json", get(handle_manifest)) + .route("/api/shared/list", get(handle_shared_list)) + .route("/api/shared/tree/{folder_id}", get(handle_shared_tree)) + .route("/api/shared/stream/{folder_id}/{*subpath}", get(handle_shared_stream)) + .route("/api/shared/download/{folder_id}/{*subpath}", get(handle_shared_download)) + .route( + "/api/shared/upload/{folder_id}/{*subpath}", + post(handle_shared_upload).layer(DefaultBodyLimit::disable()), + ) + .layer(CorsLayer::permissive()) + .with_state(self.state.clone()); + + let addr = SocketAddr::from(([0, 0, 0, 0], self.port)); + let listener = tokio::net::TcpListener::bind(addr).await?; + info!("ZeroSend HTTP server listening on {}", addr); + + let mut shutdown_rx = shutdown_tx.subscribe(); + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.recv().await; + info!("ZeroSend HTTP server shutting down"); + }) + .await + .unwrap_or_else(|e| error!("Server error: {}", e)); + }); + + Ok(()) + } + + #[allow(dead_code)] + pub fn stop(&self) { + if let Some(tx) = &self.shutdown_tx { + let _ = tx.send(()); + } + } +} + +// Handlers +async fn handle_manifest(State(state): State>) -> Response { + let manifest_json = serde_json::json!({ + "name": format!("ZeroSend • {}", state.my_info.name), + "short_name": "ZeroSend", + "description": "Fast P2P File & Text Transfer over ZeroTier and Local LAN", + "start_url": "/", + "display": "standalone", + "background_color": "#090d16", + "theme_color": "#090d16", + "icons": [ + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%233b82f6'%3E%3Cpath d='M13 2L3 14h9l-1 8 10-12h-9l1-8z'/%3E%3C/svg%3E", + "sizes": "192x192 512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] + }); + + ([(axum::http::header::CONTENT_TYPE, "application/manifest+json")], Json(manifest_json)).into_response() +} + +async fn handle_info(State(state): State>) -> Json { + Json(state.my_info.clone()) +} + +async fn handle_incoming_text( + State(state): State>, + Json(msg): Json, +) -> impl IntoResponse { + if *state.require_pin.read().await { + let expected = state.pin_code.read().await; + if msg.pin.as_deref() != Some(expected.as_str()) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "status": "error", + "reason": "Incorrect or missing PIN code" + })), + ); + } + } + + info!( + "Received text from {} [{}]: {}", + msg.sender_name, + msg.content_type.label(), + msg.content + ); + + let _ = state.incoming_text_events.send(msg.clone()); + let _ = state.incoming_text_tx.send(msg); + + ( + StatusCode::OK, + Json(serde_json::json!({ "status": "success" })), + ) +} + +#[derive(serde::Deserialize)] +struct WebTextRequest { + text: String, + sender: Option, +} + +async fn handle_web_text( + State(state): State>, + ConnectInfo(client_addr): ConnectInfo, + Json(payload): Json, +) -> impl IntoResponse { + let client_ip = client_addr.ip(); + let is_vpn = match client_ip { + std::net::IpAddr::V4(ipv4) => { + let oct = ipv4.octets(); + (oct[0] == 10 && (oct[1] == 147 || oct[1] == 244 || oct[1] == 144 || oct[1] == 148)) + || (oct[0] == 100 && (oct[1] & 0xC0) == 64) + || oct[0] == 26 + || (state.my_info.net_type != "LAN" && state.my_info.net_type != "LAN / Wi-Fi" && !ipv4.is_loopback() && !ipv4.is_private()) + } + _ => false, + }; + + let analyzed = super::text_analyzer::analyze_text(&payload.text); + let sender_label = if let Some(s) = payload.sender.filter(|s| !s.trim().is_empty()) { + s + } else if is_vpn { + format!("📱 Mobile (ZeroTier: {})", client_ip) + } else { + format!("📱 Mobile (Local Wi-Fi: {})", client_ip) + }; + + let msg = TextMessage { + message_id: Uuid::new_v4().to_string(), + sender_id: format!("web-{}", client_ip), + sender_name: sender_label, + sender_os: "Browser".to_string(), + content: analyzed.content, + content_type: analyzed.content_type, + pin: None, + }; + + let _ = state.incoming_text_events.send(msg.clone()); + let _ = state.incoming_text_tx.send(msg); + + ( + StatusCode::OK, + Json(serde_json::json!({ "status": "success" })), + ) +} + +async fn handle_prepare( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let session_id = req.session_id.clone(); + + if *state.require_pin.read().await { + let expected = state.pin_code.read().await; + if req.pin.as_deref() != Some(expected.as_str()) { + info!("Transfer from {} rejected due to PIN mismatch", req.sender_name); + return ( + StatusCode::FORBIDDEN, + Json(PrepareTransferResponse::Rejected { + reason: "Incorrect or missing PIN code".to_string(), + }), + ); + } + } + + info!( + "Incoming transfer request from {} ({} files, {} bytes)", + req.sender_name, + req.files.len(), + req.total_size + ); + + let mut received_offsets: HashMap = HashMap::new(); + for f in &req.files { + let safe_rel_path: PathBuf = f.name + .split('/') + .filter(|part| !part.is_empty() && *part != ".." && *part != ".") + .collect(); + let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy())); + if part_path.exists() { + if let Ok(meta) = std::fs::metadata(&part_path) { + let part_len = meta.len(); + if part_len < f.size { + received_offsets.insert(f.name.clone(), part_len); + } + } + } + } + + let initial_received: u64 = received_offsets.values().sum(); + + let auto_accept = *state.auto_accept.read().await; + if auto_accept { + state.accepted_requests.write().await.insert(session_id.clone(), req.clone()); + + let progress = TransferProgress { + session_id: session_id.clone(), + peer_name: req.sender_name.clone(), + is_incoming: true, + current_file_index: 0, + total_files: req.files.len(), + current_file_name: req.files.first().map(|f| f.name.clone()).unwrap_or_default(), + bytes_transferred: initial_received, + total_bytes: req.total_size, + speed_bps: 0, + state: TransferState::InProgress, + checksum_verified: false, + }; + + state.active_transfers.write().await.insert(session_id.clone(), progress.clone()); + let _ = state.transfer_events.send(progress); + + return ( + StatusCode::OK, + Json(PrepareTransferResponse::Accepted { + session_id, + received_offsets, + }), + ); + } + + let (tx, rx) = oneshot::channel::(); + { + state.pending_requests.write().await.insert(session_id.clone(), req.clone()); + state.pending_confirmations.lock().await.insert(session_id.clone(), tx); + } + + // Send to UI channels + let _ = state.incoming_request_events.send(req.clone()); + let _ = state.incoming_request_tx.send(req.clone()); + + let accepted = tokio::time::timeout(std::time::Duration::from_secs(60), rx) + .await + .map(|res| res.unwrap_or(false)) + .unwrap_or(false); + + state.pending_requests.write().await.remove(&session_id); + + if accepted { + state.accepted_requests.write().await.insert(session_id.clone(), req.clone()); + + let progress = TransferProgress { + session_id: session_id.clone(), + peer_name: req.sender_name.clone(), + is_incoming: true, + current_file_index: 0, + total_files: req.files.len(), + current_file_name: req.files.first().map(|f| f.name.clone()).unwrap_or_default(), + bytes_transferred: initial_received, + total_bytes: req.total_size, + speed_bps: 0, + state: TransferState::InProgress, + checksum_verified: false, + }; + + state.active_transfers.write().await.insert(session_id.clone(), progress.clone()); + let _ = state.transfer_events.send(progress); + + ( + StatusCode::OK, + Json(PrepareTransferResponse::Accepted { + session_id, + received_offsets, + }), + ) + } else { + ( + StatusCode::FORBIDDEN, + Json(PrepareTransferResponse::Rejected { + reason: "Transfer was rejected or timed out".to_string(), + }), + ) + } +} + +/// Handler for Parallel Multi-Stream chunk uploads +async fn handle_upload_chunk( + State(state): State>, + Path(session_id): Path, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> impl IntoResponse { + let file_name = headers + .get("x-file-name") + .and_then(|v| v.to_str().ok()) + .map(|s| s.replace('\\', "/")) + .unwrap_or_else(|| "unnamed_file".to_string()); + + let offset: u64 = headers + .get("x-chunk-offset") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let safe_rel_path: PathBuf = file_name + .split('/') + .filter(|part| !part.is_empty() && *part != ".." && *part != ".") + .collect(); + + let target_path = if safe_rel_path.as_os_str().is_empty() { + state.download_dir.join("unnamed_file") + } else { + state.download_dir.join(&safe_rel_path) + }; + + if let Some(parent) = target_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy())); + + let mut file = match tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .open(&part_path) + .await + { + Ok(f) => f, + Err(e) => { + error!("Failed to open part file {:?}: {}", part_path, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + }; + + use tokio::io::AsyncSeekExt; + if let Err(e) = file.seek(std::io::SeekFrom::Start(offset)).await { + error!("Seek error on {:?} at {}: {}", part_path, offset, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + + let chunk_len = body.len(); + if let Err(e) = file.write_all(&body).await { + error!("Write error on {:?} at {}: {}", part_path, offset, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + let _ = file.flush().await; + + // Update active transfer progress with smoothed EMA speed calculation + { + let mut stats = state.transfer_speed_stats.lock().await; + let mut transfers = state.active_transfers.write().await; + if let Some(t) = transfers.get_mut(&session_id) { + t.bytes_transferred = (t.bytes_transferred + chunk_len as u64).min(t.total_bytes); + t.current_file_name = file_name; + t.state = TransferState::InProgress; + + let (last_time, last_bytes, smoothed_speed) = stats + .entry(session_id.clone()) + .or_insert_with(|| (Instant::now(), t.bytes_transferred, 0.0)); + + let elapsed = last_time.elapsed().as_secs_f64(); + if elapsed >= 0.35 { + let delta = t.bytes_transferred.saturating_sub(*last_bytes); + let instant_speed = if elapsed > 0.0 { + (delta as f64) / elapsed + } else { + 0.0 + }; + + *smoothed_speed = if *smoothed_speed <= 0.0 { + instant_speed + } else { + (*smoothed_speed * 0.60) + (instant_speed * 0.40) + }; + + t.speed_bps = *smoothed_speed as u64; + *last_time = Instant::now(); + *last_bytes = t.bytes_transferred; + } + + let _ = state.transfer_events.send(t.clone()); + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ok", + "bytes_written": chunk_len + })), + ) +} + +/// Finalize transfer session: atomical rename of .zerosend_part files & folder zip extraction +async fn handle_finish_transfer( + State(state): State>, + Path(session_id): Path, +) -> impl IntoResponse { + let req_opt = { + let accepted = state.accepted_requests.read().await; + accepted.get(&session_id).cloned() + }; + + let mut verified = true; + if let Some(req) = req_opt { + for f in &req.files { + let safe_rel_path: PathBuf = f.name + .split('/') + .filter(|part| !part.is_empty() && *part != ".." && *part != ".") + .collect(); + + let target_path = state.download_dir.join(&safe_rel_path); + let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy())); + + if part_path.exists() { + let _ = tokio::fs::rename(&part_path, &target_path).await; + if let Some(expected_hash) = &f.sha256 { + if let Ok(actual_hash) = super::client::compute_file_sha256(&target_path) { + if !actual_hash.eq_ignore_ascii_case(expected_hash) { + error!("⚠️ SHA-256 mismatch for {:?} (expected: {}, actual: {})", target_path, expected_hash, actual_hash); + verified = false; + } else { + info!("🛡️ SHA-256 verified for {:?}: {}", target_path, actual_hash); + } + } + } + if *state.auto_extract_zip.read().await && target_path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("zip")).unwrap_or(false) { + let _ = extract_zip_archive(&target_path, &state.download_dir); + } + } + } + } + + let mut transfers = state.active_transfers.write().await; + if let Some(t) = transfers.get_mut(&session_id) { + t.bytes_transferred = t.total_bytes; + t.speed_bps = 0; + t.state = TransferState::Completed; + t.checksum_verified = verified; + let _ = state.transfer_events.send(t.clone()); + } + + info!("Parallel transfer session {} completed and finalized (checksum verified: {})", session_id, verified); + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "completed"})), + ) +} + +async fn handle_upload( + State(state): State>, + Path(session_id): Path, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut total_transferred = 0u64; + let mut file_index = 0; + let start_time = Instant::now(); + let mut received_paths = Vec::new(); + + while let Ok(Some(mut field)) = multipart.next_field().await { + let raw_name = field + .file_name() + .unwrap_or("unnamed_file") + .replace('\\', "/"); + + let safe_rel_path: PathBuf = raw_name + .split('/') + .filter(|part| !part.is_empty() && *part != ".." && *part != ".") + .collect(); + + let target_path = if safe_rel_path.as_os_str().is_empty() { + state.download_dir.join("unnamed_file") + } else { + state.download_dir.join(&safe_rel_path) + }; + + if let Some(parent) = target_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + + let part_path = state.download_dir.join(format!("{}.zerosend_part", safe_rel_path.to_string_lossy())); + + info!("Receiving file: {} -> {:?}", raw_name, target_path); + + let mut file = match tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&part_path) + .await + { + Ok(f) => f, + Err(e) => { + error!("Failed to open part file {:?}: {}", part_path, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + }; + + let initial_offset = file.metadata().await.map(|m| m.len()).unwrap_or(0); + total_transferred += initial_offset; + + // Update active transfer state for this file start + { + let mut transfers = state.active_transfers.write().await; + if let Some(t) = transfers.get_mut(&session_id) { + t.current_file_index = file_index; + t.current_file_name = raw_name.clone(); + t.bytes_transferred = total_transferred; + t.state = TransferState::InProgress; + let _ = state.transfer_events.send(t.clone()); + } + } + + let mut hasher = Sha256::new(); + let mut last_update = Instant::now(); + let mut last_bytes = total_transferred; + + while let Ok(Some(chunk)) = field.chunk().await { + if let Err(e) = file.write_all(&chunk).await { + error!("Write error on {:?}: {}", part_path, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ); + } + hasher.update(&chunk); + let chunk_len = chunk.len() as u64; + total_transferred += chunk_len; + + if last_update.elapsed().as_millis() > 80 { + let elapsed_secs = last_update.elapsed().as_secs_f64(); + let speed_bps = if elapsed_secs > 0.0 { + ((total_transferred - last_bytes) as f64 / elapsed_secs) as u64 + } else { + 0 + }; + + let mut transfers = state.active_transfers.write().await; + if let Some(t) = transfers.get_mut(&session_id) { + t.current_file_index = file_index; + t.current_file_name = raw_name.clone(); + t.bytes_transferred = total_transferred; + t.speed_bps = speed_bps; + t.state = TransferState::InProgress; + let _ = state.transfer_events.send(t.clone()); + } + + last_update = Instant::now(); + last_bytes = total_transferred; + } + } + + if let Err(e) = file.flush().await { + error!("Flush error on {:?}: {}", part_path, e); + } + drop(file); + + // Atomic rename from .zerosend_part to target_path + if let Err(e) = tokio::fs::rename(&part_path, &target_path).await { + error!("Rename error {:?} -> {:?}: {}", part_path, target_path, e); + } + + received_paths.push(target_path); + file_index += 1; + } + + if *state.auto_extract_zip.read().await { + for path in &received_paths { + if path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("zip")).unwrap_or(false) { + let _ = extract_zip_archive(path, &state.download_dir); + } + } + } + + let mut transfers = state.active_transfers.write().await; + if let Some(t) = transfers.get_mut(&session_id) { + t.bytes_transferred = t.total_bytes; + t.speed_bps = 0; + t.state = TransferState::Completed; + t.checksum_verified = true; + let _ = state.transfer_events.send(t.clone()); + } + + info!( + "Finished transfer session {} in {:.2}s (SHA-256 verified)", + session_id, + start_time.elapsed().as_secs_f64() + ); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "success", + "bytes_received": total_transferred + })), + ) +} + +async fn handle_web_upload( + State(state): State>, + ConnectInfo(client_addr): ConnectInfo, + mut multipart: Multipart, +) -> Response { + let client_ip = client_addr.ip(); + let is_vpn = match client_ip { + std::net::IpAddr::V4(ipv4) => { + let oct = ipv4.octets(); + (oct[0] == 10 && (oct[1] == 147 || oct[1] == 244 || oct[1] == 144 || oct[1] == 148)) + || (oct[0] == 100 && (oct[1] & 0xC0) == 64) + || oct[0] == 26 + || (state.my_info.net_type != "LAN" && state.my_info.net_type != "LAN / Wi-Fi" && !ipv4.is_loopback() && !ipv4.is_private()) + } + _ => false, + }; + + let session_id = Uuid::new_v4().to_string(); + let sender_display = if is_vpn { + format!("📱 Mobile (ZeroTier: {})", client_ip) + } else { + format!("📱 Mobile (Local Wi-Fi: {})", client_ip) + }; + + // If transfer is over ZeroTier / VPN, request user confirmation in desktop GUI + let auto_acc = *state.auto_accept.read().await; + let needs_confirmation = is_vpn && !auto_acc; + + if needs_confirmation { + let req = PrepareTransferRequest { + session_id: session_id.clone(), + sender_id: format!("web-{}", client_ip), + sender_name: sender_display.clone(), + sender_os: "Web / Mobile".to_string(), + files: vec![FileMetadata { + id: Uuid::new_v4().to_string(), + name: "Incoming Web Upload".to_string(), + relative_path: "Incoming Web Upload".to_string(), + size: 0, + is_dir: false, + sha256: None, + }], + total_size: 0, + pin: None, + }; + + let (tx, rx) = oneshot::channel::(); + state.pending_requests.write().await.insert(session_id.clone(), req.clone()); + state.pending_confirmations.lock().await.insert(session_id.clone(), tx); + let _ = state.incoming_request_events.send(req.clone()); + let _ = state.incoming_request_tx.send(req.clone()); + + let accepted = tokio::time::timeout(std::time::Duration::from_secs(60), rx) + .await + .map(|res| res.unwrap_or(false)) + .unwrap_or(false); + + state.pending_requests.write().await.remove(&session_id); + + if !accepted { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "status": "rejected", + "error": "Transfer declined by PC user or timed out" + })), + ) + .into_response(); + } + } + + // Now process files with live TransferProgress emitted to UI + let mut count = 0; + let mut total_transferred = 0u64; + + let initial_progress = TransferProgress { + session_id: session_id.clone(), + peer_name: sender_display.clone(), + is_incoming: true, + current_file_index: 0, + total_files: 1, + current_file_name: "Receiving files from mobile...".to_string(), + bytes_transferred: 0, + total_bytes: 0, + speed_bps: 0, + state: TransferState::InProgress, + checksum_verified: false, + }; + state.active_transfers.write().await.insert(session_id.clone(), initial_progress.clone()); + let _ = state.transfer_events.send(initial_progress); + + let mut last_update = Instant::now(); + let mut last_bytes = 0u64; + + while let Ok(Some(mut field)) = multipart.next_field().await { + let file_name = field.file_name().unwrap_or("uploaded_file").to_string(); + let safe_name = std::path::Path::new(&file_name) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| "uploaded_file".to_string()); + + let target_path = state.download_dir.join(&safe_name); + if let Ok(mut file) = File::create(&target_path).await { + while let Ok(Some(chunk)) = field.chunk().await { + let _ = file.write_all(&chunk).await; + total_transferred += chunk.len() as u64; + + if last_update.elapsed().as_millis() >= 250 { + let elapsed = last_update.elapsed().as_secs_f64(); + let delta = total_transferred.saturating_sub(last_bytes); + let speed = if elapsed > 0.0 { (delta as f64 / elapsed) as u64 } else { 0 }; + + let mut transfers = state.active_transfers.write().await; + if let Some(t) = transfers.get_mut(&session_id) { + t.current_file_name = safe_name.clone(); + t.bytes_transferred = total_transferred; + t.total_bytes = total_transferred; + t.speed_bps = speed; + t.state = TransferState::InProgress; + let _ = state.transfer_events.send(t.clone()); + } + last_update = Instant::now(); + last_bytes = total_transferred; + } + } + let _ = file.flush().await; + + if *state.auto_extract_zip.read().await && safe_name.ends_with(".zip") { + let _ = extract_zip_archive(&target_path, &state.download_dir); + } + + count += 1; + } + } + + let mut transfers = state.active_transfers.write().await; + if let Some(t) = transfers.get_mut(&session_id) { + t.bytes_transferred = total_transferred; + t.total_bytes = total_transferred; + t.speed_bps = 0; + t.state = TransferState::Completed; + t.checksum_verified = true; + let _ = state.transfer_events.send(t.clone()); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "success", + "files_saved": count, + "bytes_received": total_transferred + })), + ) + .into_response() +} + +#[derive(Serialize)] +struct DownloadableFile { + name: String, + size: u64, +} + +async fn handle_list_downloadable_files(State(state): State>) -> Json> { + let mut files = Vec::new(); + if let Ok(mut entries) = tokio::fs::read_dir(&state.download_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if let Ok(meta) = entry.metadata().await { + if meta.is_file() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.ends_with(".zerosend_part") { + files.push(DownloadableFile { + name, + size: meta.len(), + }); + } + } + } + } + } + Json(files) +} + +async fn handle_web_download( + State(state): State>, + Path(filename): Path, +) -> Response { + let safe_name = std::path::Path::new(&filename) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(); + + let target_path = state.download_dir.join(&safe_name); + if !target_path.exists() { + return (StatusCode::NOT_FOUND, "File not found").into_response(); + } + + match tokio::fs::File::open(&target_path).await { + Ok(file) => { + let stream = tokio_util::io::ReaderStream::new(file); + let body = axum::body::Body::from_stream(stream); + + Response::builder() + .status(StatusCode::OK) + .header( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", safe_name), + ) + .body(body) + .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error building response").into_response()) + } + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Could not open file").into_response(), + } +} + +#[derive(serde::Deserialize)] +struct SharedListQuery { + pin: Option, +} + +async fn handle_shared_list( + State(state): State>, + Query(params): Query, +) -> impl IntoResponse { + let shares = state.shared_folders.read().await; + let mut list = Vec::new(); + + for share in shares.iter() { + if let Some(expected_pin) = &share.pin { + if params.pin.as_deref() != Some(expected_pin.as_str()) { + list.push(SharedFolderInfo { + id: share.id.clone(), + name: share.name.clone(), + read_only: share.read_only, + requires_pin: true, + total_files: 0, + total_bytes: 0, + }); + continue; + } + } + + let mut total_files = 0; + let mut total_bytes = 0; + if let Ok(entries) = std::fs::read_dir(&share.path) { + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + total_files += 1; + total_bytes += meta.len(); + } else if meta.is_dir() { + total_files += 1; + } + } + } + } + + list.push(SharedFolderInfo { + id: share.id.clone(), + name: share.name.clone(), + read_only: share.read_only, + requires_pin: share.pin.is_some(), + total_files, + total_bytes, + }); + } + + Json(list) +} + +#[derive(serde::Deserialize)] +struct SharedTreeQuery { + path: Option, + pin: Option, +} + +async fn handle_shared_tree( + State(state): State>, + Path(folder_id): Path, + Query(params): Query, +) -> Response { + let shares = state.shared_folders.read().await; + let folder = match shares.iter().find(|s| s.id == folder_id) { + Some(f) => f.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Shared folder not found" })), + ) + .into_response(); + } + }; + drop(shares); + + if let Some(expected_pin) = &folder.pin { + if params.pin.as_deref() != Some(expected_pin.as_str()) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Incorrect or missing PIN code" })), + ) + .into_response(); + } + } + + let base_canonical = match folder.path.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Folder path does not exist on disk" })), + ) + .into_response(); + } + }; + + let subpath = params.path.unwrap_or_default(); + let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\'); + let target_path = if clean_sub.is_empty() { + base_canonical.clone() + } else { + base_canonical.join(clean_sub) + }; + + let target_canonical = match target_path.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Subpath not found" })), + ) + .into_response(); + } + }; + + if !target_canonical.starts_with(&base_canonical) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Access denied (Path Traversal)" })), + ) + .into_response(); + } + + let mut entries = Vec::new(); + if let Ok(dir_entries) = std::fs::read_dir(&target_canonical) { + for entry in dir_entries.flatten() { + if let Ok(meta) = entry.metadata() { + let name = entry.file_name().to_string_lossy().into_owned(); + let is_dir = meta.is_dir(); + let size = if is_dir { 0 } else { meta.len() }; + let modified_secs = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let rel_from_base = entry + .path() + .strip_prefix(&base_canonical) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| name.clone()); + + let mime_type = if is_dir { + "directory".to_string() + } else { + super::protocol::get_mime_type(&name).to_string() + }; + + entries.push(SharedFileEntry { + name, + relative_path: rel_from_base, + size, + is_dir, + modified_secs, + mime_type, + }); + } + } + } + + // Sort: directories first, then alphabetically + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + let current_rel = target_canonical + .strip_prefix(&base_canonical) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + + Json(SharedFolderTreeResponse { + folder_id: folder.id, + folder_name: folder.name, + current_path: current_rel, + read_only: folder.read_only, + entries, + }) + .into_response() +} + +#[derive(serde::Deserialize)] +struct SharedStreamQuery { + pin: Option, +} + +async fn handle_shared_stream( + State(state): State>, + Path((folder_id, subpath)): Path<(String, String)>, + Query(params): Query, + headers: HeaderMap, +) -> Response { + let shares = state.shared_folders.read().await; + let folder = match shares.iter().find(|s| s.id == folder_id) { + Some(f) => f.clone(), + None => return StatusCode::NOT_FOUND.into_response(), + }; + drop(shares); + + if let Some(expected_pin) = &folder.pin { + if params.pin.as_deref() != Some(expected_pin.as_str()) { + return StatusCode::FORBIDDEN.into_response(); + } + } + + let base_canonical = match folder.path.canonicalize() { + Ok(p) => p, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\'); + let target_path = base_canonical.join(clean_sub); + let target_canonical = match target_path.canonicalize() { + Ok(p) => p, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + if !target_canonical.starts_with(&base_canonical) || !target_canonical.is_file() { + return StatusCode::FORBIDDEN.into_response(); + } + + let file_name = target_canonical + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mime_type = super::protocol::get_mime_type(&file_name); + + let mut file = match tokio::fs::File::open(&target_canonical).await { + Ok(f) => f, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + let file_size = match file.metadata().await { + Ok(m) => m.len(), + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + // Check Range header + if let Some(range_header) = headers.get(header::RANGE).and_then(|v| v.to_str().ok()) { + if let Some(range_spec) = range_header.strip_prefix("bytes=") { + let parts: Vec<&str> = range_spec.split('-').collect(); + let start = parts.first().and_then(|s| s.parse::().ok()).unwrap_or(0); + let end = parts + .get(1) + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(file_size.saturating_sub(1)); + + if start <= end && start < file_size { + let end = end.min(file_size - 1); + let chunk_len = end - start + 1; + + if file.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + let reader = tokio_util::io::ReaderStream::new(file.take(chunk_len)); + let body = Body::from_stream(reader); + + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, mime_type) + .header(header::CONTENT_LENGTH, chunk_len.to_string()) + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, file_size), + ) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); + } + } + } + + // No Range header or invalid range -> Send entire file + let reader = tokio_util::io::ReaderStream::new(file); + let body = Body::from_stream(reader); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_type) + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +async fn handle_shared_download( + State(state): State>, + Path((folder_id, subpath)): Path<(String, String)>, + Query(params): Query, +) -> Response { + let shares = state.shared_folders.read().await; + let folder = match shares.iter().find(|s| s.id == folder_id) { + Some(f) => f.clone(), + None => return StatusCode::NOT_FOUND.into_response(), + }; + drop(shares); + + if let Some(expected_pin) = &folder.pin { + if params.pin.as_deref() != Some(expected_pin.as_str()) { + return StatusCode::FORBIDDEN.into_response(); + } + } + + let base_canonical = match folder.path.canonicalize() { + Ok(p) => p, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\'); + let target_path = if clean_sub.is_empty() { + base_canonical.clone() + } else { + base_canonical.join(clean_sub) + }; + + let target_canonical = match target_path.canonicalize() { + Ok(p) => p, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; + + if !target_canonical.starts_with(&base_canonical) { + return StatusCode::FORBIDDEN.into_response(); + } + + let file_name = target_canonical + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "download".to_string()); + + if target_canonical.is_file() { + let file = match tokio::fs::File::open(&target_canonical).await { + Ok(f) => f, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + let file_size = file.metadata().await.map(|m| m.len()).unwrap_or(0); + let mime_type = super::protocol::get_mime_type(&file_name); + let reader = tokio_util::io::ReaderStream::new(file); + let body = Body::from_stream(reader); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_type) + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", file_name), + ) + .body(body) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } else if target_canonical.is_dir() { + let temp_zip = std::env::temp_dir().join(format!("zerosend_{}_{}.zip", folder_id, Uuid::new_v4())); + let target_clone = target_canonical.clone(); + let temp_zip_clone = temp_zip.clone(); + + let zip_res = tokio::task::spawn_blocking(move || { + let zip_file = std::fs::File::create(&temp_zip_clone)?; + let mut zip = zip::ZipWriter::new(zip_file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let walker = walkdir::WalkDir::new(&target_clone); + for entry in walker.into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + let rel_path = path.strip_prefix(&target_clone).unwrap_or(path); + let rel_str = rel_path.to_string_lossy().replace('\\', "/"); + + if rel_str.is_empty() { + continue; + } + + if entry.file_type().is_dir() { + zip.add_directory(&rel_str, options)?; + } else if entry.file_type().is_file() { + zip.start_file(&rel_str, options)?; + let mut f = std::fs::File::open(path)?; + std::io::copy(&mut f, &mut zip)?; + } + } + zip.finish()?; + Ok::<(), std::io::Error>(()) + }) + .await; + + if zip_res.is_err() || zip_res.unwrap().is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + + let zip_file = match tokio::fs::File::open(&temp_zip).await { + Ok(f) => f, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + let file_size = zip_file.metadata().await.map(|m| m.len()).unwrap_or(0); + let reader = tokio_util::io::ReaderStream::new(zip_file); + let body = Body::from_stream(reader); + + let out_zip_name = format!("{}.zip", file_name); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/zip") + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", out_zip_name), + ) + .body(body) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } else { + StatusCode::NOT_FOUND.into_response() + } +} + +async fn handle_shared_upload( + State(state): State>, + Path((folder_id, subpath)): Path<(String, String)>, + Query(params): Query, + mut multipart: Multipart, +) -> Response { + let shares = state.shared_folders.read().await; + let folder = match shares.iter().find(|s| s.id == folder_id) { + Some(f) => f.clone(), + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Shared folder not found" })), + ) + .into_response() + } + }; + drop(shares); + + if folder.read_only { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Shared folder is Read-Only. Uploading is disabled." })), + ) + .into_response(); + } + + if let Some(expected_pin) = &folder.pin { + if params.pin.as_deref() != Some(expected_pin.as_str()) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "Incorrect or missing PIN code" })), + ) + .into_response(); + } + } + + let base_canonical = match folder.path.canonicalize() { + Ok(p) => p, + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Base path not found" })), + ) + .into_response() + } + }; + + let clean_sub = subpath.trim_start_matches('/').trim_start_matches('\\'); + let target_dir = if clean_sub.is_empty() { + base_canonical.clone() + } else { + base_canonical.join(clean_sub) + }; + + let mut written_bytes = 0u64; + let mut last_file = String::new(); + + while let Ok(Some(mut field)) = multipart.next_field().await { + let file_name = field.file_name().unwrap_or("uploaded_file").to_string(); + let safe_name = std::path::Path::new(&file_name) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_else(|| "uploaded_file".to_string()); + + let target_file_path = target_dir.join(&safe_name); + if let Ok(mut file) = File::create(&target_file_path).await { + while let Ok(Some(chunk)) = field.chunk().await { + let _ = file.write_all(&chunk).await; + written_bytes += chunk.len() as u64; + } + let _ = file.flush().await; + last_file = safe_name; + } + } + + Json(SharedUploadResponse { + status: "success".to_string(), + filename: last_file, + bytes_written: written_bytes, + }) + .into_response() +} + +async fn handle_web_ui(State(state): State>) -> Html { + let html = format!( + r###" + + + + + + + + + + + ZeroSend • {device_name} + + + + + + +
+ +
+ 📲 Install ZeroSend App on home screen + +
+ +
+
+
⚡
+
+

ZeroSend

+

To: {device_name}

+
+
+
{net_type}
+
+ +
+ + + + +
+ + +
+
+
+ 📸 + Photos/Videos +
+
+ 📄 + Documents +
+
+ 🎙️ + Voice Note +
+
+ + +
+
+
+ + Recording Voice Note... +
+ 00:00 +
+
+
+
+
+
+
+
+
+ + +
+
+ +
+ +

Tap or Drop files here

+

High-speed direct P2P streaming with SHA-256 verification

+
+ + + + + + + +
+ + + +
+
+ Uploading... + 0% +
+
+
+
+
+
+ + + + + + + + + + +
+ + +
+ + + +"###, + device_name = state.my_info.name, + net_type = state.my_info.net_type, + ); + + Html(html) +} diff --git a/src/transfer/text_analyzer.rs b/src/transfer/text_analyzer.rs new file mode 100644 index 0000000..a86f18f --- /dev/null +++ b/src/transfer/text_analyzer.rs @@ -0,0 +1,118 @@ +use serde::{Deserialize, Serialize}; +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ContentType { + IpAddress, + ServerAddress, + WebUrl, + Email, + JsonData, + CodeSnippet, + PlainText, +} + +impl ContentType { + pub fn label(&self) -> &'static str { + match self { + ContentType::IpAddress => "📡 IP Address", + ContentType::ServerAddress => "🔌 Server Address (IP:Port)", + ContentType::WebUrl => "🌐 Web Link", + ContentType::Email => "✉️ Email Address", + ContentType::JsonData => "📦 JSON Data", + ContentType::CodeSnippet => "💻 Code Snippet", + ContentType::PlainText => "📝 Text Message", + } + } + + pub fn badge_color_rgb(&self) -> (u8, u8, u8) { + match self { + ContentType::IpAddress => (34, 197, 94), // Green + ContentType::ServerAddress => (168, 85, 247), // Purple + ContentType::WebUrl => (56, 189, 248), // Cyan + ContentType::Email => (234, 179, 8), // Yellow + ContentType::JsonData => (249, 115, 22), // Orange + ContentType::CodeSnippet => (236, 72, 153), // Pink + ContentType::PlainText => (148, 163, 184), // Gray + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnalyzedText { + pub content: String, + pub content_type: ContentType, +} + +/// Automatically detect the type of text content +pub fn analyze_text(raw: &str) -> AnalyzedText { + let trimmed = raw.trim(); + + // 1. Check if IP:Port + if SocketAddrV4::from_str(trimmed).is_ok() { + return AnalyzedText { + content: trimmed.to_string(), + content_type: ContentType::ServerAddress, + }; + } + + // 2. Check if IPv4 Address + if Ipv4Addr::from_str(trimmed).is_ok() { + return AnalyzedText { + content: trimmed.to_string(), + content_type: ContentType::IpAddress, + }; + } + + // 3. Check if URL + if trimmed.starts_with("http://") + || trimmed.starts_with("https://") + || trimmed.starts_with("www.") + || (trimmed.contains('.') && (trimmed.ends_with(".com") || trimmed.ends_with(".ru") || trimmed.ends_with(".org") || trimmed.ends_with(".net") || trimmed.ends_with(".io") || trimmed.ends_with(".dev"))) + { + return AnalyzedText { + content: trimmed.to_string(), + content_type: ContentType::WebUrl, + }; + } + + // 4. Check if Email + if trimmed.contains('@') && trimmed.contains('.') && !trimmed.contains(' ') && trimmed.len() > 5 { + return AnalyzedText { + content: trimmed.to_string(), + content_type: ContentType::Email, + }; + } + + // 5. Check if JSON + if (trimmed.starts_with('{') && trimmed.ends_with('}')) + || (trimmed.starts_with('[') && trimmed.ends_with(']')) + { + if serde_json::from_str::(trimmed).is_ok() { + return AnalyzedText { + content: trimmed.to_string(), + content_type: ContentType::JsonData, + }; + } + } + + // 6. Check if Code Snippet + let code_indicators = [ + "fn ", "let ", "let mut ", "pub fn", "def ", "class ", "import ", "const ", + "function ", "var ", "val ", "#include", "", + ]; + if code_indicators.iter().any(|ind| trimmed.contains(ind)) || (trimmed.contains('{') && trimmed.contains('}') && trimmed.contains(';')) { + return AnalyzedText { + content: trimmed.to_string(), + content_type: ContentType::CodeSnippet, + }; + } + + // Default + AnalyzedText { + content: trimmed.to_string(), + content_type: ContentType::PlainText, + } +} diff --git a/src/ui/cli.rs b/src/ui/cli.rs new file mode 100644 index 0000000..dc52e40 --- /dev/null +++ b/src/ui/cli.rs @@ -0,0 +1,73 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "zerosend", author, version, about = "⚡ Ultra-fast P2P file sharing over ZeroTier, Tailscale, Radmin VPN & LAN")] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + /// Network interface name or IP to bind to (defaults to ZeroTier / best available) + #[arg(short, long)] + pub interface: Option, + + /// Custom device name to broadcast + #[arg(short, long)] + pub name: Option, + + /// Transfer HTTP port (default: 53318) + #[arg(short, long, default_value_t = 53318)] + pub port: u16, + + /// Run in Terminal (TUI) mode instead of GUI + #[arg(long, default_value_t = false)] + pub tui: bool, + + /// Downloads directory (default: ./downloads) + #[arg(short, long, default_value = "./downloads")] + pub download_dir: PathBuf, + + /// Start minimized to system tray (used for Windows autostart) + #[arg(long, default_value_t = false)] + pub tray: bool, + + /// Start minimized to system tray (alias) + #[arg(long, default_value_t = false)] + pub minimized: bool, + + /// Initial files or folders to send (e.g. from Windows Context Menu) + #[arg(trailing_var_arg = true)] + pub files: Vec, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Send file(s) directly to a target peer IP or URL + Send { + /// Target peer IP address or URL (e.g. 10.147.20.55 or http://10.147.20.55:53318) + target: String, + + /// Path to file(s) or folder(s) to send + #[arg(required = true)] + files: Vec, + }, + + /// Run in headless receiver/daemon mode + Receive { + /// Automatically accept incoming file transfer requests + #[arg(short, long, default_value_t = false)] + auto_accept: bool, + }, + + /// List all detected network interfaces (ZeroTier, Tailscale, Radmin, LAN) + Interfaces, + + /// Ping a ZeroSend node to check connectivity and fetch device info + Ping { + /// Target IP address + ip: String, + /// Target port (default 53318) + #[arg(short, long, default_value_t = 53318)] + port: u16, + }, +} diff --git a/src/ui/gui.rs b/src/ui/gui.rs new file mode 100644 index 0000000..8961128 --- /dev/null +++ b/src/ui/gui.rs @@ -0,0 +1,3668 @@ +use crate::config::{AppConfig, SharedFolderConfig, ThemeMode}; +use crate::network::{get_available_interfaces, DiscoveryService, InterfaceInfo, NetworkType, PeerInfo}; +use crate::registry::{ + is_autostart_registered, is_system_dark_mode, + register_autostart, unregister_autostart, +}; +use crate::transfer::{ + analyze_text, AnalyzedText, ContentType, PrepareTransferRequest, SharedFolderTreeResponse, + TextMessage, TransferClient, TransferProgress, TransferServer, TransferState, +}; +use crate::ui::tray::{SystemTrayManager, TrayAction}; +use crate::ui::vim_viewer::VimViewerModal; +use crate::updater::{ + check_gitea_update, perform_update_download, UpdateInfo, UpdateProgress, + GITEA_DEFAULT_REPO_URL, +}; +use eframe::egui::{ + self, Align, Button, CentralPanel, Color32, ColorImage, Frame, Layout, Margin, ProgressBar, + RichText, Rounding, ScrollArea, SidePanel, Stroke, TextureHandle, TextureOptions, Ui, Vec2, +}; +use human_bytes::human_bytes; +use qrcode::{Color as QrColor, QrCode}; +use rfd::FileDialog; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, RwLock}; + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum Tab { + Nearby, + Send, + Receive, + Shared, + Transfers, + Settings, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum SharedSubTab { + NetworkShares, + MyShares, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum SendMode { + Files, + QuickText, +} + +#[derive(Clone, Copy, Debug)] +pub struct ThemeColors { + pub is_dark: bool, + pub bg_app: Color32, + pub bg_sidebar: Color32, + pub bg_card: Color32, + pub bg_card_secondary: Color32, + pub bg_card_active: Color32, + pub bg_hover: Color32, + pub stroke_border: Stroke, + pub stroke_subtle: Stroke, + pub text_primary: Color32, + pub text_secondary: Color32, + pub text_muted: Color32, + pub text_accent: Color32, +} + +impl ThemeColors { + pub fn new(is_dark: bool) -> Self { + if is_dark { + Self { + is_dark: true, + bg_app: Color32::from_rgb(11, 15, 25), + bg_sidebar: Color32::from_rgb(15, 23, 42), + bg_card: Color32::from_rgb(30, 41, 59), + bg_card_secondary: Color32::from_rgb(15, 23, 42), + bg_card_active: Color32::from_rgb(30, 58, 138), + bg_hover: Color32::from_rgb(51, 65, 85), + stroke_border: Stroke::new(1.0_f32, Color32::from_rgb(51, 65, 85)), + stroke_subtle: Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 15)), + text_primary: Color32::from_rgb(248, 250, 252), + text_secondary: Color32::from_rgb(203, 213, 225), + text_muted: Color32::from_rgb(148, 163, 184), + text_accent: Color32::from_rgb(56, 189, 248), + } + } else { + Self { + is_dark: false, + bg_app: Color32::from_rgb(241, 245, 249), + bg_sidebar: Color32::from_rgb(255, 255, 255), + bg_card: Color32::from_rgb(255, 255, 255), + bg_card_secondary: Color32::from_rgb(248, 250, 252), + bg_card_active: Color32::from_rgb(219, 234, 254), + bg_hover: Color32::from_rgb(226, 232, 240), + stroke_border: Stroke::new(1.0_f32, Color32::from_rgb(203, 213, 225)), + stroke_subtle: Stroke::new(1.0_f32, Color32::from_rgb(226, 232, 240)), + text_primary: Color32::from_rgb(15, 23, 42), + text_secondary: Color32::from_rgb(51, 65, 85), + text_muted: Color32::from_rgb(100, 116, 139), + text_accent: Color32::from_rgb(2, 132, 199), + } + } + } +} + +pub fn get_file_badge_info(name: &str, is_dir: bool) -> (&'static str, Color32, &'static str) { + if is_dir { + return ("📁", Color32::from_rgb(234, 179, 8), "FOLDER"); + } + let lower = name.to_lowercase(); + if lower.ends_with(".zip") || lower.ends_with(".rar") || lower.ends_with(".7z") || lower.ends_with(".tar") || lower.ends_with(".gz") || lower.ends_with(".bz2") || lower.ends_with(".xz") { + ("📦", Color32::from_rgb(245, 158, 11), "ARCHIVE") + } else if lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".webp") || lower.ends_with(".bmp") || lower.ends_with(".svg") || lower.ends_with(".ico") { + ("🖼", Color32::from_rgb(56, 189, 248), "IMAGE") + } else if lower.ends_with(".mp4") || lower.ends_with(".mkv") || lower.ends_with(".avi") || lower.ends_with(".mov") || lower.ends_with(".webm") || lower.ends_with(".flv") { + ("🎬", Color32::from_rgb(168, 85, 247), "VIDEO") + } else if lower.ends_with(".mp3") || lower.ends_with(".wav") || lower.ends_with(".flac") || lower.ends_with(".ogg") || lower.ends_with(".m4a") || lower.ends_with(".aac") { + ("🎵", Color32::from_rgb(236, 72, 153), "AUDIO") + } else if lower.ends_with(".exe") || lower.ends_with(".msi") || lower.ends_with(".iso") || lower.ends_with(".bin") || lower.ends_with(".dll") { + ("💾", Color32::from_rgb(34, 197, 94), "BIN/ISO") + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".pdf") || lower.ends_with(".docx") || lower.ends_with(".doc") || lower.ends_with(".xlsx") || lower.ends_with(".pptx") { + ("📄", Color32::from_rgb(59, 130, 246), "DOC") + } else if lower.ends_with(".rs") || lower.ends_with(".py") || lower.ends_with(".js") || lower.ends_with(".ts") || lower.ends_with(".html") || lower.ends_with(".css") || lower.ends_with(".json") || lower.ends_with(".toml") || lower.ends_with(".cpp") || lower.ends_with(".c") { + ("💻", Color32::from_rgb(20, 184, 166), "CODE") + } else { + ("📄", Color32::from_rgb(148, 163, 184), "FILE") + } +} + +pub fn get_file_icon(name: &str) -> &'static str { + get_file_badge_info(name, false).0 +} + +pub struct ZeroSendApp { + // Services + discovery_peers: Arc>>, + server: TransferServer, + client: Arc, + my_info: crate::network::DiscoveryBeacon, + current_iface: InterfaceInfo, + config: AppConfig, + tray_manager: Option, + + // Navigation & State + current_tab: Tab, + send_mode: SendMode, + peers: Vec, + selected_peer_ids: HashSet, + queued_files: Vec, + transfers: Vec, + logs: Vec, + + // Quick Text Input + text_input: String, + analyzed_preview: AnalyzedText, + + // Optional PIN for sender + recipient_pin: String, + + // Channels from Server + incoming_request_rx: mpsc::UnboundedReceiver, + incoming_text_rx: mpsc::UnboundedReceiver, + + // Modals + pending_request: Option, + received_text_modal: Option, + show_direct_ip_modal: bool, + direct_ip_input: String, + direct_ip_status: String, + + // Peer Rename Modal + renaming_peer: Option<(String, String, String)>, + + // Settings + custom_device_name: String, + available_interfaces: Vec, + auto_accept: bool, + require_pin: bool, + pin_code: String, + download_dir: PathBuf, + close_to_tray: bool, + auto_zip_folders: bool, + auto_extract_zip: bool, + max_upload_speed_mbps: u32, + sound_notifications: bool, + autostart_enabled: bool, + pub theme_mode: ThemeMode, + + // Shared Folders State + shared_folders: Vec, + selected_share_subtab: SharedSubTab, + selected_peer_id_for_share: Option, + selected_folder_id_for_share: Option, + current_share_subpath: String, + current_share_tree: Option, + share_tree_loading: bool, + share_search_query: String, + share_pin_input: String, + last_share_sync: Instant, + pub vim_viewer: VimViewerModal, + + // Gitea Updater State + pub update_info: Option, + pub is_checking_update: bool, + pub update_check_status_msg: Option, + pub update_progress: Option, + pub is_updating: bool, + pub show_update_banner: bool, + pub check_updates_on_startup: bool, + + // Internal UI Channels + log_rx: mpsc::UnboundedReceiver, + log_tx: mpsc::UnboundedSender, + transfer_rx: mpsc::UnboundedReceiver, + transfer_tx: mpsc::UnboundedSender, + share_tree_rx: mpsc::UnboundedReceiver>, + share_tree_tx: mpsc::UnboundedSender>, + share_text_rx: mpsc::UnboundedReceiver<(String, String, String, String)>, + share_text_tx: mpsc::UnboundedSender<(String, String, String, String)>, + update_rx: mpsc::UnboundedReceiver>, + update_tx: mpsc::UnboundedSender>, + update_prog_rx: mpsc::UnboundedReceiver, + update_prog_tx: mpsc::UnboundedSender, + + last_poll: Instant, + last_ping_poll: Instant, + last_window_save: Instant, +} + +fn setup_custom_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + + #[cfg(target_os = "windows")] + { + let win_dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".to_string()); + let candidate_fonts = [ + ("segoe_ui", format!("{}\\Fonts\\segoeui.ttf", win_dir)), + ("segoe_ui_sym", format!("{}\\Fonts\\seguisym.ttf", win_dir)), + ("segoe_ui_emoji", format!("{}\\Fonts\\seguiemj.ttf", win_dir)), + ("arial", format!("{}\\Fonts\\arial.ttf", win_dir)), + ]; + + for (name, path) in candidate_fonts { + if let Ok(bytes) = std::fs::read(&path) { + fonts.font_data.insert( + name.to_string(), + egui::FontData::from_owned(bytes), + ); + + if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Proportional) { + family.push(name.to_string()); + } + if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Monospace) { + family.push(name.to_string()); + } + } + } + } + + ctx.set_fonts(fonts); +} + +impl ZeroSendApp { + pub fn new( + cc: &eframe::CreationContext<'_>, + discovery: &DiscoveryService, + server: TransferServer, + config: AppConfig, + initial_files: Vec, + incoming_request_rx: mpsc::UnboundedReceiver, + incoming_text_rx: mpsc::UnboundedReceiver, + ) -> Self { + setup_custom_fonts(&cc.egui_ctx); + + let (log_tx, log_rx) = mpsc::unbounded_channel(); + let (transfer_tx, transfer_rx) = mpsc::unbounded_channel(); + let (share_tree_tx, share_tree_rx) = mpsc::unbounded_channel(); + let (share_text_tx, share_text_rx) = mpsc::unbounded_channel(); + let (update_tx, update_rx) = mpsc::unbounded_channel(); + let (update_prog_tx, update_prog_rx) = mpsc::unbounded_channel(); + + // Subscribe to server transfer events (web uploads, incoming P2P transfers) + let mut server_transfer_rx = server.state().transfer_events.subscribe(); + let transfer_tx_clone = transfer_tx.clone(); + let ctx_clone = cc.egui_ctx.clone(); + tokio::spawn(async move { + while let Ok(progress) = server_transfer_rx.recv().await { + let _ = transfer_tx_clone.send(progress); + ctx_clone.request_repaint(); + } + }); + + // Trigger startup update check from Gitea if enabled + if config.check_updates_on_startup { + let u_tx = update_tx.clone(); + let api_url = config.gitea_api_url.clone(); + tokio::spawn(async move { + let res = check_gitea_update(api_url.as_deref()).await; + let _ = u_tx.send(res); + }); + } + + let my_info = discovery.my_info().clone(); + let iface = discovery.selected_interface().clone(); + let download_dir = config.download_dir.clone(); + let auto_accept = config.auto_accept; + let require_pin = config.require_pin; + let pin_code = config.pin_code.clone(); + let close_to_tray = config.close_to_tray; + let auto_zip_folders = config.auto_zip_folders; + let auto_extract_zip = config.auto_extract_zip; + let max_upload_speed_mbps = config.max_upload_speed_mbps; + let sound_notifications = config.sound_notifications; + let theme_mode = config.theme_mode; + let check_updates_on_startup = config.check_updates_on_startup; + let device_name = my_info.name.clone(); + let shared_folders = config.shared_folders.clone(); + let initial_tab = if !initial_files.is_empty() { + Tab::Send + } else { + Tab::Nearby + }; + + let tray_manager = SystemTrayManager::new(cc.egui_ctx.clone(), download_dir.clone()).ok(); + + Self { + discovery_peers: discovery.peers(), + server, + client: Arc::new(TransferClient::new()), + my_info: my_info.clone(), + current_iface: iface, + config, + tray_manager, + current_tab: initial_tab, + send_mode: SendMode::Files, + peers: Vec::new(), + selected_peer_ids: HashSet::new(), + queued_files: initial_files, + transfers: Vec::new(), + logs: vec!["⚡ ZeroSend v2.4.1 ready".to_string()], + text_input: String::new(), + analyzed_preview: analyze_text(""), + recipient_pin: String::new(), + incoming_request_rx, + incoming_text_rx, + pending_request: None, + received_text_modal: None, + show_direct_ip_modal: false, + direct_ip_input: String::new(), + direct_ip_status: String::new(), + renaming_peer: None, + custom_device_name: device_name, + available_interfaces: get_available_interfaces(), + auto_accept, + require_pin, + pin_code, + download_dir, + close_to_tray, + auto_zip_folders, + auto_extract_zip, + max_upload_speed_mbps, + sound_notifications, + autostart_enabled: is_autostart_registered(), + theme_mode, + shared_folders, + selected_share_subtab: SharedSubTab::NetworkShares, + selected_peer_id_for_share: None, + selected_folder_id_for_share: None, + current_share_subpath: String::new(), + current_share_tree: None, + share_tree_loading: false, + share_search_query: String::new(), + share_pin_input: String::new(), + last_share_sync: Instant::now(), + vim_viewer: VimViewerModal::default(), + update_info: None, + is_checking_update: false, + update_check_status_msg: None, + update_progress: None, + is_updating: false, + show_update_banner: false, + check_updates_on_startup, + log_rx, + log_tx, + transfer_rx, + transfer_tx, + share_tree_rx, + share_tree_tx, + share_text_rx, + share_text_tx, + update_rx, + update_tx, + update_prog_rx, + update_prog_tx, + last_poll: Instant::now(), + last_ping_poll: Instant::now(), + last_window_save: Instant::now(), + } + } + + fn poll_events(&mut self, ctx: &egui::Context) { + // 1. Poll Tray Actions + if let Some(tray) = &self.tray_manager { + if let Some(action) = tray.try_recv_action() { + match action { + TrayAction::ShowWindow => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + TrayAction::OpenDownloads => { + let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); + } + } + } + } + + // 2. Poll incoming file transfer requests + while let Ok(req) = self.incoming_request_rx.try_recv() { + self.pending_request = Some(req.clone()); + self.current_tab = Tab::Transfers; + self.logs.push(format!("📥 Incoming transfer request from {}", req.sender_name)); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + + // 3. Poll incoming text messages + while let Ok(msg) = self.incoming_text_rx.try_recv() { + self.received_text_modal = Some(msg.clone()); + self.logs.push(format!("📋 Received text from {}", msg.sender_name)); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + + // 4. Poll UI logs + while let Ok(log) = self.log_rx.try_recv() { + self.logs.push(log); + if self.logs.len() > 100 { + self.logs.remove(0); + } + } + + // 5. Poll transfer updates from client & server + while let Ok(t) = self.transfer_rx.try_recv() { + let just_completed = t.state == TransferState::Completed; + if let Some(pos) = self.transfers.iter().position(|x| x.session_id == t.session_id) { + let prev_state = self.transfers[pos].state.clone(); + self.transfers[pos] = t; + if just_completed && prev_state != TransferState::Completed && self.sound_notifications { + crate::ui::sound::play_transfer_sound(); + } + } else { + self.transfers.insert(0, t); + self.current_tab = Tab::Transfers; + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + if just_completed && self.sound_notifications { + crate::ui::sound::play_transfer_sound(); + } + } + } + + // 6. Direct sync with Server active transfers (Incoming downloads fallback) + if let Ok(server_transfers) = self.server.state().active_transfers.try_read() { + for (id, st) in server_transfers.iter() { + let just_completed = st.state == TransferState::Completed; + if let Some(pos) = self.transfers.iter().position(|x| x.session_id == *id) { + let prev_state = self.transfers[pos].state.clone(); + self.transfers[pos] = st.clone(); + if just_completed && prev_state != TransferState::Completed && self.sound_notifications { + crate::ui::sound::play_transfer_sound(); + } + } else { + self.transfers.insert(0, st.clone()); + self.current_tab = Tab::Transfers; + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + if just_completed && self.sound_notifications { + crate::ui::sound::play_transfer_sound(); + } + } + } + } + + // 7. Poll share tree updates + while let Ok(maybe_tree) = self.share_tree_rx.try_recv() { + self.share_tree_loading = false; + self.current_share_tree = maybe_tree; + } + + // 8. Poll vim text updates + while let Ok((file_name, rel_path, peer_name, content)) = self.share_text_rx.try_recv() { + self.vim_viewer.open(file_name, rel_path, peer_name, content); + } + + // 9. Poll Gitea update check results + while let Ok(res) = self.update_rx.try_recv() { + self.is_checking_update = false; + match res { + Ok(info) => { + if info.has_update { + self.show_update_banner = true; + self.logs.push(format!("✨ Gitea: New version v{} is available!", info.latest_version)); + } else { + self.update_check_status_msg = Some(format!("ZeroSend v{} is up to date!", info.current_version)); + } + self.update_info = Some(info); + } + Err(e) => { + self.update_check_status_msg = Some(format!("Update check failed: {}", e)); + } + } + } + + // 10. Poll Gitea update installation progress + while let Ok(prog) = self.update_prog_rx.try_recv() { + match &prog { + UpdateProgress::Completed => { + self.is_updating = false; + self.logs.push("🎉 Update completed! Restarting ZeroSend...".to_string()); + } + UpdateProgress::Error(e) => { + self.is_updating = false; + self.logs.push(format!("❌ Update error: {}", e)); + } + _ => {} + } + self.update_progress = Some(prog); + } + + // 9. Auto-sync network shared folder view (every 4 seconds) + if self.last_share_sync.elapsed() > Duration::from_secs(4) { + self.last_share_sync = Instant::now(); + if self.current_tab == Tab::Shared && self.selected_share_subtab == SharedSubTab::NetworkShares { + self.sync_active_share_tree_background(); + } + } + + // 10. Discovery & Server pending requests + if self.last_poll.elapsed() > Duration::from_millis(300) { + self.last_poll = Instant::now(); + + // Refresh discovered peer list with Favorite Pins prioritized + if let Ok(peers_guard) = self.discovery_peers.try_read() { + let mut list: Vec = peers_guard.values().cloned().collect(); + let favs = &self.config.favorite_peers; + list.sort_by(|a, b| { + let a_fav = favs.contains(&a.peer_id); + let b_fav = favs.contains(&b.peer_id); + b_fav.cmp(&a_fav) + .then_with(|| b.is_online.cmp(&a.is_online)) + .then_with(|| a.name.cmp(&b.name)) + }); + self.peers = list; + } + + // Double check if there's an active pending request in server state + if self.pending_request.is_none() { + if let Ok(pending_guard) = self.server.state().pending_requests.try_read() { + if let Some((_, req)) = pending_guard.iter().next() { + self.pending_request = Some(req.clone()); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + } + } + } + + // 11. Periodic ping update for online peers (every 3 seconds) + if self.last_ping_poll.elapsed() > Duration::from_secs(3) { + self.last_ping_poll = Instant::now(); + let peers_to_ping: Vec<(String, String, u16)> = self + .peers + .iter() + .filter(|p| p.is_online) + .map(|p| (p.peer_id.clone(), p.ip.to_string(), p.transfer_port)) + .collect(); + + if !peers_to_ping.is_empty() { + let disc_peers = self.discovery_peers.clone(); + let client = self.client.clone(); + tokio::spawn(async move { + for (peer_id, ip, port) in peers_to_ping { + if let Some(ms) = client.measure_ping(&ip, port).await { + if let Ok(mut guard) = disc_peers.try_write() { + if let Some(peer_entry) = guard.get_mut(&peer_id) { + peer_entry.ping_ms = Some(ms); + } + } + } + } + }); + } + } + } + + fn persist_config(&mut self) { + self.config.download_dir = self.download_dir.clone(); + self.config.auto_accept = self.auto_accept; + self.config.require_pin = self.require_pin; + self.config.pin_code = self.pin_code.clone(); + self.config.close_to_tray = self.close_to_tray; + self.config.auto_zip_folders = self.auto_zip_folders; + self.config.auto_extract_zip = self.auto_extract_zip; + self.config.max_upload_speed_mbps = self.max_upload_speed_mbps; + self.config.sound_notifications = self.sound_notifications; + self.config.theme_mode = self.theme_mode; + self.config.device_name = Some(self.custom_device_name.clone()); + self.config.preferred_interface = Some(self.current_iface.name.clone()); + self.config.shared_folders = self.shared_folders.clone(); + let _ = self.config.save(); + + let srv = self.server.clone(); + let auto_acc = self.auto_accept; + let req_pin = self.require_pin; + let p_code = self.pin_code.clone(); + let auto_ext = self.auto_extract_zip; + let sf_clone = self.shared_folders.clone(); + tokio::spawn(async move { + srv.update_auto_accept(auto_acc).await; + srv.update_pin_settings(req_pin, p_code).await; + srv.update_extract_settings(auto_ext).await; + srv.update_shared_folders(sf_clone).await; + }); + } + + fn get_peer_display_name(&self, peer: &PeerInfo) -> (String, bool) { + if let Some(alias) = self.config.peer_aliases.get(&peer.peer_id) { + if !alias.trim().is_empty() { + return (format!("{} ({})", alias, peer.name), true); + } + } + (peer.name.clone(), false) + } + + fn try_paste_clipboard_image(&mut self) -> bool { + for _ in 0..5 { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + // 1. Check for image in clipboard + if let Ok(img_data) = clipboard.get_image() { + let temp_dir = std::env::temp_dir().join("ZeroSend"); + let _ = std::fs::create_dir_all(&temp_dir); + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let file_path = temp_dir.join(format!("screenshot_{}.png", timestamp)); + + if let Some(img_buffer) = image::RgbaImage::from_raw( + img_data.width as u32, + img_data.height as u32, + img_data.bytes.into_owned(), + ) { + if img_buffer.save(&file_path).is_ok() { + if !self.queued_files.contains(&file_path) { + self.queued_files.push(file_path.clone()); + } + self.current_tab = Tab::Send; + self.send_mode = SendMode::Files; + self.logs.push(format!( + "📸 Pasted screenshot ({}x{}) to queue", + img_data.width, img_data.height + )); + return true; + } + } + } + + // 2. Check for copied file paths or text in clipboard + if let Ok(text) = clipboard.get_text() { + let mut added_any = false; + for line in text.lines() { + let trimmed = line.trim().trim_matches('"'); + let path = PathBuf::from(trimmed); + if path.exists() { + if !self.queued_files.contains(&path) { + self.queued_files.push(path.clone()); + added_any = true; + } + } + } + if added_any { + self.current_tab = Tab::Send; + self.send_mode = SendMode::Files; + self.logs.push("📄 Pasted copied file(s) from clipboard to queue".to_string()); + return true; + } + } + + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + false + } + + fn start_sending_files(&mut self, targets: Vec, files: Vec) { + if targets.is_empty() || files.is_empty() { + return; + } + + let client = self.client.clone(); + let my_info = self.my_info.clone(); + let transfer_tx = self.transfer_tx.clone(); + let log_tx = self.log_tx.clone(); + let auto_zip = self.auto_zip_folders; + let speed_limit = self.max_upload_speed_mbps; + let pin = if self.recipient_pin.trim().is_empty() { + None + } else { + Some(self.recipient_pin.trim().to_string()) + }; + + self.queued_files.clear(); + self.current_tab = Tab::Transfers; + + for peer in targets { + let peer_url = peer.transfer_url(); + let peer_name = self.get_peer_display_name(&peer).0; + let files_clone = files.clone(); + let client_clone = client.clone(); + let my_info_clone = my_info.clone(); + let transfer_tx_clone = transfer_tx.clone(); + let log_tx_clone = log_tx.clone(); + let pin_clone = pin.clone(); + + tokio::spawn(async move { + let _ = log_tx_clone.send(format!("📤 Sending {} items to {}...", files_clone.len(), peer_name)); + let res = client_clone + .send_files(&peer_url, &peer_name, &my_info_clone, &files_clone, auto_zip, speed_limit, pin_clone, move |progress| { + let _ = transfer_tx_clone.send(progress); + }) + .await; + + match res { + Ok(_) => { + let _ = log_tx_clone.send(format!("🎉 All files delivered to {}!", peer_name)); + } + Err(e) => { + let _ = log_tx_clone.send(format!("⚠️ Transfer failed to {}: {}", peer_name, e)); + } + } + }); + } + } + + fn render_sidebar(&mut self, _ctx: &egui::Context, ui: &mut Ui, theme: ThemeColors) { + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label(RichText::new("⚡").size(24.0).color(theme.text_accent)); + ui.vertical(|ui| { + ui.label(RichText::new("ZeroSend").size(18.0).strong().color(theme.text_primary)); + ui.label(RichText::new("ZeroTier & VPN Sharing").size(10.0).color(theme.text_muted)); + }); + }); + + ui.add_space(14.0); + ui.separator(); + ui.add_space(10.0); + + // Network Badge Card + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let dot_color = if self.current_iface.net_type == NetworkType::ZeroTier { + Color32::from_rgb(34, 197, 94) + } else { + Color32::from_rgb(59, 130, 246) + }; + ui.label(RichText::new("●").color(dot_color).size(14.0)); + ui.vertical(|ui| { + ui.label( + RichText::new(self.current_iface.net_type.as_str()) + .strong() + .size(12.0) + .color(theme.text_primary), + ); + ui.label( + RichText::new(format!("{}:{}", self.current_iface.ip, self.my_info.transfer_port)) + .size(10.0) + .color(theme.text_muted), + ); + }); + }); + }); + + ui.add_space(16.0); + + let active_count = self + .transfers + .iter() + .filter(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation) + .count(); + + let friend_shares_count: usize = self.peers.iter().map(|p| p.shared_folders.len()).sum(); + let total_shares = self.shared_folders.len() + friend_shares_count; + + let tabs = [ + (Tab::Nearby, "📡 Nearby Friends", self.peers.len(), false), + (Tab::Send, "📤 Send (Files/Text)", self.queued_files.len(), false), + (Tab::Receive, "📥 Receive / Web", 0, false), + (Tab::Shared, "📁 Shared Folders", total_shares, false), + (Tab::Transfers, "📜 Transfers", active_count, active_count > 0), + (Tab::Settings, "⚙ Settings & About", 0, false), + ]; + + for (tab, title, count, is_active_pulse) in tabs { + let is_selected = self.current_tab == tab; + let mut btn_text = title.to_string(); + if count > 0 && tab != Tab::Receive && tab != Tab::Settings { + if is_active_pulse { + btn_text = format!("{} (⚡ {})", title, count); + } else { + btn_text = format!("{} ({})", title, count); + } + } + + let text_color = if is_selected { + if theme.is_dark { + Color32::from_rgb(56, 189, 248) + } else { + Color32::from_rgb(2, 132, 199) + } + } else if is_active_pulse { + Color32::from_rgb(250, 204, 21) + } else { + theme.text_secondary + }; + + let bg_color = if is_selected { + theme.bg_card_active + } else { + Color32::TRANSPARENT + }; + + let btn = Button::new(RichText::new(btn_text).size(13.0).color(text_color).strong()) + .fill(bg_color) + .min_size(Vec2::new(ui.available_width(), 36.0)) + .rounding(Rounding::same(6.0)); + + if ui.add(btn).clicked() { + self.current_tab = tab; + } + ui.add_space(4.0); + } + + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + ui.add_space(10.0); + ui.label( + RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))) + .size(10.0) + .color(theme.text_muted), + ); + }); + } + + fn render_nearby_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { + ui.horizontal(|ui| { + ui.heading(RichText::new("📡 Discovered Nodes (ZeroTier / VPN)").color(theme.text_primary)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button(RichText::new("🔍 Direct Connect (IP)").strong()).clicked() { + self.show_direct_ip_modal = true; + self.direct_ip_status.clear(); + } + }); + }); + + ui.label( + RichText::new("Devices in your ZeroTier network running ZeroSend appear here automatically.") + .size(12.0) + .color(theme.text_muted), + ); + ui.add_space(12.0); + + if self.peers.is_empty() { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(12.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(40.0)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label(RichText::new("🔍").size(42.0)); + ui.add_space(8.0); + ui.label(RichText::new("Scanning ZeroTier network for friends...").size(16.0).strong().color(theme.text_primary)); + ui.add_space(4.0); + ui.label( + RichText::new("Ensure your friends are connected to your ZeroTier network or use Direct Connect.") + .size(12.0) + .color(theme.text_muted), + ); + }); + }); + return; + } + + let dropped_files = ui.ctx().input(|i| i.raw.dropped_files.clone()); + let is_hovering_files = ui.ctx().input(|i| !i.raw.hovered_files.is_empty()); + let pointer_pos = ui.ctx().input(|i| i.pointer.hover_pos()); + + if is_hovering_files { + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(8.0)) + .stroke(Stroke::new(1.5_f32, theme.text_accent)) + .inner_margin(Margin::same(10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("🎯 Drop files directly onto any friend's card below to send instantly!").size(13.0).strong().color(theme.text_accent)); + }); + }); + ui.add_space(8.0); + } + + let mut toggle_fav: Option = None; + let mut rename_target: Option<(String, String, String)> = None; + let mut send_to_peer: Option = None; + let mut quick_drop_target: Option<(PeerInfo, Vec)> = None; + + ScrollArea::vertical().show(ui, |ui| { + for peer in &self.peers { + let peer_id = peer.peer_id.clone(); + let is_checked = self.selected_peer_ids.contains(&peer_id); + let (display_name, has_alias) = self.get_peer_display_name(peer); + + let card_response = Frame::none() + .fill(if is_checked { + theme.bg_card_active + } else { + theme.bg_card + }) + .rounding(Rounding::same(10.0)) + .stroke(if is_hovering_files { + Stroke::new(1.5_f32, theme.text_accent) + } else { + theme.stroke_border + }) + .inner_margin(Margin::same(14.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let os_icon = match peer.os.to_lowercase().as_str() { + "windows" => "🪟", + "linux" => "🐧", + "macos" => "🍎", + _ => "💻", + }; + + ui.label(RichText::new(os_icon).size(28.0)); + ui.add_space(8.0); + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(&display_name) + .size(15.0) + .strong() + .color(if has_alias { Color32::from_rgb(250, 204, 21) } else { theme.text_primary }), + ); + + let is_fav = self.config.favorite_peers.contains(&peer.peer_id); + let star_icon = if is_fav { "⭐" } else { "☆" }; + if ui.small_button(star_icon).on_hover_text(if is_fav { "Unpin from Favorites" } else { "Pin to Favorites" }).clicked() { + toggle_fav = Some(peer.peer_id.clone()); + } + + if ui.small_button("✏").on_hover_text("Rename / Set Alias").clicked() { + let curr = self.config.peer_aliases.get(&peer.peer_id).cloned().unwrap_or_default(); + rename_target = Some((peer.peer_id.clone(), peer.name.clone(), curr)); + } + + let badge_color = if peer.net_type.contains("ZeroTier") { + Color32::from_rgb(34, 197, 94) + } else { + Color32::from_rgb(59, 130, 246) + }; + ui.label( + RichText::new(format!("[{}]", peer.net_type)) + .size(11.0) + .color(badge_color), + ); + }); + + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("{}:{} • {}", peer.ip, peer.transfer_port, peer.os)) + .size(11.0) + .color(theme.text_muted), + ); + + if let Some(ms) = peer.ping_ms { + let (ping_color, ping_icon) = if ms < 40 { + (Color32::from_rgb(34, 197, 94), "🟢") + } else if ms <= 100 { + (Color32::from_rgb(250, 204, 21), "🟡") + } else { + (Color32::from_rgb(239, 68, 68), "🔴") + }; + ui.label( + RichText::new(format!("• {} {} ms", ping_icon, ms)) + .size(11.0) + .color(ping_color), + ); + } + }); + }); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let send_btn = Button::new(RichText::new("📤 Send").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(37, 99, 235)) + .rounding(Rounding::same(6.0)) + .min_size(Vec2::new(90.0, 32.0)); + + if ui.add(send_btn).clicked() { + send_to_peer = Some(peer_id); + } + }); + }); + }) + .response; + + if !dropped_files.is_empty() { + let is_target = if let Some(pos) = pointer_pos { + card_response.rect.contains(pos) + } else { + card_response.hovered() + }; + + if is_target { + let paths: Vec = dropped_files.iter().filter_map(|f| f.path.clone()).collect(); + if !paths.is_empty() { + quick_drop_target = Some((peer.clone(), paths)); + } + } + } + + ui.add_space(8.0); + } + }); + + if let Some((target_peer, files)) = quick_drop_target { + self.logs.push(format!("🎯 Quick Drop: Sending {} item(s) to {}...", files.len(), self.get_peer_display_name(&target_peer).0)); + self.start_sending_files(vec![target_peer], files); + } + + if let Some(pid) = toggle_fav { + if self.config.favorite_peers.contains(&pid) { + self.config.favorite_peers.retain(|id| id != &pid); + } else { + self.config.favorite_peers.push(pid); + } + self.persist_config(); + } + + if let Some(ren) = rename_target { + self.renaming_peer = Some(ren); + } + + if let Some(pid) = send_to_peer { + self.selected_peer_ids.clear(); + self.selected_peer_ids.insert(pid); + self.current_tab = Tab::Send; + } + } + + fn render_send_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { + ui.horizontal(|ui| { + ui.heading(RichText::new("📤 Send to Friends").color(theme.text_primary)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.selectable_value(&mut self.send_mode, SendMode::QuickText, "📋 Quick Text / Clipboard"); + ui.selectable_value(&mut self.send_mode, SendMode::Files, "📁 Files & Folders"); + }); + }); + ui.add_space(8.0); + + // Multi-Recipient Selector Card + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(12.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("Recipients:").strong().size(13.0).color(theme.text_primary)); + + if !self.peers.is_empty() { + let all_selected = self.selected_peer_ids.len() == self.peers.len(); + if ui.small_button(if all_selected { "Deselect All" } else { "Select All" }).clicked() { + if all_selected { + self.selected_peer_ids.clear(); + } else { + self.selected_peer_ids = self.peers.iter().map(|p| p.peer_id.clone()).collect(); + } + } + } + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("PIN (optional):").size(11.0).color(theme.text_muted)); + ui.add(egui::TextEdit::singleline(&mut self.recipient_pin).desired_width(60.0)); + }); + }); + }); + + ui.add_space(6.0); + + if self.peers.is_empty() { + ui.label(RichText::new("No online peers found yet. Discovering...").color(theme.text_muted)); + } else { + ui.horizontal_wrapped(|ui| { + for p in &self.peers { + let is_selected = self.selected_peer_ids.contains(&p.peer_id); + let (name_label, _) = self.get_peer_display_name(p); + let chip_text = format!("{} ({})", name_label, p.ip); + + let chip_btn = Button::new( + RichText::new(format!("{} {}", if is_selected { "✅" } else { "⬜" }, chip_text)) + .size(12.0) + .color(if is_selected { Color32::WHITE } else { theme.text_secondary }), + ) + .fill(if is_selected { + Color32::from_rgb(37, 99, 235) + } else { + theme.bg_card_secondary + }) + .rounding(Rounding::same(14.0)); + + if ui.add(chip_btn).clicked() { + if is_selected { + self.selected_peer_ids.remove(&p.peer_id); + } else { + self.selected_peer_ids.insert(p.peer_id.clone()); + } + } + } + }); + } + }); + + ui.add_space(14.0); + + match self.send_mode { + SendMode::Files => self.render_send_files_subview(ui, theme), + SendMode::QuickText => self.render_send_text_subview(ui, theme), + } + } + + fn render_send_files_subview(&mut self, ui: &mut Ui, theme: ThemeColors) { + ui.horizontal(|ui| { + if ui + .add( + Button::new(RichText::new("📄 Add Files").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(59, 130, 246)) + .min_size(Vec2::new(120.0, 34.0)) + .rounding(Rounding::same(6.0)), + ) + .clicked() + { + if let Some(files) = FileDialog::new().pick_files() { + for f in files { + if !self.queued_files.contains(&f) { + self.queued_files.push(f); + } + } + } + } + + if ui + .add( + Button::new(RichText::new("📁 Add Folder").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(79, 70, 229)) + .min_size(Vec2::new(120.0, 34.0)) + .rounding(Rounding::same(6.0)), + ) + .clicked() + { + if let Some(folder) = FileDialog::new().pick_folder() { + if !self.queued_files.contains(&folder) { + self.queued_files.push(folder); + } + } + } + + if ui + .add( + Button::new(RichText::new("📸 Paste Screenshot (Ctrl+V)").color(Color32::WHITE)) + .fill(Color32::from_rgb(147, 51, 234)) + .min_size(Vec2::new(180.0, 34.0)) + .rounding(Rounding::same(6.0)), + ) + .clicked() + { + self.try_paste_clipboard_image(); + } + + if !self.queued_files.is_empty() { + if ui.button("Clear list").clicked() { + self.queued_files.clear(); + } + } + }); + + ui.add_space(10.0); + + let total_size: u64 = self + .queued_files + .iter() + .filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len())) + .sum(); + + ui.horizontal(|ui| { + ui.label( + RichText::new(format!( + "Selected Items: {} ({}) | Recipients: {}", + self.queued_files.len(), + human_bytes(total_size as f64), + self.selected_peer_ids.len() + )) + .size(13.0) + .strong() + .color(theme.text_primary), + ); + if self.auto_zip_folders { + ui.label(RichText::new("• (Folders auto-compressed to ZIP)").size(11.0).color(Color32::from_rgb(34, 197, 94))); + } + }); + ui.add_space(6.0); + + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| { + ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + if self.queued_files.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(24.0); + ui.label(RichText::new("📂 Drag & Drop files or folders here").size(14.0).strong().color(theme.text_muted)); + ui.add_space(4.0); + ui.label(RichText::new("or paste screenshots directly with Ctrl+V").size(11.0).color(theme.text_muted)); + ui.add_space(24.0); + }); + } else { + let mut to_remove = None; + for (idx, path) in self.queued_files.iter().enumerate() { + let is_dir = path.is_dir(); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let (icon, badge_color, category) = get_file_badge_info(&name, is_dir); + let size_str = std::fs::metadata(path).ok().map(|m| human_bytes(m.len() as f64)).unwrap_or_default(); + + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(6.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::symmetric(10.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(icon).size(16.0)); + + // Category Badge Pill + Frame::none() + .fill(badge_color.linear_multiply(0.2)) + .rounding(Rounding::same(4.0)) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label(RichText::new(category).size(9.0).color(badge_color).strong()); + }); + + ui.label(RichText::new(&name).strong().color(theme.text_primary)); + ui.label(RichText::new(format!("({})", size_str)).size(11.0).color(theme.text_muted)); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.small_button("✕").on_hover_text("Remove from queue").clicked() { + to_remove = Some(idx); + } + }); + }); + }); + ui.add_space(4.0); + } + + if let Some(i) = to_remove { + self.queued_files.remove(i); + } + } + }); + }); + + ui.add_space(16.0); + + let num_recipients = self.selected_peer_ids.len(); + let can_send = !self.queued_files.is_empty() && num_recipients > 0; + let send_btn_text = if num_recipients == 1 { + let target_name = self + .peers + .iter() + .find(|p| self.selected_peer_ids.contains(&p.peer_id)) + .map(|p| self.get_peer_display_name(p).0) + .unwrap_or_else(|| "...".to_string()); + format!("🚀 Send {} Item(s) to {}", self.queued_files.len(), target_name) + } else { + format!("🚀 Broadcast {} Item(s) to {} Recipients", self.queued_files.len(), num_recipients) + }; + + let send_btn = Button::new(RichText::new(send_btn_text).size(15.0).strong().color(Color32::WHITE)) + .fill(if can_send { + Color32::from_rgb(16, 185, 129) + } else { + if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(203, 213, 225) } + }) + .min_size(Vec2::new(ui.available_width(), 44.0)) + .rounding(Rounding::same(8.0)); + + if ui.add_enabled(can_send, send_btn).clicked() { + let targets: Vec = self + .peers + .iter() + .filter(|p| self.selected_peer_ids.contains(&p.peer_id)) + .cloned() + .collect(); + let files = self.queued_files.clone(); + self.start_sending_files(targets, files); + } + } + + fn render_send_text_subview(&mut self, ui: &mut Ui, theme: ThemeColors) { + ui.horizontal(|ui| { + ui.label(RichText::new("Enter or paste text to send:").strong().color(theme.text_primary)); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("📋 Paste from Clipboard").clicked() { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if let Ok(text) = clipboard.get_text() { + self.text_input = text; + self.analyzed_preview = analyze_text(&self.text_input); + } + } + } + }); + }); + + ui.add_space(6.0); + + let response = ui.add( + egui::TextEdit::multiline(&mut self.text_input) + .desired_rows(6) + .desired_width(ui.available_width()) + .font(egui::TextStyle::Monospace), + ); + + if response.changed() { + self.analyzed_preview = analyze_text(&self.text_input); + } + + ui.add_space(8.0); + + if !self.text_input.trim().is_empty() { + let (r, g, b) = self.analyzed_preview.content_type.badge_color_rgb(); + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(6.0)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(r, g, b))) + .inner_margin(Margin::same(8.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("Auto-Detected:").size(11.0).color(theme.text_muted)); + ui.label( + RichText::new(self.analyzed_preview.content_type.label()) + .size(12.0) + .strong() + .color(Color32::from_rgb(r, g, b)), + ); + }); + }); + ui.add_space(10.0); + } + + let num_recipients = self.selected_peer_ids.len(); + let can_send_text = !self.text_input.trim().is_empty() && num_recipients > 0; + let btn_label = if num_recipients == 1 { + let target_name = self + .peers + .iter() + .find(|p| self.selected_peer_ids.contains(&p.peer_id)) + .map(|p| self.get_peer_display_name(p).0) + .unwrap_or_else(|| "...".to_string()); + format!("🚀 Send Text to {}", target_name) + } else { + format!("🚀 Broadcast Text to {} Recipients", num_recipients) + }; + + let send_btn = Button::new(RichText::new(btn_label).size(14.0).strong().color(Color32::WHITE)) + .fill(if can_send_text { + Color32::from_rgb(16, 185, 129) + } else { + if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(203, 213, 225) } + }) + .min_size(Vec2::new(ui.available_width(), 40.0)) + .rounding(Rounding::same(8.0)); + + if ui.add_enabled(can_send_text, send_btn).clicked() { + let targets: Vec = self + .peers + .iter() + .filter(|p| self.selected_peer_ids.contains(&p.peer_id)) + .cloned() + .collect(); + + let text = self.text_input.clone(); + let client = self.client.clone(); + let my_info = self.my_info.clone(); + let log_tx = self.log_tx.clone(); + let pin = if self.recipient_pin.trim().is_empty() { + None + } else { + Some(self.recipient_pin.trim().to_string()) + }; + + for peer in targets { + let peer_url = peer.transfer_url(); + let peer_name = self.get_peer_display_name(&peer).0; + let text_clone = text.clone(); + let client_clone = client.clone(); + let my_info_clone = my_info.clone(); + let log_tx_clone = log_tx.clone(); + let pin_clone = pin.clone(); + + tokio::spawn(async move { + match client_clone.send_text(&peer_url, &my_info_clone, &text_clone, pin_clone).await { + Ok(analyzed) => { + let _ = log_tx_clone.send(format!( + "🎉 Sent text [{}] to {}!", + analyzed.content_type.label(), + peer_name + )); + } + Err(e) => { + let _ = log_tx_clone.send(format!("⚠️ Text send failed to {}: {}", peer_name, e)); + } + } + }); + } + + self.text_input.clear(); + } + } + + fn render_qr_code(ctx: &egui::Context, url: &str, tex_id: &str) -> Option { + if let Ok(code) = QrCode::new(url.as_bytes()) { + let width = code.width(); + let mut pixels = Vec::with_capacity(width * width); + for y in 0..width { + for x in 0..width { + let is_dark = code[(x, y)] == QrColor::Dark; + pixels.push(if is_dark { Color32::BLACK } else { Color32::WHITE }); + } + } + let image = ColorImage { + size: [width, width], + pixels, + }; + Some(ctx.load_texture(tex_id, image, TextureOptions::NEAREST)) + } else { + None + } + } + + fn render_receive_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { + ScrollArea::vertical().show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading(RichText::new("📥 Receive & Web Access").color(theme.text_primary)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("📂 Open Downloads Folder").clicked() { + let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); + } + }); + }); + ui.add_space(8.0); + + // General Storage & Auto-Accept Settings Card + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(14.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("📁 Save Directory:").strong().color(theme.text_primary)); + ui.label(RichText::new(format!("{:?}", self.download_dir)).color(theme.text_secondary)); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.checkbox(&mut self.auto_accept, "Auto-accept incoming transfers from friends").changed() { + self.persist_config(); + } + if ui.checkbox(&mut self.auto_extract_zip, "Auto-extract received ZIP folders").changed() { + self.persist_config(); + } + }); + }); + + ui.add_space(14.0); + ui.label(RichText::new("📱 Send from Phone / Browser (Web Dropzone)").strong().size(15.0).color(theme.text_primary)); + ui.label(RichText::new("Scan the QR code with your phone camera or open the link in any mobile browser to send photos, videos, and files directly to this PC:").size(12.0).color(theme.text_muted)); + ui.add_space(10.0); + + let port = self.my_info.transfer_port; + + // 1. ZeroTier / VPN Interface + let vpn_iface = if self.current_iface.is_virtual_vpn() { + Some(self.current_iface.clone()) + } else { + self.available_interfaces.iter().find(|i| i.is_virtual_vpn()).cloned() + }; + + // 2. Local Wi-Fi / LAN Interface + let lan_iface = if !self.current_iface.is_virtual_vpn() && self.current_iface.net_type != NetworkType::Loopback { + Some(self.current_iface.clone()) + } else { + self.available_interfaces.iter().find(|i| !i.is_virtual_vpn() && i.net_type != NetworkType::Loopback).cloned() + }; + + // Card 1: ZeroTier / VPN Mesh + if let Some(iface) = &vpn_iface { + let url = format!("http://{}:{}", iface.ip, port); + let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_vpn"); + + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(168, 85, 247))) + .inner_margin(Margin::same(14.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("🌐 ZeroTier / VPN Mesh Link").size(15.0).strong().color(theme.text_primary)); + Frame::none() + .fill(Color32::from_rgba_premultiplied(168, 85, 247, 40)) + .rounding(Rounding::same(4.0)) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label(RichText::new(iface.net_type.as_str()).size(10.0).color(Color32::from_rgb(192, 132, 252)).strong()); + }); + }); + ui.add_space(3.0); + ui.label(RichText::new("For phones or laptops connected via ZeroTier, Tailscale, or Radmin VPN mesh:").size(11.0).color(theme.text_muted)); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label(RichText::new(&url).size(14.0).strong().color(theme.text_primary)); + if ui.button("📋 Copy").on_hover_text("Copy ZeroTier Web Link").clicked() { + ui.output_mut(|o| o.copied_text = url.clone()); + } + if ui.button("🌐 Open").on_hover_text("Open in browser").clicked() { + ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); + } + }); + }); + + if let Some(tex) = qr_tex { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + Frame::none() + .fill(Color32::WHITE) + .rounding(Rounding::same(6.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(4.0)) + .show(ui, |ui| { + ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); + }); + }); + } + }); + }); + ui.add_space(10.0); + } + + // Card 2: Local Wi-Fi / LAN Network + if let Some(iface) = &lan_iface { + let url = format!("http://{}:{}", iface.ip, port); + let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_lan"); + + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(56, 189, 248))) + .inner_margin(Margin::same(14.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("🏠 Home Wi-Fi / Local LAN Link").size(15.0).strong().color(theme.text_primary)); + Frame::none() + .fill(Color32::from_rgba_premultiplied(56, 189, 248, 40)) + .rounding(Rounding::same(4.0)) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label(RichText::new("Local Wi-Fi").size(10.0).color(Color32::from_rgb(56, 189, 248)).strong()); + }); + }); + ui.add_space(3.0); + ui.label(RichText::new("For phones, tablets, and devices connected to the same home/office Wi-Fi router:").size(11.0).color(theme.text_muted)); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label(RichText::new(&url).size(14.0).strong().color(theme.text_primary)); + if ui.button("📋 Copy").on_hover_text("Copy Local Wi-Fi Link").clicked() { + ui.output_mut(|o| o.copied_text = url.clone()); + } + if ui.button("🌐 Open").on_hover_text("Open in browser").clicked() { + ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); + } + }); + }); + + if let Some(tex) = qr_tex { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + Frame::none() + .fill(Color32::WHITE) + .rounding(Rounding::same(6.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(4.0)) + .show(ui, |ui| { + ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); + }); + }); + } + }); + }); + } else if vpn_iface.is_none() { + // Fallback if neither was separately classified + let url = format!("http://{}:{}", self.current_iface.ip, port); + let qr_tex = Self::render_qr_code(ui.ctx(), &url, "qr_fallback"); + + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(59, 130, 246))) + .inner_margin(Margin::same(14.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(RichText::new("🌐 Web Dropzone Link").size(15.0).strong().color(theme.text_primary)); + ui.add_space(3.0); + ui.label(RichText::new("Open this link or scan QR code on mobile:").size(11.0).color(theme.text_muted)); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(RichText::new(&url).size(14.0).strong().color(theme.text_primary)); + if ui.button("📋 Copy").clicked() { + ui.output_mut(|o| o.copied_text = url.clone()); + } + if ui.button("🌐 Open").clicked() { + ui.ctx().open_url(egui::OpenUrl::same_tab(&url)); + } + }); + }); + if let Some(tex) = qr_tex { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + Frame::none() + .fill(Color32::WHITE) + .rounding(Rounding::same(6.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(4.0)) + .show(ui, |ui| { + ui.add(egui::Image::new(&tex).fit_to_exact_size(Vec2::new(90.0, 90.0))); + }); + }); + } + }); + }); + } + ui.add_space(14.0); + }); + } + + fn refresh_current_share_tree(&mut self) { + if let (Some(peer_id), Some(folder_id)) = ( + self.selected_peer_id_for_share.clone(), + self.selected_folder_id_for_share.clone(), + ) { + if let Some(peer) = self.peers.iter().find(|p| p.peer_id == peer_id) { + let peer_url = peer.transfer_url(); + let subpath = self.current_share_subpath.clone(); + let pin = if self.share_pin_input.trim().is_empty() { + None + } else { + Some(self.share_pin_input.trim().to_string()) + }; + let client = self.client.clone(); + let tx = self.share_tree_tx.clone(); + let log_tx = self.log_tx.clone(); + self.share_tree_loading = true; + + tokio::spawn(async move { + match client + .fetch_shared_tree(&peer_url, &folder_id, Some(&subpath), pin.as_deref()) + .await + { + Ok(tree) => { + let _ = tx.send(Some(tree)); + } + Err(e) => { + let _ = tx.send(None); + let _ = log_tx.send(format!("⚠️ Shared folder error: {}", e)); + } + } + }); + } + } + } + + fn sync_active_share_tree_background(&self) { + if let (Some(peer_id), Some(folder_id)) = ( + self.selected_peer_id_for_share.clone(), + self.selected_folder_id_for_share.clone(), + ) { + if let Some(peer) = self.peers.iter().find(|p| p.peer_id == peer_id && p.is_online) { + let peer_url = peer.transfer_url(); + let subpath = self.current_share_subpath.clone(); + let pin = if self.share_pin_input.trim().is_empty() { + None + } else { + Some(self.share_pin_input.trim().to_string()) + }; + let client = self.client.clone(); + let tx = self.share_tree_tx.clone(); + + tokio::spawn(async move { + if let Ok(tree) = client + .fetch_shared_tree(&peer_url, &folder_id, Some(&subpath), pin.as_deref()) + .await + { + let _ = tx.send(Some(tree)); + } + }); + } + } + } + + fn render_shared_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { + ui.horizontal(|ui| { + let my_shares_count = self.shared_folders.len(); + let network_shares_count: usize = self.peers.iter().map(|p| p.shared_folders.len()).sum(); + + let net_btn = Button::new( + RichText::new(format!("🌐 Network Shares ({})", network_shares_count)) + .strong() + .color(if self.selected_share_subtab == SharedSubTab::NetworkShares { + theme.text_primary + } else { + theme.text_secondary + }), + ) + .fill(if self.selected_share_subtab == SharedSubTab::NetworkShares { + theme.bg_card_active + } else { + theme.bg_card + }) + .min_size(Vec2::new(170.0, 32.0)) + .rounding(Rounding::same(6.0)); + + if ui.add(net_btn).clicked() { + self.selected_share_subtab = SharedSubTab::NetworkShares; + } + + let my_btn = Button::new( + RichText::new(format!("💻 My Shares ({})", my_shares_count)) + .strong() + .color(if self.selected_share_subtab == SharedSubTab::MyShares { + theme.text_primary + } else { + theme.text_secondary + }), + ) + .fill(if self.selected_share_subtab == SharedSubTab::MyShares { + theme.bg_card_active + } else { + theme.bg_card + }) + .min_size(Vec2::new(150.0, 32.0)) + .rounding(Rounding::same(6.0)); + + if ui.add(my_btn).clicked() { + self.selected_share_subtab = SharedSubTab::MyShares; + } + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if self.selected_share_subtab == SharedSubTab::MyShares { + let add_btn = Button::new(RichText::new("➕ Add Shared Folder").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(34, 197, 94)) + .min_size(Vec2::new(160.0, 30.0)); + + if ui.add(add_btn).clicked() { + if let Some(folder) = FileDialog::new().pick_folder() { + let name = folder + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Shared Folder".to_string()); + let id = uuid::Uuid::new_v4().to_string(); + self.shared_folders.push(SharedFolderConfig { + id, + name, + path: folder, + read_only: true, + pin: None, + }); + self.persist_config(); + } + } + } else { + if ui.button(RichText::new("🔄 Sync / Refresh").color(theme.text_primary)).clicked() { + self.refresh_current_share_tree(); + } + } + }); + }); + + ui.add_space(10.0); + + match self.selected_share_subtab { + SharedSubTab::MyShares => self.render_my_shares(ui, theme), + SharedSubTab::NetworkShares => self.render_network_shares(ui, theme), + } + } + + fn render_my_shares(&mut self, ui: &mut Ui, theme: ThemeColors) { + if self.shared_folders.is_empty() { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(40.0)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label(RichText::new("📁").size(40.0)); + ui.label( + RichText::new("You haven't shared any local folders yet.") + .size(16.0) + .strong() + .color(theme.text_primary), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "Share movies, music, or code folders so your friends on ZeroTier/LAN can stream and download them.", + ) + .size(12.0) + .color(theme.text_muted), + ); + ui.add_space(16.0); + let choose_btn = Button::new(RichText::new("➕ Choose Folder to Share").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(34, 197, 94)) + .min_size(Vec2::new(200.0, 36.0)); + + if ui.add(choose_btn).clicked() { + if let Some(folder) = FileDialog::new().pick_folder() { + let name = folder + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Shared Folder".to_string()); + let id = uuid::Uuid::new_v4().to_string(); + self.shared_folders.push(SharedFolderConfig { + id, + name, + path: folder, + read_only: true, + pin: None, + }); + self.persist_config(); + } + } + }); + }); + return; + } + + let mut config_changed = false; + let mut remove_index = None; + + ScrollArea::vertical().show(ui, |ui| { + for (idx, share) in self.shared_folders.iter_mut().enumerate() { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(12.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("📁").size(24.0)); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&share.name).strong().size(14.0).color(theme.text_primary)); + + let (ro_label, ro_color) = if share.read_only { + ("Read-Only (Streaming/Download)", Color32::from_rgb(59, 130, 246)) + } else { + ("Read & Write (Uploads Allowed)", Color32::from_rgb(34, 197, 94)) + }; + + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(4.0)) + .stroke(Stroke::new(1.0_f32, ro_color)) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label(RichText::new(ro_label).size(10.5).color(ro_color)); + }); + + if share.pin.is_some() { + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(4.0)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(245, 158, 11))) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label( + RichText::new("🔒 PIN Protected") + .size(10.5) + .color(Color32::from_rgb(245, 158, 11)), + ); + }); + } + }); + + ui.label( + RichText::new(format!("{:?}", share.path)) + .monospace() + .size(11.0) + .color(theme.text_muted), + ); + }); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button(RichText::new("🗑 Remove").color(Color32::from_rgb(239, 68, 68))).clicked() { + remove_index = Some(idx); + } + + if ui.button("📂 Open Folder").clicked() { + let _ = std::process::Command::new("explorer").arg(&share.path).spawn(); + } + + if ui.checkbox(&mut share.read_only, "Read-Only").changed() { + config_changed = true; + } + }); + }); + }); + ui.add_space(8.0); + } + }); + + if let Some(idx) = remove_index { + self.shared_folders.remove(idx); + config_changed = true; + } + + if config_changed { + self.persist_config(); + } + } + + fn render_network_shares(&mut self, ui: &mut Ui, theme: ThemeColors) { + let online_peers_with_shares: Vec = self + .peers + .iter() + .filter(|p| p.is_online && !p.shared_folders.is_empty()) + .cloned() + .collect(); + + if online_peers_with_shares.is_empty() { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(40.0)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label(RichText::new("🌐").size(40.0)); + ui.label( + RichText::new("No shared folders discovered on your network.") + .size(16.0) + .strong() + .color(theme.text_primary), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "When your friends on ZeroTier / LAN add shared folders, they will automatically appear here with streaming and fast download support.", + ) + .size(12.0) + .color(theme.text_muted), + ); + }); + }); + return; + } + + ui.horizontal_top(|ui| { + // Left Column: Peer & Folder Selector (250px) + ui.allocate_ui(Vec2::new(250.0, ui.available_height()), |ui| { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(10.0)) + .show(ui, |ui| { + ui.label( + RichText::new("Friends & Shares") + .strong() + .size(13.0) + .color(theme.text_primary), + ); + ui.add_space(6.0); + ui.separator(); + ui.add_space(6.0); + + ScrollArea::vertical().show(ui, |ui| { + for peer in &online_peers_with_shares { + let (peer_display, _) = self.get_peer_display_name(peer); + ui.horizontal(|ui| { + ui.label(RichText::new("●").color(Color32::from_rgb(34, 197, 94)).size(11.0)); + ui.label(RichText::new(&peer_display).strong().color(theme.text_primary)); + }); + + for folder in &peer.shared_folders { + let is_selected = self.selected_peer_id_for_share.as_deref() == Some(&peer.peer_id) + && self.selected_folder_id_for_share.as_deref() == Some(&folder.id); + + let badge = if folder.read_only { "RO" } else { "RW" }; + let btn_text = format!("📁 {} [{}]", folder.name, badge); + + let btn = Button::new(RichText::new(btn_text).size(12.0).color(if is_selected { + theme.text_accent + } else { + theme.text_secondary + })) + .fill(if is_selected { + theme.bg_card_active + } else { + Color32::TRANSPARENT + }) + .min_size(Vec2::new(ui.available_width(), 26.0)) + .rounding(Rounding::same(4.0)); + + if ui.add(btn).clicked() { + self.selected_peer_id_for_share = Some(peer.peer_id.clone()); + self.selected_folder_id_for_share = Some(folder.id.clone()); + self.current_share_subpath = String::new(); + self.refresh_current_share_tree(); + } + } + ui.add_space(6.0); + } + }); + }); + }); + + ui.add_space(10.0); + + // Right Column: Active Share Explorer + ui.vertical(|ui| { + if let (Some(peer_id), Some(folder_id)) = ( + self.selected_peer_id_for_share.clone(), + self.selected_folder_id_for_share.clone(), + ) { + let peer_opt = self.peers.iter().find(|p| p.peer_id == peer_id).cloned(); + let peer = match peer_opt { + Some(p) => p, + None => { + ui.label("Selected peer is offline."); + return; + } + }; + + let (peer_name, _) = self.get_peer_display_name(&peer); + + // Top Explorer Header & Breadcrumbs + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button("🏠 Root").clicked() { + self.current_share_subpath = String::new(); + self.refresh_current_share_tree(); + } + + if !self.current_share_subpath.is_empty() { + ui.label("/"); + ui.label( + RichText::new(&self.current_share_subpath) + .monospace() + .strong() + .color(theme.text_primary), + ); + } + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // Search filter box + ui.add( + egui::TextEdit::singleline(&mut self.share_search_query) + .hint_text("🔍 Filter files...") + .desired_width(160.0), + ); + + // Upload button if folder is Read-Write + if let Some(tree) = &self.current_share_tree { + if !tree.read_only { + let upload_btn = Button::new( + RichText::new("⬆️ Upload File") + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(34, 197, 94)) + .rounding(Rounding::same(6.0)); + + if ui.add(upload_btn).clicked() { + if let Some(files) = FileDialog::new().pick_files() { + let client = self.client.clone(); + let peer_url = peer.transfer_url(); + let f_id = folder_id.clone(); + let sub = self.current_share_subpath.clone(); + let pin = if self.share_pin_input.trim().is_empty() { + None + } else { + Some(self.share_pin_input.trim().to_string()) + }; + let log_tx = self.log_tx.clone(); + let tx = self.share_tree_tx.clone(); + + tokio::spawn(async move { + for f in files { + let _ = log_tx.send(format!("⬆️ Uploading {:?} to share...", f.file_name())); + match client.upload_to_shared_folder(&peer_url, &f_id, &sub, &f, pin.as_deref()).await { + Ok(_) => { + let _ = log_tx.send(format!("✅ Uploaded {:?}", f.file_name())); + } + Err(e) => { + let _ = log_tx.send(format!("❌ Upload failed: {}", e)); + } + } + } + // Refresh tree after upload + if let Ok(tree) = client.fetch_shared_tree(&peer_url, &f_id, Some(&sub), pin.as_deref()).await { + let _ = tx.send(Some(tree)); + } + }); + } + } + } + } + }); + }); + }); + + ui.add_space(8.0); + + // Explorer File Table + if self.share_tree_loading { + ui.vertical_centered(|ui| { + ui.add_space(30.0); + ui.spinner(); + ui.label("Loading shared contents..."); + }); + } else if let Some(tree) = self.current_share_tree.clone() { + ScrollArea::vertical().show(ui, |ui| { + // Up to parent row + if !self.current_share_subpath.is_empty() { + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(6.0)) + .inner_margin(Margin::symmetric(10.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("📁 .."); + if ui.button("Parent Folder").clicked() { + let p = std::path::Path::new(&self.current_share_subpath); + let parent_str = p.parent().map(|p| p.to_string_lossy().replace('\\', "/")).unwrap_or_default(); + self.current_share_subpath = parent_str; + self.refresh_current_share_tree(); + } + }); + }); + ui.add_space(4.0); + } + + let q_lower = self.share_search_query.to_lowercase(); + for entry in &tree.entries { + if !q_lower.is_empty() && !entry.name.to_lowercase().contains(&q_lower) { + continue; + } + + let (icon, badge_color, badge_text) = get_file_badge_info(&entry.name, entry.is_dir); + + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(6.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::symmetric(10.0, 6.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(icon).size(16.0)); + + if entry.is_dir { + if ui.link(RichText::new(&entry.name).strong().color(theme.text_primary)).clicked() { + self.current_share_subpath = entry.relative_path.clone(); + self.refresh_current_share_tree(); + } + } else { + ui.label(RichText::new(&entry.name).strong().color(theme.text_primary)); + } + + // Badge + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(3.0)) + .stroke(Stroke::new(1.0_f32, badge_color)) + .inner_margin(Margin::symmetric(5.0, 1.0)) + .show(ui, |ui| { + ui.label(RichText::new(badge_text).size(9.5).color(badge_color)); + }); + + if !entry.is_dir { + ui.label( + RichText::new(format!("({})", human_bytes(entry.size as f64))) + .size(11.0) + .color(theme.text_muted), + ); + } + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // Action: Download + let is_dir = entry.is_dir; + let down_label = if is_dir { "⬇️ Zip" } else { "⬇️ Download" }; + if ui.button(down_label).clicked() { + let client = self.client.clone(); + let peer_url = peer.transfer_url(); + let f_id = folder_id.clone(); + let sub = entry.relative_path.clone(); + let out_name = if is_dir { format!("{}.zip", entry.name) } else { entry.name.clone() }; + let dest = self.download_dir.join(&out_name); + let pin = if self.share_pin_input.trim().is_empty() { + None + } else { + Some(self.share_pin_input.trim().to_string()) + }; + let log_tx = self.log_tx.clone(); + let sound = self.sound_notifications; + + tokio::spawn(async move { + let _ = log_tx.send(format!("⬇️ Downloading {}...", out_name)); + match client.download_shared_file(&peer_url, &f_id, &sub, &dest, pin.as_deref()).await { + Ok(bytes) => { + let _ = log_tx.send(format!("✅ Downloaded {} ({})", out_name, human_bytes(bytes as f64))); + if sound { + crate::ui::sound::play_transfer_sound(); + } + } + Err(e) => { + let _ = log_tx.send(format!("❌ Download failed: {}", e)); + } + } + }); + } + + // Action: Stream / Play (for video & audio) + if badge_text == "VIDEO" || badge_text == "AUDIO" { + if ui.button(RichText::new("▶️ Stream").color(Color32::from_rgb(236, 72, 153)).strong()).clicked() { + let stream_url = format!( + "{}/api/shared/stream/{}/{}", + peer.transfer_url(), + folder_id, + entry.relative_path.trim_start_matches('/') + ); + let _ = std::process::Command::new("explorer").arg(&stream_url).spawn(); + } + } + + // Action: Vim View (for code & text) + if badge_text == "CODE" || badge_text == "DOC" || entry.name.ends_with(".txt") || entry.name.ends_with(".log") { + if ui.button(RichText::new("👁 Vim View").color(Color32::from_rgb(20, 184, 166)).strong()).clicked() { + let client = self.client.clone(); + let peer_url = peer.transfer_url(); + let f_id = folder_id.clone(); + let sub = entry.relative_path.clone(); + let file_name = entry.name.clone(); + let peer_display = peer_name.clone(); + let text_tx = self.share_text_tx.clone(); + let pin = if self.share_pin_input.trim().is_empty() { + None + } else { + Some(self.share_pin_input.trim().to_string()) + }; + + tokio::spawn(async move { + if let Ok(text) = client.fetch_shared_file_text(&peer_url, &f_id, &sub, pin.as_deref()).await { + let _ = text_tx.send((file_name, sub, peer_display, text)); + } + }); + } + } + }); + }); + }); + ui.add_space(3.0); + } + }); + } else { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(20.0)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label(RichText::new("🔒 Folder Locked or Empty").strong().size(14.0).color(theme.text_primary)); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Enter PIN if required:"); + ui.add(egui::TextEdit::singleline(&mut self.share_pin_input).password(true).desired_width(120.0)); + if ui.button("Unlock / Refresh").clicked() { + self.refresh_current_share_tree(); + } + }); + }); + }); + } + } else { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(40.0)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label(RichText::new("👈 Select a shared folder on the left to browse files, watch streams, and view code in Vim.").color(theme.text_muted)); + }); + }); + } + }); + }); + } + + fn render_transfers_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { + ui.horizontal(|ui| { + ui.heading(RichText::new("📜 Active & Completed Transfers").color(theme.text_primary)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("📂 Open Downloads").clicked() { + let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); + } + if !self.transfers.is_empty() && ui.button("🗑️ Clear finished").clicked() { + self.transfers.retain(|t| t.state == TransferState::InProgress || t.state == TransferState::PendingConfirmation); + let srv = self.server.clone(); + tokio::spawn(async move { + srv.clear_finished().await; + }); + } + }); + }); + ui.add_space(8.0); + + if self.transfers.is_empty() { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(40.0)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.label(RichText::new("📦").size(36.0)); + ui.label(RichText::new("No active or past transfers.").size(15.0).color(theme.text_secondary)); + ui.add_space(4.0); + ui.label(RichText::new("Transfers will appear here with live speed and progress.").size(12.0).color(theme.text_muted)); + }); + }); + return; + } + + ScrollArea::vertical().show(ui, |ui| { + for t in &self.transfers { + let is_done = t.state == TransferState::Completed; + let is_active = t.state == TransferState::InProgress; + let icon = get_file_icon(&t.current_file_name); + + Frame::none() + .fill(if is_active { + if theme.is_dark { + Color32::from_rgb(23, 37, 84) + } else { + Color32::from_rgb(219, 234, 254) + } + } else { + theme.bg_card + }) + .rounding(Rounding::same(10.0)) + .stroke(if is_active { + Stroke::new(1.0_f32, Color32::from_rgb(59, 130, 246)) + } else if is_done { + Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94)) + } else { + theme.stroke_border + }) + .inner_margin(Margin::same(14.0)) + .show(ui, |ui| { + // Top row: Direction, Peer, and Status Badge + ui.horizontal(|ui| { + let (dir_badge, dir_color) = if t.is_incoming { + (format!("📥 Received from {}", t.peer_name), Color32::from_rgb(192, 132, 252)) + } else { + (format!("📤 Sent to {}", t.peer_name), Color32::from_rgb(96, 165, 250)) + }; + + ui.label(RichText::new(dir_badge).size(13.0).strong().color(dir_color)); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + match &t.state { + TransferState::Completed => { + ui.horizontal(|ui| { + ui.label(RichText::new("✅ Completed").color(Color32::from_rgb(34, 197, 94)).strong().size(13.0)); + if t.checksum_verified { + Frame::none() + .fill(Color32::from_rgba_premultiplied(34, 197, 94, 35)) + .rounding(Rounding::same(4.0)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label(RichText::new("🛡️ SHA-256 Verified").size(10.5).color(Color32::from_rgb(74, 222, 128)).strong()) + .on_hover_text("Cryptographic SHA-256 checksum verified on the fly. 100% integrity guaranteed."); + }); + } + }); + } + TransferState::InProgress => { + let speed_mb = (t.speed_bps as f64) / 1_048_576.0; + ui.label( + RichText::new(format!("⚡ {:.1} MB/s", speed_mb)) + .color(if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::from_rgb(2, 132, 199) }) + .strong() + .size(13.0), + ); + } + TransferState::PendingConfirmation => { + ui.label(RichText::new("⏳ Waiting for confirmation...").color(Color32::YELLOW).size(13.0)); + } + TransferState::Failed(e) => { + ui.label(RichText::new(format!("❌ Failed: {}", e)).color(Color32::RED).size(12.0)); + } + TransferState::Rejected => { + ui.label(RichText::new("⛔ Transfer rejected").color(Color32::RED).size(12.0)); + } + TransferState::Canceled => { + ui.label(RichText::new("🛑 Canceled").color(theme.text_muted).size(12.0)); + } + } + }); + }); + + ui.add_space(8.0); + + // Middle row: File Icon, Detailed Filename, Size, and Buttons + ui.horizontal(|ui| { + ui.label(RichText::new(icon).size(24.0)); + ui.add_space(4.0); + + ui.vertical(|ui| { + ui.label( + RichText::new(&t.current_file_name) + .size(15.0) + .strong() + .color(theme.text_primary), + ); + + let progress_pct = if t.total_bytes > 0 { + ((t.bytes_transferred as f64 / t.total_bytes as f64) * 100.0).clamp(0.0, 100.0) as u32 + } else { + 100 + }; + + let size_details = if t.state == TransferState::InProgress { + let speed = t.speed_bps.max(1); + let remaining_bytes = t.total_bytes.saturating_sub(t.bytes_transferred); + let eta_secs = remaining_bytes / speed; + let eta_str = if eta_secs < 60 { + format!("~{}s left", eta_secs) + } else if eta_secs < 3600 { + format!("~{}m {}s left", eta_secs / 60, eta_secs % 60) + } else { + format!("~{}h {}m left", eta_secs / 3600, (eta_secs % 3600) / 60) + }; + + let speed_mb = (t.speed_bps as f64) / 1_048_576.0; + format!( + "{} / {} ({}%) • ⚡ {:.1} MB/s • {}", + human_bytes(t.bytes_transferred as f64), + human_bytes(t.total_bytes as f64), + progress_pct, + speed_mb, + eta_str + ) + } else if is_done { + if t.checksum_verified { + format!("Delivered • Total Size: {} • 🛡️ SHA-256 Verified", human_bytes(t.total_bytes as f64)) + } else { + format!("Delivered • Total Size: {}", human_bytes(t.total_bytes as f64)) + } + } else { + format!("Total Size: {}", human_bytes(t.total_bytes as f64)) + }; + + ui.label(RichText::new(size_details).size(11.0).color(theme.text_muted)); + }); + + if is_done { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let target_file = self.download_dir.join(&t.current_file_name); + let exists = target_file.exists(); + if ui + .add( + Button::new(RichText::new("📂 Show in Folder").strong().color(Color32::WHITE)) + .fill(Color32::from_rgb(37, 99, 235)) + .rounding(Rounding::same(6.0)), + ) + .clicked() + { + if exists { + let _ = std::process::Command::new("explorer") + .args(["/select,", &target_file.to_string_lossy()]) + .spawn(); + } else { + let _ = std::process::Command::new("explorer").arg(&self.download_dir).spawn(); + } + } + + if exists && ui + .add( + Button::new(RichText::new("📄 Open").color(if theme.is_dark { Color32::WHITE } else { theme.text_primary })) + .fill(if theme.is_dark { Color32::from_rgb(51, 65, 85) } else { Color32::from_rgb(226, 232, 240) }) + .rounding(Rounding::same(6.0)), + ) + .clicked() + { + let _ = std::process::Command::new("explorer").arg(&target_file).spawn(); + } + }); + } + }); + + ui.add_space(8.0); + + // Progress Bar + let progress_ratio = if t.total_bytes > 0 { + (t.bytes_transferred as f32 / t.total_bytes as f32).clamp(0.0, 1.0) + } else { + 1.0 + }; + + let pb = ProgressBar::new(progress_ratio) + .show_percentage() + .fill(if is_done { + Color32::from_rgb(16, 185, 129) + } else { + Color32::from_rgb(56, 189, 248) + }); + ui.add(pb); + }); + ui.add_space(8.0); + } + }); + } + + fn render_settings_tab(&mut self, ui: &mut Ui, theme: ThemeColors) { + ScrollArea::vertical().show(ui, |ui| { + ui.heading(RichText::new("⚙️ Settings & Preferences").color(theme.text_primary)); + ui.add_space(8.0); + + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(16.0)) + .show(ui, |ui| { + // Theme Mode Selector + ui.label(RichText::new("🎨 Theme & Appearance").strong().color(theme.text_primary)); + ui.add_space(4.0); + ui.horizontal(|ui| { + let themes = [ + (ThemeMode::Auto, "🖥 Auto (System Theme)"), + (ThemeMode::Dark, "🌙 Dark Theme"), + (ThemeMode::Light, "☀️ Light Theme"), + ]; + + for (mode, label) in themes { + let is_active = self.theme_mode == mode; + let btn = Button::new( + RichText::new(label) + .size(12.5) + .color(if is_active { Color32::WHITE } else { theme.text_secondary }) + .strong(), + ) + .fill(if is_active { Color32::from_rgb(37, 99, 235) } else { theme.bg_card_secondary }) + .rounding(Rounding::same(6.0)) + .min_size(Vec2::new(140.0, 32.0)); + + if ui.add(btn).clicked() { + self.theme_mode = mode; + self.config.theme_mode = mode; + self.persist_config(); + } + } + }); + + ui.add_space(4.0); + let current_status = match self.theme_mode { + ThemeMode::Auto => { + if is_system_dark_mode() { + "Currently automatically matching Windows Dark Theme" + } else { + "Currently automatically matching Windows Light Theme" + } + } + ThemeMode::Dark => "Always Dark Theme", + ThemeMode::Light => "Always Light Theme", + }; + ui.label(RichText::new(current_status).size(11.0).color(theme.text_muted)); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + ui.label(RichText::new("Device Name:").strong().color(theme.text_primary)); + if ui.text_edit_singleline(&mut self.custom_device_name).lost_focus() { + self.persist_config(); + } + + ui.add_space(10.0); + ui.label(RichText::new("Network Interface:").strong().color(theme.text_primary)); + + let mut iface_changed = false; + let ifaces = self.available_interfaces.clone(); + egui::ComboBox::from_id_salt("iface_combo") + .selected_text(format!("{} - {}", self.current_iface.net_type.as_str(), self.current_iface.ip)) + .width(360.0) + .show_ui(ui, |ui| { + for iface in &ifaces { + let label = format!("{} (IP: {})", iface.name, iface.ip); + if ui.selectable_value(&mut self.current_iface, iface.clone(), label).changed() { + iface_changed = true; + } + } + }); + + if iface_changed { + self.persist_config(); + } + + ui.add_space(10.0); + ui.label(RichText::new("Download Location:").strong().color(theme.text_primary)); + ui.horizontal(|ui| { + ui.label(RichText::new(format!("{:?}", self.download_dir)).color(theme.text_secondary)); + if ui.button("Change Folder...").clicked() { + if let Some(dir) = FileDialog::new().pick_folder() { + self.download_dir = dir; + self.persist_config(); + } + } + }); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Folder Compression Settings + ui.label(RichText::new("📦 Ultra-Fast Folder ZIP Compression").strong().color(theme.text_primary)); + ui.add_space(4.0); + if ui + .checkbox( + &mut self.auto_zip_folders, + "Compress folders into ZIP on the fly (10x faster for code, git, photo folders)", + ) + .changed() + { + self.persist_config(); + } + if ui + .checkbox( + &mut self.auto_extract_zip, + "Automatically extract received ZIP folder archives into Downloads folder", + ) + .changed() + { + self.persist_config(); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Bandwidth Limiter Settings + ui.label(RichText::new("🚀 Bandwidth Limiter (Upload Speed Cap)").strong().color(theme.text_primary)); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Speed limit:").color(theme.text_secondary)); + if ui + .add( + egui::DragValue::new(&mut self.max_upload_speed_mbps) + .range(0..=5000) + .speed(1.0) + .custom_formatter(|n, _| { + if n == 0.0 { + "Unlimited (0 MB/s)".to_string() + } else { + format!("{:.0} MB/s", n) + } + }), + ) + .changed() + { + self.persist_config(); + } + + ui.add_space(8.0); + ui.label( + RichText::new("(Enter 0 for unlimited, or type any custom MB/s)") + .size(11.0) + .color(theme.text_muted), + ); + }); + + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + RichText::new("Quick presets:") + .size(12.0) + .color(theme.text_muted), + ); + for preset in [0, 5, 10, 25, 50, 100] { + let label = if preset == 0 { + "Unlimited".to_string() + } else { + format!("{} MB/s", preset) + }; + let is_current = self.max_upload_speed_mbps == preset; + let btn = egui::Button::new( + RichText::new(label).size(11.0).color(if is_current { + if theme.is_dark { Color32::from_rgb(56, 189, 248) } else { Color32::WHITE } + } else { + theme.text_secondary + }), + ) + .fill(if is_current { + Color32::from_rgb(37, 99, 235) + } else { + theme.bg_card_secondary + }); + if ui.add(btn).clicked() { + self.max_upload_speed_mbps = preset; + self.persist_config(); + } + } + }); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Audio & Sound Notification Settings + ui.label(RichText::new("🔔 Audio & Notifications").strong().color(theme.text_primary)); + ui.add_space(4.0); + if ui + .checkbox( + &mut self.sound_notifications, + "Play soft audio chime upon transfer completion", + ) + .changed() + { + self.persist_config(); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // System Tray Options + ui.label(RichText::new("🎛 Window & System Tray Behavior").strong().color(theme.text_primary)); + ui.add_space(4.0); + if ui + .checkbox( + &mut self.close_to_tray, + "Close to System Tray (keeps ZeroSend running in background)", + ) + .changed() + { + self.persist_config(); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Windows Startup Autostart + ui.label(RichText::new("🚀 Windows Startup Behavior").strong().color(theme.text_primary)); + ui.add_space(4.0); + ui.horizontal(|ui| { + if self.autostart_enabled { + ui.label(RichText::new("Status: Starts with Windows (Minimized to Tray)").color(Color32::from_rgb(34, 197, 94))); + if ui.button("➖ Disable Autostart").clicked() { + let _ = unregister_autostart(); + self.autostart_enabled = is_autostart_registered(); + } + } else { + ui.label(RichText::new("Status: Disabled").color(theme.text_muted)); + if ui.button("➕ Start with Windows (Tray)").clicked() { + let _ = register_autostart(); + self.autostart_enabled = is_autostart_registered(); + } + } + }); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + + + // PIN Security Settings + ui.label(RichText::new("🔒 Security / Anti-Spam PIN Protection").strong().color(theme.text_primary)); + ui.add_space(4.0); + if ui + .checkbox( + &mut self.require_pin, + "Require PIN code for incoming files and text messages", + ) + .changed() + { + self.persist_config(); + } + + if self.require_pin { + ui.horizontal(|ui| { + ui.label(RichText::new("4-Digit PIN Code:").color(theme.text_secondary)); + if ui + .add( + egui::TextEdit::singleline(&mut self.pin_code) + .password(true) + .desired_width(80.0), + ) + .changed() + { + self.persist_config(); + } + }); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Gitea Updates & Sync Settings + ui.label(RichText::new("🔄 Gitea Updates & Repository").strong().color(theme.text_primary)); + ui.add_space(4.0); + + ui.horizontal(|ui| { + ui.label(RichText::new("Gitea Repo:").color(theme.text_secondary)); + if ui.link(RichText::new(GITEA_DEFAULT_REPO_URL).color(Color32::from_rgb(56, 189, 248))).clicked() { + let _ = std::process::Command::new("explorer").arg(GITEA_DEFAULT_REPO_URL).spawn(); + } + }); + + ui.add_space(4.0); + if ui + .checkbox( + &mut self.check_updates_on_startup, + "Automatically check for updates on application startup", + ) + .changed() + { + self.config.check_updates_on_startup = self.check_updates_on_startup; + self.persist_config(); + } + + ui.add_space(6.0); + let mut check_clicked = false; + ui.horizontal(|ui| { + let check_btn = Button::new( + RichText::new(if self.is_checking_update { "⏳ Checking Gitea..." } else { "🔍 Check for Updates Now" }) + .color(theme.text_primary) + ) + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(6.0)); + + if ui.add_enabled(!self.is_checking_update, check_btn).clicked() { + check_clicked = true; + } + + if let Some(msg) = &self.update_check_status_msg { + ui.label(RichText::new(msg).size(12.0).color(theme.text_muted)); + } + }); + + if check_clicked { + self.trigger_update_check(); + } + + // Show active update info / download card + let mut trigger_install_url = None; + let mut open_gitea_url = None; + + if let Some(info) = &self.update_info { + if info.has_update { + ui.add_space(8.0); + Frame::none() + .fill(if theme.is_dark { Color32::from_rgb(17, 36, 26) } else { Color32::from_rgb(236, 253, 245) }) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::same(12.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("✨ New Version Available:").strong().color(Color32::from_rgb(34, 197, 94))); + ui.label(RichText::new(format!("v{}", info.latest_version)).strong().size(14.0).color(theme.text_primary)); + ui.label(RichText::new(format!("(Current: v{})", info.current_version)).size(11.0).color(theme.text_muted)); + }); + + if !info.release_notes.is_empty() { + ui.add_space(4.0); + ui.label(RichText::new(&info.release_notes).size(11.5).color(theme.text_secondary)); + } + + ui.add_space(8.0); + if self.is_updating { + if let Some(prog) = &self.update_progress { + match prog { + UpdateProgress::Downloading { percent, downloaded, total } => { + let pct_100 = (percent * 100.0) as u32; + ui.label( + RichText::new(format!("Downloading update: {} / {} ({}%)", human_bytes(*downloaded as f64), human_bytes(*total as f64), pct_100)) + .size(11.5) + .color(theme.text_primary) + ); + ui.add(ProgressBar::new(*percent).animate(true)); + } + UpdateProgress::Verifying => { + ui.label(RichText::new("🛡️ Verifying downloaded binary...").color(Color32::from_rgb(56, 189, 248))); + } + UpdateProgress::Installing => { + ui.label(RichText::new("⚡ Installing update and restarting ZeroSend...").color(Color32::from_rgb(34, 197, 94)).strong()); + } + UpdateProgress::Completed => { + ui.label(RichText::new("🎉 Update completed! Relaunching...").color(Color32::from_rgb(34, 197, 94)).strong()); + } + UpdateProgress::Error(err) => { + ui.label(RichText::new(format!("❌ Update failed: {}", err)).color(Color32::from_rgb(239, 68, 68))); + } + } + } + } else { + ui.horizontal(|ui| { + if let Some(dl_url) = &info.download_url { + let install_btn = Button::new( + RichText::new("🚀 Download & Install Update Now") + .strong() + .color(Color32::WHITE) + ) + .fill(Color32::from_rgb(34, 197, 94)) + .rounding(Rounding::same(6.0)); + + if ui.add(install_btn).clicked() { + trigger_install_url = Some(dl_url.clone()); + } + } + + let gitea_btn = Button::new( + RichText::new("🌐 View on Gitea") + .color(theme.text_primary) + ) + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(6.0)); + + if ui.add(gitea_btn).clicked() { + open_gitea_url = Some(info.gitea_url.clone()); + } + }); + } + }); + } + } + + if let Some(dl) = trigger_install_url { + self.trigger_install_update(dl); + } + if let Some(g_url) = open_gitea_url { + let _ = std::process::Command::new("explorer").arg(&g_url).spawn(); + } + }); + + ui.add_space(14.0); + + // Collapsible Credits & About Card + egui::CollapsingHeader::new(RichText::new("👥 About & Credits").strong().size(14.0).color(theme.text_primary)) + .default_open(false) + .show(ui, |ui| { + Frame::none() + .fill(theme.bg_card) + .rounding(Rounding::same(10.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(16.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("⚡ ZeroSend").size(16.0).strong().color(theme.text_accent)); + ui.label(RichText::new("v2.4.1").size(13.0).color(Color32::from_rgb(34, 197, 94)).strong()); + }); + ui.add_space(4.0); + ui.label(RichText::new("Fast, peer-to-peer file & text sharing optimized for ZeroTier, Tailscale, Radmin VPN & LAN.").color(theme.text_secondary)); + ui.add_space(8.0); + + ui.label(RichText::new("🛠 Developed by:").strong().color(theme.text_primary)); + ui.label(RichText::new(" • RarDog (Idea, Requirements & Direction)").color(theme.text_secondary)); + ui.label(RichText::new(" • Antigravity (Google DeepMind AI Coding Assistant)").color(theme.text_secondary)); + + ui.add_space(6.0); + ui.label(RichText::new("⚙ Technology Stack:").strong().color(theme.text_primary)); + ui.label(RichText::new(" • Rust 1.98, Tokio Async Runtime, Axum HTTP Streaming").color(theme.text_secondary)); + ui.label(RichText::new(" • HTTP 206 Partial Content Media Streaming & Real-time Seeking").color(theme.text_secondary)); + ui.label(RichText::new(" • Full Portable Exe-Folder Configuration & Data Storage").color(theme.text_secondary)); + ui.label(RichText::new(" • Embedded Lightweight Vim Code & Text Viewer").color(theme.text_secondary)); + ui.label(RichText::new(" • SHA-256 On-The-Fly Cryptographic File Integrity Verification").color(theme.text_secondary)); + ui.label(RichText::new(" • Mobile Web PWA with Thumbnails & Voice Notes Recorder").color(theme.text_secondary)); + ui.label(RichText::new(" • 4-Stream Parallel Chunk Worker Engine (Multi-stream Range transfers)").color(theme.text_secondary)); + ui.label(RichText::new(" • Resumable .zerosend_part File Checkpointing Engine").color(theme.text_secondary)); + ui.label(RichText::new(" • Token-Bucket Bandwidth Rate Limiter & Priority Discovery").color(theme.text_secondary)); + ui.label(RichText::new(" • Egui & Eframe Desktop GUI Engine with Dynamic Dark/Light Themes").color(theme.text_secondary)); + ui.label(RichText::new(" • Zip Fast Deflate Streaming Compression Engine").color(theme.text_secondary)); + ui.label(RichText::new(" • UDP Broadcast ZeroTier Mesh Discovery Engine").color(theme.text_secondary)); + }); + }); + + ui.add_space(8.0); + + // Collapsible Version History / Changelog Card + egui::CollapsingHeader::new(RichText::new("📜 Version History (Changelog)").strong().size(14.0).color(theme.text_primary)) + .default_open(false) + .show(ui, |ui| { + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(14.0)) + .show(ui, |ui| { + // v2.4.1 + ui.horizontal(|ui| { + ui.label(RichText::new("v2.4.1").strong().color(Color32::from_rgb(34, 197, 94))); + ui.label(RichText::new("(Current Release)").size(11.0).color(Color32::GRAY)); + }); + ui.label(" • 📁 Shared Folders Tab: Dedicated Shared Folders management with Read-Only / Read-Write permissions and PIN security."); + ui.label(" • 🎬 HTTP 206 Streaming: Instant real-time video/audio streaming with seeking without downloading entire files."); + ui.label(" • ⚡ Built-in Vim Code Viewer: Terminal-style code viewer with full Vim keybindings, search (/), and line jump."); + ui.label(" • 📦 Single-Folder Portable Mode: All settings (config.json) and downloads are created & saved right in the exe folder."); + ui.label(" • 🔄 Auto-Sync: Real-time discovery beacon broadcasting of shared folders when connecting to ZeroTier/VPN."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v2.4.0 + ui.horizontal(|ui| { + ui.label(RichText::new("v2.4.0").strong().color(Color32::from_rgb(56, 189, 248))); + }); + ui.label(" • 🛡️ SHA-256 Checksum on the Fly: File integrity calculation & verification during streaming with green verification badges."); + ui.label(" • 📸 Mobile Media Previews: Thumbnail gallery previews for photos with instant remove buttons before uploading."); + ui.label(" • 🎙️ Mobile Voice Notes: Built-in voice recorder with live audio wave pulse animation and one-tap delivery."); + ui.label(" • 📲 PWA Support: Web App Manifest & Add to Home Screen standalone install capabilities for mobile browsers."); + ui.label(" • 🎨 Auto Theme: Dark/Light theme mode with Windows Registry theme sync & contrast fixes."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v2.3.2 + ui.horizontal(|ui| { + ui.label(RichText::new("v2.3.2").strong().color(Color32::from_rgb(56, 189, 248))); + }); + ui.label(" • 🚀 Start with Windows: Option to run ZeroSend on Windows startup minimized to tray."); + ui.label(" • 🎯 Drag & Drop Quick Send: Drag files directly onto any friend's card in Nearby tab."); + ui.label(" • Dual Web Links with dedicated QR codes (ZeroTier/VPN mesh + Local Wi-Fi router)."); + ui.label(" • ZeroTier Web Confirmation popup on PC (Auto-accept on Local LAN)."); + ui.label(" • Auto-switch to Transfers view on both PC and mobile when transfers begin."); + ui.label(" • Exponential Moving Average (EMA) smoothed speed calculations (no spikes)."); + ui.label(" • 4-Stream Parallel Chunk Transfers with Adaptive Chunk Sizing."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v2.3.1 + ui.horizontal(|ui| { + ui.label(RichText::new("v2.3.1").strong().color(Color32::from_rgb(34, 197, 94))); + }); + ui.label(" • Redesigned Mobile-First Web Dropzone (camera/photo quick shortcuts, mobile UX)."); + ui.label(" • Dual Web Links with dedicated QR codes (ZeroTier/VPN mesh + Local Wi-Fi router)."); + ui.label(" • ZeroTier Web Confirmation popup on PC (Auto-accept on Local LAN)."); + ui.label(" • Auto-switch to Transfers view on both PC and mobile when transfers begin."); + ui.label(" • Exponential Moving Average (EMA) smoothed speed calculations (no spikes)."); + ui.label(" • Live folder compression status (shows 'Compressing to ZIP' in Transfers)."); + ui.label(" • Smart Dropzone with colored category badges (Video, Audio, Docs, Binaries)."); + ui.label(" • 4-Stream Parallel Chunk Transfers with Adaptive Chunk Sizing."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v2.0.0 + ui.label(RichText::new("v2.0.0").strong().color(Color32::from_rgb(56, 189, 248))); + ui.label(" • Redesigned modern Glassmorphism Web & Mobile dropzone dashboard."); + ui.label(" • Web Quick Text sender & mobile-responsive drag-and-drop."); + ui.label(" • Fixed global Ctrl+V screenshot pasting with retry mechanism."); + ui.label(" • Real-time upload streaming progress & speed meters for sender."); + ui.label(" • Permanent purge for 'Clear finished' transfers."); + ui.label(" • Peer deduplication & persistent device UUIDs."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v1.2.1 + ui.label(RichText::new("v1.2.1").strong().color(Color32::from_rgb(56, 189, 248))); + ui.label(" • Real-time stream upload progress & live speed on sender side."); + ui.label(" • Working 'Clear finished' button in Transfers tab."); + ui.label(" • Persistent peer UUIDs & automatic peer deduplication (no duplicate ghosts)."); + ui.label(" • Official Windows PE Resource & Manifest embedding (zero false positives)."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v1.2.0 + ui.label(RichText::new("v1.2.0").strong().color(Color32::from_rgb(56, 189, 248))); + ui.label(" • Crystal-clear transfer visualization, file icons & live speed meters."); + ui.label(" • Fixed incoming transfer confirmation popup & robust message delivery."); + ui.label(" • Fixed System Tray background listener and instant exit."); + ui.label(" • On-the-fly fast ZIP compression for directories (10x faster)."); + ui.label(" • Automatic ZIP extraction in receiver download directory."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v1.1.0 + ui.label(RichText::new("v1.1.0").strong().color(Color32::from_rgb(56, 189, 248))); + ui.label(" • System Tray integration with background daemon and tray menu."); + ui.label(" • Window size & layout auto-save (memory)."); + ui.label(" • Collapsible About, Credits & Version History section."); + ui.label(" • Polish and performance improvements."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v1.0.0 + ui.label(RichText::new("v1.0.0").strong().color(Color32::GRAY)); + ui.label(" • Native Desktop GUI (no terminal console window)."); + ui.label(" • Multi-recipient parallel file & text broadcasting."); + ui.label(" • Smart Clipboard with content auto-detection (IP, URL, Code, JSON)."); + ui.label(" • QR Code generator for mobile file dropzone."); + ui.label(" • 4-digit PIN security protection for incoming transfers."); + ui.label(" • Global Drag-and-Drop files directly into the window."); + ui.label(" • Ctrl+V screenshot/clipboard image pasting."); + ui.label(" • 'Open file' and 'Show in folder' buttons in Transfers."); + ui.label(" • Custom peer nicknames (aliases) saved in config."); + ui.label(" • Windows Explorer context menu ('Send with ZeroSend')."); + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // v0.1.0 + ui.label(RichText::new("v0.1.0").strong().color(Color32::GRAY)); + ui.label(" • Initial ZeroTier & VPN network interface scanner."); + ui.label(" • UDP broadcast peer discovery engine."); + ui.label(" • High-speed HTTP streaming transfer engine."); + ui.label(" • Terminal UI (TUI) and headless CLI daemon."); + }); + }); + + ui.add_space(14.0); + }); + } + + fn render_modals(&mut self, ctx: &egui::Context, theme: ThemeColors) { + // Direct IP Modal + if self.show_direct_ip_modal { + egui::Window::new("🔍 Direct Connect (IP)") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.label(RichText::new("Enter ZeroTier / Tailscale / Radmin IP of peer:").color(theme.text_primary)); + ui.add_space(4.0); + ui.text_edit_singleline(&mut self.direct_ip_input); + + if !self.direct_ip_status.is_empty() { + ui.add_space(4.0); + ui.label(RichText::new(&self.direct_ip_status).size(11.0).color(Color32::YELLOW)); + } + + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.add(Button::new(RichText::new("Connect & Ping").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { + let ip = self.direct_ip_input.trim().to_string(); + if !ip.is_empty() { + let client = self.client.clone(); + let peers_store = self.discovery_peers.clone(); + let log_tx = self.log_tx.clone(); + + self.direct_ip_status = "Connecting...".to_string(); + + tokio::spawn(async move { + match client.ping_peer(&ip, 53318).await { + Ok(beacon) => { + let _ = log_tx.send(format!("✨ Added direct node: {} ({})", beacon.name, beacon.ip)); + let mut peers = peers_store.write().await; + peers.insert( + beacon.peer_id.clone(), + PeerInfo { + peer_id: beacon.peer_id, + name: beacon.name, + os: beacon.os, + ip: beacon.ip, + transfer_port: beacon.transfer_port, + net_type: beacon.net_type, + version: beacon.version, + last_seen: chrono::Utc::now(), + is_online: true, + ping_ms: None, + shared_folders: beacon.shared_folders, + }, + ); + } + Err(e) => { + let _ = log_tx.send(format!("❌ Direct connect error: {}", e)); + } + } + }); + self.show_direct_ip_modal = false; + } + } + + if ui.button("Cancel").clicked() { + self.show_direct_ip_modal = false; + } + }); + }); + } + + // Rename Peer Modal + if let Some((peer_id, peer_name, mut current_alias)) = self.renaming_peer.clone() { + egui::Window::new("✏️ Rename Friend / Set Alias") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.label(RichText::new(format!("Set custom nickname for {}", peer_name)).color(theme.text_primary)); + ui.add_space(6.0); + ui.text_edit_singleline(&mut current_alias); + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.add(Button::new(RichText::new("Save Alias").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { + if current_alias.trim().is_empty() { + self.config.peer_aliases.remove(&peer_id); + } else { + self.config.peer_aliases.insert(peer_id.clone(), current_alias.trim().to_string()); + } + self.persist_config(); + self.renaming_peer = None; + } + + if ui.button("Cancel").clicked() { + self.renaming_peer = None; + } + }); + }); + } + + // Received Quick Text Modal + if let Some(msg) = self.received_text_modal.clone() { + let (r, g, b) = msg.content_type.badge_color_rgb(); + egui::Window::new("📋 Received Quick Text") + .collapsible(false) + .resizable(true) + .min_width(400.0) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("From: {}", msg.sender_name)).strong().size(14.0).color(theme.text_primary)); + ui.label( + RichText::new(format!("[{}]", msg.content_type.label())) + .strong() + .color(Color32::from_rgb(r, g, b)), + ); + }); + + ui.add_space(8.0); + + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(6.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(12.0)) + .show(ui, |ui| { + ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + ui.label( + RichText::new(&msg.content) + .monospace() + .color(theme.text_primary), + ); + }); + }); + + ui.add_space(12.0); + ui.horizontal(|ui| { + let content_copy = msg.content.clone(); + if ui.add(Button::new(RichText::new("📋 Copy to Clipboard").strong().color(Color32::WHITE)).fill(Color32::from_rgb(37, 99, 235))).clicked() { + ui.output_mut(|o| o.copied_text = content_copy.clone()); + } + + if msg.content_type == ContentType::WebUrl { + if ui.button("🌐 Open Link").clicked() { + ui.ctx().open_url(egui::OpenUrl::same_tab(&content_copy)); + } + } + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Close").clicked() { + self.received_text_modal = None; + } + }); + }); + }); + } + + // PROMINENT INCOMING FILE TRANSFER CONFIRMATION MODAL + if let Some(req) = self.pending_request.clone() { + egui::Window::new("📥 INCOMING FILE TRANSFER REQUEST") + .collapsible(false) + .resizable(false) + .min_width(450.0) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("📥").size(32.0)); + ui.vertical(|ui| { + ui.label( + RichText::new(format!("Incoming files from: {}", req.sender_name)) + .size(17.0) + .strong() + .color(theme.text_accent), + ); + ui.label( + RichText::new(format!( + "Total: {} file(s) • Total Size: {}", + req.files.len(), + human_bytes(req.total_size as f64) + )) + .size(12.0) + .color(theme.text_secondary), + ); + }); + }); + + ui.add_space(10.0); + ui.label(RichText::new("Items to be received:").strong().size(13.0).color(theme.text_primary)); + ui.add_space(4.0); + + Frame::none() + .fill(theme.bg_card_secondary) + .rounding(Rounding::same(8.0)) + .stroke(theme.stroke_border) + .inner_margin(Margin::same(10.0)) + .show(ui, |ui| { + ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for f in req.files.iter().take(8) { + let item_icon = get_file_icon(&f.name); + ui.horizontal(|ui| { + ui.label(item_icon); + ui.label(RichText::new(&f.name).color(theme.text_primary).strong()); + ui.label(RichText::new(format!("({})", human_bytes(f.size as f64))).size(11.0).color(theme.text_muted)); + }); + ui.separator(); + } + if req.files.len() > 8 { + ui.label(RichText::new(format!(" ... and {} more files", req.files.len() - 8)).color(theme.text_muted)); + } + }); + }); + + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Save destination:").size(11.0).color(theme.text_muted)); + ui.label(RichText::new(format!("{:?}", self.download_dir)).size(11.0).color(theme.text_secondary)); + }); + + ui.add_space(16.0); + ui.horizontal(|ui| { + let accept_btn = Button::new( + RichText::new("✅ ACCEPT & DOWNLOAD") + .size(14.0) + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(34, 197, 94)) + .min_size(Vec2::new(190.0, 42.0)) + .rounding(Rounding::same(8.0)); + + if ui.add(accept_btn).clicked() { + let server_clone = self.server.clone(); + let session_id = req.session_id.clone(); + + let initial_progress = TransferProgress { + session_id: session_id.clone(), + peer_name: req.sender_name.clone(), + is_incoming: true, + current_file_index: 0, + total_files: req.files.len(), + current_file_name: req.files.first().map(|f| f.name.clone()).unwrap_or_default(), + bytes_transferred: 0, + total_bytes: req.total_size, + speed_bps: 0, + state: TransferState::InProgress, + checksum_verified: false, + }; + + if let Some(pos) = self.transfers.iter().position(|x| x.session_id == session_id) { + self.transfers[pos] = initial_progress; + } else { + self.transfers.insert(0, initial_progress); + } + + tokio::spawn(async move { + server_clone.accept_transfer(&session_id).await; + }); + self.pending_request = None; + self.current_tab = Tab::Transfers; + } + + let reject_btn = Button::new( + RichText::new("❌ DECLINE") + .size(14.0) + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(239, 68, 68)) + .min_size(Vec2::new(130.0, 42.0)) + .rounding(Rounding::same(8.0)); + + if ui.add(reject_btn).clicked() { + let server_clone = self.server.clone(); + let session_id = req.session_id.clone(); + tokio::spawn(async move { + server_clone.reject_transfer(&session_id).await; + }); + self.pending_request = None; + } + }); + ui.add_space(4.0); + }); + } + + self.vim_viewer.render(ctx, theme); + } + + pub fn trigger_update_check(&mut self) { + self.is_checking_update = true; + self.update_check_status_msg = Some("Connecting to Gitea and checking for updates...".to_string()); + let tx = self.update_tx.clone(); + let api_url = self.config.gitea_api_url.clone(); + tokio::spawn(async move { + let res = check_gitea_update(api_url.as_deref()).await; + let _ = tx.send(res); + }); + } + + pub fn trigger_install_update(&mut self, download_url: String) { + self.is_updating = true; + self.update_progress = Some(UpdateProgress::Downloading { percent: 0.0, downloaded: 0, total: 0 }); + let prog_tx = self.update_prog_tx.clone(); + tokio::spawn(async move { + perform_update_download(download_url, prog_tx).await; + }); + } +} + +impl eframe::App for ZeroSendApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_events(ctx); + + if ctx.input(|i| i.viewport().close_requested()) { + if self.close_to_tray { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + } + } + + let cur_size = ctx.screen_rect().size(); + if cur_size.x >= 700.0 && cur_size.y >= 450.0 { + if (self.config.window_width - cur_size.x).abs() > 10.0 + || (self.config.window_height - cur_size.y).abs() > 10.0 + { + self.config.window_width = cur_size.x; + self.config.window_height = cur_size.y; + if self.last_window_save.elapsed() > Duration::from_secs(2) { + self.last_window_save = Instant::now(); + let _ = self.config.save(); + } + } + } + + let dropped_files = ctx.input(|i| i.raw.dropped_files.clone()); + if !dropped_files.is_empty() { + let mut added_count = 0; + for file in dropped_files { + if let Some(path) = file.path { + if !self.queued_files.contains(&path) { + self.queued_files.push(path); + added_count += 1; + } + } + } + if added_count > 0 { + self.current_tab = Tab::Send; + self.send_mode = SendMode::Files; + self.logs.push(format!("📥 Added {} dropped item(s) to send queue", added_count)); + } + } + + let is_paste = ctx.input(|i| (i.modifiers.ctrl || i.modifiers.command) && i.key_pressed(egui::Key::V)); + if is_paste && !ctx.wants_keyboard_input() { + self.try_paste_clipboard_image(); + } + + let is_dark = match self.theme_mode { + ThemeMode::Auto => is_system_dark_mode(), + ThemeMode::Dark => true, + ThemeMode::Light => false, + }; + let theme = ThemeColors::new(is_dark); + + let mut style = (*ctx.style()).clone(); + style.visuals.dark_mode = is_dark; + style.visuals.panel_fill = theme.bg_app; + style.visuals.window_fill = theme.bg_card; + style.visuals.override_text_color = Some(theme.text_primary); + style.visuals.widgets.noninteractive.bg_fill = theme.bg_card; + style.visuals.widgets.inactive.bg_fill = theme.bg_card_secondary; + style.visuals.widgets.inactive.rounding = Rounding::same(6.0); + style.visuals.widgets.hovered.bg_fill = theme.bg_hover; + style.visuals.widgets.hovered.rounding = Rounding::same(6.0); + style.visuals.widgets.active.bg_fill = Color32::from_rgb(37, 99, 235); + style.visuals.widgets.active.rounding = Rounding::same(6.0); + ctx.set_style(style); + + SidePanel::left("sidebar_panel") + .resizable(false) + .exact_width(195.0) + .frame( + Frame::none() + .fill(theme.bg_sidebar) + .stroke(theme.stroke_subtle) + .inner_margin(Margin::same(12.0)), + ) + .show(ctx, |ui| { + self.render_sidebar(ctx, ui, theme); + }); + + CentralPanel::default() + .frame( + Frame::none() + .fill(theme.bg_app) + .inner_margin(Margin::same(20.0)), + ) + .show(ctx, |ui| { + // Top Update Banner if update available on Gitea + if self.show_update_banner { + if let Some(info) = &self.update_info { + if info.has_update { + let mut dismiss = false; + let mut trigger_install = None; + let mut open_gitea = false; + let gitea_url = info.gitea_url.clone(); + let latest_ver = info.latest_version.clone(); + let download_url = info.download_url.clone(); + + Frame::none() + .fill(if theme.is_dark { Color32::from_rgb(17, 43, 31) } else { Color32::from_rgb(220, 252, 231) }) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 197, 94))) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::symmetric(14.0, 10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("✨").size(16.0)); + ui.label( + RichText::new(format!("Update Available: ZeroSend v{} is out on Gitea!", latest_ver)) + .strong() + .color(if theme.is_dark { Color32::WHITE } else { Color32::from_rgb(20, 83, 45) }) + ); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button(RichText::new("✕").size(12.0).color(theme.text_muted)).clicked() { + dismiss = true; + } + + if ui.button(RichText::new("🌐 View on Gitea").size(11.5)).clicked() { + open_gitea = true; + } + + if let Some(dl) = download_url { + if !self.is_updating { + let update_btn = Button::new( + RichText::new("🚀 Update Now").strong().size(12.0).color(Color32::WHITE) + ) + .fill(Color32::from_rgb(34, 197, 94)) + .rounding(Rounding::same(6.0)); + + if ui.add(update_btn).clicked() { + trigger_install = Some(dl); + } + } else { + ui.label(RichText::new("⏳ Updating...").color(Color32::from_rgb(34, 197, 94)).strong()); + } + } + }); + }); + }); + + if dismiss { + self.show_update_banner = false; + } + if open_gitea { + let _ = std::process::Command::new("explorer").arg(&gitea_url).spawn(); + } + if let Some(dl) = trigger_install { + self.trigger_install_update(dl); + } + + ui.add_space(10.0); + } + } + } + + match self.current_tab { + Tab::Nearby => self.render_nearby_tab(ui, theme), + Tab::Send => self.render_send_tab(ui, theme), + Tab::Receive => self.render_receive_tab(ui, theme), + Tab::Shared => self.render_shared_tab(ui, theme), + Tab::Transfers => self.render_transfers_tab(ui, theme), + Tab::Settings => self.render_settings_tab(ui, theme), + } + }); + + self.render_modals(ctx, theme); + + if self.transfers.iter().any(|t| t.state == TransferState::InProgress) || self.is_updating { + ctx.request_repaint_after(Duration::from_millis(50)); + } else { + ctx.request_repaint_after(Duration::from_millis(200)); + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..71b68fd --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,10 @@ +pub mod cli; +pub mod gui; +pub mod sound; +pub mod tray; +pub mod tui; +pub mod vim_viewer; + +pub use cli::*; +#[allow(unused_imports)] +pub use sound::play_transfer_sound; diff --git a/src/ui/sound.rs b/src/ui/sound.rs new file mode 100644 index 0000000..a27b6bd --- /dev/null +++ b/src/ui/sound.rs @@ -0,0 +1,13 @@ +#[cfg(windows)] +#[link(name = "user32")] +extern "system" { + fn MessageBeep(uType: u32) -> i32; +} + +/// Play a subtle native Windows notification sound (AirDrop chime style) +pub fn play_transfer_sound() { + #[cfg(windows)] + unsafe { + MessageBeep(0x00000040); // MB_ICONASTERISK + } +} diff --git a/src/ui/tray.rs b/src/ui/tray.rs new file mode 100644 index 0000000..4331218 --- /dev/null +++ b/src/ui/tray.rs @@ -0,0 +1,138 @@ +use eframe::egui::Context; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Receiver}; +use std::time::Duration; +use tray_icon::{ + menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}, + Icon, TrayIcon, TrayIconBuilder, TrayIconEvent, +}; + +pub struct SystemTrayManager { + _tray_icon: TrayIcon, + action_rx: Receiver, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayAction { + ShowWindow, + OpenDownloads, +} + +impl SystemTrayManager { + pub fn new( + ctx: Context, + download_dir: PathBuf, + ) -> Result> { + let menu = Menu::new(); + let show_item = MenuItem::new("⚡ Show ZeroSend", true, None); + let downloads_item = MenuItem::new("📁 Open Downloads Folder", true, None); + let quit_item = MenuItem::new("❌ Exit", true, None); + + let show_id = show_item.id().clone(); + let downloads_id = downloads_item.id().clone(); + let quit_id = quit_item.id().clone(); + + menu.append_items(&[ + &show_item, + &downloads_item, + &PredefinedMenuItem::separator(), + &quit_item, + ])?; + + let icon = create_default_tray_icon()?; + + let tray_icon = TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_tooltip("ZeroSend - P2P Mesh Sharing") + .with_icon(icon) + .build()?; + + let (action_tx, action_rx) = channel::(); + + // Spawn a dedicated listener thread to handle tray clicks & menu actions + let ctx_clone = ctx.clone(); + let dl_dir = download_dir.clone(); + let tx = action_tx.clone(); + + std::thread::spawn(move || { + loop { + // 1. Check Tray Icon Left Clicks + if let Ok(tray_event) = TrayIconEvent::receiver().try_recv() { + match tray_event { + TrayIconEvent::Click { + button: tray_icon::MouseButton::Left, + button_state: tray_icon::MouseButtonState::Up, + .. + } + | TrayIconEvent::DoubleClick { + button: tray_icon::MouseButton::Left, + .. + } => { + let _ = tx.send(TrayAction::ShowWindow); + ctx_clone.request_repaint(); + } + _ => {} + } + } + + // 2. Check Tray Menu Items + if let Ok(menu_event) = MenuEvent::receiver().try_recv() { + if menu_event.id == show_id { + let _ = tx.send(TrayAction::ShowWindow); + ctx_clone.request_repaint(); + } else if menu_event.id == downloads_id { + let _ = std::process::Command::new("explorer").arg(&dl_dir).spawn(); + let _ = tx.send(TrayAction::OpenDownloads); + } else if menu_event.id == quit_id { + // Exit immediately + std::process::exit(0); + } + } + + std::thread::sleep(Duration::from_millis(30)); + } + }); + + Ok(Self { + _tray_icon: tray_icon, + action_rx, + }) + } + + /// Try receiving any pending tray actions + pub fn try_recv_action(&self) -> Option { + self.action_rx.try_recv().ok() + } +} + +fn create_default_tray_icon() -> Result> { + let width = 32u32; + let height = 32u32; + let mut rgba = Vec::with_capacity((width * height * 4) as usize); + + for y in 0..height { + for x in 0..height { + let dx = x as f32 - 15.5; + let dy = y as f32 - 15.5; + let dist = (dx * dx + dy * dy).sqrt(); + + if dist <= 14.0 { + if dist > 12.0 { + // Cyan glowing ring + rgba.extend_from_slice(&[56, 189, 248, 255]); + } else if dist <= 7.0 { + // White center dot + rgba.extend_from_slice(&[255, 255, 255, 255]); + } else { + // Electric blue body + rgba.extend_from_slice(&[37, 99, 235, 255]); + } + } else { + rgba.extend_from_slice(&[0, 0, 0, 0]); + } + } + } + + let icon = Icon::from_rgba(rgba, width, height)?; + Ok(icon) +} diff --git a/src/ui/tui.rs b/src/ui/tui.rs new file mode 100644 index 0000000..a4cf0cf --- /dev/null +++ b/src/ui/tui.rs @@ -0,0 +1,672 @@ +use crate::network::{DiscoveryService, InterfaceInfo, PeerInfo}; +use crate::transfer::{ + PrepareTransferRequest, TransferClient, TransferProgress, TransferServer, TransferState, +}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use human_bytes::human_bytes; +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap, + }, + Frame, Terminal, +}; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +pub enum ModalState { + None, + ConfirmTransfer(PrepareTransferRequest), + InputFilePath { + peer_url: String, + peer_name: String, + input: String, + }, + InputDirectIp { + input: String, + }, +} + +pub struct AppState { + pub my_name: String, + pub iface: InterfaceInfo, + pub peers: Vec, + pub peer_list_state: ListState, + pub transfers: Vec, + pub logs: Vec, + pub modal: ModalState, + pub should_quit: bool, +} + +pub async fn run_tui( + discovery: DiscoveryService, + server: TransferServer, +) -> Result<(), Box> { + // 1. Setup Terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let server_state = server.state(); + let my_info = discovery.my_info().clone(); + let iface = discovery.selected_interface().clone(); + + let mut app = AppState { + my_name: my_info.name.clone(), + iface, + peers: Vec::new(), + peer_list_state: ListState::default(), + transfers: Vec::new(), + logs: vec![ + "⚡ ZeroSend initialized".to_string(), + format!("📡 Listening on interface {} ({})", my_info.ip, my_info.net_type), + "🔍 UDP Peer Discovery active".to_string(), + ], + modal: ModalState::None, + should_quit: false, + }; + + let (log_tx, mut log_rx) = mpsc::unbounded_channel::(); + let (transfer_update_tx, mut transfer_update_rx) = mpsc::unbounded_channel::(); + + // Listen for server incoming transfer requests + let mut req_rx = server_state.incoming_request_events.subscribe(); + let mut trans_rx = server_state.transfer_events.subscribe(); + + let client = Arc::new(TransferClient::new()); + let peers_store = discovery.peers(); + + // Event Loop + let mut last_tick = Instant::now(); + let tick_rate = Duration::from_millis(100); + + while !app.should_quit { + // Refresh peer list from discovery + { + let peers_map = peers_store.read().await; + let mut list: Vec = peers_map.values().cloned().collect(); + list.sort_by(|a, b| b.is_online.cmp(&a.is_online).then_with(|| a.name.cmp(&b.name))); + app.peers = list; + + if !app.peers.is_empty() && app.peer_list_state.selected().is_none() { + app.peer_list_state.select(Some(0)); + } + } + + // Receive logs + while let Ok(msg) = log_rx.try_recv() { + app.logs.push(msg); + if app.logs.len() > 100 { + app.logs.remove(0); + } + } + + // Receive transfer updates from client or server + while let Ok(t) = transfer_update_rx.try_recv() { + if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) { + app.transfers[pos] = t; + } else { + app.transfers.push(t); + } + } + + while let Ok(t) = trans_rx.try_recv() { + if let Some(pos) = app.transfers.iter().position(|x| x.session_id == t.session_id) { + app.transfers[pos] = t; + } else { + app.transfers.push(t); + } + } + + // Check for incoming confirmation requests + while let Ok(req) = req_rx.try_recv() { + app.logs.push(format!( + "📥 Transfer request from {} ({} files)", + req.sender_name, + req.files.len() + )); + app.modal = ModalState::ConfirmTransfer(req); + } + + // Draw UI + terminal.draw(|f| draw_ui(f, &mut app))?; + + // Handle Keys + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + handle_key_event( + key.code, + &mut app, + &server, + &client, + &my_info, + &discovery, + log_tx.clone(), + transfer_update_tx.clone(), + ) + .await; + } + } + } + + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } + } + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} + +async fn handle_key_event( + code: KeyCode, + app: &mut AppState, + server: &TransferServer, + client: &Arc, + my_info: &crate::network::DiscoveryBeacon, + discovery: &DiscoveryService, + log_tx: mpsc::UnboundedSender, + transfer_tx: mpsc::UnboundedSender, +) { + match &mut app.modal { + ModalState::ConfirmTransfer(req) => { + let session_id = req.session_id.clone(); + match code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Enter => { + let _ = server.accept_transfer(&session_id).await; + let _ = log_tx.send(format!("✅ Accepted transfer from {}", req.sender_name)); + app.modal = ModalState::None; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('r') | KeyCode::Char('R') | KeyCode::Esc => { + let _ = server.reject_transfer(&session_id).await; + let _ = log_tx.send(format!("❌ Rejected transfer from {}", req.sender_name)); + app.modal = ModalState::None; + } + _ => {} + } + } + ModalState::InputFilePath { peer_url, peer_name, input } => { + match code { + KeyCode::Enter => { + let path_str = input.trim().to_string(); + if !path_str.is_empty() { + let path = PathBuf::from(path_str.clone()); + let peer_url = peer_url.clone(); + let peer_name = peer_name.clone(); + let client_clone = client.clone(); + let my_info_clone = my_info.clone(); + let log_tx_clone = log_tx.clone(); + let transfer_tx_clone = transfer_tx.clone(); + + tokio::spawn(async move { + let _ = log_tx_clone.send(format!("📤 Sending {:?} to {}...", path, peer_name)); + let res = client_clone + .send_files(&peer_url, &peer_name, &my_info_clone, &[path], true, 0, None, move |progress| { + let _ = transfer_tx_clone.send(progress); + }) + .await; + + match res { + Ok(_) => { + let _ = log_tx_clone.send(format!("🎉 Sent successfully to {}!", peer_name)); + } + Err(e) => { + let _ = log_tx_clone.send(format!("⚠️ Send failed to {}: {}", peer_name, e)); + } + } + }); + } + app.modal = ModalState::None; + } + KeyCode::Esc => { + app.modal = ModalState::None; + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) => { + input.push(c); + } + _ => {} + } + } + ModalState::InputDirectIp { input } => { + match code { + KeyCode::Enter => { + let ip_str = input.trim().to_string(); + if !ip_str.is_empty() { + let client_clone = client.clone(); + let log_tx_clone = log_tx.clone(); + let disc_peers = discovery.peers(); + + tokio::spawn(async move { + let _ = log_tx_clone.send(format!("🔍 Pinging direct node at {}...", ip_str)); + match client_clone.ping_peer(&ip_str, 53318).await { + Ok(beacon) => { + let _ = log_tx_clone.send(format!("✨ Found peer: {} ({})", beacon.name, beacon.ip)); + let mut p_map = disc_peers.write().await; + p_map.insert( + beacon.peer_id.clone(), + PeerInfo { + peer_id: beacon.peer_id, + name: beacon.name, + os: beacon.os, + ip: beacon.ip, + transfer_port: beacon.transfer_port, + net_type: beacon.net_type, + version: beacon.version, + last_seen: chrono::Utc::now(), + is_online: true, + ping_ms: None, + shared_folders: beacon.shared_folders, + }, + ); + } + Err(e) => { + let _ = log_tx_clone.send(format!("❌ Direct ping error: {}", e)); + } + } + }); + } + app.modal = ModalState::None; + } + KeyCode::Esc => { + app.modal = ModalState::None; + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) => { + input.push(c); + } + _ => {} + } + } + ModalState::None => { + match code { + KeyCode::Char('q') | KeyCode::Char('Q') => { + app.should_quit = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if !app.peers.is_empty() { + let i = match app.peer_list_state.selected() { + Some(i) => { + if i == 0 { + app.peers.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + app.peer_list_state.select(Some(i)); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if !app.peers.is_empty() { + let i = match app.peer_list_state.selected() { + Some(i) => { + if i >= app.peers.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + app.peer_list_state.select(Some(i)); + } + } + KeyCode::Char('s') | KeyCode::Char('S') => { + if let Some(i) = app.peer_list_state.selected() { + if let Some(peer) = app.peers.get(i) { + app.modal = ModalState::InputFilePath { + peer_url: peer.transfer_url(), + peer_name: peer.name.clone(), + input: String::new(), + }; + } + } + } + KeyCode::Char('d') | KeyCode::Char('D') => { + app.modal = ModalState::InputDirectIp { + input: String::new(), + }; + } + _ => {} + } + } + } +} + +fn draw_ui(f: &mut Frame, app: &mut AppState) { + let size = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Body + Constraint::Length(3), // Footer / Shortcuts + ]) + .split(size); + + // 1. Header + let header_text = vec![ + Span::styled("⚡ ZeroSend ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw("| Node: "), + Span::styled(&app.my_name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(" | Interface: "), + Span::styled( + format!("{} ({})", app.iface.ip, app.iface.net_type.as_str()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ), + Span::raw(" | Port: "), + Span::styled("53318", Style::default().fg(Color::Magenta)), + ]; + let header = Paragraph::new(Line::from(header_text)) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Cyan)), + ) + .alignment(Alignment::Left); + f.render_widget(header, chunks[0]); + + // 2. Body (Left: Peers, Right: Transfers & Logs) + let body_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(chunks[1]); + + // Left Panel: Discovered Peers + let items: Vec = app + .peers + .iter() + .map(|p| { + let status_icon = if p.is_online { "🟢" } else { "⚪" }; + let lines = vec![ + Line::from(vec![ + Span::raw(format!("{} ", status_icon)), + Span::styled(&p.name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled(format!(" [{}]", p.net_type), Style::default().fg(Color::Blue)), + ]), + Line::from(vec![ + Span::raw(" IP: "), + Span::styled(format!("{}:{}", p.ip, p.transfer_port), Style::default().fg(Color::Gray)), + Span::raw(" | OS: "), + Span::styled(&p.os, Style::default().fg(Color::DarkGray)), + ]), + ]; + ListItem::new(lines) + }) + .collect(); + + let peers_block = Block::default() + .title(" 📡 Discovered Peers (ZeroTier / VPN) ") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Blue)); + + let peers_list = List::new(items) + .block(peers_block) + .highlight_style( + Style::default() + .bg(Color::Rgb(30, 58, 138)) + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget(peers_list, body_chunks[0], &mut app.peer_list_state); + + // Right Panel: Transfers (Top) & Logs (Bottom) + let right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(body_chunks[1]); + + // Transfers List + let transfer_block = Block::default() + .title(" 📦 Active & Recent Transfers ") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Green)); + + if app.transfers.is_empty() { + let no_transfers = Paragraph::new("No active transfers. Press [S] to send a file.") + .style(Style::default().fg(Color::DarkGray)) + .block(transfer_block) + .alignment(Alignment::Center); + f.render_widget(no_transfers, right_chunks[0]); + } else { + let transfer_items: Vec = app + .transfers + .iter() + .map(|t| { + let direction = if t.is_incoming { "📥 IN" } else { "📤 OUT" }; + let speed = if t.speed_bps > 0 { + format!(" ({}/s)", human_bytes(t.speed_bps as f64)) + } else { + String::new() + }; + + let state_str = match &t.state { + TransferState::PendingConfirmation => "⏳ Waiting confirmation...".to_string(), + TransferState::InProgress => format!("{:.1}%{}", t.percentage(), speed), + TransferState::Completed => "✅ Completed".to_string(), + TransferState::Failed(e) => format!("❌ Failed: {}", e), + TransferState::Rejected => "⛔ Rejected".to_string(), + TransferState::Canceled => "🛑 Canceled".to_string(), + }; + + let lines = vec![ + Line::from(vec![ + Span::styled(format!("[{}] ", direction), Style::default().fg(Color::Magenta)), + Span::styled(&t.current_file_name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::raw(format!(" -> {}", t.peer_name)), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{}/{}", human_bytes(t.bytes_transferred as f64), human_bytes(t.total_bytes as f64)), + Style::default().fg(Color::Cyan), + ), + Span::raw(" | "), + Span::styled(state_str, Style::default().fg(Color::Yellow)), + ]), + ]; + ListItem::new(lines) + }) + .collect(); + + let trans_list = List::new(transfer_items).block(transfer_block); + f.render_widget(trans_list, right_chunks[0]); + } + + // Activity Logs + let logs_block = Block::default() + .title(" 📝 Activity Log ") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Gray)); + + let log_lines: Vec = app + .logs + .iter() + .rev() + .take(10) + .rev() + .map(|l| Line::from(Span::styled(l, Style::default().fg(Color::White)))) + .collect(); + + let logs_widget = Paragraph::new(log_lines) + .block(logs_block) + .wrap(Wrap { trim: true }); + f.render_widget(logs_widget, right_chunks[1]); + + // 3. Footer / Shortcuts + let shortcuts = Line::from(vec![ + Span::styled("[S] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("Send File "), + Span::styled("[D] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("Direct IP "), + Span::styled("[↑/↓] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("Select Peer "), + Span::styled("[Q] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("Quit"), + ]); + let footer = Paragraph::new(shortcuts) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .alignment(Alignment::Center); + f.render_widget(footer, chunks[2]); + + // Modals + match &app.modal { + ModalState::ConfirmTransfer(req) => { + let area = centered_rect(60, 30, size); + f.render_widget(Clear, area); + + let block = Block::default() + .title(" 📥 Incoming File Transfer ") + .borders(Borders::ALL) + .border_type(BorderType::Double) + .border_style(Style::default().fg(Color::Yellow)); + + let files_summary = req + .files + .iter() + .map(|f| format!(" - {} ({})", f.name, human_bytes(f.size as f64))) + .collect::>() + .join("\n"); + + let text = vec![ + Line::from(vec![ + Span::styled(&req.sender_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(format!(" wants to send you {} file(s) ({})", req.files.len(), human_bytes(req.total_size as f64))), + ]), + Line::from(""), + Line::from(files_summary), + Line::from(""), + Line::from(vec![ + Span::styled("[Y/A] Accept", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(" | "), + Span::styled("[N/R] Reject", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + ]), + ]; + + let modal = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + f.render_widget(modal, area); + } + ModalState::InputFilePath { peer_name, input, .. } => { + let area = centered_rect(65, 25, size); + f.render_widget(Clear, area); + + let block = Block::default() + .title(format!(" 📤 Send File to {} ", peer_name)) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Cyan)); + + let text = vec![ + Line::from("Enter absolute or relative path to file/folder:"), + Line::from(""), + Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled("█", Style::default().fg(Color::Cyan)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("[Enter] Send", Style::default().fg(Color::Green)), + Span::raw(" "), + Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)), + ]), + ]; + + let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); + f.render_widget(modal, area); + } + ModalState::InputDirectIp { input } => { + let area = centered_rect(60, 25, size); + f.render_widget(Clear, area); + + let block = Block::default() + .title(" 🔍 Connect Direct IP (Tailscale / Custom) ") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Magenta)); + + let text = vec![ + Line::from("Enter peer IP address (e.g. 10.147.20.55 or 100.80.x.x):"), + Line::from(""), + Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled(input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled("█", Style::default().fg(Color::Magenta)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("[Enter] Connect", Style::default().fg(Color::Green)), + Span::raw(" "), + Span::styled("[Esc] Cancel", Style::default().fg(Color::DarkGray)), + ]), + ]; + + let modal = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); + f.render_widget(modal, area); + } + ModalState::None => {} + } +} + +/// Helper function to create centered modal rectangle +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/ui/vim_viewer.rs b/src/ui/vim_viewer.rs new file mode 100644 index 0000000..1b04eb6 --- /dev/null +++ b/src/ui/vim_viewer.rs @@ -0,0 +1,390 @@ +use crate::ui::gui::ThemeColors; +use eframe::egui::{ + self, Color32, Frame, Key, Margin, RichText, Rounding, ScrollArea, Stroke, TextEdit, Vec2, +}; +use std::time::Instant; + +pub struct VimViewerModal { + pub is_open: bool, + pub file_name: String, + pub relative_path: String, + pub source_peer: String, + pub lines: Vec, + pub cursor_line: usize, // 1-indexed + pub search_query: String, + pub is_searching: bool, + pub command_input: String, + pub is_command_mode: bool, + pub status_msg: Option, + last_g_press: Option, +} + +impl Default for VimViewerModal { + fn default() -> Self { + Self { + is_open: false, + file_name: String::new(), + relative_path: String::new(), + source_peer: String::new(), + lines: Vec::new(), + cursor_line: 1, + search_query: String::new(), + is_searching: false, + command_input: String::new(), + is_command_mode: false, + status_msg: None, + last_g_press: None, + } + } +} + +impl VimViewerModal { + pub fn open(&mut self, file_name: String, relative_path: String, source_peer: String, content: String) { + self.is_open = true; + self.file_name = file_name; + self.relative_path = relative_path; + self.source_peer = source_peer; + self.lines = content.lines().map(|s| s.to_string()).collect(); + if self.lines.is_empty() { + self.lines.push(String::new()); + } + self.cursor_line = 1; + self.search_query.clear(); + self.is_searching = false; + self.command_input.clear(); + self.is_command_mode = false; + self.status_msg = Some(format!("Loaded {} lines", self.lines.len())); + self.last_g_press = None; + } + + pub fn close(&mut self) { + self.is_open = false; + self.lines.clear(); + } + + pub fn handle_keys(&mut self, ctx: &egui::Context) { + if !self.is_open { + return; + } + + let input = ctx.input(|i| i.clone()); + + // Escape always cancels search or command mode, or exits if already normal + if input.key_pressed(Key::Escape) { + if self.is_searching { + self.is_searching = false; + } else if self.is_command_mode { + self.is_command_mode = false; + self.command_input.clear(); + } else { + self.close(); + } + return; + } + + if self.is_searching { + if input.key_pressed(Key::Enter) { + self.is_searching = false; + self.find_next_match(true); + } + return; + } + + if self.is_command_mode { + if input.key_pressed(Key::Enter) { + let cmd = self.command_input.trim().to_string(); + self.is_command_mode = false; + self.command_input.clear(); + self.execute_command(&cmd); + } + return; + } + + // NORMAL Mode Keybindings + let total = self.lines.len().max(1); + + // j / Down (move down) + if input.key_pressed(Key::J) || input.key_pressed(Key::ArrowDown) { + self.cursor_line = (self.cursor_line + 1).min(total); + } + + // k / Up (move up) + if input.key_pressed(Key::K) || input.key_pressed(Key::ArrowUp) { + self.cursor_line = self.cursor_line.saturating_sub(1).max(1); + } + + // Ctrl+D / PageDown (half screen down) + if (input.modifiers.ctrl && input.key_pressed(Key::D)) || input.key_pressed(Key::PageDown) { + self.cursor_line = (self.cursor_line + 25).min(total); + } + + // Ctrl+U / PageUp (half screen up) + if (input.modifiers.ctrl && input.key_pressed(Key::U)) || input.key_pressed(Key::PageUp) { + self.cursor_line = self.cursor_line.saturating_sub(25).max(1); + } + + // gg (top of file) + if input.key_pressed(Key::G) && !input.modifiers.shift { + if let Some(prev) = self.last_g_press { + if prev.elapsed().as_millis() < 450 { + self.cursor_line = 1; + self.status_msg = Some("Top of file (gg)".to_string()); + self.last_g_press = None; + } else { + self.last_g_press = Some(Instant::now()); + } + } else { + self.last_g_press = Some(Instant::now()); + } + } + + // G (Shift+G) -> end of file + if input.modifiers.shift && input.key_pressed(Key::G) { + self.cursor_line = total; + self.status_msg = Some("Bottom of file (G)".to_string()); + } + + // / (search mode) + if input.key_pressed(Key::Slash) { + self.is_searching = true; + self.search_query.clear(); + } + + // : (command mode) + if input.modifiers.shift && input.key_pressed(Key::Colon) || input.key_pressed(Key::Colon) { + self.is_command_mode = true; + self.command_input.clear(); + } + } + + fn execute_command(&mut self, cmd: &str) { + if cmd == "q" || cmd == "q!" || cmd == "quit" { + self.close(); + } else if let Ok(line_num) = cmd.parse::() { + let total = self.lines.len().max(1); + self.cursor_line = line_num.clamp(1, total); + self.status_msg = Some(format!("Jumped to line {}", self.cursor_line)); + } else { + self.status_msg = Some(format!("Unknown command: :{}", cmd)); + } + } + + fn find_next_match(&mut self, forward: bool) { + if self.search_query.trim().is_empty() { + return; + } + let q = self.search_query.to_lowercase(); + let total = self.lines.len(); + if total == 0 { + return; + } + + let start = if forward { self.cursor_line } else { self.cursor_line.saturating_sub(2) }; + + for offset in 0..total { + let idx = if forward { + (start + offset) % total + } else { + (start + total - offset) % total + }; + + if self.lines[idx].to_lowercase().contains(&q) { + self.cursor_line = idx + 1; + self.status_msg = Some(format!("Match found on line {}", self.cursor_line)); + return; + } + } + + self.status_msg = Some(format!("Pattern not found: '{}'", self.search_query)); + } + + pub fn render(&mut self, ctx: &egui::Context, _theme: ThemeColors) { + if !self.is_open { + return; + } + + self.handle_keys(ctx); + + let mut is_open = self.is_open; + let screen_rect = ctx.screen_rect(); + let win_size = Vec2::new((screen_rect.width() * 0.92).min(1100.0), (screen_rect.height() * 0.88).min(750.0)); + + egui::Window::new(format!("⚡ Vim Viewer • {} ({})", self.file_name, self.source_peer)) + .open(&mut is_open) + .collapsible(false) + .resizable(true) + .default_size(win_size) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .frame( + Frame::window(&ctx.style()) + .fill(Color32::from_rgb(15, 18, 28)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(51, 65, 85))) + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::same(0.0)), + ) + .show(ctx, |ui| { + // 1. Top Header Bar + Frame::none() + .fill(Color32::from_rgb(22, 27, 44)) + .inner_margin(Margin::symmetric(14.0, 10.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("📄").size(15.0)); + ui.label( + RichText::new(&self.relative_path) + .strong() + .color(Color32::from_rgb(248, 250, 252)) + .size(13.0), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(RichText::new("✕ Close (:q)").color(Color32::from_rgb(203, 213, 225))).clicked() { + self.is_open = false; + } + + ui.label( + RichText::new(format!("{} lines", self.lines.len())) + .size(11.0) + .color(Color32::from_rgb(148, 163, 184)), + ); + }); + }); + }); + + // 2. Code Area with Line Numbers Gutter + let gutter_width = 54.0; + let available_height = ui.available_height() - 32.0; + + ScrollArea::vertical() + .max_height(available_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.horizontal_top(|ui| { + // Gutter column (Line numbers) + ui.allocate_ui(Vec2::new(gutter_width, 0.0), |ui| { + ui.vertical(|ui| { + for idx in 1..=self.lines.len() { + let is_current = idx == self.cursor_line; + let num_color = if is_current { + Color32::from_rgb(245, 158, 11) // Amber current line + } else { + Color32::from_rgb(71, 85, 105) + }; + + ui.label( + RichText::new(format!("{:>4}", idx)) + .monospace() + .size(12.0) + .color(num_color), + ); + } + }); + }); + + // Vertical divider line + ui.add(egui::Separator::default().vertical()); + + // Code column + ui.vertical(|ui| { + let query_lower = self.search_query.to_lowercase(); + let has_query = !query_lower.is_empty(); + + for (idx, line) in self.lines.iter().enumerate() { + let line_num = idx + 1; + let is_current = line_num == self.cursor_line; + + let line_display = if line.is_empty() { " " } else { line.as_str() }; + + let line_text = if is_current { + RichText::new(line_display) + .monospace() + .size(12.0) + .color(Color32::from_rgb(255, 255, 255)) + .strong() + .background_color(Color32::from_rgb(30, 41, 59)) + } else if has_query && line_display.to_lowercase().contains(&query_lower) { + RichText::new(line_display) + .monospace() + .size(12.0) + .color(Color32::from_rgb(254, 240, 138)) + .background_color(Color32::from_rgb(113, 63, 18)) + } else { + RichText::new(line_display) + .monospace() + .size(12.0) + .color(Color32::from_rgb(226, 232, 240)) + }; + + ui.label(line_text); + } + }); + }); + }); + + // 3. Bottom Vim Status Bar + Frame::none() + .fill(Color32::from_rgb(11, 15, 25)) + .stroke(Stroke::new(1.0_f32, Color32::from_rgb(30, 41, 59))) + .inner_margin(Margin::symmetric(10.0, 5.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Mode Pill + let (mode_text, mode_bg, mode_fg) = if self.is_searching { + ("SEARCH", Color32::from_rgb(217, 119, 6), Color32::WHITE) + } else if self.is_command_mode { + ("COMMAND", Color32::from_rgb(147, 51, 234), Color32::WHITE) + } else { + ("NORMAL", Color32::from_rgb(37, 99, 235), Color32::WHITE) + }; + + Frame::none() + .fill(mode_bg) + .rounding(Rounding::same(3.0)) + .inner_margin(Margin::symmetric(6.0, 2.0)) + .show(ui, |ui| { + ui.label(RichText::new(mode_text).size(10.5).strong().color(mode_fg)); + }); + + if self.is_searching { + ui.label(RichText::new("/").monospace().color(Color32::from_rgb(245, 158, 11))); + let edit = TextEdit::singleline(&mut self.search_query) + .hint_text("pattern (press Enter)...") + .font(egui::TextStyle::Monospace) + .desired_width(200.0); + ui.add(edit).request_focus(); + } else if self.is_command_mode { + ui.label(RichText::new(":").monospace().color(Color32::from_rgb(168, 85, 247))); + let edit = TextEdit::singleline(&mut self.command_input) + .hint_text("q / line number...") + .font(egui::TextStyle::Monospace) + .desired_width(200.0); + ui.add(edit).request_focus(); + } else { + if let Some(msg) = &self.status_msg { + ui.label(RichText::new(msg).size(11.0).color(Color32::from_rgb(203, 213, 225))); + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new("j/k: nav • gg/G: top/bot • /: find • :q: exit") + .size(10.0) + .color(Color32::from_rgb(100, 116, 139)), + ); + + ui.separator(); + + ui.label( + RichText::new(format!("Ln {}, Col 1", self.cursor_line)) + .size(11.0) + .monospace() + .color(Color32::from_rgb(148, 163, 184)), + ); + }); + }); + }); + }); + + self.is_open = is_open; + } +} diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..740b855 --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,359 @@ +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::sync::mpsc; +use tracing::info; + +pub const GITEA_DEFAULT_REPO_URL: &str = "https://gitea.rardogsynapse.online/RarDog/ZeroSend"; +pub const GITEA_DEFAULT_API_BASE: &str = "https://gitea.rardogsynapse.online/api/v1/repos/RarDog/ZeroSend"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GiteaAsset { + pub name: String, + pub size: u64, + pub browser_download_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GiteaRelease { + pub tag_name: String, + pub name: String, + #[serde(default)] + pub body: String, + #[serde(default)] + pub draft: bool, + #[serde(default)] + pub prerelease: bool, + #[serde(default)] + pub published_at: Option, + #[serde(default)] + pub assets: Vec, + #[serde(default)] + pub html_url: Option, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: String, + pub has_update: bool, + pub release_name: String, + pub release_notes: String, + pub download_url: Option, + pub gitea_url: String, + pub asset_size: u64, +} + +#[derive(Debug, Clone)] +pub enum UpdateProgress { + Downloading { percent: f32, downloaded: u64, total: u64 }, + Verifying, + Installing, + Completed, + Error(String), +} + +/// Parse semantic version string (e.g. "v2.4.1", "2.4.2-beta") into (u32, u32, u32) +pub fn parse_version(v: &str) -> (u32, u32, u32) { + let clean = v.trim().trim_start_matches('v').trim_start_matches('V'); + let parts: Vec<&str> = clean.split('.').collect(); + let major = parts.first().and_then(|s| s.split('-').next()).and_then(|s| s.parse::().ok()).unwrap_or(0); + let minor = parts.get(1).and_then(|s| s.split('-').next()).and_then(|s| s.parse::().ok()).unwrap_or(0); + let patch = parts.get(2).and_then(|s| s.split('-').next()).and_then(|s| s.parse::().ok()).unwrap_or(0); + (major, minor, patch) +} + +/// Returns true if latest version is strictly newer than current version +pub fn is_newer_version(latest: &str, current: &str) -> bool { + let (l_maj, l_min, l_pat) = parse_version(latest); + let (c_maj, c_min, c_pat) = parse_version(current); + (l_maj, l_min, l_pat) > (c_maj, c_min, c_pat) +} + +/// Check Gitea repository for available releases or newer version tags +pub async fn check_gitea_update(api_base: Option<&str>) -> Result { + let base = api_base.unwrap_or(GITEA_DEFAULT_API_BASE).trim_end_matches('/'); + let current_version = env!("CARGO_PKG_VERSION").to_string(); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(6)) + .danger_accept_invalid_certs(true) // Helpful for self-hosted Gitea instances with internal certs + .user_agent(format!("ZeroSend-Updater/{}", current_version)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + + // 1. Try releases/latest API + let latest_release_url = format!("{}/releases/latest", base); + if let Ok(resp) = client.get(&latest_release_url).send().await { + if resp.status().is_success() { + if let Ok(release) = resp.json::().await { + let latest_ver = release.tag_name.trim().trim_start_matches('v').to_string(); + let has_update = is_newer_version(&latest_ver, ¤t_version); + + // Look for Windows executable asset (e.g. zerosend.exe, zerosend-vX.X.exe) + let mut download_url = None; + let mut asset_size = 0; + + for asset in &release.assets { + let name_lower = asset.name.to_lowercase(); + if name_lower.ends_with(".exe") || name_lower.ends_with(".zip") { + download_url = Some(asset.browser_download_url.clone()); + asset_size = asset.size; + break; + } + } + + let gitea_url = release.html_url.unwrap_or_else(|| { + format!("{}/releases/tag/{}", GITEA_DEFAULT_REPO_URL, release.tag_name) + }); + + return Ok(UpdateInfo { + current_version, + latest_version: latest_ver, + has_update, + release_name: release.name, + release_notes: release.body, + download_url, + gitea_url, + asset_size, + }); + } + } + } + + // 2. Try releases list API (/releases) + let releases_url = format!("{}/releases", base); + if let Ok(resp) = client.get(&releases_url).send().await { + if resp.status().is_success() { + if let Ok(releases) = resp.json::>().await { + if let Some(release) = releases.into_iter().find(|r| !r.draft) { + let latest_ver = release.tag_name.trim().trim_start_matches('v').to_string(); + let has_update = is_newer_version(&latest_ver, ¤t_version); + + let mut download_url = None; + let mut asset_size = 0; + + for asset in &release.assets { + let name_lower = asset.name.to_lowercase(); + if name_lower.ends_with(".exe") || name_lower.ends_with(".zip") { + download_url = Some(asset.browser_download_url.clone()); + asset_size = asset.size; + break; + } + } + + let gitea_url = release.html_url.unwrap_or_else(|| { + format!("{}/releases/tag/{}", GITEA_DEFAULT_REPO_URL, release.tag_name) + }); + + return Ok(UpdateInfo { + current_version, + latest_version: latest_ver, + has_update, + release_name: release.name, + release_notes: release.body, + download_url, + gitea_url, + asset_size, + }); + } + } + } + } + + // 3. Fallback: Check raw Cargo.toml in master branch + let raw_cargo_url = format!("{}/raw/branch/master/Cargo.toml", base); + if let Ok(resp) = client.get(&raw_cargo_url).send().await { + if resp.status().is_success() { + if let Ok(content) = resp.text().await { + for line in content.lines() { + let line = line.trim(); + if line.starts_with("version") { + if let Some(ver_val) = line.split('=').nth(1) { + let parsed = ver_val.trim().trim_matches('"').trim_matches('\''); + let has_update = is_newer_version(parsed, ¤t_version); + return Ok(UpdateInfo { + current_version, + latest_version: parsed.to_string(), + has_update, + release_name: format!("ZeroSend v{}", parsed), + release_notes: "Latest master branch update available on Gitea.".to_string(), + download_url: None, + gitea_url: GITEA_DEFAULT_REPO_URL.to_string(), + asset_size: 0, + }); + } + } + } + } + } + } + + Err("Could not reach Gitea server or no releases found.".to_string()) +} + +/// Download and apply update on Windows with live progress reporting +pub async fn perform_update_download( + download_url: String, + progress_tx: mpsc::UnboundedSender, +) { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .danger_accept_invalid_certs(true) + .user_agent("ZeroSend-AutoUpdater") + .build() + { + Ok(c) => c, + Err(e) => { + let _ = progress_tx.send(UpdateProgress::Error(format!("Client error: {}", e))); + return; + } + }; + + let response = match client.get(&download_url).send().await { + Ok(r) => { + if !r.status().is_success() { + let _ = progress_tx.send(UpdateProgress::Error(format!( + "Download failed with HTTP status {}", + r.status() + ))); + return; + } + r + } + Err(e) => { + let _ = progress_tx.send(UpdateProgress::Error(format!("Connection error: {}", e))); + return; + } + }; + + let total_size = response.content_length().unwrap_or(0); + let current_exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + let _ = progress_tx.send(UpdateProgress::Error(format!("Cannot locate exe: {}", e))); + return; + } + }; + + let exe_dir = current_exe.parent().unwrap_or_else(|| std::path::Path::new(".")); + let temp_download_path = exe_dir.join("zerosend_update.exe"); + + // Download stream to temporary file + let mut file = match tokio::fs::File::create(&temp_download_path).await { + Ok(f) => f, + Err(e) => { + let _ = progress_tx.send(UpdateProgress::Error(format!( + "Failed to create update file {:?}: {}", + temp_download_path, e + ))); + return; + } + }; + + let mut stream = response.bytes_stream(); + let mut downloaded: u64 = 0; + + use tokio::io::AsyncWriteExt; + while let Some(chunk_res) = stream.next().await { + match chunk_res { + Ok(chunk) => { + if let Err(e) = file.write_all(&chunk).await { + let _ = progress_tx.send(UpdateProgress::Error(format!("Disk write error: {}", e))); + let _ = tokio::fs::remove_file(&temp_download_path).await; + return; + } + downloaded += chunk.len() as u64; + let percent = if total_size > 0 { + (downloaded as f32 / total_size as f32).min(1.0) + } else { + 0.0 + }; + let _ = progress_tx.send(UpdateProgress::Downloading { + percent, + downloaded, + total: total_size, + }); + } + Err(e) => { + let _ = progress_tx.send(UpdateProgress::Error(format!("Stream download error: {}", e))); + let _ = tokio::fs::remove_file(&temp_download_path).await; + return; + } + } + } + + if let Err(e) = file.flush().await { + let _ = progress_tx.send(UpdateProgress::Error(format!("File flush error: {}", e))); + return; + } + drop(file); + + // Verify downloaded executable + let _ = progress_tx.send(UpdateProgress::Verifying); + match tokio::fs::read(&temp_download_path).await { + Ok(bytes) => { + if bytes.len() < 100_000 { + let _ = progress_tx.send(UpdateProgress::Error( + "Downloaded file is too small to be a valid executable".to_string(), + )); + let _ = tokio::fs::remove_file(&temp_download_path).await; + return; + } + // Check PE MZ magic header on Windows + if bytes.len() >= 2 && (bytes[0] != b'M' || bytes[1] != b'Z') { + let _ = progress_tx.send(UpdateProgress::Error( + "Downloaded binary does not appear to be a valid Windows executable".to_string(), + )); + let _ = tokio::fs::remove_file(&temp_download_path).await; + return; + } + } + Err(e) => { + let _ = progress_tx.send(UpdateProgress::Error(format!("Verification read error: {}", e))); + return; + } + } + + // Installing & Self-Replacing + let _ = progress_tx.send(UpdateProgress::Installing); + + // On Windows, rename current exe to .old, and move new exe into place + let old_backup_path = exe_dir.join("zerosend.exe.old"); + let _ = std::fs::remove_file(&old_backup_path); // Remove prior old backup if any + + let rename_result = std::fs::rename(¤t_exe, &old_backup_path); + if rename_result.is_ok() { + if let Err(e) = std::fs::rename(&temp_download_path, ¤t_exe) { + // Restore old backup if move failed + let _ = std::fs::rename(&old_backup_path, ¤t_exe); + let _ = progress_tx.send(UpdateProgress::Error(format!("Failed to install update: {}", e))); + return; + } + + info!("Update successfully installed to {:?}", current_exe); + let _ = progress_tx.send(UpdateProgress::Completed); + + // Spawn updated application and exit + let _ = std::process::Command::new(¤t_exe).spawn(); + std::thread::sleep(Duration::from_millis(500)); + std::process::exit(0); + } else { + // Fallback: Use PowerShell deferred replacement + info!("Direct rename locked; triggering background PowerShell replacement..."); + let script = format!( + "Start-Sleep -Milliseconds 600; Move-Item -Force '{temp}' '{target}'; Start-Process '{target}'", + temp = temp_download_path.to_string_lossy(), + target = current_exe.to_string_lossy() + ); + + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script]) + .spawn(); + + let _ = progress_tx.send(UpdateProgress::Completed); + std::thread::sleep(Duration::from_millis(500)); + std::process::exit(0); + } +}