commit bf0b237afde26d2a9d012ea2d7634cb7e60c0749 Author: loki5512344 Date: Thu Jul 9 11:52:57 2026 +0200 Initial commit: split VNOX monorepo into VNOX-Client Desktop client with egui + wgpu UI, audio pipeline (cpal/opus/rodio), networking (LNEx protocol), and identity management with encrypted vault. Jitter buffer module extracted from voice-node for standalone operation. diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..664494e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,29 @@ +[env] +# audiopus_sys builds opus from source via CMake. +# CMake 4.x removed compatibility with cmake_minimum_required < 3.5. +# This flag tells CMake to accept the old policy version. +CMAKE_POLICY_VERSION_MINIMUM = "3.5" +# Cap linker+rustc memory pressure so builds don't OOM-kill the host. +RUST_MIN_STACK = "8388608" + +[build] +# Limit parallelism so GPUI's giant dep tree doesn't saturate all cores. +# 4 jobs is a safe-ish ceiling for a 15GB / 24-thread box. +jobs = 6 + +[profile.dev] +# Less debug info = less RAM per codegen unit, faster cold builds. +debug = 1 +# More codegen units = smaller per-unit memory footprint. +codegen-units = 16 +# Don't keep intermediate artifacts for every dep. +split-debuginfo = "unpacked" + +[profile.dev.package."*"] +# Deps don't need debuginfo at all → big savings on big trees like GPUI. +debug = 0 +codegen-units = 16 + +[profile.release] +debug = 0 +codegen-units = 16 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e08c794 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9b6ea63 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5587 @@ +# 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.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +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 = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[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.0", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + +[[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.60.2", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[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", +] + +[[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-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-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", +] + +[[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.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-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.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.0", + "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.0", + "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 = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[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 = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2 0.5.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "cpal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" +dependencies = [ + "alsa", + "block2 0.6.2", + "coreaudio-rs", + "dasp_sample", + "jni 0.22.4", + "js-sys", + "libc", + "mach2 0.6.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "web-sys", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[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.0", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "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 = "easyfft" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "767e39eef2ad8a3b6f1d733be3ec70364d21d437d06d4f18ea76ce08df20b75f" +dependencies = [ + "array-init", + "generic_singleton", + "num-complex", + "realfft", + "rustfft", +] + +[[package]] +name = "ecolor" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "eframe" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "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", + "pollster", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.0", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "emath" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +dependencies = [ + "bytemuck", +] + +[[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", +] + +[[package]] +name = "epaint" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "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 = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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 = "generic_singleton" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6e923c8e978e57cf63e2e200ca967d1d20f0ea2662b28f6d4e11c44aa6ab16" +dependencies = [ + "anymap3", + "parking_lot", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.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", + "rand_core 0.10.1", +] + +[[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 = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +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.0", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "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", + "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 = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.0", + "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.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.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.0", + "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.0", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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 = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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", + "tiff", +] + +[[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 = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.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", +] + +[[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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "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 = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[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 = "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.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "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 = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nnnoiseless" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805d5964d1e7a0006a7fdced7dae75084d66d18b35f1dfe81bd76929b1f8da0c" +dependencies = [ + "easyfft", + "once_cell", +] + +[[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-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_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", + "proc-macro2", + "quote", + "syn", +] + +[[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.0", + "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.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "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.0", + "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-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", +] + +[[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.0", + "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.0", + "block2 0.6.2", + "dispatch2", + "libc", + "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.0", + "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.0", + "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.0", + "block2 0.6.2", + "libc", + "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.0", + "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.0", + "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.0", + "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.0", + "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.0", + "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 = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + +[[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-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[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 = "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 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[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", +] + +[[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 = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.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 = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.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 = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" +dependencies = [ + "num-traits", + "rand 0.10.2", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[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.0", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.0", +] + +[[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.18", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +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 = "rodio" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" +dependencies = [ + "cpal 0.17.3", + "dasp_sample", + "num-rational", + "rand 0.10.2", + "rand_distr", + "rtrb", + "symphonia", + "thiserror 2.0.18", +] + +[[package]] +name = "rtrb" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" + +[[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 = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "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.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[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-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +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.0", + "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.0", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "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.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[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 = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[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", +] + +[[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 = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[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 = "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-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[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", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vnox-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "chacha20poly1305", + "cpal 0.18.1", + "dirs", + "ed25519-dalek", + "eframe", + "hex", + "hkdf", + "nnnoiseless", + "opus", + "rand 0.8.6", + "rodio", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "x25519-dalek", +] + +[[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 = "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.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "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.0", + "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.0", + "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.0", + "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.0", + "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.0", + "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.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "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 = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "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 = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.0", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "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" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[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 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[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.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[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 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "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.0", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "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 = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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", + "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 = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[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", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +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", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ab0513c0a66a60a8718d5ad712a5094845a20d95b5c1c81e2bd8e904a52b4f" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[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 = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d8fed4d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,52 @@ +[workspace] +resolver = "2" + +[package] +name = "vnox-client" +description = "VNOX desktop client — egui + wgpu" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0" +authors = ["VNOX Contributors"] +repository = "https://github.com/loki5512344/Vnox" +rust-version = "1.85" +default-run = "vnox-client" + +[lib] +name = "vnox_client" +path = "src/lib.rs" + +[[bin]] +name = "vnox-client" +path = "src/main.rs" + +[[bin]] +name = "vnox-e2e-voice" +path = "src/bin/e2e_voice.rs" + +[dependencies] +tokio = { version = "1.52", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ed25519-dalek = { version = "2", features = ["rand_core"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +chacha20poly1305 = "0.10" +hkdf = "0.13" +sha2 = "0.11" +rand = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +hex = "0.4" +uuid = { version = "1", features = ["v4"] } +argon2 = "0.5" +opus = "0.3" +cpal = "0.18" +rodio = "0.22" +dirs = "6" +eframe = { version = "0.33", features = ["wgpu"] } +nnnoiseless = { version = "0.5.2", default-features = false, optional = true } + +[features] +default = [] +rnnoise = ["dep:nnnoiseless"] diff --git a/src/audio/config.rs b/src/audio/config.rs new file mode 100644 index 0000000..304dd5f --- /dev/null +++ b/src/audio/config.rs @@ -0,0 +1,141 @@ +use anyhow::{Result, anyhow}; +use cpal::traits::DeviceTrait; +use cpal::{SampleFormat, StreamConfig}; +use tracing::info; + +pub const OPUS_RATE: u32 = 48_000; +pub const FRAME_MS: u32 = 20; + +/// Maps settings `packet_interval` index to frame duration in ms. +pub fn frame_ms_from_interval(interval: usize) -> u32 { + match interval { + 0 => 10, + 1 => 20, + _ => 40, + } +} + +/// Maps settings `jitter_size` index to playout delay in ms. +pub fn jitter_ms_from_setting(jitter_size: usize) -> u32 { + match jitter_size { + 0 => 20, + 1 => 40, + _ => 80, + } +} + +pub fn bitrate_bps_from_kbps(kbps: f32) -> u32 { + (kbps.clamp(8.0, 512.0) * 1000.0) as u32 +} + +pub struct AudioConfig { + pub stream: StreamConfig, + pub sample_rate: u32, +} + +impl AudioConfig { + pub fn find(device: &cpal::Device, is_input: bool) -> Result { + let ranges: Vec<_> = if is_input { + device.supported_input_configs()?.collect() + } else { + device.supported_output_configs()?.collect() + }; + + // Prefer 48 kHz mono F32 — matches Opus native rate. + for r in &ranges { + if r.channels() == 1 + && r.sample_format() == SampleFormat::F32 + && r.min_sample_rate() <= OPUS_RATE + && r.max_sample_rate() >= OPUS_RATE + { + let stream = r.with_sample_rate(OPUS_RATE).config(); + info!( + "audio {}: {} Hz mono", + if is_input { "in" } else { "out" }, + OPUS_RATE + ); + return Ok(Self { + stream, + sample_rate: OPUS_RATE, + }); + } + } + + // Fallback: device native rate, resample to/from Opus in capture/playback. + let default = if is_input { + device.default_input_config() + } else { + device.default_output_config() + } + .map_err(|e| anyhow!("no default audio config: {e}"))?; + + let sample_rate = default.sample_rate(); + let stream: StreamConfig = default.config(); + info!( + "audio {}: {} Hz {}ch (will resample for Opus)", + if is_input { "in" } else { "out" }, + sample_rate, + stream.channels + ); + Ok(Self { + stream, + sample_rate, + }) + } +} + +pub fn frame_samples(sample_rate: u32) -> usize { + (sample_rate as usize * FRAME_MS as usize) / 1000 +} + +/// Linear resample mono F32 buffer to target sample count. +pub fn resample_linear(input: &[f32], out_len: usize) -> Vec { + if input.is_empty() { + return vec![0.0; out_len]; + } + if input.len() == out_len { + return input.to_vec(); + } + let mut out = Vec::with_capacity(out_len); + for i in 0..out_len { + let src = i as f64 * (input.len() - 1) as f64 / (out_len - 1).max(1) as f64; + let lo = src.floor() as usize; + let hi = (lo + 1).min(input.len() - 1); + let t = src - lo as f64; + out.push(input[lo] * (1.0 - t) as f32 + input[hi] * t as f32); + } + out +} + +/// Resample between two sample rates (mono). +pub fn resample_rate(input: &[f32], from_hz: u32, to_hz: u32) -> Vec { + if from_hz == to_hz { + return input.to_vec(); + } + let out_len = ((input.len() as u64 * to_hz as u64) / from_hz as u64) as usize; + resample_linear(input, out_len.max(1)) +} + +#[cfg(test)] +mod tests { + use super::{bitrate_bps_from_kbps, frame_ms_from_interval, jitter_ms_from_setting}; + + #[test] + fn jitter_size_maps_to_ms() { + assert_eq!(jitter_ms_from_setting(0), 20); + assert_eq!(jitter_ms_from_setting(1), 40); + assert_eq!(jitter_ms_from_setting(2), 80); + } + + #[test] + fn packet_interval_maps_to_frame_ms() { + assert_eq!(frame_ms_from_interval(0), 10); + assert_eq!(frame_ms_from_interval(1), 20); + assert_eq!(frame_ms_from_interval(2), 40); + } + + #[test] + fn bitrate_kbps_to_bps() { + assert_eq!(bitrate_bps_from_kbps(64.0), 64_000); + } +} diff --git a/src/audio/devices/input.rs b/src/audio/devices/input.rs new file mode 100644 index 0000000..44c451a --- /dev/null +++ b/src/audio/devices/input.rs @@ -0,0 +1,42 @@ +use anyhow::{Result, anyhow}; +use cpal::Device; +use cpal::traits::{DeviceTrait, HostTrait}; + +pub fn input_at(index: usize) -> Result { + let host = cpal::default_host(); + if index == 0 { + return host + .default_input_device() + .ok_or_else(|| anyhow!("no default input device")); + } + + let devices: Vec = host.input_devices()?.collect(); + + let mut names: Vec = Vec::new(); + for dev in &devices { + if let Ok(name) = dev.description().map(|d| d.name().to_string()) + && !name.is_empty() + && !names.contains(&name) + { + names.push(name); + } + } + + let name = names + .get(index.saturating_sub(1)) + .ok_or_else(|| anyhow!("input device index {index} out of range"))?; + + for dev in devices { + if dev + .description() + .map(|d| d.name().to_string()) + .ok() + .as_deref() + == Some(name.as_str()) + { + return Ok(dev); + } + } + + Err(anyhow!("input device not found: {name}")) +} diff --git a/src/audio/devices/mod.rs b/src/audio/devices/mod.rs new file mode 100644 index 0000000..23a19df --- /dev/null +++ b/src/audio/devices/mod.rs @@ -0,0 +1,120 @@ +mod input; +mod output; + +pub use input::input_at; +pub use output::output_at; + +use anyhow::Result; +use cpal::traits::{DeviceTrait, HostTrait}; + +const DEFAULT_LABEL: &str = "Default"; + +#[derive(Debug, Clone)] +pub struct DeviceLabel { + pub display: String, + pub raw_name: String, + pub host_api: String, + pub channels: u16, +} + +#[derive(Debug, Default)] +pub struct DeviceLists { + pub input_labels: Vec, + pub output_labels: Vec, + pub input_devices: Vec, + pub output_devices: Vec, +} + +pub fn enumerate() -> Result { + let host = cpal::default_host(); + let host_api = host.id().name().to_string(); + let (input_labels, input_devices) = list_labels(host.input_devices()?, &host_api); + let (output_labels, output_devices) = list_labels(host.output_devices()?, &host_api); + Ok(DeviceLists { + input_labels, + output_labels, + input_devices, + output_devices, + }) +} + +fn list_labels( + devices: impl Iterator, + host_api: &str, +) -> (Vec, Vec) { + let mut labels = vec![DEFAULT_LABEL.into()]; + let mut metadatas = vec![DeviceLabel { + display: DEFAULT_LABEL.into(), + raw_name: String::new(), + host_api: host_api.into(), + channels: 0, + }]; + for dev in devices { + let Ok(name) = dev.description().map(|d| d.name().to_string()) else { + continue; + }; + if name.is_empty() { + continue; + } + let channels = supported_channel_count(&dev); + let display = format!("{} ({})", name, host_api); + if metadatas.iter().any(|m| m.raw_name == name) { + continue; + } + labels.push(display.clone()); + metadatas.push(DeviceLabel { + display, + raw_name: name, + host_api: host_api.into(), + channels, + }); + } + (labels, metadatas) +} + +fn supported_channel_count(dev: &cpal::Device) -> u16 { + if let Ok(cfg) = dev.default_input_config() { + return cfg.channels(); + } + if let Ok(cfg) = dev.default_output_config() { + return cfg.channels(); + } + if let Ok(configs) = dev.supported_input_configs() + && let Some(cfg) = configs.into_iter().next() + { + return cfg.channels(); + } + if let Ok(configs) = dev.supported_output_configs() + && let Some(cfg) = configs.into_iter().next() + { + return cfg.channels(); + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn default_is_first_label() { + let lists = enumerate().unwrap_or_default(); + if !lists.input_labels.is_empty() { + assert_eq!(lists.input_labels[0], "Default"); + } + if !lists.output_labels.is_empty() { + assert_eq!(lists.output_labels[0], "Default"); + } + } + #[test] + fn default_meta_is_sentinel() { + let lists = enumerate().unwrap_or_default(); + assert_eq!( + lists.input_devices.first().map(|m| m.raw_name.as_str()), + Some("") + ); + assert_eq!( + lists.output_devices.first().map(|m| m.raw_name.as_str()), + Some("") + ); + } +} diff --git a/src/audio/devices/output.rs b/src/audio/devices/output.rs new file mode 100644 index 0000000..68b37eb --- /dev/null +++ b/src/audio/devices/output.rs @@ -0,0 +1,42 @@ +use anyhow::{Result, anyhow}; +use cpal::Device; +use cpal::traits::{DeviceTrait, HostTrait}; + +pub fn output_at(index: usize) -> Result { + let host = cpal::default_host(); + if index == 0 { + return host + .default_output_device() + .ok_or_else(|| anyhow!("no default output device")); + } + + let devices: Vec = host.output_devices()?.collect(); + + let mut names: Vec = Vec::new(); + for dev in &devices { + if let Ok(name) = dev.description().map(|d| d.name().to_string()) + && !name.is_empty() + && !names.contains(&name) + { + names.push(name); + } + } + + let name = names + .get(index.saturating_sub(1)) + .ok_or_else(|| anyhow!("output device index {index} out of range"))?; + + for dev in devices { + if dev + .description() + .map(|d| d.name().to_string()) + .ok() + .as_deref() + == Some(name.as_str()) + { + return Ok(dev); + } + } + + Err(anyhow!("output device not found: {name}")) +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs new file mode 100644 index 0000000..6fbd2f8 --- /dev/null +++ b/src/audio/mod.rs @@ -0,0 +1,60 @@ +pub mod config; +pub mod devices; +mod pipeline; +mod processing; + +pub use devices::{DeviceLabel, DeviceLists}; +pub use pipeline::AudioPipeline; +pub use pipeline::JitterConfig; +pub use processing::{DenoiseState, TransmitGate}; + +#[derive(Debug, Clone, Copy)] +pub struct AudioSync { + pub mic_enabled: bool, + pub vad_mode: usize, + pub vad_threshold: f32, + pub input_volume: f32, + pub ptt_held: bool, + pub output_volume: f32, + pub jitter_size: usize, + pub adaptive_buffer: bool, + pub voice_bitrate_kbps: f32, + pub noise_suppress: bool, +} + +#[derive(Debug, Clone)] +pub struct PipelineConfig { + pub channel_key: u64, + pub mic_device_index: usize, + pub output_device_index: usize, + pub voice_bitrate_kbps: f32, + pub packet_interval: usize, +} + +impl PartialEq for PipelineConfig { + fn eq(&self, other: &Self) -> bool { + self.channel_key == other.channel_key + && self.mic_device_index == other.mic_device_index + && self.output_device_index == other.output_device_index + && self.packet_interval == other.packet_interval + } +} + +#[derive(Debug, Clone)] +pub struct IncomingVoice { + pub voice_seq: u32, + pub timestamp: u32, + pub channel_id: u64, + pub opus_data: Vec, +} + +pub struct EncodedFrame { + pub channel_id: u64, + pub voice_seq: u32, + pub timestamp: u32, + pub data: Vec, +} + +pub fn start(cfg: PipelineConfig) -> anyhow::Result { + pipeline::start(cfg) +} diff --git a/src/audio/pipeline/capture.rs b/src/audio/pipeline/capture.rs new file mode 100644 index 0000000..3c248d7 --- /dev/null +++ b/src/audio/pipeline/capture.rs @@ -0,0 +1,148 @@ +use anyhow::Result; +use cpal::traits::{DeviceTrait, StreamTrait}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use tracing::{info, warn}; + +use crate::audio::{ + EncodedFrame, + config::{self, AudioConfig, OPUS_RATE}, + processing::{DenoiseState, TransmitGate}, +}; + +const MAX_OPUS: usize = 4000; + +#[allow(clippy::too_many_arguments)] +pub fn run( + device: cpal::Device, + cfg: AudioConfig, + channel_id: u64, + tx: std::sync::mpsc::Sender, + gate: Arc>, + frame_ms: u32, + bitrate_bps: Arc, + local_speaking: Arc, +) -> Result<()> { + let mut encoder = opus::Encoder::new(OPUS_RATE, opus::Channels::Mono, opus::Application::Voip)?; + let mut last_bitrate_bps: u32 = 0; + apply_bitrate(&mut encoder, &bitrate_bps, &mut last_bitrate_bps)?; + + let mut denoise = DenoiseState::new()?; + + let capture_frame = frame_samples_at(cfg.sample_rate, frame_ms); + let opus_frame = frame_samples_at(OPUS_RATE, frame_ms); + let needs_resample = cfg.sample_rate != OPUS_RATE; + let in_channels = cfg.stream.channels as usize; + + let buf: Arc>> = Arc::new(Mutex::new(Vec::new())); + let buf2 = buf.clone(); + let mut voice_seq: u32 = 0; + let mut timestamp: u32 = 0; + + let stream = device.build_input_stream( + cfg.stream, + move |data: &[f32], _| { + let mut b = buf2.lock().unwrap(); + if in_channels <= 1 { + b.extend_from_slice(data); + } else { + for frame in data.chunks(in_channels) { + let sum: f32 = frame.iter().sum(); + b.push(sum / in_channels as f32); + } + } + }, + |e| tracing::error!("input: {e}"), + None, + )?; + stream.play()?; + info!("capture started ({} Hz)", cfg.sample_rate); + + loop { + std::thread::sleep(std::time::Duration::from_millis(5)); + let mut b = buf.lock().unwrap(); + while b.len() >= capture_frame { + let raw: Vec = b.drain(..capture_frame).collect(); + drop(b); + + let frame = if needs_resample { + config::resample_rate(&raw, cfg.sample_rate, OPUS_RATE) + } else { + raw + }; + let mut pcm = if frame.len() >= opus_frame { + frame[..opus_frame].to_vec() + } else { + let mut padded = frame; + padded.resize(opus_frame, 0.0); + padded + }; + + let gate_snapshot = gate.lock().unwrap().clone(); + TransmitGate::apply_input_volume(&mut pcm, gate_snapshot.input_volume); + if gate_snapshot.noise_suppress { + denoise.process(&mut pcm); + } + let speaking = TransmitGate::should_transmit(&gate_snapshot, &pcm); + local_speaking.store(speaking, Ordering::Relaxed); + if !speaking { + b = buf.lock().unwrap(); + continue; + } + + apply_bitrate(&mut encoder, &bitrate_bps, &mut last_bitrate_bps)?; + + let mut out = vec![0u8; MAX_OPUS]; + match encoder.encode_float(&pcm, &mut out) { + Ok(n) => { + out.truncate(n); + if tx + .send(EncodedFrame { + channel_id, + voice_seq, + timestamp, + data: out, + }) + .is_err() + { + return Ok(()); + } + voice_seq = voice_seq.wrapping_add(1); + timestamp = timestamp.wrapping_add(opus_frame as u32); + } + Err(e) => warn!("encode: {e}"), + } + b = buf.lock().unwrap(); + } + } +} + +fn frame_samples_at(sample_rate: u32, frame_ms: u32) -> usize { + (sample_rate as usize * frame_ms as usize) / 1000 +} + +fn apply_bitrate( + encoder: &mut opus::Encoder, + bitrate_bps: &AtomicU32, + last: &mut u32, +) -> Result<()> { + let bps = bitrate_bps.load(Ordering::Relaxed).clamp(8_000, 512_000); + if bps == *last { + return Ok(()); + } + *last = bps; + encoder.set_bitrate(opus::Bitrate::Bits(bps as i32))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::frame_samples_at; + + #[test] + fn frame_sizes_scale_with_ms() { + assert_eq!(frame_samples_at(48_000, 10), 480); + assert_eq!(frame_samples_at(48_000, 20), 960); + assert_eq!(frame_samples_at(48_000, 40), 1920); + } +} diff --git a/src/audio/pipeline/mod.rs b/src/audio/pipeline/mod.rs new file mode 100644 index 0000000..cffdd60 --- /dev/null +++ b/src/audio/pipeline/mod.rs @@ -0,0 +1,168 @@ +mod capture; +mod playback; + +use cpal::traits::DeviceTrait; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}, + mpsc::{self, Receiver, Sender}, +}; +use tracing::info; + +use crate::audio::{ + AudioSync, EncodedFrame, IncomingVoice, PipelineConfig, config, devices, + processing::TransmitGate, +}; + +pub use playback::JitterConfig; + +pub struct AudioPipeline { + encoded_rx: Receiver, + voice_tx: Sender, + _encoded_tx: Sender, + gate: Arc>, + output_volume: Arc>, + jitter_cfg: Arc>, + bitrate_bps: Arc, + /// Local user is currently transmitting (PTT held or VAD above threshold and mic enabled). + local_speaking: Arc, + /// UNIX millisecond timestamp of the last voice packet received from a remote peer. + last_remote_voice_ms: Arc, +} + +impl AudioPipeline { + pub fn poll_encoded(&self) -> Option { + self.encoded_rx.try_recv().ok() + } + + pub fn push_voice(&self, packet: IncomingVoice) { + let _ = self.voice_tx.send(packet); + self.last_remote_voice_ms.store( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + Ordering::Relaxed, + ); + } + + /// Returns true if the local user is currently speaking / transmitting. + pub fn local_speaking(&self) -> bool { + self.local_speaking.load(Ordering::Relaxed) + } + + /// Returns true if we received a voice packet from a remote peer within the + /// last `within_ms` milliseconds. + pub fn remote_speaking(&self, within_ms: u64) -> bool { + let last = self.last_remote_voice_ms.load(Ordering::Relaxed); + if last == 0 { + return false; + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + now.saturating_sub(last) <= within_ms + } + + pub fn sync_controls(&self, sync: AudioSync) { + let mut g = self.gate.lock().unwrap(); + g.mic_enabled = sync.mic_enabled; + g.vad_mode = sync.vad_mode.min(2) as u8; + g.vad_threshold = sync.vad_threshold; + g.input_volume = sync.input_volume; + g.ptt_held = sync.ptt_held; + g.noise_suppress = sync.noise_suppress; + *self.output_volume.lock().unwrap() = sync.output_volume; + *self.jitter_cfg.lock().unwrap() = JitterConfig { + target_ms: config::jitter_ms_from_setting(sync.jitter_size), + adaptive: sync.adaptive_buffer, + }; + self.bitrate_bps.store( + config::bitrate_bps_from_kbps(sync.voice_bitrate_kbps), + Ordering::Relaxed, + ); + } +} + +pub fn start(cfg: PipelineConfig) -> anyhow::Result { + let input = devices::input_at(cfg.mic_device_index)?; + let output = devices::output_at(cfg.output_device_index)?; + info!( + "audio in: {} out: {}", + input + .description() + .map(|d| d.name().to_owned()) + .unwrap_or_default(), + output + .description() + .map(|d| d.name().to_owned()) + .unwrap_or_default() + ); + + let in_cfg = config::AudioConfig::find(&input, true)?; + let out_cfg = config::AudioConfig::find(&output, false)?; + let frame_ms = config::frame_ms_from_interval(cfg.packet_interval); + + let (encoded_tx, encoded_rx) = mpsc::channel::(); + let (voice_tx, voice_rx) = mpsc::channel::(); + let gate = Arc::new(Mutex::new(TransmitGate::default())); + let gate_cap = gate.clone(); + let output_volume = Arc::new(Mutex::new(90.0f32)); + let vol_play = output_volume.clone(); + let jitter_cfg = Arc::new(Mutex::new(JitterConfig { + target_ms: 40, + adaptive: true, + })); + let jitter_play = jitter_cfg.clone(); + let bitrate_bps = Arc::new(AtomicU32::new(config::bitrate_bps_from_kbps( + cfg.voice_bitrate_kbps, + ))); + let bitrate_cap = bitrate_bps.clone(); + + let local_speaking = Arc::new(AtomicBool::new(false)); + let local_speaking_cap = local_speaking.clone(); + let last_remote_voice_ms = Arc::new(AtomicU64::new(0)); + let last_remote_voice_play = last_remote_voice_ms.clone(); + + let channel_id = cfg.channel_key; + let enc_tx = encoded_tx.clone(); + std::thread::spawn(move || { + if let Err(e) = capture::run( + input, + in_cfg, + channel_id, + enc_tx, + gate_cap, + frame_ms, + bitrate_cap, + local_speaking_cap, + ) { + tracing::error!("capture: {e}"); + } + }); + std::thread::spawn(move || { + if let Err(e) = playback::run( + output, + out_cfg, + voice_rx, + vol_play, + jitter_play, + last_remote_voice_play, + ) { + tracing::error!("playback: {e}"); + } + }); + + Ok(AudioPipeline { + encoded_rx, + voice_tx, + _encoded_tx: encoded_tx, + gate, + output_volume, + jitter_cfg, + bitrate_bps, + local_speaking, + last_remote_voice_ms, + }) +} diff --git a/src/audio/pipeline/playback.rs b/src/audio/pipeline/playback.rs new file mode 100644 index 0000000..ee32340 --- /dev/null +++ b/src/audio/pipeline/playback.rs @@ -0,0 +1,149 @@ +use crate::jitter::{BufferedPacket, JitterBuffer}; +use anyhow::Result; +use cpal::traits::{DeviceTrait, StreamTrait}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + mpsc::TryRecvError, + Arc, Mutex, +}; +use std::time::{Duration, Instant}; +use tracing::{info, warn}; + +use crate::audio::config::{self, AudioConfig, OPUS_RATE}; +use crate::audio::IncomingVoice; + +#[derive(Debug, Clone)] +pub struct JitterConfig { + pub target_ms: u32, + pub adaptive: bool, +} + +pub fn run( + device: cpal::Device, + cfg: AudioConfig, + opus_rx: std::sync::mpsc::Receiver, + output_volume: Arc>, + jitter_cfg: Arc>, + last_remote_voice_ms: Arc, +) -> Result<()> { + let mut decoder = opus::Decoder::new(OPUS_RATE, opus::Channels::Mono)?; + let pb: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pb2 = pb.clone(); + let vol2 = output_volume.clone(); + let out_channels = cfg.stream.channels as usize; + let needs_resample = cfg.sample_rate != OPUS_RATE; + let opus_frame = config::frame_samples(OPUS_RATE); + + let stream = device.build_output_stream( + cfg.stream, + move |out: &mut [f32], _| { + let gain = (*vol2.lock().unwrap() / 100.0).clamp(0.0, 2.0); + let mut buf = pb2.lock().unwrap(); + if out_channels == 1 { + let n = buf.len().min(out.len()); + for (o, s) in out[..n].iter_mut().zip(buf[..n].iter()) { + *o = s * gain; + } + buf.drain(..n); + out[n..].fill(0.0); + } else { + let frames = out.len() / out_channels; + for i in 0..frames { + let sample = if i < buf.len() { buf[i] * gain } else { 0.0 }; + for ch in 0..out_channels { + out[i * out_channels + ch] = sample; + } + } + let drain_n = frames.min(buf.len()); + buf.drain(..drain_n); + } + }, + |e| tracing::error!("output: {e}"), + None, + )?; + stream.play()?; + info!("playback started ({} Hz)", cfg.sample_rate); + + let clock = Instant::now(); + let initial = jitter_cfg.lock().unwrap(); + let mut jitter = JitterBuffer::new(initial.target_ms, initial.adaptive); + drop(initial); + let mut stream_open = true; + + while stream_open || !jitter.is_empty() { + let cfg_now = jitter_cfg.lock().unwrap().clone(); + jitter.set_target_ms(cfg_now.target_ms); + if jitter.is_adaptive() != cfg_now.adaptive { + let target = jitter.target_ms(); + let now_ms = clock.elapsed().as_millis() as u64; + let mut new_jb = JitterBuffer::new(target, cfg_now.adaptive); + while let Some(mut pkt) = jitter.pop_ready(now_ms) { + pkt.arrived_at = now_ms; + new_jb.push(pkt); + } + jitter = new_jb; + tracing::info!(adaptive = cfg_now.adaptive, "jitter: adaptive mode toggled"); + } + loop { + match opus_rx.try_recv() { + Ok(pkt) => { + last_remote_voice_ms.store( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + Ordering::Relaxed, + ); + jitter.push(BufferedPacket { + voice_seq: pkt.voice_seq, + timestamp: pkt.timestamp, + channel_id: pkt.channel_id, + opus_data: pkt.opus_data, + arrived_at: clock.elapsed().as_millis() as u64, + }); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + stream_open = false; + break; + } + } + } + let now_ms = clock.elapsed().as_millis() as u64; + while let Some(pkt) = jitter.pop_ready(now_ms) { + decode_into_playback( + &mut decoder, + &pkt.opus_data, + opus_frame, + needs_resample, + cfg.sample_rate, + &pb, + ); + } + std::thread::sleep(Duration::from_millis(2)); + } + Ok(()) +} + +fn decode_into_playback( + decoder: &mut opus::Decoder, + data: &[u8], + opus_frame: usize, + needs_resample: bool, + device_rate: u32, + pb: &Arc>>, +) { + let mut pcm = vec![0f32; opus_frame]; + match decoder.decode_float(data, &mut pcm, false) { + Ok(n) => { + pcm.truncate(n); + let samples = if needs_resample { + config::resample_rate(&pcm, OPUS_RATE, device_rate) + } else { + pcm + }; + pb.lock().unwrap().extend_from_slice(&samples); + } + Err(e) => warn!("decode: {e}"), + } +} diff --git a/src/audio/processing/mod.rs b/src/audio/processing/mod.rs new file mode 100644 index 0000000..4375601 --- /dev/null +++ b/src/audio/processing/mod.rs @@ -0,0 +1,142 @@ +mod rnnoise; + +pub use rnnoise::DenoiseState; + +pub struct TransmitGate { + pub mic_enabled: bool, + pub vad_mode: u8, + pub vad_threshold: f32, + pub ptt_held: bool, + pub input_volume: f32, + pub noise_suppress: bool, +} + +impl Default for TransmitGate { + fn default() -> Self { + Self { + mic_enabled: true, + vad_mode: 0, + vad_threshold: 40.0, + ptt_held: false, + input_volume: 80.0, + noise_suppress: false, + } + } +} + +impl Clone for TransmitGate { + fn clone(&self) -> Self { + Self { + mic_enabled: self.mic_enabled, + vad_mode: self.vad_mode, + vad_threshold: self.vad_threshold, + ptt_held: self.ptt_held, + input_volume: self.input_volume, + noise_suppress: self.noise_suppress, + } + } +} + +impl std::fmt::Debug for TransmitGate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TransmitGate") + .field("mic_enabled", &self.mic_enabled) + .field("vad_mode", &self.vad_mode) + .field("vad_threshold", &self.vad_threshold) + .field("ptt_held", &self.ptt_held) + .field("input_volume", &self.input_volume) + .field("noise_suppress", &self.noise_suppress) + .finish() + } +} + +pub fn frame_rms(pcm: &[f32]) -> f32 { + if pcm.is_empty() { + return 0.0; + } + let mean_sq: f32 = pcm.iter().map(|s| s * s).sum::() / pcm.len() as f32; + mean_sq.sqrt() +} + +fn vad_rms_threshold(ui_threshold: f32) -> f32 { + 0.002 + (ui_threshold.clamp(0.0, 100.0) / 100.0) * 0.118 +} + +impl TransmitGate { + pub fn apply_input_volume(pcm: &mut [f32], volume_pct: f32) { + let gain = (volume_pct / 100.0).clamp(0.0, 2.0); + if (gain - 1.0).abs() < f32::EPSILON { + return; + } + for s in pcm { + *s *= gain; + } + } + + pub fn should_transmit(&self, pcm: &[f32]) -> bool { + if !self.mic_enabled { + return false; + } + match self.vad_mode { + 2 => true, + 0 => self.ptt_held, + 1 => frame_rms(pcm) >= vad_rms_threshold(self.vad_threshold), + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn loud_frame() -> Vec { + vec![0.25f32; 960] + } + + fn quiet_frame() -> Vec { + vec![0.0001f32; 960] + } + + #[test] + fn mic_disabled_blocks() { + let gate = TransmitGate { + mic_enabled: false, + vad_mode: 2, + ..Default::default() + }; + assert!(!gate.should_transmit(&loud_frame())); + } + + #[test] + fn always_on_when_mic_enabled() { + let gate = TransmitGate { + vad_mode: 2, + ..Default::default() + }; + assert!(gate.should_transmit(&quiet_frame())); + } + + #[test] + fn ptt_requires_held() { + let mut gate = TransmitGate { + vad_mode: 0, + ptt_held: false, + ..Default::default() + }; + assert!(!gate.should_transmit(&loud_frame())); + gate.ptt_held = true; + assert!(gate.should_transmit(&loud_frame())); + } + + #[test] + fn vad_uses_threshold() { + let gate = TransmitGate { + vad_mode: 1, + vad_threshold: 40.0, + ..Default::default() + }; + assert!(!gate.should_transmit(&quiet_frame())); + assert!(gate.should_transmit(&loud_frame())); + } +} diff --git a/src/audio/processing/rnnoise.rs b/src/audio/processing/rnnoise.rs new file mode 100644 index 0000000..405c129 --- /dev/null +++ b/src/audio/processing/rnnoise.rs @@ -0,0 +1,46 @@ +#[cfg(feature = "rnnoise")] +mod imp { + use anyhow::Result; + + const FRAME_SIZE: usize = nnnoiseless::DenoiseState::FRAME_SIZE; + + pub struct DenoiseState { + inner: Box>, + out_buf: Vec, + } + + impl DenoiseState { + pub fn new() -> Result { + let inner = nnnoiseless::DenoiseState::new(); + Ok(Self { + inner, + out_buf: vec![0.0; FRAME_SIZE], + }) + } + + pub fn process(&mut self, pcm: &mut [f32]) { + for chunk in pcm.chunks_exact_mut(FRAME_SIZE) { + let input: Vec = chunk.iter().map(|&s| s * 32768.0).collect(); + self.inner.process_frame(&mut self.out_buf, &input); + for (out, &denoised) in chunk.iter_mut().zip(self.out_buf.iter()) { + *out = (denoised / 32768.0).clamp(-1.0, 1.0); + } + } + } + } +} + +#[cfg(not(feature = "rnnoise"))] +mod imp { + pub struct DenoiseState; + + impl DenoiseState { + pub fn new() -> anyhow::Result { + Ok(Self) + } + + pub fn process(&mut self, _pcm: &mut [f32]) {} + } +} + +pub use imp::DenoiseState; diff --git a/src/bin/e2e_voice.rs b/src/bin/e2e_voice.rs new file mode 100644 index 0000000..5973ed8 --- /dev/null +++ b/src/bin/e2e_voice.rs @@ -0,0 +1,131 @@ +//! Headless voice path smoke test: two TCP sessions, UDP relay between them. +//! +//! Requires gateway and voice-node running: +//! cargo run -p vnox-gateway -- --config dev/config.toml +//! cargo run -p vnox-voice-node -- --config dev/config.toml + +use anyhow::{Context, Result, anyhow}; +use tokio::net::{TcpStream, UdpSocket}; +use tokio::time::{Duration, timeout}; +use tracing::info; +use vnox_client::{ + identity, + net::{crypto::SessionCrypto, framing, handshake, payloads::*, voice}, +}; + +const GATEWAY: &str = "127.0.0.1:7600"; +const VOICE_CHANNEL: &str = "voice"; + +struct Session { + _stream: TcpStream, + _crypto: SessionCrypto, + udp: UdpSocket, + voice_endpoint: std::net::SocketAddr, +} + +async fn connect_client(nickname: &str) -> Result { + let identity = identity::generate(nickname); + let mut stream = TcpStream::connect(GATEWAY).await.context("TCP connect")?; + let mut seq = 0u32; + let (_session_id, _node_name, _private_mode, crypto) = + handshake::run(&mut stream, &identity, &mut seq).await?; + + framing::io::write_encrypted( + &mut stream, + PID_JOIN_CHANNEL, + &mut seq, + &serde_json::to_vec(&JoinChannelPayload { + channel_id: VOICE_CHANNEL.into(), + })?, + &crypto, + ) + .await?; + + let pkt = framing::io::read_encrypted(&mut stream, &crypto).await?; + if pkt.packet_id != PID_CHANNEL_STATE { + return Err(anyhow!( + "expected CHANNEL_STATE, got 0x{:04X}", + pkt.packet_id + )); + } + let state: ChannelStatePayload = serde_json::from_slice(&pkt.payload)?; + let voice_endpoint: std::net::SocketAddr = state + .voice_endpoint + .parse() + .context("parse voice_endpoint")?; + + let udp = UdpSocket::bind("0.0.0.0:0").await?; + info!( + "{nickname}: voice_endpoint={voice_endpoint} udp={}", + udp.local_addr()? + ); + + Ok(Session { + _stream: stream, + _crypto: crypto, + udp, + voice_endpoint, + }) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into())) + .init(); + + let channel_key = voice::channel_key(VOICE_CHANNEL); + let marker = b"vnox-e2e-test-payload"; + + // Dummy crypto for the test packet + let dummy_shared = [0u8; 32]; + let dummy_crypto = SessionCrypto::derive(&dummy_shared, "test"); + let sender_pubkey = [0u8; 32]; + let packet = voice::build_packet(channel_key, 1, 0, marker, &dummy_crypto, 0, &sender_pubkey); + + let alice = connect_client("alice").await?; + let bob = connect_client("bob").await?; + + // Register both clients on the voice node. + alice + .udp + .send_to(&packet, alice.voice_endpoint) + .await + .context("alice register")?; + bob.udp + .send_to(&packet, alice.voice_endpoint) + .await + .context("bob register")?; + tokio::time::sleep(Duration::from_millis(50)).await; + + let recv = tokio::spawn(async move { + let mut buf = vec![0u8; 1472]; + let (len, from) = bob.udp.recv_from(&mut buf).await.context("bob recv")?; + if len >= 20 && &buf[20..len] == marker { + info!("PASS: bob received relayed packet from {from} ({len} bytes)"); + Ok(()) + } else { + Err(anyhow!("unexpected UDP payload (len={len})")) + } + }); + + // Alice speaks; voice node should relay to bob. + alice + .udp + .send_to(&packet, alice.voice_endpoint) + .await + .context("alice send")?; + + info!( + "sent voice packet to {} (channel_key={channel_key})", + alice.voice_endpoint + ); + + timeout(Duration::from_secs(5), recv) + .await + .context("timed out waiting for voice relay")? + .context("voice recv task failed")??; + + info!("e2e voice test passed"); + Ok(()) +} diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..9898740 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,189 @@ +use anyhow::Result; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::identity_vault::Vault; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Identity { + pub nickname: String, + /// hex-encoded public key (64 chars) + pub pubkey_hex: String, + /// hex-encoded private key — never leave device + #[serde(skip_serializing_if = "Option::is_none")] + pub privkey_hex: Option, +} + +impl Identity { + /// Short form of the pubkey for display (first 8 hex chars + …) + pub fn short_id(&self) -> String { + format!("{}…", &self.pubkey_hex[..8]) + } + + pub fn signing_key(&self) -> Option { + let hex = self.privkey_hex.as_ref()?; + let bytes = hex::decode(hex).ok()?; + SigningKey::from_bytes(bytes.as_slice().try_into().ok()?).into() + } + + pub fn verifying_key(&self) -> Option { + let bytes = hex::decode(&self.pubkey_hex).ok()?; + VerifyingKey::from_bytes(bytes.as_slice().try_into().ok()?).ok() + } +} + +/// Load identity from disk or generate a new one. +/// +/// Tries the vault format first (`identity.vault.json`). Falls back to the +/// legacy plain `identity.json` for existing installs. +/// +/// If a vault is found and is encrypted, returns +/// `LoadError::PassphraseRequired` so the caller can prompt the user. +pub fn load_or_generate() -> Result { + let vault_path = vault_path(); + let legacy_path = identity_path(); + + if vault_path.exists() { + let text = std::fs::read_to_string(&vault_path)?; + let vault: Vault = serde_json::from_str(&text)?; + if vault.is_encrypted() { + return Err(anyhow::anyhow!(LoadError::PassphraseRequired { + path: vault_path, + })); + } + let inner = vault.open(None)?; + let identity: Identity = serde_json::from_str(&inner)?; + return Ok(identity); + } + + if legacy_path.exists() { + let text = std::fs::read_to_string(&legacy_path)?; + let identity: Identity = serde_json::from_str(&text)?; + return Ok(identity); + } + + // Generate new keypair and save as plain vault. + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let identity = Identity { + nickname: "user".into(), + pubkey_hex: hex::encode(verifying_key.to_bytes()), + privkey_hex: Some(hex::encode(signing_key.to_bytes())), + }; + save(&identity, None)?; + Ok(identity) +} + +/// Load an encrypted vault with the given passphrase. +pub fn load_with_passphrase(passphrase: &str) -> Result { + let vault_path = vault_path(); + let text = std::fs::read_to_string(&vault_path)?; + let vault: Vault = serde_json::from_str(&text)?; + let inner = vault.open(Some(passphrase))?; + let identity: Identity = serde_json::from_str(&inner)?; + Ok(identity) +} + +/// Save identity to disk, optionally encrypting with a passphrase. +/// When `passphrase` is `None` or empty, writes a plain vault. +pub fn save(identity: &Identity, passphrase: Option<&str>) -> Result<()> { + let path = vault_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let inner_json = serde_json::to_string_pretty(identity)?; + let vault = Vault::seal(&inner_json, passphrase)?; + std::fs::write(&path, serde_json::to_string_pretty(&vault)?)?; + Ok(()) +} + +/// True if the on-disk vault is encrypted and requires a passphrase. +pub fn is_vault_encrypted() -> bool { + let path = vault_path(); + if !path.exists() { + return false; + } + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => return false, + }; + serde_json::from_str::(&text) + .map(|v| v.is_encrypted()) + .unwrap_or(false) +} + +/// Ephemeral identity for tests (not persisted). +pub fn generate(nickname: &str) -> Identity { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + Identity { + nickname: nickname.into(), + pubkey_hex: hex::encode(verifying_key.to_bytes()), + privkey_hex: Some(hex::encode(signing_key.to_bytes())), + } +} + +/// Export the identity to a portable keyfile (JSON). +/// +/// When a passphrase is provided, the keyfile is encrypted with Argon2id + +/// ChaCha20-Poly1305 (same scheme as the on-disk vault). When no passphrase +/// is provided, the keyfile is plain JSON — useful for tests but **not** +/// recommended for production use. +/// +/// The returned string can be written to a `.vnoxkey` file or pasted into +/// a chat message for transfer. +pub fn export_keyfile(identity: &Identity, passphrase: Option<&str>) -> Result { + let inner = serde_json::to_string_pretty(identity)?; + let vault = crate::identity_vault::Vault::seal(&inner, passphrase)?; + Ok(serde_json::to_string_pretty(&vault)?) +} + +/// Import an identity from a previously exported keyfile. +/// +/// If the keyfile is encrypted, a passphrase must be provided. Returns the +/// deserialized identity without writing it to disk — call `save()` to persist. +pub fn import_keyfile(keyfile_json: &str, passphrase: Option<&str>) -> Result { + let vault: crate::identity_vault::Vault = serde_json::from_str(keyfile_json)?; + let inner = vault.open(passphrase)?; + let identity: Identity = serde_json::from_str(&inner)?; + Ok(identity) +} + +/// Errors that can occur during identity loading. +#[derive(Debug)] +pub enum LoadError { + /// The vault on disk is encrypted and needs a passphrase to unlock. + PassphraseRequired { path: PathBuf }, +} + +impl std::fmt::Display for LoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadError::PassphraseRequired { path } => { + write!( + f, + "identity vault at {} is encrypted; passphrase required", + path.display() + ) + } + } + } +} + +impl std::error::Error for LoadError {} + +fn vault_path() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("vnox") + .join("identity.vault.json") +} + +fn identity_path() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("vnox") + .join("identity.json") +} diff --git a/src/identity_vault.rs b/src/identity_vault.rs new file mode 100644 index 0000000..0dc1b72 --- /dev/null +++ b/src/identity_vault.rs @@ -0,0 +1,158 @@ +//! Encrypted vault for the identity keypair. +//! +//! Format on disk (JSON): +//! ```json +//! { +//! "version": 1, +//! "scheme": "argon2id+chacha20-poly1305", +//! "salt": "", +//! "nonce": "", +//! "ciphertext": "" +//! } +//! ``` +//! +//! When `passphrase` is `None`, the identity is stored as plain JSON (legacy behavior). + +use anyhow::Result; +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead}; +use serde::{Deserialize, Serialize}; +const VAULT_VERSION: u32 = 1; +const VAULT_SCHEME: &str = "argon2id+chacha20-poly1305"; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Vault { + pub version: u32, + pub scheme: String, + /// Hex-encoded salt for Argon2id. + #[serde(default)] + pub salt: String, + /// Hex-encoded ChaCha20-Poly1305 nonce. + #[serde(default)] + pub nonce: String, + /// Hex-encoded ciphertext (encrypts the inner Identity JSON). + /// Empty when no passphrase is set. + #[serde(default)] + pub ciphertext: String, + /// Plaintext Identity JSON — only set when no passphrase is used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plaintext: Option, +} + +impl Vault { + /// Seal the given JSON-serialized identity with a passphrase. + /// If `passphrase` is empty, stores as plaintext (no encryption). + pub fn seal(identity_json: &str, passphrase: Option<&str>) -> Result { + if passphrase.map(|p| p.is_empty()).unwrap_or(true) { + return Ok(Self { + version: VAULT_VERSION, + scheme: "plain".into(), + salt: String::new(), + nonce: String::new(), + ciphertext: String::new(), + plaintext: Some(identity_json.into()), + }); + } + let mut salt = [0u8; SALT_LEN]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut salt); + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce_bytes); + + let key = derive_key(passphrase.unwrap(), &salt)?; + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("chacha key init: {e}"))?; + let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes); + let ct = cipher + .encrypt(nonce, identity_json.as_bytes()) + .map_err(|e| anyhow::anyhow!("encrypt identity: {e:?}"))?; + + Ok(Self { + version: VAULT_VERSION, + scheme: VAULT_SCHEME.into(), + salt: hex::encode(salt), + nonce: hex::encode(nonce_bytes), + ciphertext: hex::encode(&ct), + plaintext: None, + }) + } + + /// Open the vault and return the inner Identity JSON. + /// If `passphrase` is None but the vault is encrypted, returns an error. + pub fn open(&self, passphrase: Option<&str>) -> Result { + if self.scheme == "plain" { + return self + .plaintext + .clone() + .ok_or_else(|| anyhow::anyhow!("plain vault has no plaintext")); + } + let passphrase = passphrase + .ok_or_else(|| anyhow::anyhow!("vault is encrypted but no passphrase provided"))?; + let salt = hex::decode(&self.salt).map_err(|e| anyhow::anyhow!("bad vault salt: {e}"))?; + let nonce_bytes = + hex::decode(&self.nonce).map_err(|e| anyhow::anyhow!("bad vault nonce: {e}"))?; + let ct = hex::decode(&self.ciphertext) + .map_err(|e| anyhow::anyhow!("bad vault ciphertext: {e}"))?; + let key = derive_key(passphrase, &salt)?; + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("chacha key init: {e}"))?; + let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes); + let pt = cipher + .decrypt(nonce, ct.as_ref()) + .map_err(|_| anyhow::anyhow!("wrong passphrase or corrupted vault"))?; + String::from_utf8(pt).map_err(|e| anyhow::anyhow!("vault plaintext is not UTF-8: {e}")) + } + + pub fn is_encrypted(&self) -> bool { + self.scheme != "plain" + } +} + +fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> { + // m=64 MiB, t=3, p=4 — moderate cost, ~250 ms on a modern CPU. + let params = Params::new(64 * 1024, 3, 4, Some(KEY_LEN)) + .map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut out = [0u8; KEY_LEN]; + argon + .hash_password_into(passphrase.as_bytes(), salt, &mut out) + .map_err(|e| anyhow::anyhow!("argon2 derive: {e}"))?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_encrypted() { + let inner = r#"{"nickname":"alice","pubkey_hex":"abcd","privkey_hex":"0011"}"#; + let v = Vault::seal(inner, Some("hunter2")).unwrap(); + assert!(v.is_encrypted()); + let opened = v.open(Some("hunter2")).unwrap(); + assert_eq!(opened, inner); + } + + #[test] + fn wrong_passphrase_fails() { + let v = Vault::seal("secret", Some("hunter2")).unwrap(); + assert!(v.open(Some("wrong")).is_err()); + } + + #[test] + fn plaintext_roundtrip() { + let inner = r#"{"nickname":"bob"}"#; + let v = Vault::seal(inner, None).unwrap(); + assert!(!v.is_encrypted()); + let opened = v.open(None).unwrap(); + assert_eq!(opened, inner); + } + + #[test] + fn empty_passphrase_treated_as_plain() { + let v = Vault::seal("x", Some("")).unwrap(); + assert!(!v.is_encrypted()); + } +} diff --git a/src/jitter/adaptive.rs b/src/jitter/adaptive.rs new file mode 100644 index 0000000..7ca0edc --- /dev/null +++ b/src/jitter/adaptive.rs @@ -0,0 +1,75 @@ +use tracing::info; + +use super::JitterBuffer; + +pub const MIN_TARGET_MS: u32 = 20; +pub const MAX_TARGET_MS: u32 = 150; +pub const JITTER_WINDOW: usize = 64; +const LOSS_INCREASE_THRESH: u32 = 5; +const LOSS_DECREASE_THRESH: u32 = 1; +const STABLE_WINDOWS_BEFORE_DECREASE: u32 = 10; +pub const LOSS_WINDOW_PACKETS: u32 = 50; +const ADJUST_STEP_MS: u32 = 5; + +impl JitterBuffer { + pub(super) fn observed_jitter_ms(&self) -> u32 { + if self.arrival_count < 2 { + return 0; + } + let mut deltas = Vec::with_capacity(self.arrival_count - 1); + for i in 0..self.arrival_count - 1 { + let idx0 = (self.arrival_idx + JITTER_WINDOW - self.arrival_count + i) % JITTER_WINDOW; + let idx1 = + (self.arrival_idx + JITTER_WINDOW - self.arrival_count + i + 1) % JITTER_WINDOW; + let delta = self.arrival_times[idx1].saturating_sub(self.arrival_times[idx0]); + deltas.push(delta); + } + if deltas.is_empty() { + return 0; + } + deltas.sort_unstable(); + let p90_idx = ((deltas.len() as f64) * 0.90).ceil() as usize - 1; + let p90_idx = p90_idx.min(deltas.len() - 1); + deltas[p90_idx] as u32 + } + + pub(super) fn adapt_target(&mut self) { + let loss_pct = if self.total_expected > 0 { + (self.loss_count as f64 / self.total_expected as f64 * 100.0) as u32 + } else { + 0 + }; + let jitter_ms = self.observed_jitter_ms(); + let jitter_based_target = (jitter_ms * 2).clamp(MIN_TARGET_MS, MAX_TARGET_MS); + + if loss_pct >= LOSS_INCREASE_THRESH { + let new_target = (self.target_ms + ADJUST_STEP_MS).min(MAX_TARGET_MS); + if new_target != self.target_ms { + info!( + target_ms = new_target, + loss_pct, jitter_ms, "adaptive: increasing target due to packet loss" + ); + self.target_ms = new_target; + } + self.stable_windows = 0; + } else if loss_pct <= LOSS_DECREASE_THRESH { + self.stable_windows += 1; + if self.stable_windows >= STABLE_WINDOWS_BEFORE_DECREASE { + let candidate = self.target_ms.saturating_sub(ADJUST_STEP_MS); + let new_target = candidate.max(jitter_based_target).max(MIN_TARGET_MS); + if new_target != self.target_ms { + info!( + target_ms = new_target, + loss_pct, jitter_ms, "adaptive: decreasing target, link stable" + ); + self.target_ms = new_target; + } + self.stable_windows = 0; + } + } else { + self.stable_windows = 0; + } + self.loss_count = 0; + self.total_expected = 0; + } +} diff --git a/src/jitter/mod.rs b/src/jitter/mod.rs new file mode 100644 index 0000000..fb0e0fa --- /dev/null +++ b/src/jitter/mod.rs @@ -0,0 +1,100 @@ +use std::collections::BTreeMap; + +pub mod adaptive; +pub mod relay; + +pub use adaptive::{JITTER_WINDOW, LOSS_WINDOW_PACKETS, MAX_TARGET_MS, MIN_TARGET_MS}; + +pub struct JitterBuffer { + target_ms: u32, + adaptive: bool, + packets: BTreeMap, + last_played: Option, + arrival_times: Vec, + arrival_idx: usize, + arrival_count: usize, + loss_count: u32, + total_expected: u32, + stable_windows: u32, +} + +impl JitterBuffer { + pub fn new(target_ms: u32, adaptive: bool) -> Self { + Self { + target_ms, + adaptive, + packets: BTreeMap::new(), + last_played: None, + arrival_times: vec![0u64; JITTER_WINDOW], + arrival_idx: 0, + arrival_count: 0, + loss_count: 0, + total_expected: 0, + stable_windows: 0, + } + } + + pub fn push(&mut self, pkt: BufferedPacket) { + let arrived = pkt.arrived_at; + self.arrival_times[self.arrival_idx] = arrived; + self.arrival_idx = (self.arrival_idx + 1) % JITTER_WINDOW; + if self.arrival_count < JITTER_WINDOW { + self.arrival_count += 1; + } + + let gap = self + .packets + .keys() + .next_back() + .map(|&highest| { + if pkt.voice_seq > highest { + pkt.voice_seq.wrapping_sub(highest).saturating_sub(1) + } else { + 0 + } + }) + .unwrap_or(0); + + if gap > 0 { + self.loss_count += gap; + } + self.total_expected += 1 + gap; + self.packets.insert(pkt.voice_seq, pkt); + + if self.adaptive && self.total_expected >= LOSS_WINDOW_PACKETS { + self.adapt_target(); + } + } + + pub fn len(&self) -> usize { + self.packets.len() + } + + pub fn is_empty(&self) -> bool { + self.packets.is_empty() + } + + pub fn set_target_ms(&mut self, target_ms: u32) { + self.target_ms = target_ms.clamp(MIN_TARGET_MS, MAX_TARGET_MS); + } + + pub fn target_ms(&self) -> u32 { + self.target_ms + } + + pub fn is_adaptive(&self) -> bool { + self.adaptive + } +} + +#[derive(Debug)] +pub struct BufferedPacket { + pub voice_seq: u32, + pub timestamp: u32, + pub channel_id: u64, + pub opus_data: Vec, + pub arrived_at: u64, +} + +#[cfg(test)] +mod tests; diff --git a/src/jitter/relay.rs b/src/jitter/relay.rs new file mode 100644 index 0000000..6bc07b5 --- /dev/null +++ b/src/jitter/relay.rs @@ -0,0 +1,44 @@ +use super::{BufferedPacket, JitterBuffer}; + +impl JitterBuffer { + pub fn pop_ready(&mut self, now_ms: u64) -> Option { + if self.adaptive { + return self.pop_ready_adaptive(now_ms); + } + self.pop_ready_fixed(now_ms) + } + + pub fn has_gap(&self) -> bool { + match self.last_played { + None => false, + Some(last) => { + let expected = last.wrapping_add(1); + !self.packets.contains_key(&expected) && !self.packets.is_empty() + } + } + } + + fn pop_ready_fixed(&mut self, now_ms: u64) -> Option { + let seq = *self.packets.keys().next()?; + let pkt = self.packets.get(&seq)?; + let buffered_for = now_ms.saturating_sub(pkt.arrived_at); + if buffered_for >= self.target_ms as u64 { + let pkt = self.packets.remove(&seq)?; + self.last_played = Some(seq); + return Some(pkt); + } + None + } + + fn pop_ready_adaptive(&mut self, now_ms: u64) -> Option { + let seq = *self.packets.keys().next()?; + let pkt = self.packets.get(&seq)?; + let buffered_for = now_ms.saturating_sub(pkt.arrived_at); + if buffered_for >= self.target_ms as u64 { + let pkt = self.packets.remove(&seq)?; + self.last_played = Some(seq); + return Some(pkt); + } + None + } +} diff --git a/src/jitter/tests.rs b/src/jitter/tests.rs new file mode 100644 index 0000000..fa8dc3e --- /dev/null +++ b/src/jitter/tests.rs @@ -0,0 +1,103 @@ +use super::*; + +fn pkt(seq: u32, arrived_at: u64) -> BufferedPacket { + BufferedPacket { + voice_seq: seq, + timestamp: 0, + channel_id: 1, + opus_data: vec![seq as u8], + arrived_at, + } +} + +#[test] +fn pop_ready_waits_for_target_ms() { + let mut jb = JitterBuffer::new(20, false); + jb.push(pkt(1, 100)); + assert!(jb.pop_ready(110).is_none()); + assert_eq!(jb.pop_ready(120).unwrap().voice_seq, 1); + assert!(jb.pop_ready(120).is_none()); +} + +#[test] +fn pop_ready_releases_in_sequence_order() { + let mut jb = JitterBuffer::new(0, false); + jb.push(pkt(2, 0)); + jb.push(pkt(1, 0)); + assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 1); + assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 2); +} + +#[test] +fn has_gap_after_played_seq() { + let mut jb = JitterBuffer::new(0, false); + jb.push(pkt(1, 0)); + jb.pop_ready(0); + jb.push(pkt(3, 0)); + assert!(jb.has_gap()); +} + +#[test] +fn no_gap_when_next_seq_present() { + let mut jb = JitterBuffer::new(0, false); + jb.push(pkt(1, 0)); + jb.pop_ready(0); + jb.push(pkt(2, 0)); + assert!(!jb.has_gap()); +} + +#[test] +fn adaptive_mode_increases_on_loss() { + let mut jb = JitterBuffer::new(40, true); + jb.push(pkt(1, 0)); + jb.push(pkt(3, 1)); + for i in 4..=50 { + jb.push(pkt(i, i as u64)); + } + assert!(jb.target_ms <= MAX_TARGET_MS); +} + +#[test] +fn set_target_ms_clamps() { + let mut jb = JitterBuffer::new(40, false); + jb.set_target_ms(0); + assert_eq!(jb.target_ms, MIN_TARGET_MS); + jb.set_target_ms(500); + assert_eq!(jb.target_ms, MAX_TARGET_MS); +} + +#[test] +fn adaptive_mode_respects_flag() { + let mut jb = JitterBuffer::new(40, false); + jb.push(pkt(1, 0)); + for i in 2..=100 { + jb.push(pkt(i, i as u64)); + } + assert_eq!(jb.target_ms, 40); +} + +#[test] +fn observed_jitter_smooth_timeline() { + let mut jb = JitterBuffer::new(40, true); + for i in 0..JITTER_WINDOW { + jb.push(pkt(i as u32, (i as u64) * 10)); + } + let jitter = jb.observed_jitter_ms(); + assert!( + (8..=12).contains(&jitter), + "jitter = {jitter} (expected ~10)" + ); +} + +#[test] +fn observed_jitter_spike() { + let mut jb = JitterBuffer::new(40, true); + let times = [ + 0, 10, 20, 70, 80, 90, 140, 150, 160, 210, 220, 230, 280, 290, 300, + ]; + for (i, &t) in times.iter().enumerate() { + jb.push(pkt(i as u32, t)); + } + let jitter = jb.observed_jitter_ms(); + assert!(jitter >= 40, "jitter = {jitter} (expected >= 40)"); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..16a87a3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod audio; +pub mod identity; +pub mod identity_vault; +pub mod jitter; +pub mod net; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..50223d5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,39 @@ +mod ui; + +pub use ui::VnoxApp; +pub use ui::{chat, connect, keybind, members, settings, sidebar, state, theme, titlebar}; + +use anyhow::Result; +use tracing::info; +use tracing_subscriber::EnvFilter; +use vnox_client::{identity, net}; + +fn main() -> Result<()> { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new("info,wgpu=warn,wgpu_hal=warn,egui_wgpu=warn,naga=warn") + }); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + let identity = identity::load_or_generate()?; + info!("identity: {}", identity.short_id()); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let _guard = rt.enter(); + + let net = net::spawn(identity.clone()); + + eframe::run_native( + "Vnox", + eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_title("Vnox") + .with_inner_size([900.0, 560.0]) + .with_min_inner_size([600.0, 400.0]), + ..Default::default() + }, + Box::new(|cc| Ok(Box::new(ui::VnoxApp::new(cc, identity, net)))), + ) + .map_err(|e| anyhow::anyhow!("eframe: {e}")) +} diff --git a/src/net/crypto/cipher.rs b/src/net/crypto/cipher.rs new file mode 100644 index 0000000..8709083 --- /dev/null +++ b/src/net/crypto/cipher.rs @@ -0,0 +1,46 @@ +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::{Aead, Payload}, +}; + +fn make_nonce(cid: &[u8; 8], seq: u64) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(cid); + nonce[8..12].copy_from_slice(&(seq as u32).to_le_bytes()); + nonce +} + +pub fn encrypt_with_key(key: &[u8; 32], cid: &[u8; 8], seq: u64, plaintext: &[u8]) -> Vec { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let nonce_bytes = make_nonce(cid, seq); + let nonce = Nonce::from_slice(&nonce_bytes); + cipher + .encrypt( + nonce, + Payload { + msg: plaintext, + aad: &[], + }, + ) + .expect("ChaCha20-Poly1305 encryption infallible") +} + +pub fn decrypt_with_key( + key: &[u8; 32], + cid: &[u8; 8], + seq: u64, + ciphertext: &[u8], +) -> anyhow::Result> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let nonce_bytes = make_nonce(cid, seq); + let nonce = Nonce::from_slice(&nonce_bytes); + cipher + .decrypt( + nonce, + Payload { + msg: ciphertext, + aad: &[], + }, + ) + .map_err(|e| anyhow::anyhow!("decryption failed: {e:?}")) +} diff --git a/src/net/crypto/mod.rs b/src/net/crypto/mod.rs new file mode 100644 index 0000000..10857d8 --- /dev/null +++ b/src/net/crypto/mod.rs @@ -0,0 +1,99 @@ +use hkdf::Hkdf; +use sha2::{Digest, Sha256}; +use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret}; + +pub mod cipher; + +/// Session encryption context (mirrors gateway/src/proto/crypto.rs). +/// +/// Two derived keys: +/// - `c2s_key`: client → server +/// - `s2c_key`: server → client +/// +/// Nonce: `cid (8 bytes) || packet_seq (4 bytes LE)`. +#[derive(Clone)] +pub struct SessionCrypto { + c2s_key: [u8; 32], + s2c_key: [u8; 32], + cid: [u8; 8], +} + +impl SessionCrypto { + pub fn derive(shared_secret: &[u8; 32], session_id: &str) -> Self { + let salt = b"VNOX-LNEx-KDF-v1"; + let hk = Hkdf::::new(Some(salt), shared_secret); + + let mut keys = [0u8; 64]; + hk.expand(b"session-keys", &mut keys) + .expect("64 bytes within HKDF max"); + + let c2s_key: [u8; 32] = keys[..32].try_into().unwrap(); + let s2c_key: [u8; 32] = keys[32..64].try_into().unwrap(); + + let hash = Sha256::digest(session_id.as_bytes()); + let cid: [u8; 8] = hash[..8].try_into().unwrap(); + + Self { + c2s_key, + s2c_key, + cid, + } + } + + pub fn encrypt_c2s(&self, seq: u64, plaintext: &[u8]) -> Vec { + cipher::encrypt_with_key(&self.c2s_key, &self.cid, seq, plaintext) + } + + pub fn decrypt_s2c(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result> { + cipher::decrypt_with_key(&self.s2c_key, &self.cid, seq, ciphertext) + } + + pub fn decrypt_c2s(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result> { + cipher::decrypt_with_key(&self.c2s_key, &self.cid, seq, ciphertext) + } + + pub fn c2s_key(&self) -> &[u8; 32] { + &self.c2s_key + } + + pub fn cid(&self) -> &[u8; 8] { + &self.cid + } + + pub fn new_ephemeral() -> (EphemeralSecret, PublicKey) { + let mut rng = rand::thread_rng(); + let sk = EphemeralSecret::random_from_rng(&mut rng); + let pk = PublicKey::from(&sk); + (sk, pk) + } + + pub fn ecdh(secret: EphemeralSecret, peer_public: &PublicKey) -> SharedSecret { + secret.diffie_hellman(peer_public) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_then_decrypt_roundtrip() { + let shared_secret = [0xABu8; 32]; + let crypto = SessionCrypto::derive(&shared_secret, "test-session"); + + let plaintext = b"hello encrypted world"; + let ct = crypto.encrypt_c2s(42, plaintext); + let decrypted = crypto.decrypt_c2s(42, &ct).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn tampered_ciphertext_fails() { + let shared_secret = [0xABu8; 32]; + let crypto = SessionCrypto::derive(&shared_secret, "test-session"); + + let mut ct = crypto.encrypt_c2s(0, b"data"); + ct[5] ^= 0xFF; + assert!(crypto.decrypt_c2s(0, &ct).is_err()); + } +} diff --git a/src/net/framing/io.rs b/src/net/framing/io.rs new file mode 100644 index 0000000..fea1cd6 --- /dev/null +++ b/src/net/framing/io.rs @@ -0,0 +1,51 @@ +use anyhow::{Result, anyhow}; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; + +use super::super::crypto::SessionCrypto; +use super::super::payloads::HDR_SIZE; +use super::RawPacket; +use super::parse_header; + +const ENCRYPTED_FLAG: u16 = 1 << 1; +const KNOWN_FLAGS: u16 = ENCRYPTED_FLAG; + +pub async fn read_encrypted(stream: &mut TcpStream, crypto: &SessionCrypto) -> Result { + let mut buf = [0u8; HDR_SIZE]; + stream.read_exact(&mut buf).await?; + let (packet_id, flags, sequence, payload_length) = parse_header(&buf)?; + if flags & !KNOWN_FLAGS != 0 { + return Err(anyhow!("unknown packet flags: 0x{:04X}", flags)); + } + if flags & ENCRYPTED_FLAG == 0 { + return Err(anyhow!("packet not encrypted")); + } + let mut encrypted = vec![0u8; payload_length as usize]; + if !encrypted.is_empty() { + stream.read_exact(&mut encrypted).await?; + } + let payload = crypto.decrypt_s2c(sequence as u64, &encrypted)?; + Ok(RawPacket { packet_id, payload }) +} + +pub async fn write_encrypted( + stream: &mut TcpStream, + id: u16, + seq: &mut u32, + payload: &[u8], + crypto: &SessionCrypto, +) -> Result<()> { + let encrypted = crypto.encrypt_c2s(*seq as u64, payload); + let mut hdr = [0u8; HDR_SIZE]; + hdr[0..2].copy_from_slice(&id.to_be_bytes()); + hdr[2..4].copy_from_slice(&ENCRYPTED_FLAG.to_be_bytes()); + hdr[4..8].copy_from_slice(&seq.to_be_bytes()); + hdr[8..12].copy_from_slice(&(encrypted.len() as u32).to_be_bytes()); + *seq = seq.wrapping_add(1); + let mut frame = Vec::with_capacity(HDR_SIZE + encrypted.len()); + frame.extend_from_slice(&hdr); + frame.extend_from_slice(&encrypted); + stream.write_all(&frame).await?; + Ok(()) +} diff --git a/src/net/framing/mod.rs b/src/net/framing/mod.rs new file mode 100644 index 0000000..aa2ffcd --- /dev/null +++ b/src/net/framing/mod.rs @@ -0,0 +1,101 @@ +use anyhow::{Result, anyhow}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use super::payloads::{HDR_SIZE, MAX_PAYLOAD}; + +pub struct RawPacket { + pub packet_id: u16, + pub payload: Vec, +} + +pub(super) fn parse_header(buf: &[u8; HDR_SIZE]) -> Result<(u16, u16, u32, u32)> { + let packet_id = u16::from_be_bytes([buf[0], buf[1]]); + let flags = u16::from_be_bytes([buf[2], buf[3]]); + let sequence = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); + let payload_length = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]); + if payload_length > MAX_PAYLOAD { + return Err(anyhow!("payload too large: {payload_length}")); + } + Ok((packet_id, flags, sequence, payload_length)) +} + +pub fn encode_frame(id: u16, seq: &mut u32, payload: &[u8]) -> Vec { + let mut hdr = [0u8; HDR_SIZE]; + hdr[0..2].copy_from_slice(&id.to_be_bytes()); + hdr[4..8].copy_from_slice(&seq.to_be_bytes()); + hdr[8..12].copy_from_slice(&(payload.len() as u32).to_be_bytes()); + *seq = seq.wrapping_add(1); + let mut frame = Vec::with_capacity(HDR_SIZE + payload.len()); + frame.extend_from_slice(&hdr); + frame.extend_from_slice(payload); + frame +} + +pub fn decode_frame(bytes: &[u8]) -> Result { + if bytes.len() < HDR_SIZE { + return Err(anyhow!("frame too short")); + } + let hdr: [u8; HDR_SIZE] = bytes[..HDR_SIZE].try_into().expect("header length"); + let (packet_id, _, _, payload_length) = parse_header(&hdr)?; + let end = HDR_SIZE + payload_length as usize; + if bytes.len() < end { + return Err(anyhow!("truncated frame")); + } + Ok(RawPacket { + packet_id, + payload: bytes[HDR_SIZE..end].to_vec(), + }) +} + +pub async fn read(stream: &mut TcpStream) -> Result { + let mut buf = [0u8; HDR_SIZE]; + stream.read_exact(&mut buf).await?; + let (packet_id, _, _, payload_length) = parse_header(&buf)?; + let mut payload = vec![0u8; payload_length as usize]; + if !payload.is_empty() { + stream.read_exact(&mut payload).await?; + } + Ok(RawPacket { packet_id, payload }) +} + +pub async fn write(stream: &mut TcpStream, id: u16, seq: &mut u32, payload: &[u8]) -> Result<()> { + let frame = encode_frame(id, seq, payload); + stream.write_all(&frame).await?; + Ok(()) +} + +pub mod io; + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::payloads::PID_PING; + + #[test] + fn encode_decode_roundtrip() { + let payload = br#"{"ping":true}"#; + let mut seq = 7u32; + let frame = encode_frame(PID_PING, &mut seq, payload); + assert_eq!(seq, 8); + let pkt = decode_frame(&frame).unwrap(); + assert_eq!(pkt.packet_id, PID_PING); + assert_eq!(pkt.payload, payload); + } + + #[test] + fn decode_rejects_oversized_length() { + let mut hdr = [0u8; HDR_SIZE]; + hdr[0..2].copy_from_slice(&PID_PING.to_be_bytes()); + hdr[8..12].copy_from_slice(&(MAX_PAYLOAD + 1).to_be_bytes()); + assert!(decode_frame(&hdr).is_err()); + } + + #[test] + fn decode_rejects_truncated_body() { + let mut seq = 0u32; + let frame = encode_frame(PID_PING, &mut seq, b"x"); + let truncated = &frame[..frame.len() - 1]; + assert!(decode_frame(truncated).is_err()); + } +} diff --git a/src/net/handshake.rs b/src/net/handshake.rs new file mode 100644 index 0000000..fb00081 --- /dev/null +++ b/src/net/handshake.rs @@ -0,0 +1,78 @@ +use anyhow::{Result, anyhow}; +use tokio::net::TcpStream; +use tracing::{debug, info}; + +use super::crypto::SessionCrypto; +use super::framing; +use super::payloads::*; +use crate::identity::Identity; + +/// Perform HELLO → AUTH → SESSION with X25519 ECDH key exchange. +/// +/// Returns (session_id, node_name, private_mode, crypto). +/// All subsequent packets must be encrypted with `crypto`. +pub async fn run( + stream: &mut TcpStream, + identity: &Identity, + seq: &mut u32, +) -> Result<(String, String, bool, SessionCrypto)> { + // HELLO — receive server's ephemeral X25519 public key + let pkt = framing::read(stream).await?; + if pkt.packet_id != PID_HELLO { + return Err(anyhow!("expected HELLO, got 0x{:04X}", pkt.packet_id)); + } + let hello: HelloPayload = serde_json::from_slice(&pkt.payload)?; + debug!("← HELLO node={}", hello.node_name); + + // Generate our own ephemeral X25519 keypair + let (eph_sk, eph_pk) = SessionCrypto::new_ephemeral(); + let eph_pk_hex = hex::encode(eph_pk.as_bytes()); + + // Parse server's ephemeral key from HELLO + let server_eph_raw = + hex::decode(&hello.server_eph_pubkey).map_err(|_| anyhow!("bad server_eph_pubkey hex"))?; + let server_eph_key: [u8; 32] = server_eph_raw + .try_into() + .map_err(|_| anyhow!("bad server_eph_pubkey length"))?; + let server_eph_pk = x25519_dalek::PublicKey::from(server_eph_key); + + // AUTH — include our ephemeral public key + let nonce = hex::decode(&hello.challenge_nonce)?; + let sk = identity + .signing_key() + .ok_or_else(|| anyhow!("no signing key"))?; + use ed25519_dalek::Signer; + let sig = sk.sign(&nonce); + + let auth = AuthPayload { + client_pubkey: identity.pubkey_hex.clone(), + nickname: identity.nickname.clone(), + lnex_version: LNEX_VERSION.into(), + signature: hex::encode(sig.to_bytes()), + client_eph_pubkey: eph_pk_hex, + }; + framing::write(stream, PID_AUTH, seq, &serde_json::to_vec(&auth)?).await?; + debug!("→ AUTH nick={}", identity.nickname); + + // SESSION + let pkt = framing::read(stream).await?; + if pkt.packet_id == PID_ERROR { + let e: ErrorPayload = serde_json::from_slice(&pkt.payload)?; + return Err(anyhow!("auth rejected: {}", e.message)); + } + if pkt.packet_id != PID_SESSION { + return Err(anyhow!("expected SESSION, got 0x{:04X}", pkt.packet_id)); + } + let sess: SessionPayload = serde_json::from_slice(&pkt.payload)?; + + // Derive encryption keys from ECDH shared secret + let shared_secret = SessionCrypto::ecdh(eph_sk, &server_eph_pk); + let crypto = SessionCrypto::derive(shared_secret.as_bytes(), &sess.session_id); + + info!( + "authenticated — session {} encrypted", + &sess.session_id[..8] + ); + + Ok((sess.session_id, hello.node_name, hello.private_mode, crypto)) +} diff --git a/src/net/internal/dispatch/channel.rs b/src/net/internal/dispatch/channel.rs new file mode 100644 index 0000000..2cf8f30 --- /dev/null +++ b/src/net/internal/dispatch/channel.rs @@ -0,0 +1,162 @@ +use anyhow::{Result, anyhow}; +use serde_json; +use tokio::sync::mpsc; + +use super::super::super::payloads::*; +use super::super::super::types::NetEvent; +use super::super::super::types::{ChannelListItem as InfoChannelListItem, ChatMsg, MemberInfo}; + +pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender) -> Result<()> { + match pid { + PID_CHANNEL_STATE => { + let p: ChannelStatePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ChannelState { + channel_id: p.channel_id, + channel_name: p.channel_name, + kind: p.kind, + members: p + .members + .into_iter() + .map(|m| MemberInfo { + user_id: m.user_id, + nickname: m.nickname, + in_voice: m.in_voice, + }) + .collect(), + voice_endpoint: p.voice_endpoint, + }) + .await; + } + PID_CHANNEL_CREATE => { + let p: ChannelCreatePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ChannelCreated { + channel_id: p.channel_id, + channel_name: p.channel_name, + kind: p.kind, + }) + .await; + } + PID_CHANNEL_DELETE => { + let p: ChannelDeletePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ChannelDeleted { + channel_id: p.channel_id, + }) + .await; + } + PID_CHANNEL_LIST => { + let p: ChannelListPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ChannelListEvent { + channels: p + .channels + .into_iter() + .map(|c| InfoChannelListItem { + channel_id: c.channel_id, + channel_name: c.channel_name, + kind: c.kind, + }) + .collect(), + }) + .await; + } + PID_CHAT_MESSAGE => { + let p: ChatMessagePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ChatMessage { + message_id: p.message_id, + channel_id: p.channel_id, + sender_id: p.sender_id.clone(), + nickname: p.sender_id, + content: p.content, + timestamp: p.timestamp, + reply_to: p.reply_to, + }) + .await; + } + PID_CHAT_HISTORY => { + let p: ChatHistoryPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ChatHistory { + channel_id: p.channel_id, + messages: p + .messages + .into_iter() + .map(|m| ChatMsg { + message_id: m.message_id, + sender_id: m.sender_id, + content: m.content, + timestamp: m.timestamp, + reply_to: m.reply_to, + }) + .collect(), + }) + .await; + } + PID_USER_JOIN => { + let p: UserJoinPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::UserJoin { + channel_id: p.channel_id, + user_id: p.user_id, + nickname: p.nickname, + }) + .await; + } + PID_USER_LEAVE => { + let p: UserLeavePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::UserLeave { + channel_id: p.channel_id, + user_id: p.user_id, + }) + .await; + } + PID_PONG => { + let p: PongPayload = serde_json::from_slice(payload)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let rtt_ms = (now - p.timestamp).max(0) as u32; + let _ = tx.send(NetEvent::LatencyUpdate { rtt_ms }).await; + } + PID_READ_RECEIPT_BROADCAST => { + let p: serde_json::Value = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ReadReceiptBroadcast { + channel_id: p["channel_id"].as_str().unwrap_or_default().into(), + user_id: p["user_id"].as_str().unwrap_or_default().into(), + last_read_message_id: p["last_read_message_id"] + .as_str() + .unwrap_or_default() + .into(), + }) + .await; + } + PID_ERROR => { + let p: ErrorPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::Error { + code: p.code, + message: p.message, + }) + .await; + } + PID_DISCONNECT => return Err(anyhow!("server disconnected")), + PID_TYPING_START => { + let p: serde_json::Value = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::TypingStart { + user_id: p["user_id"].as_str().unwrap_or_default().into(), + nickname: p["nickname"].as_str().unwrap_or_default().into(), + channel_id: p["channel_id"].as_str().unwrap_or_default().into(), + }) + .await; + } + _ => {} + } + Ok(()) +} diff --git a/src/net/internal/dispatch/dm.rs b/src/net/internal/dispatch/dm.rs new file mode 100644 index 0000000..127f709 --- /dev/null +++ b/src/net/internal/dispatch/dm.rs @@ -0,0 +1,152 @@ +use anyhow::Result; +use serde_json; +use tokio::sync::mpsc; +use tracing::debug; + +use super::super::super::payloads::*; +use super::super::super::types::*; + +pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender) -> Result<()> { + match pid { + PID_DM_START => { + let p: DmStartResponsePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::DmStart { + dm_id: p.dm_id, + other_user_id: p.other_user_id, + other_nickname: p.other_nickname, + messages: p + .messages + .into_iter() + .map(|m| ChatMsg { + message_id: String::new(), + sender_id: m.sender_id, + content: m.content, + timestamp: m.timestamp, + reply_to: None, + }) + .collect(), + unread_count: p.unread_count, + }) + .await; + } + PID_DM_MESSAGE => { + let p: DmMessagePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::DmMessage { + dm_id: p.dm_id, + sender_id: p.sender_id, + content: p.content, + timestamp: p.timestamp, + }) + .await; + } + PID_DM_HISTORY => { + let p: DmHistoryPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::DmHistory { + dm_id: p.dm_id, + messages: p + .messages + .into_iter() + .map(|m| ChatMsg { + message_id: String::new(), + sender_id: m.sender_id, + content: m.content, + timestamp: m.timestamp, + reply_to: None, + }) + .collect(), + }) + .await; + } + PID_FRIEND_LIST => { + let p: FriendListPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::FriendList { + friends: p + .friends + .into_iter() + .map(|f| FriendInfo { + user_id: f.user_id, + nickname: f.nickname, + status: f.status, + since: f.since, + }) + .collect(), + }) + .await; + } + PID_MESSAGE_REACTION_ADD => { + let p: ReactionPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ReactionAdded { + channel_id: p.channel_id, + message_id: p.message_id, + user_id: p.user_id, + emoji: p.emoji, + }) + .await; + } + PID_MESSAGE_REACTION_REMOVE => { + let p: ReactionPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::ReactionRemoved { + channel_id: p.channel_id, + message_id: p.message_id, + user_id: p.user_id, + emoji: p.emoji, + }) + .await; + } + PID_MESSAGE_EDIT => { + let p: ChatMessagePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::MessageEdited { + channel_id: p.channel_id, + message_id: p.message_id, + content: p.content, + edited: p.edited, + }) + .await; + } + PID_MESSAGE_DELETE => { + let p: MessageDeletePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::MessageDeleted { + channel_id: p.channel_id, + message_id: p.message_id, + }) + .await; + } + PID_FRIEND_EVENT => { + let p: FriendEventPayload = serde_json::from_slice(payload)?; + match p.event_type.as_str() { + "request" => { + let _ = tx + .send(NetEvent::FriendRequested { + user_id: p.user_id, + nickname: p.nickname.unwrap_or_default(), + }) + .await; + } + "accepted" => { + let _ = tx + .send(NetEvent::FriendAccepted { + user_id: p.user_id, + nickname: p.nickname.unwrap_or_default(), + }) + .await; + } + "removed" => { + let _ = tx + .send(NetEvent::FriendRemoved { user_id: p.user_id }) + .await; + } + _ => debug!("unhandled friend event: {}", p.event_type), + } + } + _ => {} + } + Ok(()) +} diff --git a/src/net/internal/dispatch/mod.rs b/src/net/internal/dispatch/mod.rs new file mode 100644 index 0000000..2ebd8f2 --- /dev/null +++ b/src/net/internal/dispatch/mod.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use tokio::sync::mpsc; +use tracing::debug; + +use super::super::payloads::*; +use super::super::types::*; + +mod channel; +mod dm; +mod social; + +pub async fn incoming(pid: u16, payload: &[u8], tx: &mpsc::Sender) -> Result<()> { + match pid { + PID_CHANNEL_STATE + | PID_CHAT_MESSAGE + | PID_CHAT_HISTORY + | PID_USER_JOIN + | PID_USER_LEAVE + | PID_PONG + | PID_READ_RECEIPT_BROADCAST + | PID_ERROR + | PID_DISCONNECT + | PID_TYPING_START => channel::handle(pid, payload, tx).await?, + + PID_DM_START + | PID_DM_MESSAGE + | PID_DM_HISTORY + | PID_FRIEND_LIST + | PID_MESSAGE_REACTION_ADD + | PID_MESSAGE_REACTION_REMOVE + | PID_MESSAGE_EDIT + | PID_MESSAGE_DELETE + | PID_FRIEND_EVENT => dm::handle(pid, payload, tx).await?, + + PID_GUILD_LIST + | PID_GUILD_CREATE + | PID_GUILD_DELETE + | PID_GUILD_MEMBER_JOIN + | PID_GUILD_MEMBER_LEAVE + | PID_GUILD_MEMBER_KICK + | PID_INVITE_CREATE + | PID_INVITE_ACCEPT + | PID_INVITE_DELETE + | PID_ROLE_CREATE + | PID_ROLE_DELETE + | PID_USER_ROLE_UPDATE + | PID_PRESENCE_SYNC + | PID_PRESENCE_EVENT => social::handle(pid, payload, tx).await?, + + other => debug!("unhandled 0x{:04X}", other), + } + Ok(()) +} diff --git a/src/net/internal/dispatch/social.rs b/src/net/internal/dispatch/social.rs new file mode 100644 index 0000000..0af184e --- /dev/null +++ b/src/net/internal/dispatch/social.rs @@ -0,0 +1,232 @@ +use anyhow::Result; +use serde_json; +use tokio::sync::mpsc; + +use super::super::super::payloads::*; +use super::super::super::types::*; + +pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender) -> Result<()> { + match pid { + PID_GUILD_LIST => { + let p: GuildListPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildList { + guilds: p + .guilds + .into_iter() + .map(|g| GuildInfo { + guild_id: g.guild_id, + name: g.name, + owner_id: g.owner_id, + icon_url: g.icon_url, + member_count: g.member_count, + }) + .collect(), + }) + .await; + } + PID_GUILD_CREATE => { + let p: GuildInfoPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildCreated { + guild_id: p.guild_id, + name: p.name, + owner_id: p.owner_id, + }) + .await; + } + PID_GUILD_DELETE => { + let p: GuildDeletePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildDeleted { + guild_id: p.guild_id, + }) + .await; + } + PID_GUILD_MEMBER_JOIN => { + let p: serde_json::Value = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildMemberJoined { + guild_id: p["guild_id"].as_str().unwrap_or_default().into(), + user_id: p["user_id"].as_str().unwrap_or_default().into(), + nickname: p["nickname"].as_str().unwrap_or_default().into(), + }) + .await; + } + PID_GUILD_MEMBER_LEAVE => { + let p: serde_json::Value = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildMemberLeft { + guild_id: p["guild_id"].as_str().unwrap_or_default().into(), + user_id: p["user_id"].as_str().unwrap_or_default().into(), + }) + .await; + } + PID_USER_ROLE_UPDATE => { + let p: UserRoleUpdatePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::UserColor { + user_id: p.user_id, + color: p.color, + }) + .await; + } + PID_PRESENCE_SYNC => { + let p: PresenceSyncPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::PresenceSync { + presences: p + .presences + .into_iter() + .map(|p| PresenceInfo { + user_id: p.user_id, + status: p.status, + custom_status: p.custom_status, + activity: p.activity, + activity_text: p.activity_text, + }) + .collect(), + }) + .await; + } + PID_PRESENCE_EVENT => { + let p: PresenceEventPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::PresenceUpdated { + user_id: p.user_id, + status: p.status, + custom_status: p.custom_status, + activity: p.activity, + }) + .await; + } + PID_GUILD_MEMBER_KICK => { + let p: serde_json::Value = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildMemberKicked { + guild_id: p["guild_id"].as_str().unwrap_or_default().into(), + user_id: p["user_id"].as_str().unwrap_or_default().into(), + }) + .await; + } + PID_INVITE_CREATE => { + let p: InviteInfoPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::InviteCreated { + id: p.id, + guild_id: p.guild_id, + guild_name: p.guild_name, + code: p.code, + creator_id: p.creator_id, + max_uses: p.max_uses, + uses: p.uses, + expires_at: p.expires_at, + created_at: p.created_at, + }) + .await; + } + PID_INVITE_ACCEPT => { + let p: serde_json::Value = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::InviteAccepted { + guild_id: p["guild_id"].as_str().unwrap_or_default().into(), + guild_name: p["guild_name"].as_str().unwrap_or_default().into(), + }) + .await; + } + PID_INVITE_DELETE => { + let p: InviteDeletePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::InviteDeleted { + guild_id: p.guild_id, + invite_id: p.invite_id, + }) + .await; + } + PID_ROLE_CREATE => { + let p: serde_json::Value = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::RoleCreated { + role_id: p["id"].as_str().unwrap_or_default().into(), + guild_id: p["guild_id"].as_str().unwrap_or_default().into(), + name: p["name"].as_str().unwrap_or_default().into(), + }) + .await; + } + PID_ROLE_DELETE => { + let p: RoleDeletePayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::RoleDeleted { + role_id: p.role_id, + guild_id: p.guild_id, + }) + .await; + } + PID_GUILD_AUDIT_LOG => { + let p: GuildAuditLogPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildAuditLog { + guild_id: p.guild_id, + entries: p.entries, + }) + .await; + } + PID_GUILD_MEMBER_LIST => { + let p: GuildMemberListPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildMemberList { + guild_id: p.guild_id, + members: p.members, + }) + .await; + } + PID_GUILD_ROLE_LIST => { + let p: GuildRoleListPayload = serde_json::from_slice(payload)?; + let _ = tx + .send(NetEvent::GuildRoleList { + guild_id: p.guild_id, + roles: p.roles, + }) + .await; + } + PID_BLOCK_LIST => { + // Server may send {"blocked": [...]} — try to parse properly first, + // fall back to JSON value extraction. + let blocked: Vec = match serde_json::from_slice::(payload) { + Ok(p) => p.blocked, + Err(_) => { + let v: serde_json::Value = serde_json::from_slice(payload)?; + v["blocked"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } + }; + let _ = tx.send(NetEvent::BlockList { blocked }).await; + } + PID_BLOCK_USER => { + let v: serde_json::Value = serde_json::from_slice(payload).unwrap_or_default(); + let user_id = v["user_id"] + .as_str() + .or_else(|| v["blocked"].as_str()) + .unwrap_or_default() + .to_string(); + let _ = tx.send(NetEvent::BlockedUser { user_id }).await; + } + PID_UNBLOCK_USER => { + let v: serde_json::Value = serde_json::from_slice(payload).unwrap_or_default(); + let user_id = v["user_id"] + .as_str() + .or_else(|| v["unblocked"].as_str()) + .unwrap_or_default() + .to_string(); + let _ = tx.send(NetEvent::UnblockedUser { user_id }).await; + } + _ => {} + } + Ok(()) +} diff --git a/src/net/internal/mod.rs b/src/net/internal/mod.rs new file mode 100644 index 0000000..65e23b2 --- /dev/null +++ b/src/net/internal/mod.rs @@ -0,0 +1 @@ +pub(super) mod dispatch; diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..45c4858 --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,43 @@ +pub mod crypto; +pub mod framing; +pub mod handshake; +mod internal; +pub mod payloads; +mod session; +mod types; +pub mod voice; + +pub use types::{ChatMsg, FriendInfo, GuildInfo, MemberInfo, NetCommand, NetEvent, PresenceInfo}; + +use std::sync::Arc; +use std::sync::Mutex; +use tokio::sync::mpsc; + +use crate::identity::Identity; + +// ─── Handle ────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct NetHandle { + cmd_tx: mpsc::Sender, + event_rx: Arc>>, +} + +impl NetHandle { + pub async fn send(&self, cmd: NetCommand) { + let _ = self.cmd_tx.send(cmd).await; + } + pub fn try_recv(&self) -> Option { + self.event_rx.try_lock().ok()?.try_recv().ok() + } +} + +pub fn spawn(identity: Identity) -> NetHandle { + let (cmd_tx, cmd_rx) = mpsc::channel::(64); + let (event_tx, event_rx) = mpsc::channel::(256); + tokio::spawn(session::net_task(identity, cmd_rx, event_tx)); + NetHandle { + cmd_tx, + event_rx: Arc::new(Mutex::new(event_rx)), + } +} diff --git a/src/net/payloads/channel.rs b/src/net/payloads/channel.rs new file mode 100644 index 0000000..201da86 --- /dev/null +++ b/src/net/payloads/channel.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct HelloPayload { + pub lnex_version: String, + pub server_pubkey: String, + pub challenge_nonce: String, + pub node_name: String, + pub server_eph_pubkey: String, + #[serde(default)] + pub private_mode: bool, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthPayload { + pub client_pubkey: String, + pub nickname: String, + pub lnex_version: String, + pub signature: String, + pub client_eph_pubkey: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SessionPayload { + pub session_id: String, + pub token: String, + pub expires_at: i64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct PingPayload { + pub timestamp: i64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct PongPayload { + pub timestamp: i64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct JoinChannelPayload { + pub channel_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct LeaveChannelPayload { + pub channel_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ChannelStatePayload { + pub channel_id: String, + pub channel_name: String, + pub kind: String, + pub members: Vec, + pub voice_endpoint: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ChannelCreatePayload { + pub channel_id: String, + pub channel_name: String, + /// "text" or "voice". + pub kind: String, + #[serde(default)] + pub guild_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ChannelDeletePayload { + pub channel_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ChannelListPayload { + pub channels: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ChannelListItem { + pub channel_id: String, + pub channel_name: String, + pub kind: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct WireMember { + pub user_id: String, + pub nickname: String, + pub in_voice: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ChatMessagePayload { + pub message_id: String, + pub channel_id: String, + pub sender_id: String, + pub content: String, + pub timestamp: i64, + #[serde(default)] + pub edited: bool, + /// Optional message_id this message is replying to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_to: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ChatHistoryPayload { + pub channel_id: String, + pub messages: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ReactionPayload { + pub message_id: String, + pub channel_id: String, + pub emoji: String, + #[serde(default)] + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MessageEditPayload { + pub message_id: String, + pub channel_id: String, + pub content: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MessageDeletePayload { + pub message_id: String, + pub channel_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UserJoinPayload { + pub channel_id: String, + pub user_id: String, + pub nickname: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UserLeavePayload { + pub channel_id: String, + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ErrorPayload { + pub code: u32, + pub message: String, +} diff --git a/src/net/payloads/dm.rs b/src/net/payloads/dm.rs new file mode 100644 index 0000000..16a7c48 --- /dev/null +++ b/src/net/payloads/dm.rs @@ -0,0 +1,133 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct DmStartPayload { + pub target_user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DmStartResponsePayload { + pub dm_id: String, + pub other_user_id: String, + pub other_nickname: String, + pub messages: Vec, + #[serde(default)] + pub unread_count: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DmMessagePayload { + pub dm_id: String, + pub sender_id: String, + pub content: String, + pub timestamp: i64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct DmHistoryPayload { + pub dm_id: String, + #[serde(default)] + pub messages: Vec, + #[serde(default)] + pub search_query: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct PresenceUpdatePayload { + pub status: String, + /// Free-form activity type ("playing", "listening", "watching", "streaming"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity_type: Option, + /// Free-form activity text (e.g. "CS2 ranked"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity_text: Option, + /// Free-form custom status text (Discord-style "what's on your mind?"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PresenceInfoPayload { + pub user_id: String, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity_text: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct PresenceSyncPayload { + pub presences: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct PresenceEventPayload { + pub user_id: String, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activity_text: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FriendRequestPayload { + pub target_user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FriendAcceptPayload { + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FriendDeclinePayload { + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FriendRemovePayload { + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FriendInfoPayload { + pub user_id: String, + pub nickname: String, + pub status: String, + pub since: i64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FriendListPayload { + pub friends: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct BlockUserPayload { + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UnblockUserPayload { + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct BlockListPayload { + pub blocked: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FriendEventPayload { + pub event_type: String, + pub user_id: String, + pub nickname: Option, +} diff --git a/src/net/payloads/guild.rs b/src/net/payloads/guild.rs new file mode 100644 index 0000000..d59aee4 --- /dev/null +++ b/src/net/payloads/guild.rs @@ -0,0 +1,176 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildCreatePayload { + pub name: String, + pub icon_url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuildInfoPayload { + pub guild_id: String, + pub name: String, + pub owner_id: String, + pub icon_url: Option, + pub member_count: u32, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildListPayload { + pub guilds: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildDeletePayload { + pub guild_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildJoinPayload { + pub guild_id: String, + pub invite_code: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildLeavePayload { + pub guild_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UserRoleUpdatePayload { + pub user_id: String, + pub guild_id: String, + pub color: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct InviteCreatePayload { + pub guild_id: String, + pub max_uses: Option, + pub expires_in_seconds: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct InviteAcceptPayload { + pub code: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct InviteDeletePayload { + pub guild_id: String, + pub invite_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RoleCreatePayload { + pub guild_id: String, + pub name: String, + pub color: Option, + pub permissions: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RoleDeletePayload { + pub guild_id: String, + pub role_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildMemberKickPayload { + pub guild_id: String, + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct InviteInfoPayload { + pub id: String, + pub guild_id: String, + pub guild_name: String, + pub code: String, + pub creator_id: String, + pub max_uses: Option, + pub uses: i64, + pub expires_at: Option, + pub created_at: i64, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildAuditLogFetchPayload { + pub guild_id: String, + #[serde(default = "default_audit_limit")] + pub limit: i64, +} + +fn default_audit_limit() -> i64 { + 50 +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuditLogEntryPayload { + pub id: String, + pub guild_id: String, + pub actor_id: String, + pub action: String, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub target_type: Option, + #[serde(default)] + pub reason: Option, + pub created_at: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuildAuditLogPayload { + pub guild_id: String, + pub entries: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildMemberListFetchPayload { + pub guild_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuildMemberInfoPayload { + pub user_id: String, + pub nickname: String, + pub joined_at: i64, + pub role_color: String, + pub role_name: String, + pub is_owner: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuildMemberListPayload { + pub guild_id: String, + pub members: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RoleAssignPayload { + pub guild_id: String, + pub user_id: String, + pub role_id: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GuildRoleListFetchPayload { + pub guild_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuildRoleInfoPayload { + pub id: String, + pub guild_id: String, + pub name: String, + pub color: String, + pub permissions: u64, + pub position: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuildRoleListPayload { + pub guild_id: String, + pub roles: Vec, +} diff --git a/src/net/payloads/mod.rs b/src/net/payloads/mod.rs new file mode 100644 index 0000000..56d82f2 --- /dev/null +++ b/src/net/payloads/mod.rs @@ -0,0 +1,76 @@ +/// Wire payload types and packet ID constants. +pub const PID_HELLO: u16 = 0x0001; +pub const PID_AUTH: u16 = 0x0002; +pub const PID_SESSION: u16 = 0x0003; +pub const PID_PING: u16 = 0x0004; +pub const PID_PONG: u16 = 0x0005; +pub const PID_VOICE_PACKET: u16 = 0x0010; +pub const PID_CHAT_MESSAGE: u16 = 0x0020; +pub const PID_CHAT_HISTORY: u16 = 0x0021; +pub const PID_JOIN_CHANNEL: u16 = 0x0030; +pub const PID_LEAVE_CHANNEL: u16 = 0x0031; +pub const PID_CHANNEL_STATE: u16 = 0x0032; +pub const PID_CHANNEL_CREATE: u16 = 0x0033; +pub const PID_CHANNEL_DELETE: u16 = 0x0034; +pub const PID_CHANNEL_LIST: u16 = 0x0035; +pub const PID_USER_JOIN: u16 = 0x0040; +pub const PID_USER_LEAVE: u16 = 0x0041; +pub const PID_DM_START: u16 = 0x0060; +pub const PID_DM_MESSAGE: u16 = 0x0061; +pub const PID_DM_HISTORY: u16 = 0x0062; +pub const PID_DM_READ_ACK: u16 = 0x0063; +pub const PID_MESSAGE_REACTION_ADD: u16 = 0x0066; +pub const PID_MESSAGE_REACTION_REMOVE: u16 = 0x0067; +pub const PID_MESSAGE_EDIT: u16 = 0x0068; +pub const PID_MESSAGE_DELETE: u16 = 0x0069; +pub const PID_READ_RECEIPT: u16 = 0x0064; +pub const PID_READ_RECEIPT_BROADCAST: u16 = 0x0065; +pub const PID_TYPING_START: u16 = 0x0070; +pub const PID_GUILD_CREATE: u16 = 0x0100; +pub const PID_GUILD_DELETE: u16 = 0x0101; +pub const PID_GUILD_LIST: u16 = 0x0102; +pub const PID_GUILD_MEMBER_JOIN: u16 = 0x0103; +pub const PID_GUILD_MEMBER_LEAVE: u16 = 0x0104; +pub const PID_GUILD_MEMBER_KICK: u16 = 0x0105; +pub const PID_ROLE_CREATE: u16 = 0x0106; +pub const PID_ROLE_DELETE: u16 = 0x0107; +pub const PID_INVITE_CREATE: u16 = 0x0108; +pub const PID_INVITE_ACCEPT: u16 = 0x0109; +pub const PID_INVITE_DELETE: u16 = 0x010A; +pub const PID_USER_ROLE_UPDATE: u16 = 0x010B; +pub const PID_GUILD_AUDIT_LOG_FETCH: u16 = 0x010C; +pub const PID_GUILD_AUDIT_LOG: u16 = 0x010D; +pub const PID_GUILD_MEMBER_LIST_FETCH: u16 = 0x010E; +pub const PID_GUILD_MEMBER_LIST: u16 = 0x010F; +pub const PID_GUILD_ROLE_ASSIGN: u16 = 0x0110; +pub const PID_GUILD_ROLE_UNASSIGN: u16 = 0x0111; +pub const PID_GUILD_ROLE_LIST_FETCH: u16 = 0x0112; +pub const PID_GUILD_ROLE_LIST: u16 = 0x0113; +pub const PID_PERMISSION_CHECK: u16 = 0x0050; +pub const PID_PERMISSION_DENY: u16 = 0x0051; +pub const PID_PRESENCE_UPDATE: u16 = 0x0140; +pub const PID_PRESENCE_SYNC: u16 = 0x0141; +pub const PID_PRESENCE_EVENT: u16 = 0x0142; +pub const PID_FRIEND_REQUEST: u16 = 0x0150; +pub const PID_FRIEND_ACCEPT: u16 = 0x0151; +pub const PID_FRIEND_DECLINE: u16 = 0x0152; +pub const PID_FRIEND_REMOVE: u16 = 0x0153; +pub const PID_FRIEND_LIST: u16 = 0x0154; +pub const PID_BLOCK_USER: u16 = 0x0155; +pub const PID_UNBLOCK_USER: u16 = 0x0156; +pub const PID_BLOCK_LIST: u16 = 0x0157; +pub const PID_FRIEND_EVENT: u16 = 0x0158; +pub const PID_ERROR: u16 = 0x00F0; +pub const PID_DISCONNECT: u16 = 0x00FF; + +pub const LNEX_VERSION: &str = "v1"; +pub const HDR_SIZE: usize = 12; +pub const MAX_PAYLOAD: u32 = 4 * 1024 * 1024; + +mod channel; +mod dm; +mod guild; + +pub use channel::*; +pub use dm::*; +pub use guild::*; diff --git a/src/net/session/handlers/core.rs b/src/net/session/handlers/core.rs new file mode 100644 index 0000000..40eab47 --- /dev/null +++ b/src/net/session/handlers/core.rs @@ -0,0 +1,167 @@ +use std::time::Duration; + +use anyhow::Result; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::time::{MissedTickBehavior, interval}; +use tracing::warn; + +use crate::identity::Identity; +use crate::net::{ + framing, handshake, internal, + payloads::*, + types::{NetCommand, NetEvent}, +}; +use serde_json; + +use super::super::switch::ReconnectRequested; +use super::{VoiceState, handle_cmd}; + +pub async fn session_loop( + identity: &Identity, + address: &str, + channels: &mut Vec, + restored: bool, + cmd_rx: &mut mpsc::Receiver, + event_tx: &mpsc::Sender, +) -> Result<()> { + let mut stream = TcpStream::connect(address).await?; + let mut seq = 0u32; + + let (session_id, node_name, private_mode, crypto) = + handshake::run(&mut stream, identity, &mut seq).await?; + let _ = event_tx + .send(NetEvent::Connected { + session_id: session_id.clone(), + node_name, + restored, + private_mode, + }) + .await; + + for channel_id in channels.clone() { + framing::io::write_encrypted( + &mut stream, + PID_JOIN_CHANNEL, + &mut seq, + &serde_json::to_vec(&JoinChannelPayload { channel_id })?, + &crypto, + ) + .await?; + } + + let mut voice_state = VoiceState::new(&crypto, event_tx).await?; + let mut voice_endpoint: Option = None; + let mut ping_tick = interval(Duration::from_secs(2)); + ping_tick.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = ping_tick.tick() => { + let ts = now_ms(); + let payload = serde_json::to_vec(&PingPayload { timestamp: ts })?; + framing::io::write_encrypted(&mut stream, PID_PING, &mut seq, &payload, &crypto).await?; + } + result = framing::io::read_encrypted(&mut stream, &crypto) => { + let pkt = result?; + if pkt.packet_id == PID_CHANNEL_STATE + && let Ok(p) = serde_json::from_slice::(&pkt.payload) + { + voice_endpoint = if p.voice_endpoint.is_empty() { + None + } else { + p.voice_endpoint.parse().ok() + }; + } + internal::dispatch::incoming(pkt.packet_id, &pkt.payload, event_tx).await?; + } + cmd = cmd_rx.recv() => { + match cmd { + None | Some(NetCommand::Disconnect) => { + let _ = framing::io::write_encrypted(&mut stream, PID_DISCONNECT, &mut seq, + &serde_json::to_vec(&serde_json::json!({"reason":"bye"})).unwrap(), &crypto).await; + return Ok(()); + } + Some(NetCommand::Connect { address, auto_reconnect }) => { + warn!("connect while connected to {address}, reconnecting"); + if channels.is_empty() { + *channels = vec!["general".into()]; + } + return Err(anyhow::Error::new(ReconnectRequested { + address, + auto_reconnect, + })); + } + Some(NetCommand::JoinChannel { channel_id }) => { + track_channel(channels, &channel_id); + framing::io::write_encrypted(&mut stream, PID_JOIN_CHANNEL, &mut seq, + &serde_json::to_vec(&JoinChannelPayload { channel_id })?, &crypto).await?; + } + Some(NetCommand::LeaveChannel { channel_id }) => { + channels.retain(|c| c != &channel_id); + framing::io::write_encrypted(&mut stream, PID_LEAVE_CHANNEL, &mut seq, + &serde_json::to_vec(&LeaveChannelPayload { channel_id })?, &crypto).await?; + } + Some(NetCommand::ChannelCreate { channel_id, channel_name, kind }) => { + let payload = serde_json::to_vec(&ChannelCreatePayload { + channel_id, + channel_name, + kind, + guild_id: None, + })?; + framing::io::write_encrypted(&mut stream, PID_CHANNEL_CREATE, &mut seq, &payload, &crypto).await?; + } + Some(NetCommand::ChannelDelete { channel_id }) => { + let payload = serde_json::to_vec(&ChannelDeletePayload { channel_id })?; + framing::io::write_encrypted(&mut stream, PID_CHANNEL_DELETE, &mut seq, &payload, &crypto).await?; + } + Some(NetCommand::ChannelList) => { + framing::io::write_encrypted(&mut stream, PID_CHANNEL_LIST, &mut seq, b"{}", &crypto).await?; + } + Some(NetCommand::SendChat { channel_id, content, reply_to }) => { + let msg = ChatMessagePayload { + message_id: uuid_v4(), channel_id, + sender_id: identity.pubkey_hex.clone(), + content, timestamp: now_ms(), + edited: false, + reply_to, + }; + framing::io::write_encrypted(&mut stream, PID_CHAT_MESSAGE, &mut seq, &serde_json::to_vec(&msg)?, &crypto).await?; + } + Some(NetCommand::SendVoice { channel_id, voice_seq: voice_pkt_seq, timestamp, opus_data }) => { + let sender_pubkey = identity.verifying_key() + .map(|k| k.to_bytes()) + .unwrap_or([0u8; 32]); + voice_state.send_voice(channel_id, voice_pkt_seq, timestamp, &opus_data, &crypto, voice_endpoint, &sender_pubkey).await?; + } + Some(NetCommand::ReadReceipt { channel_id, last_read_message_id }) => { + let payload = serde_json::to_vec(&serde_json::json!({ + "channel_id": channel_id, + "last_read_message_id": last_read_message_id, + "user_id": identity.pubkey_hex.clone(), + }))?; + framing::io::write_encrypted(&mut stream, PID_READ_RECEIPT, &mut seq, &payload, &crypto).await?; + } + Some(other) => handle_cmd(other, &mut stream, &mut seq, &crypto, identity).await?, + } + } + } + } +} + +pub(super) fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +pub(super) fn uuid_v4() -> String { + uuid::Uuid::new_v4().to_string() +} + +fn track_channel(channels: &mut Vec, channel_id: &str) { + if !channels.iter().any(|c| c == channel_id) { + channels.push(channel_id.to_string()); + } +} diff --git a/src/net/session/handlers/mod.rs b/src/net/session/handlers/mod.rs new file mode 100644 index 0000000..ee32ce3 --- /dev/null +++ b/src/net/session/handlers/mod.rs @@ -0,0 +1,7 @@ +mod core; +mod social; +mod voice; + +pub use core::session_loop; +pub use social::handle_cmd; +pub use voice::State as VoiceState; diff --git a/src/net/session/handlers/social/friends.rs b/src/net/session/handlers/social/friends.rs new file mode 100644 index 0000000..480c8fc --- /dev/null +++ b/src/net/session/handlers/social/friends.rs @@ -0,0 +1,62 @@ +use anyhow::Result; +use serde_json; +use tokio::net::TcpStream; + +use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand}; + +pub async fn handle_cmd( + cmd: NetCommand, + stream: &mut TcpStream, + seq: &mut u32, + crypto: &SessionCrypto, +) -> Result<()> { + match cmd { + NetCommand::PresenceUpdate { + status, + custom_status, + activity, + activity_text, + } => { + let payload = serde_json::to_vec(&PresenceUpdatePayload { + status, + activity_type: activity, + activity_text, + custom_status, + })?; + framing::io::write_encrypted(stream, PID_PRESENCE_UPDATE, seq, &payload, crypto) + .await?; + } + NetCommand::FriendRequest { target_user_id } => { + let payload = serde_json::to_vec(&FriendRequestPayload { target_user_id })?; + framing::io::write_encrypted(stream, PID_FRIEND_REQUEST, seq, &payload, crypto).await?; + } + NetCommand::FriendAccept { user_id } => { + let payload = serde_json::to_vec(&FriendAcceptPayload { user_id })?; + framing::io::write_encrypted(stream, PID_FRIEND_ACCEPT, seq, &payload, crypto).await?; + } + NetCommand::FriendDecline { user_id } => { + let payload = serde_json::to_vec(&FriendDeclinePayload { user_id })?; + framing::io::write_encrypted(stream, PID_FRIEND_DECLINE, seq, &payload, crypto).await?; + } + NetCommand::FriendRemove { user_id } => { + let payload = serde_json::to_vec(&FriendRemovePayload { user_id })?; + framing::io::write_encrypted(stream, PID_FRIEND_REMOVE, seq, &payload, crypto).await?; + } + NetCommand::FriendList => { + framing::io::write_encrypted(stream, PID_FRIEND_LIST, seq, b"{}", crypto).await?; + } + NetCommand::BlockUser { user_id } => { + let payload = serde_json::to_vec(&BlockUserPayload { user_id })?; + framing::io::write_encrypted(stream, PID_BLOCK_USER, seq, &payload, crypto).await?; + } + NetCommand::UnblockUser { user_id } => { + let payload = serde_json::to_vec(&UnblockUserPayload { user_id })?; + framing::io::write_encrypted(stream, PID_UNBLOCK_USER, seq, &payload, crypto).await?; + } + NetCommand::BlockList => { + framing::io::write_encrypted(stream, PID_BLOCK_LIST, seq, b"{}", crypto).await?; + } + _ => {} + } + Ok(()) +} diff --git a/src/net/session/handlers/social/guild.rs b/src/net/session/handlers/social/guild.rs new file mode 100644 index 0000000..eb0b84e --- /dev/null +++ b/src/net/session/handlers/social/guild.rs @@ -0,0 +1,143 @@ +use anyhow::Result; +use serde_json; +use tokio::net::TcpStream; + +use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand}; + +pub async fn handle_cmd( + cmd: NetCommand, + stream: &mut TcpStream, + seq: &mut u32, + crypto: &SessionCrypto, +) -> Result<()> { + match cmd { + NetCommand::GuildCreate { name } => { + let payload = serde_json::to_vec(&GuildCreatePayload { + name, + icon_url: None, + })?; + framing::io::write_encrypted(stream, PID_GUILD_CREATE, seq, &payload, crypto).await?; + } + NetCommand::GuildDelete { guild_id } => { + let payload = serde_json::to_vec(&GuildDeletePayload { guild_id })?; + framing::io::write_encrypted(stream, PID_GUILD_DELETE, seq, &payload, crypto).await?; + } + NetCommand::GuildList => { + framing::io::write_encrypted(stream, PID_GUILD_LIST, seq, b"{}", crypto).await?; + } + NetCommand::GuildJoin { + guild_id, + invite_code, + } => { + let payload = serde_json::to_vec(&GuildJoinPayload { + guild_id, + invite_code, + })?; + framing::io::write_encrypted(stream, PID_GUILD_MEMBER_JOIN, seq, &payload, crypto) + .await?; + } + NetCommand::GuildLeave { guild_id } => { + let payload = serde_json::to_vec(&GuildLeavePayload { guild_id })?; + framing::io::write_encrypted(stream, PID_GUILD_MEMBER_LEAVE, seq, &payload, crypto) + .await?; + } + NetCommand::InviteCreate { + guild_id, + max_uses, + expires_in_seconds, + } => { + let payload = serde_json::to_vec(&InviteCreatePayload { + guild_id, + max_uses, + expires_in_seconds, + })?; + framing::io::write_encrypted(stream, PID_INVITE_CREATE, seq, &payload, crypto).await?; + } + NetCommand::InviteAccept { code } => { + let payload = serde_json::to_vec(&InviteAcceptPayload { code })?; + framing::io::write_encrypted(stream, PID_INVITE_ACCEPT, seq, &payload, crypto).await?; + } + NetCommand::InviteDelete { + guild_id, + invite_id, + } => { + let payload = serde_json::to_vec(&InviteDeletePayload { + guild_id, + invite_id, + })?; + framing::io::write_encrypted(stream, PID_INVITE_DELETE, seq, &payload, crypto).await?; + } + NetCommand::RoleCreate { + guild_id, + name, + color, + permissions, + } => { + let payload = serde_json::to_vec(&RoleCreatePayload { + guild_id, + name, + color, + permissions, + })?; + framing::io::write_encrypted(stream, PID_ROLE_CREATE, seq, &payload, crypto).await?; + } + NetCommand::RoleDelete { guild_id, role_id } => { + let payload = serde_json::to_vec(&RoleDeletePayload { guild_id, role_id })?; + framing::io::write_encrypted(stream, PID_ROLE_DELETE, seq, &payload, crypto).await?; + } + NetCommand::GuildMemberKick { guild_id, user_id } => { + let payload = serde_json::to_vec(&GuildMemberKickPayload { guild_id, user_id })?; + framing::io::write_encrypted(stream, PID_GUILD_MEMBER_KICK, seq, &payload, crypto) + .await?; + } + NetCommand::GuildAuditLogFetch { guild_id, limit } => { + let payload = serde_json::to_vec(&GuildAuditLogFetchPayload { guild_id, limit })?; + framing::io::write_encrypted(stream, PID_GUILD_AUDIT_LOG_FETCH, seq, &payload, crypto) + .await?; + } + NetCommand::GuildMemberListFetch { guild_id } => { + let payload = serde_json::to_vec(&GuildMemberListFetchPayload { guild_id })?; + framing::io::write_encrypted( + stream, + PID_GUILD_MEMBER_LIST_FETCH, + seq, + &payload, + crypto, + ) + .await?; + } + NetCommand::GuildRoleAssign { + guild_id, + user_id, + role_id, + } => { + let payload = serde_json::to_vec(&RoleAssignPayload { + guild_id, + user_id, + role_id, + })?; + framing::io::write_encrypted(stream, PID_GUILD_ROLE_ASSIGN, seq, &payload, crypto) + .await?; + } + NetCommand::GuildRoleUnassign { + guild_id, + user_id, + role_id, + } => { + let payload = serde_json::to_vec(&RoleAssignPayload { + guild_id, + user_id, + role_id, + })?; + framing::io::write_encrypted(stream, PID_GUILD_ROLE_UNASSIGN, seq, &payload, crypto) + .await?; + } + NetCommand::GuildRoleListFetch { guild_id } => { + let payload = serde_json::to_vec(&GuildRoleListFetchPayload { guild_id })?; + framing::io::write_encrypted(stream, PID_GUILD_ROLE_LIST_FETCH, seq, &payload, crypto) + .await?; + } + _ => {} + } + Ok(()) +} diff --git a/src/net/session/handlers/social/mod.rs b/src/net/session/handlers/social/mod.rs new file mode 100644 index 0000000..2bb3063 --- /dev/null +++ b/src/net/session/handlers/social/mod.rs @@ -0,0 +1,146 @@ +mod friends; +mod guild; + +use anyhow::Result; +use serde_json; +use tokio::net::TcpStream; + +use crate::identity::Identity; +use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand}; + +use super::core::now_ms; + +pub async fn handle_cmd( + cmd: NetCommand, + stream: &mut TcpStream, + seq: &mut u32, + crypto: &SessionCrypto, + identity: &Identity, +) -> Result<()> { + match cmd { + cmd @ (NetCommand::GuildCreate { .. } + | NetCommand::GuildDelete { .. } + | NetCommand::GuildList + | NetCommand::GuildJoin { .. } + | NetCommand::GuildLeave { .. } + | NetCommand::InviteCreate { .. } + | NetCommand::InviteAccept { .. } + | NetCommand::InviteDelete { .. } + | NetCommand::RoleCreate { .. } + | NetCommand::RoleDelete { .. } + | NetCommand::GuildMemberKick { .. } + | NetCommand::GuildAuditLogFetch { .. } + | NetCommand::GuildMemberListFetch { .. } + | NetCommand::GuildRoleAssign { .. } + | NetCommand::GuildRoleUnassign { .. } + | NetCommand::GuildRoleListFetch { .. }) => { + guild::handle_cmd(cmd, stream, seq, crypto).await? + } + + cmd @ (NetCommand::PresenceUpdate { .. } + | NetCommand::FriendRequest { .. } + | NetCommand::FriendAccept { .. } + | NetCommand::FriendDecline { .. } + | NetCommand::FriendRemove { .. } + | NetCommand::FriendList + | NetCommand::BlockUser { .. } + | NetCommand::UnblockUser { .. } + | NetCommand::BlockList) => friends::handle_cmd(cmd, stream, seq, crypto).await?, + + NetCommand::ReactionAdd { + channel_id, + message_id, + emoji, + } => { + let payload = serde_json::to_vec(&ReactionPayload { + message_id, + channel_id, + emoji, + user_id: String::new(), + })?; + framing::io::write_encrypted(stream, PID_MESSAGE_REACTION_ADD, seq, &payload, crypto) + .await?; + } + NetCommand::ReactionRemove { + channel_id, + message_id, + emoji, + } => { + let payload = serde_json::to_vec(&ReactionPayload { + message_id, + channel_id, + emoji, + user_id: String::new(), + })?; + framing::io::write_encrypted( + stream, + PID_MESSAGE_REACTION_REMOVE, + seq, + &payload, + crypto, + ) + .await?; + } + NetCommand::MessageEdit { + channel_id, + message_id, + content, + } => { + let payload = serde_json::to_vec(&MessageEditPayload { + message_id, + channel_id, + content, + })?; + framing::io::write_encrypted(stream, PID_MESSAGE_EDIT, seq, &payload, crypto).await?; + } + NetCommand::MessageDelete { + channel_id, + message_id, + } => { + let payload = serde_json::to_vec(&MessageDeletePayload { + message_id, + channel_id, + })?; + framing::io::write_encrypted(stream, PID_MESSAGE_DELETE, seq, &payload, crypto).await?; + } + NetCommand::DmStart { target_user_id } => { + let payload = serde_json::to_vec(&DmStartPayload { target_user_id })?; + framing::io::write_encrypted(stream, PID_DM_START, seq, &payload, crypto).await?; + } + NetCommand::DmSend { dm_id, content } => { + let msg = DmMessagePayload { + dm_id, + sender_id: identity.pubkey_hex.clone(), + content, + timestamp: now_ms(), + }; + framing::io::write_encrypted( + stream, + PID_DM_MESSAGE, + seq, + &serde_json::to_vec(&msg)?, + crypto, + ) + .await?; + } + NetCommand::TypingStart { channel_id } => { + let payload = serde_json::to_vec(&serde_json::json!({"channel_id": channel_id}))?; + framing::io::write_encrypted(stream, PID_TYPING_START, seq, &payload, crypto).await?; + } + NetCommand::DmReadAck { dm_id } => { + let payload = serde_json::to_vec(&serde_json::json!({"dm_id": dm_id}))?; + framing::io::write_encrypted(stream, PID_DM_READ_ACK, seq, &payload, crypto).await?; + } + NetCommand::DmSearch { dm_id, query } => { + let payload = serde_json::to_vec(&DmHistoryPayload { + dm_id, + messages: vec![], + search_query: if query.is_empty() { None } else { Some(query) }, + limit: None, + })?; + framing::io::write_encrypted(stream, PID_DM_HISTORY, seq, &payload, crypto).await?; + } + _ => {} + } + Ok(()) +} diff --git a/src/net/session/handlers/voice.rs b/src/net/session/handlers/voice.rs new file mode 100644 index 0000000..444897a --- /dev/null +++ b/src/net/session/handlers/voice.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +use anyhow::Result; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; + +use crate::net::{crypto::SessionCrypto, types::NetEvent}; + +pub struct State { + pub udp: Arc, + pub seq: u64, +} + +impl State { + pub async fn new(crypto: &SessionCrypto, event_tx: &mpsc::Sender) -> Result { + let udp = Arc::new(UdpSocket::bind("0.0.0.0:0").await?); + crate::net::voice::spawn_recv(udp.clone(), (*crypto).clone(), event_tx.clone()); + Ok(Self { udp, seq: 0 }) + } + + #[allow(clippy::too_many_arguments)] + pub async fn send_voice( + &mut self, + channel_id: u64, + voice_pkt_seq: u32, + timestamp: u32, + opus_data: &[u8], + crypto: &SessionCrypto, + endpoint: Option, + sender_pubkey: &[u8; 32], + ) -> Result<()> { + if let Some(ep) = endpoint { + let pkt = crate::net::voice::build_packet( + channel_id, + voice_pkt_seq, + timestamp, + opus_data, + crypto, + self.seq, + sender_pubkey, + ); + let _ = self.udp.send_to(&pkt, ep).await; + self.seq += 1; + } + Ok(()) + } +} diff --git a/src/net/session/mod.rs b/src/net/session/mod.rs new file mode 100644 index 0000000..0b56776 --- /dev/null +++ b/src/net/session/mod.rs @@ -0,0 +1,6 @@ +mod handlers; +mod reconnect; +mod switch; +mod task; + +pub use task::net_task; diff --git a/src/net/session/reconnect.rs b/src/net/session/reconnect.rs new file mode 100644 index 0000000..0da0552 --- /dev/null +++ b/src/net/session/reconnect.rs @@ -0,0 +1,24 @@ +use std::time::Duration; + +/// Max delay between reconnect attempts. +pub const MAX_BACKOFF_SECS: u64 = 30; + +/// Exponential backoff after a failed session (1s, 2s, 4s, … capped at 30s). +pub fn backoff_delay(failure_count: u32) -> Duration { + let shift = failure_count.min(5); + let secs = (1u64 << shift).min(MAX_BACKOFF_SECS); + Duration::from_secs(secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_grows_and_caps() { + assert_eq!(backoff_delay(0).as_secs(), 1); + assert_eq!(backoff_delay(1).as_secs(), 2); + assert_eq!(backoff_delay(3).as_secs(), 8); + assert_eq!(backoff_delay(10).as_secs(), MAX_BACKOFF_SECS); + } +} diff --git a/src/net/session/switch.rs b/src/net/session/switch.rs new file mode 100644 index 0000000..082f733 --- /dev/null +++ b/src/net/session/switch.rs @@ -0,0 +1,20 @@ +/// User switched node while connected (`NetCommand::Connect`). +#[derive(Debug)] +pub struct ReconnectRequested { + pub address: String, + pub auto_reconnect: bool, +} + +impl std::fmt::Display for ReconnectRequested { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "reconnect to {}", self.address) + } +} + +impl std::error::Error for ReconnectRequested {} + +pub struct SessionTarget { + pub address: String, + pub auto_reconnect: bool, + pub channels: Vec, +} diff --git a/src/net/session/task.rs b/src/net/session/task.rs new file mode 100644 index 0000000..9b05a4c --- /dev/null +++ b/src/net/session/task.rs @@ -0,0 +1,116 @@ +use tokio::sync::mpsc; +use tokio::time::sleep; +use tracing::{error, info}; + +use crate::identity::Identity; +use crate::net::types::{NetCommand, NetEvent}; + +use super::{ + handlers::session_loop, + reconnect, + switch::{ReconnectRequested, SessionTarget}, +}; + +pub async fn net_task( + identity: Identity, + mut cmd_rx: mpsc::Receiver, + event_tx: mpsc::Sender, +) { + let mut target: Option = None; + let mut failures: u32 = 0; + + loop { + let Some(mut session) = target.take() else { + match wait_for_connect(&mut cmd_rx).await { + Some(t) => { + failures = 0; + target = Some(t); + } + None => return, + } + continue; + }; + + if failures > 0 { + let delay = reconnect::backoff_delay(failures - 1); + let _ = event_tx + .send(NetEvent::Reconnecting { + attempt: failures, + delay_secs: delay.as_secs(), + }) + .await; + sleep(delay).await; + } + + info!( + "connecting to {} (attempt {})", + session.address, + failures + 1 + ); + + let restored = failures > 0; + match session_loop( + &identity, + &session.address, + &mut session.channels, + restored, + &mut cmd_rx, + &event_tx, + ) + .await + { + Ok(()) => { + failures = 0; + let _ = event_tx + .send(NetEvent::Disconnected { + reason: "disconnected".into(), + will_retry: false, + }) + .await; + } + Err(e) => { + if let Some(req) = e.downcast_ref::() { + info!("switching node to {}", req.address); + session.address = req.address.clone(); + session.auto_reconnect = req.auto_reconnect; + failures = 0; + target = Some(session); + continue; + } + error!("session ended: {e}"); + let retry = session.auto_reconnect; + let _ = event_tx + .send(NetEvent::Disconnected { + reason: e.to_string(), + will_retry: retry, + }) + .await; + if retry { + failures = failures.saturating_add(1); + target = Some(session); + } else { + failures = 0; + } + } + } + } +} + +async fn wait_for_connect(cmd_rx: &mut mpsc::Receiver) -> Option { + loop { + match cmd_rx.recv().await { + Some(NetCommand::Connect { + address, + auto_reconnect, + }) => { + return Some(SessionTarget { + address, + auto_reconnect, + channels: vec!["general".into()], + }); + } + Some(NetCommand::Disconnect) | None => return None, + _ => {} + } + } +} diff --git a/src/net/types/command.rs b/src/net/types/command.rs new file mode 100644 index 0000000..29bef48 --- /dev/null +++ b/src/net/types/command.rs @@ -0,0 +1,162 @@ +#[derive(Debug)] +pub enum NetCommand { + Connect { + address: String, + auto_reconnect: bool, + }, + JoinChannel { + channel_id: String, + }, + LeaveChannel { + channel_id: String, + }, + ChannelCreate { + channel_id: String, + channel_name: String, + kind: String, + }, + ChannelDelete { + channel_id: String, + }, + ChannelList, + SendChat { + channel_id: String, + content: String, + /// Optional message_id this message is replying to. + reply_to: Option, + }, + SendVoice { + channel_id: u64, + voice_seq: u32, + timestamp: u32, + opus_data: Vec, + }, + DmStart { + target_user_id: String, + }, + DmSend { + dm_id: String, + content: String, + }, + DmReadAck { + dm_id: String, + }, + DmSearch { + dm_id: String, + query: String, + }, + GuildCreate { + name: String, + }, + GuildDelete { + guild_id: String, + }, + GuildList, + GuildJoin { + guild_id: String, + invite_code: Option, + }, + GuildLeave { + guild_id: String, + }, + InviteCreate { + guild_id: String, + max_uses: Option, + expires_in_seconds: Option, + }, + InviteAccept { + code: String, + }, + InviteDelete { + guild_id: String, + invite_id: String, + }, + RoleCreate { + guild_id: String, + name: String, + color: Option, + permissions: Option, + }, + RoleDelete { + guild_id: String, + role_id: String, + }, + GuildMemberKick { + guild_id: String, + user_id: String, + }, + GuildAuditLogFetch { + guild_id: String, + limit: i64, + }, + GuildMemberListFetch { + guild_id: String, + }, + GuildRoleAssign { + guild_id: String, + user_id: String, + role_id: String, + }, + GuildRoleUnassign { + guild_id: String, + user_id: String, + role_id: String, + }, + GuildRoleListFetch { + guild_id: String, + }, + PresenceUpdate { + status: String, + custom_status: Option, + activity: Option, + /// Free-form text describing the current activity (e.g. "playing CS2"). + activity_text: Option, + }, + TypingStart { + channel_id: String, + }, + ReadReceipt { + channel_id: String, + last_read_message_id: String, + }, + FriendRequest { + target_user_id: String, + }, + FriendAccept { + user_id: String, + }, + FriendDecline { + user_id: String, + }, + FriendRemove { + user_id: String, + }, + FriendList, + BlockUser { + user_id: String, + }, + UnblockUser { + user_id: String, + }, + BlockList, + ReactionAdd { + channel_id: String, + message_id: String, + emoji: String, + }, + ReactionRemove { + channel_id: String, + message_id: String, + emoji: String, + }, + MessageEdit { + channel_id: String, + message_id: String, + content: String, + }, + MessageDelete { + channel_id: String, + message_id: String, + }, + Disconnect, +} diff --git a/src/net/types/event.rs b/src/net/types/event.rs new file mode 100644 index 0000000..5fcb960 --- /dev/null +++ b/src/net/types/event.rs @@ -0,0 +1,227 @@ +use super::info::{ChannelListItem, ChatMsg, FriendInfo, GuildInfo, MemberInfo, PresenceInfo}; +use std::net::SocketAddr; + +#[derive(Debug, Clone)] +pub enum NetEvent { + Connected { + session_id: String, + node_name: String, + restored: bool, + private_mode: bool, + }, + Disconnected { + reason: String, + will_retry: bool, + }, + Reconnecting { + attempt: u32, + delay_secs: u64, + }, + ChannelState { + channel_id: String, + channel_name: String, + kind: String, + members: Vec, + voice_endpoint: String, + }, + ChannelCreated { + channel_id: String, + channel_name: String, + kind: String, + }, + ChannelDeleted { + channel_id: String, + }, + ChannelListEvent { + channels: Vec, + }, + ChatMessage { + message_id: String, + channel_id: String, + sender_id: String, + nickname: String, + content: String, + timestamp: i64, + /// Optional message_id this message is replying to. + reply_to: Option, + }, + ChatHistory { + channel_id: String, + messages: Vec, + }, + UserJoin { + channel_id: String, + user_id: String, + nickname: String, + }, + UserLeave { + channel_id: String, + user_id: String, + }, + VoicePacket { + channel_id: u64, + voice_seq: u32, + timestamp: u32, + sender: SocketAddr, + /// Hex-encoded Ed25519 public key of the sender (Phase 1.3 attribution). + /// Empty for legacy packets that didn't include sender_id. + sender_id: String, + opus_data: Vec, + }, + Error { + code: u32, + message: String, + }, + LatencyUpdate { + rtt_ms: u32, + }, + DmStart { + dm_id: String, + other_user_id: String, + other_nickname: String, + messages: Vec, + unread_count: u32, + }, + DmMessage { + dm_id: String, + sender_id: String, + content: String, + timestamp: i64, + }, + DmHistory { + dm_id: String, + messages: Vec, + }, + TypingStart { + user_id: String, + nickname: String, + channel_id: String, + }, + ReadReceiptBroadcast { + channel_id: String, + user_id: String, + last_read_message_id: String, + }, + GuildList { + guilds: Vec, + }, + GuildCreated { + guild_id: String, + name: String, + owner_id: String, + }, + GuildDeleted { + guild_id: String, + }, + GuildMemberJoined { + guild_id: String, + user_id: String, + nickname: String, + }, + GuildMemberLeft { + guild_id: String, + user_id: String, + }, + GuildMemberKicked { + guild_id: String, + user_id: String, + }, + InviteCreated { + id: String, + guild_id: String, + guild_name: String, + code: String, + creator_id: String, + max_uses: Option, + uses: i64, + expires_at: Option, + created_at: i64, + }, + InviteAccepted { + guild_id: String, + guild_name: String, + }, + InviteDeleted { + guild_id: String, + invite_id: String, + }, + RoleCreated { + role_id: String, + guild_id: String, + name: String, + }, + RoleDeleted { + role_id: String, + guild_id: String, + }, + GuildAuditLog { + guild_id: String, + entries: Vec, + }, + GuildMemberList { + guild_id: String, + members: Vec, + }, + GuildRoleList { + guild_id: String, + roles: Vec, + }, + UserColor { + user_id: String, + color: String, + }, + PresenceSync { + presences: Vec, + }, + PresenceUpdated { + user_id: String, + status: String, + custom_status: Option, + activity: Option, + }, + FriendList { + friends: Vec, + }, + FriendRequested { + user_id: String, + nickname: String, + }, + FriendAccepted { + user_id: String, + nickname: String, + }, + FriendRemoved { + user_id: String, + }, + BlockList { + blocked: Vec, + }, + BlockedUser { + user_id: String, + }, + UnblockedUser { + user_id: String, + }, + ReactionAdded { + channel_id: String, + message_id: String, + user_id: String, + emoji: String, + }, + ReactionRemoved { + channel_id: String, + message_id: String, + user_id: String, + emoji: String, + }, + MessageEdited { + channel_id: String, + message_id: String, + content: String, + edited: bool, + }, + MessageDeleted { + channel_id: String, + message_id: String, + }, +} diff --git a/src/net/types/info.rs b/src/net/types/info.rs new file mode 100644 index 0000000..8e803e1 --- /dev/null +++ b/src/net/types/info.rs @@ -0,0 +1,49 @@ +#[derive(Debug, Clone)] +pub struct MemberInfo { + pub user_id: String, + pub nickname: String, + pub in_voice: bool, +} + +#[derive(Debug, Clone)] +pub struct ChannelListItem { + pub channel_id: String, + pub channel_name: String, + pub kind: String, +} + +#[derive(Debug, Clone)] +pub struct ChatMsg { + pub message_id: String, + pub sender_id: String, + pub content: String, + pub timestamp: i64, + /// Optional message_id this message is replying to. + pub reply_to: Option, +} + +#[derive(Debug, Clone)] +pub struct GuildInfo { + pub guild_id: String, + pub name: String, + pub owner_id: String, + pub icon_url: Option, + pub member_count: u32, +} + +#[derive(Debug, Clone)] +pub struct PresenceInfo { + pub user_id: String, + pub status: String, + pub custom_status: Option, + pub activity: Option, + pub activity_text: Option, +} + +#[derive(Debug, Clone)] +pub struct FriendInfo { + pub user_id: String, + pub nickname: String, + pub status: String, + pub since: i64, +} diff --git a/src/net/types/mod.rs b/src/net/types/mod.rs new file mode 100644 index 0000000..6bbdc1e --- /dev/null +++ b/src/net/types/mod.rs @@ -0,0 +1,8 @@ +/// Public API types: commands UI→net and events net→UI. +mod command; +mod event; +mod info; + +pub use command::NetCommand; +pub use event::NetEvent; +pub use info::{ChannelListItem, ChatMsg, FriendInfo, GuildInfo, MemberInfo, PresenceInfo}; diff --git a/src/net/voice/mod.rs b/src/net/voice/mod.rs new file mode 100644 index 0000000..949eda0 --- /dev/null +++ b/src/net/voice/mod.rs @@ -0,0 +1,84 @@ +use super::crypto::SessionCrypto; +use super::payloads::PID_VOICE_PACKET; + +mod receive; +pub use receive::spawn_recv; + +/// Stable string channel id to u64 key for UDP voice packets. +pub fn channel_key(id: &str) -> u64 { + let mut h: u64 = 5381; + for b in id.bytes() { + h = h.wrapping_mul(33).wrapping_add(u64::from(b)); + } + h +} + +/// Build a UDP voice packet with encryption. +/// +/// Plaintext layout (Phase 1.2+): +/// `[voice_seq:4][timestamp:4][channel_id:8][sender_id:32][opus_data:N]` +/// +/// `sender_id` is the raw Ed25519 public key of the sender, so receivers can +/// attribute the voice activity to a specific user (Phase 1.3 speaking indicators). +pub fn build_packet( + channel_id: u64, + voice_seq: u32, + timestamp: u32, + opus: &[u8], + crypto: &SessionCrypto, + packet_seq: u64, + sender_pubkey: &[u8; 32], +) -> Vec { + let mut plaintext = Vec::with_capacity(16 + 32 + opus.len()); + plaintext.extend_from_slice(&voice_seq.to_be_bytes()); + plaintext.extend_from_slice(×tamp.to_be_bytes()); + plaintext.extend_from_slice(&channel_id.to_be_bytes()); + plaintext.extend_from_slice(sender_pubkey); + plaintext.extend_from_slice(opus); + + let encrypted = crypto.encrypt_c2s(packet_seq, &plaintext); + + let mut buf = Vec::with_capacity(4 + encrypted.len()); + buf.extend_from_slice(&PID_VOICE_PACKET.to_be_bytes()); + buf.extend_from_slice(&0u16.to_be_bytes()); + buf.extend_from_slice(&encrypted); + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_key_is_stable() { + assert_eq!(channel_key("voice"), channel_key("voice")); + assert_ne!(channel_key("voice"), channel_key("general")); + } + + #[test] + fn build_packet_header_layout() { + let key = channel_key("voice"); + let shared = [0u8; 32]; + let crypto = SessionCrypto::derive(&shared, "test"); + let sender = [0xABu8; 32]; + let pkt = build_packet(key, 42, 1000, b"opus", &crypto, 0, &sender); + + let packet_id = u16::from_be_bytes([pkt[0], pkt[1]]); + assert_eq!(packet_id, PID_VOICE_PACKET); + + let encrypted_payload = &pkt[4..]; + let plaintext = crypto.decrypt_c2s(0, encrypted_payload).unwrap(); + assert_eq!(plaintext.len(), 16 + 32 + 4); + + let pkt_voice_seq = u32::from_be_bytes(plaintext[0..4].try_into().unwrap()); + let timestamp = u32::from_be_bytes(plaintext[4..8].try_into().unwrap()); + let channel_id = u64::from_be_bytes(plaintext[8..16].try_into().unwrap()); + let sender_id: [u8; 32] = plaintext[16..48].try_into().unwrap(); + + assert_eq!(pkt_voice_seq, 42); + assert_eq!(timestamp, 1000); + assert_eq!(channel_id, key); + assert_eq!(sender_id, sender); + assert_eq!(&plaintext[48..], b"opus"); + } +} diff --git a/src/net/voice/receive.rs b/src/net/voice/receive.rs new file mode 100644 index 0000000..92c1a8a --- /dev/null +++ b/src/net/voice/receive.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; +use tokio::net::UdpSocket; +use tokio::sync::mpsc; +use tracing::warn; + +use super::super::crypto::SessionCrypto; +use super::super::payloads::PID_VOICE_PACKET; +use super::super::types::NetEvent; + +/// Spawn a task that reads UDP datagrams, decrypts them, and forwards as VoicePacket events. +pub fn spawn_recv(udp: Arc, crypto: SessionCrypto, tx: mpsc::Sender) { + tokio::spawn(async move { + let mut buf = vec![0u8; 1472]; + let mut voice_seq: u64 = 0; + + loop { + match udp.recv_from(&mut buf).await { + Ok((len, src)) => { + if len < 4 { + continue; + } + + let packet_id = u16::from_be_bytes([buf[0], buf[1]]); + if packet_id != PID_VOICE_PACKET { + continue; + } + + let encrypted_payload = &buf[4..len]; + let plaintext = match crypto.decrypt_s2c(voice_seq, encrypted_payload) { + Ok(p) => p, + Err(e) => { + warn!("voice packet decryption failed from {src}: {e}"); + voice_seq += 1; + continue; + } + }; + + // Layout: [voice_seq:4][timestamp:4][channel_id:8][sender_id:32][opus] + // Legacy format (no sender_id) is 16+ bytes — gracefully handle. + if plaintext.len() < 16 { + warn!("voice packet payload too short from {src}"); + voice_seq += 1; + continue; + } + + let pkt_voice_seq = u32::from_be_bytes(plaintext[0..4].try_into().unwrap()); + let timestamp = u32::from_be_bytes(plaintext[4..8].try_into().unwrap()); + let channel_id = u64::from_be_bytes(plaintext[8..16].try_into().unwrap()); + + // sender_id: 32 raw bytes if present, hex-encoded. + // Empty string when packet is from legacy sender without sender_id. + let (sender_id_hex, opus_data) = if plaintext.len() >= 48 { + let sender_bytes: [u8; 32] = plaintext[16..48].try_into().unwrap(); + (hex::encode(sender_bytes), plaintext[48..].to_vec()) + } else { + // Legacy: no sender_id in payload. + (String::new(), plaintext[16..].to_vec()) + }; + + let _ = tx + .send(NetEvent::VoicePacket { + channel_id, + voice_seq: pkt_voice_seq, + timestamp, + sender: src, + sender_id: sender_id_hex, + opus_data, + }) + .await; + + voice_seq += 1; + } + Err(e) => { + warn!("UDP recv: {e}"); + break; + } + } + } + }); +} diff --git a/src/ui/app/frame.rs b/src/ui/app/frame.rs new file mode 100644 index 0000000..4db52e2 --- /dev/null +++ b/src/ui/app/frame.rs @@ -0,0 +1,51 @@ +use eframe::egui::{self, Frame}; + +use crate::connect; +use crate::state::ConnState; +use crate::{chat, members, settings, sidebar, theme, titlebar}; + +use super::VnoxApp; + +impl eframe::App for VnoxApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.handle_voice_keybinds(ctx); + self.sync_audio_controls(ctx); + self.poll_net(); + + if matches!( + self.ui.conn, + ConnState::Connecting | ConnState::Reconnecting { .. } + ) { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + if matches!(self.ui.conn, ConnState::Connected { .. }) { + ctx.request_repaint_after(std::time::Duration::from_secs(1)); + } + if self.audio.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(20)); + } + + titlebar::show_titlebar(ctx, &mut self.ui); + + if matches!(self.ui.conn, ConnState::Connected { .. }) { + sidebar::guild_bar(ctx, &mut self.ui, &self.net); + } + sidebar::show(ctx, &mut self.ui, &self.identity, &self.net); + if matches!(self.ui.conn, ConnState::Connected { .. }) { + members::show(ctx, &mut self.ui); + } + egui::CentralPanel::default() + .frame(Frame::NONE.fill(theme::BG_BASE)) + .show(ctx, |ui| match &self.ui.conn.clone() { + ConnState::Disconnected + | ConnState::Connecting + | ConnState::Reconnecting { .. } => { + connect::show(ui, &mut self.ui, &self.identity, &self.net) + } + ConnState::Connected { .. } => { + chat::show(ui, ctx, &mut self.ui, &self.identity, &self.net) + } + }); + settings::show_window(ctx, &mut self.ui, &self.identity); + } +} diff --git a/src/ui/app/mod.rs b/src/ui/app/mod.rs new file mode 100644 index 0000000..e7a2dfa --- /dev/null +++ b/src/ui/app/mod.rs @@ -0,0 +1,123 @@ +mod frame; +mod net_poll; +mod setup; + +use vnox_client::{ + audio::{AudioPipeline, AudioSync}, + identity::Identity, + net::{NetCommand, NetHandle}, +}; + +use crate::keybind; +use crate::state::{ConnState, VoiceUiState}; +use crate::theme::apply_visuals; + +pub struct VnoxApp { + pub(crate) identity: Identity, + pub(crate) net: NetHandle, + pub(crate) ui: crate::state::UiState, + pub(crate) audio: Option, + pub(crate) voice_channel: Option, + pub(crate) last_pipeline_config: Option, +} + +impl VnoxApp { + pub fn new(cc: &eframe::CreationContext, identity: Identity, net: NetHandle) -> Self { + apply_visuals(&cc.egui_ctx); + Self { + identity, + net, + ui: crate::state::UiState::default(), + audio: None, + voice_channel: None, + last_pipeline_config: None, + } + } + + pub(crate) fn stop_voice(&mut self) { + self.audio = None; + self.voice_channel = None; + self.last_pipeline_config = None; + self.ui.voice = VoiceUiState::Off; + self.ui.voice_joined_at = None; + } + + pub(crate) fn sync_voice_audio(&mut self) { + if !matches!(self.ui.conn, ConnState::Connected { .. }) { + self.stop_voice(); + return; + } + let active_voice = self + .ui + .active_channel + .as_ref() + .and_then(|id| self.ui.channels.iter().find(|c| &c.id == id)) + .filter(|c| c.kind == "voice") + .map(|c| c.id.clone()); + + match active_voice { + Some(id) => self.start_voice(&id), + None => self.stop_voice(), + } + } + + pub(crate) fn sync_audio_controls(&mut self, ctx: &eframe::egui::Context) { + let Some(pipeline) = self.audio.as_ref() else { + return; + }; + let ptt_held = if self.ui.vad_mode == 0 { + keybind::binding_held(ctx, &self.ui.kb_ptt) + } else { + false + }; + pipeline.sync_controls(AudioSync { + mic_enabled: self.ui.mic_enabled, + vad_mode: self.ui.vad_mode, + vad_threshold: self.ui.vad_threshold, + input_volume: self.ui.input_volume, + ptt_held, + output_volume: self.ui.output_volume, + jitter_size: self.ui.jitter_size, + adaptive_buffer: self.ui.adaptive_buffer, + voice_bitrate_kbps: self.ui.voice_bitrate, + noise_suppress: self.ui.noise_suppress, + }); + + if let Some(ch) = self.voice_channel.clone() { + let cfg = self.pipeline_config(&ch); + if self.last_pipeline_config.as_ref() != Some(&cfg) { + self.start_voice(&ch); + } + } + } + + pub(crate) fn handle_voice_keybinds(&mut self, ctx: &eframe::egui::Context) { + if ctx.wants_keyboard_input() { + return; + } + if keybind::binding_pressed(ctx, &self.ui.kb_mute) { + self.ui.mic_enabled = !self.ui.mic_enabled; + } + if keybind::binding_pressed(ctx, &self.ui.kb_deafen) { + self.ui.deafen = !self.ui.deafen; + } + } + + pub(crate) fn flush_encoded_voice(&mut self) { + let Some(pipeline) = self.audio.as_ref() else { + return; + }; + while let Some(frame) = pipeline.poll_encoded() { + let net = self.net.clone(); + tokio::spawn(async move { + net.send(NetCommand::SendVoice { + channel_id: frame.channel_id, + voice_seq: frame.voice_seq, + timestamp: frame.timestamp, + opus_data: frame.data, + }) + .await; + }); + } + } +} diff --git a/src/ui/app/net_poll/chat/channel.rs b/src/ui/app/net_poll/chat/channel.rs new file mode 100644 index 0000000..4c6b31a --- /dev/null +++ b/src/ui/app/net_poll/chat/channel.rs @@ -0,0 +1,138 @@ +use crate::state::{Channel, DmConversation, UiState}; +use vnox_client::net::{NetCommand, NetEvent, NetHandle}; + +use super::into_msg; + +pub(super) fn handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) { + match event { + NetEvent::ChannelState { + channel_id, + channel_name, + kind, + members, + .. + } => { + ui.server_channel = Some(channel_id.clone()); + let mems: Vec = members + .iter() + .map(|m| { + ui.user_names.insert(m.user_id.clone(), m.nickname.clone()); + m.nickname.clone() + }) + .collect(); + if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) { + ch.members = mems; + } else { + ui.channels.push(Channel { + id: channel_id.clone(), + name: channel_name, + kind: kind.clone(), + members: mems, + }); + } + if ui.active_channel.is_none() { + ui.active_channel = Some(channel_id.clone()); + } + let net = net.clone(); + let ch_id = channel_id; + tokio::spawn(async move { + net.send(NetCommand::ReadReceipt { + channel_id: ch_id, + last_read_message_id: uuid::Uuid::new_v4().to_string(), + }) + .await; + }); + } + NetEvent::UserJoin { + channel_id, + user_id, + nickname, + .. + } => { + ui.user_names.insert(user_id, nickname.clone()); + if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) + && !ch.members.contains(&nickname) + { + ch.members.push(nickname); + } + } + NetEvent::UserLeave { + channel_id, + user_id, + } => { + if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) { + let nickname = ui.user_names.remove(&user_id); + ch.members.retain(|m| { + m != &user_id && nickname.as_deref().map(|n| m != n).unwrap_or(true) + }); + } + } + NetEvent::ChannelCreated { + channel_id, + channel_name, + kind, + } => { + // Another session created a channel — add to local list if not present. + if !ui.channels.iter().any(|c| c.id == channel_id) { + ui.channels.push(Channel { + id: channel_id, + name: channel_name, + kind, + members: Vec::new(), + }); + } + } + NetEvent::ChannelDeleted { channel_id } => { + ui.channels.retain(|c| c.id != channel_id); + // Clear active_channel if it pointed at the deleted channel. + if ui.active_channel.as_deref() == Some(&channel_id) { + ui.active_channel = ui.channels.first().map(|c| c.id.clone()); + } + // Drop any cached messages for the deleted channel. + ui.messages.remove(&channel_id); + } + NetEvent::ChannelListEvent { channels } => { + // Replace local channel list with the server-authoritative one. + // Preserve `members` for channels we were already tracking. + let old: std::collections::HashMap = std::mem::take(&mut ui.channels) + .into_iter() + .map(|c| (c.id.clone(), c)) + .collect(); + ui.channels = channels + .into_iter() + .map(|c| { + let members = old + .get(&c.channel_id) + .map(|o| o.members.clone()) + .unwrap_or_default(); + Channel { + id: c.channel_id, + name: c.channel_name, + kind: c.kind, + members, + } + }) + .collect(); + } + NetEvent::DmStart { + dm_id, + other_user_id, + other_nickname, + messages, + unread_count, + } => { + if !ui.dm_conversations.iter().any(|c| c.dm_id == dm_id) { + ui.dm_conversations.push(DmConversation { + dm_id: dm_id.clone(), + other_user_id, + other_nickname, + unread_count, + }); + } + ui.dm_messages + .insert(dm_id.clone(), messages.into_iter().map(into_msg).collect()); + ui.active_dm_id = Some(dm_id); + } + _ => {} + } +} diff --git a/src/ui/app/net_poll/chat/messages.rs b/src/ui/app/net_poll/chat/messages.rs new file mode 100644 index 0000000..8d8fa52 --- /dev/null +++ b/src/ui/app/net_poll/chat/messages.rs @@ -0,0 +1,149 @@ +use crate::state::{ChatMessage, UiState}; +use std::collections::HashMap; +use vnox_client::net::{NetEvent, NetHandle}; + +use super::into_msg; + +pub(super) fn handle(ui: &mut UiState, _net: &NetHandle, event: NetEvent) { + match event { + NetEvent::ChatMessage { + message_id, + channel_id, + sender_id, + content, + timestamp, + reply_to, + .. + } => { + ui.messages + .entry(channel_id) + .or_default() + .push(ChatMessage { + message_id, + sender_id, + content, + timestamp, + edited: false, + reactions: HashMap::new(), + reply_to, + }); + ui.scroll_bottom = true; + } + NetEvent::ChatHistory { + channel_id, + messages, + } => { + ui.messages + .insert(channel_id, messages.into_iter().map(into_msg).collect()); + ui.scroll_bottom = true; + } + NetEvent::Error { message, .. } => { + ui.status_msg = Some(format!("error: {message}")); + } + NetEvent::ReactionAdded { + channel_id, + message_id, + user_id, + emoji, + } => { + if let Some(msgs) = ui.messages.get_mut(&channel_id) + && let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id) + { + msg.reactions.entry(emoji).or_default().push(user_id); + } + } + NetEvent::ReactionRemoved { + channel_id, + message_id, + user_id, + emoji, + } => { + if let Some(msgs) = ui.messages.get_mut(&channel_id) + && let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id) + && let Some(users) = msg.reactions.get_mut(&emoji) + { + users.retain(|u| u != &user_id); + if users.is_empty() { + msg.reactions.remove(&emoji); + } + } + } + NetEvent::MessageEdited { + channel_id, + message_id, + content, + .. + } => { + if let Some(msgs) = ui.messages.get_mut(&channel_id) + && let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id) + { + msg.content = content; + msg.edited = true; + } + } + NetEvent::MessageDeleted { + channel_id, + message_id, + } => { + if let Some(msgs) = ui.messages.get_mut(&channel_id) { + msgs.retain(|m| m.message_id != message_id); + } + } + NetEvent::DmMessage { + dm_id, + sender_id, + content, + timestamp, + .. + } => { + ui.dm_messages + .entry(dm_id.clone()) + .or_default() + .push(ChatMessage { + message_id: uuid::Uuid::new_v4().to_string(), + sender_id, + content, + timestamp, + edited: false, + reactions: HashMap::new(), + reply_to: None, + }); + if ui.active_dm_id.as_deref() != Some(&dm_id) + && let Some(c) = ui.dm_conversations.iter_mut().find(|c| c.dm_id == dm_id) + { + c.unread_count += 1; + } + } + NetEvent::DmHistory { dm_id, messages } => { + ui.dm_messages + .insert(dm_id, messages.into_iter().map(into_msg).collect()); + } + NetEvent::ReadReceiptBroadcast { + channel_id, + user_id, + last_read_message_id, + } => { + ui.read_receipts + .entry(channel_id) + .or_default() + .insert(user_id, last_read_message_id); + } + NetEvent::UserColor { user_id, color } => { + if let Some(c) = parse_hex_color(&color) { + ui.user_colors.insert(user_id, c); + } + } + _ => {} + } +} + +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(eframe::egui::Color32::from_rgb(r, g, b)) +} diff --git a/src/ui/app/net_poll/chat/mod.rs b/src/ui/app/net_poll/chat/mod.rs new file mode 100644 index 0000000..1fb37b9 --- /dev/null +++ b/src/ui/app/net_poll/chat/mod.rs @@ -0,0 +1,28 @@ +mod channel; +mod messages; + +use crate::state::{ChatMessage, UiState}; +use std::collections::HashMap; +use vnox_client::net::{ChatMsg, NetEvent, NetHandle}; + +fn into_msg(m: ChatMsg) -> ChatMessage { + ChatMessage { + message_id: m.message_id, + sender_id: m.sender_id, + content: m.content, + timestamp: m.timestamp, + edited: false, + reactions: HashMap::new(), + reply_to: m.reply_to, + } +} + +pub(crate) fn handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) { + match event { + NetEvent::ChannelState { .. } + | NetEvent::UserJoin { .. } + | NetEvent::UserLeave { .. } + | NetEvent::DmStart { .. } => channel::handle(ui, net, event), + e => messages::handle(ui, net, e), + } +} diff --git a/src/ui/app/net_poll/mod.rs b/src/ui/app/net_poll/mod.rs new file mode 100644 index 0000000..5ec1d4a --- /dev/null +++ b/src/ui/app/net_poll/mod.rs @@ -0,0 +1,169 @@ +mod chat; +mod social; +mod status; + +use vnox_client::{ + audio::IncomingVoice, + net::{NetCommand, NetEvent, NetHandle}, +}; + +use crate::state::ConnState; + +use super::VnoxApp; + +impl VnoxApp { + pub(crate) fn poll_net(&mut self) { + while let Some(ev) = self.net.try_recv() { + match ev { + NetEvent::Connected { + session_id, + node_name, + restored, + private_mode, + } => self.on_connected(session_id, node_name, restored, private_mode), + NetEvent::Disconnected { reason, will_retry } => { + self.stop_voice(); + status::handle_disconnected(&mut self.ui, &reason, will_retry); + } + NetEvent::Reconnecting { + attempt, + delay_secs, + } => { + self.stop_voice(); + status::handle_reconnecting(&mut self.ui, attempt, delay_secs); + } + NetEvent::VoicePacket { + channel_id, + voice_seq, + timestamp, + opus_data, + sender_id, + .. + } => { + // Track which remote user is speaking (Phase 1.3 attribution). + if !sender_id.is_empty() { + self.ui.last_remote_speaker_id = sender_id.clone(); + self.ui.last_voice_packet_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + } + if !self.ui.deafen + && let Some(pipeline) = self.audio.as_ref() + { + pipeline.push_voice(IncomingVoice { + voice_seq, + timestamp, + channel_id, + opus_data, + }); + } + } + event => process_net_event(&mut self.ui, &self.net, event), + } + self.sync_voice_audio(); + self.flush_encoded_voice(); + } + // Refresh speaking indicators from the audio pipeline. + if let Some(pipeline) = self.audio.as_ref() { + self.ui.local_speaking = pipeline.local_speaking(); + self.ui.remote_speaking = pipeline.remote_speaking(500); + } else { + self.ui.local_speaking = false; + self.ui.remote_speaking = false; + } + // Decay remote speaker id if no packet arrived in 700 ms. + if !self.ui.remote_speaking { + self.ui.last_remote_speaker_id.clear(); + } + } + + pub(crate) fn on_connected( + &mut self, + session_id: String, + node_name: String, + restored: bool, + private_mode: bool, + ) { + self.ui.conn = ConnState::Connected { + session_id, + node_name, + }; + self.ui.private_mode = private_mode; + if restored { + self.ui.status_msg = Some("reconnected".into()); + if let Some(ch) = self.ui.active_channel.clone() { + let net = self.net.clone(); + tokio::spawn(async move { + net.send(NetCommand::JoinChannel { channel_id: ch }).await; + }); + } + } else { + self.ui.seed_default_channels(); + self.ui.active_channel = Some("general".into()); + self.ui.status_msg = None; + let net = self.net.clone(); + tokio::spawn(async move { + net.send(NetCommand::JoinChannel { + channel_id: "general".into(), + }) + .await; + }); + } + } +} + +pub(crate) fn process_net_event(ui: &mut crate::state::UiState, net: &NetHandle, event: NetEvent) { + match &event { + NetEvent::ChannelState { .. } + | NetEvent::ChannelCreated { .. } + | NetEvent::ChannelDeleted { .. } + | NetEvent::ChannelListEvent { .. } + | NetEvent::ChatMessage { .. } + | NetEvent::ChatHistory { .. } + | NetEvent::UserJoin { .. } + | NetEvent::UserLeave { .. } + | NetEvent::Error { .. } + | NetEvent::ReactionAdded { .. } + | NetEvent::ReactionRemoved { .. } + | NetEvent::MessageEdited { .. } + | NetEvent::MessageDeleted { .. } + | NetEvent::DmStart { .. } + | NetEvent::DmMessage { .. } + | NetEvent::DmHistory { .. } + | NetEvent::ReadReceiptBroadcast { .. } + | NetEvent::UserColor { .. } => chat::handle(ui, net, event), + + NetEvent::GuildList { .. } + | NetEvent::GuildCreated { .. } + | NetEvent::GuildDeleted { .. } + | NetEvent::GuildMemberJoined { .. } + | NetEvent::GuildMemberLeft { .. } + | NetEvent::GuildMemberKicked { .. } + | NetEvent::InviteCreated { .. } + | NetEvent::InviteAccepted { .. } + | NetEvent::InviteDeleted { .. } + | NetEvent::RoleCreated { .. } + | NetEvent::RoleDeleted { .. } + | NetEvent::GuildAuditLog { .. } + | NetEvent::GuildMemberList { .. } + | NetEvent::GuildRoleList { .. } + | NetEvent::FriendList { .. } + | NetEvent::FriendRequested { .. } + | NetEvent::FriendAccepted { .. } + | NetEvent::FriendRemoved { .. } + | NetEvent::BlockList { .. } + | NetEvent::BlockedUser { .. } + | NetEvent::UnblockedUser { .. } => social::handle(ui, event), + + NetEvent::LatencyUpdate { .. } + | NetEvent::TypingStart { .. } + | NetEvent::PresenceSync { .. } + | NetEvent::PresenceUpdated { .. } => status::handle(ui, net, event), + + NetEvent::Connected { .. } + | NetEvent::Disconnected { .. } + | NetEvent::Reconnecting { .. } + | NetEvent::VoicePacket { .. } => {} + } +} diff --git a/src/ui/app/net_poll/social.rs b/src/ui/app/net_poll/social.rs new file mode 100644 index 0000000..2789002 --- /dev/null +++ b/src/ui/app/net_poll/social.rs @@ -0,0 +1,148 @@ +use vnox_client::net::NetEvent; + +use crate::state::{FriendState, GuildState}; + +use crate::state::UiState; + +pub(crate) fn handle(ui: &mut UiState, event: NetEvent) { + match event { + NetEvent::GuildAuditLog { guild_id, entries } => { + ui.audit_log_entries = entries; + ui.audit_log_guild_id = Some(guild_id); + } + NetEvent::GuildMemberList { guild_id, members } => { + ui.guild_member_list_guild_id = Some(guild_id); + ui.guild_member_list = members; + } + NetEvent::GuildRoleList { guild_id, roles } => { + ui.guild_role_list_guild_id = Some(guild_id); + ui.guild_role_list = roles; + } + NetEvent::BlockList { blocked } => { + ui.blocked_users = blocked; + } + NetEvent::BlockedUser { user_id } => { + if !ui.blocked_users.contains(&user_id) { + ui.blocked_users.push(user_id); + } + } + NetEvent::UnblockedUser { user_id } => { + ui.blocked_users.retain(|u| u != &user_id); + } + NetEvent::GuildList { guilds } => { + ui.guilds = guilds + .into_iter() + .map(|g| GuildState { + guild_id: g.guild_id, + name: g.name, + owner_id: g.owner_id, + member_count: g.member_count, + }) + .collect(); + } + NetEvent::GuildCreated { + guild_id, + name, + owner_id, + } => { + ui.guilds.push(GuildState { + guild_id: guild_id.clone(), + name, + owner_id, + member_count: 1, + }); + ui.active_guild_id = Some(guild_id); + } + NetEvent::GuildDeleted { guild_id } => { + ui.guilds.retain(|g| g.guild_id != guild_id); + if ui.active_guild_id.as_deref() == Some(&guild_id) { + ui.active_guild_id = None; + } + } + NetEvent::GuildMemberJoined { + guild_id, + user_id: _, + nickname: _, + } => { + if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) { + g.member_count += 1; + } + } + NetEvent::GuildMemberLeft { + guild_id, + user_id: _, + } => { + if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) { + g.member_count = g.member_count.saturating_sub(1); + } + } + NetEvent::GuildMemberKicked { + guild_id, + user_id: _, + } => { + if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) { + g.member_count = g.member_count.saturating_sub(1); + } + } + NetEvent::InviteCreated { .. } => { + // UI will fetch invite list separately if needed + } + NetEvent::InviteAccepted { + guild_id, + guild_name, + } if !ui.guilds.iter().any(|g| g.guild_id == guild_id) => { + ui.guilds.push(GuildState { + guild_id, + name: guild_name, + owner_id: String::new(), + member_count: 1, + }); + } + NetEvent::InviteDeleted { + guild_id: _, + invite_id: _, + } => {} + NetEvent::RoleCreated { .. } => {} + NetEvent::RoleDeleted { .. } => {} + NetEvent::FriendList { friends } => { + ui.friends = friends + .into_iter() + .map(|f| FriendState { + user_id: f.user_id, + nickname: f.nickname, + status: f.status, + since: f.since, + }) + .collect(); + } + NetEvent::FriendRequested { user_id, nickname } => { + ui.pending_friend_requests.push(FriendState { + user_id, + nickname, + status: "offline".into(), + since: 0, + }); + } + NetEvent::FriendAccepted { user_id, nickname } => { + // Remove from pending requests + ui.pending_friend_requests.retain(|f| f.user_id != user_id); + // Avoid duplicate entries in friends list + if !ui.friends.iter().any(|f| f.user_id == user_id) { + ui.friends.push(FriendState { + user_id, + nickname, + status: "online".into(), + since: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0), + }); + } + } + NetEvent::FriendRemoved { user_id } => { + ui.friends.retain(|f| f.user_id != user_id); + ui.pending_friend_requests.retain(|f| f.user_id != user_id); + } + _ => {} + } +} diff --git a/src/ui/app/net_poll/status.rs b/src/ui/app/net_poll/status.rs new file mode 100644 index 0000000..3e17e8c --- /dev/null +++ b/src/ui/app/net_poll/status.rs @@ -0,0 +1,69 @@ +use vnox_client::net::{NetEvent, NetHandle, PresenceInfo}; + +use crate::state::ConnState; + +use crate::state::UiState; + +pub(crate) fn handle_disconnected(ui: &mut UiState, reason: &str, will_retry: bool) { + if will_retry { + ui.status_msg = Some(format!("connection lost: {reason}")); + } else { + ui.conn = ConnState::Disconnected; + ui.channels.clear(); + ui.active_channel = None; + ui.user_names.clear(); + ui.server_channel = None; + ui.rtt_ms = None; + ui.status_msg = Some(format!("disconnected: {reason}")); + } + ui.dm_conversations.clear(); + ui.dm_messages.clear(); + ui.active_dm_id = None; + ui.new_dm_input = None; + ui.private_mode = false; +} + +pub(crate) fn handle_reconnecting(ui: &mut UiState, attempt: u32, delay_secs: u64) { + ui.conn = ConnState::Reconnecting { attempt }; + ui.status_msg = Some(format!("reconnecting in {delay_secs}s (attempt {attempt})")); +} + +pub(crate) fn handle(ui: &mut UiState, _net: &NetHandle, event: NetEvent) { + match event { + NetEvent::LatencyUpdate { rtt_ms } => { + ui.rtt_ms = Some(rtt_ms); + } + NetEvent::TypingStart { + user_id, + nickname, + channel_id, + } => { + let entry = ui.typing_users.entry(channel_id).or_default(); + entry.retain(|(u, _)| u != &user_id); + entry.push((nickname, std::time::Instant::now())); + } + NetEvent::PresenceSync { presences } => { + for p in presences { + ui.presences.insert(p.user_id.clone(), p); + } + } + NetEvent::PresenceUpdated { + user_id, + status, + custom_status, + activity, + } => { + ui.presences.insert( + user_id.clone(), + PresenceInfo { + user_id, + status, + custom_status, + activity, + activity_text: None, + }, + ); + } + _ => {} + } +} diff --git a/src/ui/app/setup.rs b/src/ui/app/setup.rs new file mode 100644 index 0000000..8941b4b --- /dev/null +++ b/src/ui/app/setup.rs @@ -0,0 +1,50 @@ +use tracing::{info, warn}; +use vnox_client::audio::{self, PipelineConfig}; +use vnox_client::net::voice; + +use super::VnoxApp; + +impl VnoxApp { + pub(crate) fn pipeline_config(&self, channel_id: &str) -> PipelineConfig { + PipelineConfig { + channel_key: voice::channel_key(channel_id), + mic_device_index: self.ui.mic_device, + output_device_index: self.ui.output_device, + voice_bitrate_kbps: self.ui.voice_bitrate, + packet_interval: self.ui.packet_interval, + } + } + + pub(crate) fn start_voice(&mut self, channel_id: &str) { + let cfg = self.pipeline_config(channel_id); + if self.voice_channel.as_deref() == Some(channel_id) + && self.audio.is_some() + && self.last_pipeline_config.as_ref() == Some(&cfg) + { + return; + } + self.audio = None; + match audio::start(cfg.clone()) { + Ok(pipeline) => { + info!("voice active on channel {channel_id}"); + self.audio = Some(pipeline); + self.voice_channel = Some(channel_id.to_string()); + self.last_pipeline_config = Some(cfg); + self.ui.voice = crate::state::VoiceUiState::Active { + channel: channel_id.to_string(), + }; + if self.ui.voice_joined_at.is_none() { + self.ui.voice_joined_at = Some(std::time::Instant::now()); + } + self.ui.status_msg = None; + } + Err(e) => { + warn!("audio start failed: {e}"); + self.voice_channel = None; + self.last_pipeline_config = None; + self.ui.voice = crate::state::VoiceUiState::Error(e.to_string()); + self.ui.status_msg = Some(format!("microphone error: {e}")); + } + } + } +} diff --git a/src/ui/chat/dm/messages.rs b/src/ui/chat/dm/messages.rs new file mode 100644 index 0000000..59b96b6 --- /dev/null +++ b/src/ui/chat/dm/messages.rs @@ -0,0 +1,165 @@ +use super::super::text::message_row; +use crate::state::{ChatMessage, UiState}; +use crate::theme as t; +use eframe::egui::{self, Context, FontId, Frame, RichText, ScrollArea, TextEdit}; +use std::collections::HashMap; +use vnox_client::{ + identity::Identity, + net::{NetCommand, NetHandle}, +}; + +pub(super) fn search_bar( + ui: &mut egui::Ui, + s: &mut UiState, + net: &NetHandle, + dm_id: &str, + _h: f32, +) { + Frame::NONE + .inner_margin(egui::Margin::symmetric(12, 4)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let resp = ui.add_sized( + egui::vec2(ui.available_width(), 20.0), + TextEdit::singleline(&mut s.dm_search_query) + .font(FontId::proportional(12.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(RichText::new("search…").color(t::TEXT_DISABLED)) + .frame(true), + ); + if resp.changed() { + let query = s.dm_search_query.trim().to_string(); + let net = net.clone(); + let id = dm_id.to_string(); + tokio::spawn(async move { + net.send(NetCommand::DmSearch { dm_id: id, query }).await; + }); + } + }); + }); +} + +pub(super) fn show( + ui: &mut egui::Ui, + s: &mut UiState, + net: &NetHandle, + identity: &Identity, + dm_id: &str, + max_h: f32, +) { + let msgs = s.dm_messages.get(dm_id).cloned().unwrap_or_default(); + ScrollArea::vertical() + .max_height(max_h) + .auto_shrink([false; 2]) + .stick_to_bottom(s.scroll_bottom) + .show(ui, |ui| { + ui.add_space(12.0); + if msgs.is_empty() { + ui.label( + RichText::new("no messages yet") + .font(FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); + } else { + ui.label( + RichText::new("today") + .font(FontId::proportional(11.0)) + .color(t::TEXT_DISABLED), + ); + ui.add_space(8.0); + let mut prev: Option = None; + for msg in &msgs { + let group_start = prev.as_deref() != Some(&msg.sender_id); + message_row(ui, s, net, identity, msg, dm_id, group_start); + prev = Some(msg.sender_id.clone()); + } + } + ui.add_space(8.0); + }); + s.scroll_bottom = false; +} + +pub(super) fn input_bar( + ui: &mut egui::Ui, + ctx: &Context, + s: &mut UiState, + net: &NetHandle, + _identity: &Identity, + dm_id: &str, +) { + let r = ui.available_rect_before_wrap(); + ui.painter().hline( + r.x_range(), + r.top(), + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let input = egui::TextEdit::singleline(&mut s.chat_input) + .font(FontId::proportional(13.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(RichText::new("message…").color(t::TEXT_DISABLED)) + .frame(false) + .desired_width(ui.available_width() - 44.0); + + let enter = Frame::NONE + .fill(t::BG_INTERACTIVE) + .corner_radius(t::R_MD) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| ui.add(input)) + .inner; + + let submit = (enter.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + || ui + .add( + egui::Button::new(RichText::new("→").color(t::ACCENT).size(14.0)) + .fill(t::ACCENT_10) + .stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT)) + .corner_radius(t::R_MD) + .min_size(egui::vec2(32.0, 32.0)), + ) + .clicked(); + + if submit && !s.chat_input.trim().is_empty() { + send_dm(s, net, dm_id); + ctx.request_repaint(); + } + }); + }); +} + +fn send_dm(s: &mut UiState, net: &NetHandle, dm_id: &str) { + let content = s.chat_input.trim().to_string(); + if content.is_empty() { + return; + } + s.chat_input.clear(); + + s.dm_messages + .entry(dm_id.to_string()) + .or_default() + .push(ChatMessage { + message_id: uuid::Uuid::new_v4().to_string(), + sender_id: String::new(), + content: content.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + edited: false, + reactions: HashMap::new(), + reply_to: None, + }); + s.scroll_bottom = true; + + let net = net.clone(); + let id = dm_id.to_string(); + tokio::spawn(async move { + net.send(NetCommand::DmSend { dm_id: id, content }).await; + }); +} diff --git a/src/ui/chat/dm/mod.rs b/src/ui/chat/dm/mod.rs new file mode 100644 index 0000000..4286921 --- /dev/null +++ b/src/ui/chat/dm/mod.rs @@ -0,0 +1,71 @@ +mod messages; + +use eframe::egui::{self, Context, FontId, Frame, RichText}; +use vnox_client::{identity::Identity, net::NetHandle}; + +use crate::state::UiState; +use crate::theme as t; + +pub(crate) fn dm_chat( + ui: &mut egui::Ui, + ctx: &Context, + s: &mut UiState, + identity: &Identity, + net: &NetHandle, + dm_id: &str, +) { + let conv = s + .dm_conversations + .iter() + .find(|c| c.dm_id == dm_id) + .cloned(); + + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(16, 0)) + .show(ui, |ui| { + ui.set_height(40.0); + ui.horizontal_centered(|ui| { + ui.label(RichText::new("@").color(t::TEXT_MUTED).size(16.0)); + ui.add_space(4.0); + let name = conv + .as_ref() + .map(|c| c.other_nickname.as_str()) + .unwrap_or("?"); + ui.label( + RichText::new(name) + .font(FontId::proportional(14.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + }); + }); + + let r = ui.max_rect(); + ui.painter().hline( + r.x_range(), + r.top() + 40.0, + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + let search_h = 32.0; + let input_h = 52.0; + messages::search_bar(ui, s, net, dm_id, search_h); + ui.painter().hline( + r.x_range(), + r.top() + 40.0 + search_h, + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + messages::show( + ui, + s, + net, + identity, + dm_id, + ui.available_height() - input_h - search_h, + ); + messages::input_bar(ui, ctx, s, net, identity, dm_id); + }); +} diff --git a/src/ui/chat/friends/list.rs b/src/ui/chat/friends/list.rs new file mode 100644 index 0000000..918b030 --- /dev/null +++ b/src/ui/chat/friends/list.rs @@ -0,0 +1,270 @@ +use super::{muted_label, pending, section_header, status_color, status_label}; +use crate::state::{FriendState, UiState}; +use crate::theme as t; +use eframe::egui::{self, FontId, Frame, RichText, ScrollArea}; +use vnox_client::net::{NetCommand, NetHandle}; + +pub(super) fn render_content(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + ScrollArea::vertical() + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.add_space(4.0); + + match s.friends_tab.as_str() { + "online" => render_online(ui, s, net), + "pending" => render_pending(ui, s, net), + "blocked" => render_blocked(ui, s, net), + _ => render_all(ui, s, net), + } + }); +} + +fn render_online(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + section_header(ui, "ONLINE"); + let online: Vec<&FriendState> = s + .friends + .iter() + .filter(|f| matches!(f.status.as_str(), "online" | "idle" | "dnd")) + .collect(); + if online.is_empty() { + muted_label(ui, "no friends online"); + } else { + for f in &online { + friend_row(ui, f, net); + } + } +} + +fn render_all(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + section_header(ui, "ONLINE"); + let online: Vec<&FriendState> = s + .friends + .iter() + .filter(|f| matches!(f.status.as_str(), "online" | "idle" | "dnd")) + .collect(); + if online.is_empty() { + muted_label(ui, "no friends online"); + } else { + for f in &online { + friend_row(ui, f, net); + } + } + + ui.add_space(16.0); + + section_header(ui, "OFFLINE"); + let offline: Vec<&FriendState> = s + .friends + .iter() + .filter(|f| !matches!(f.status.as_str(), "online" | "idle" | "dnd")) + .collect(); + if offline.is_empty() { + if s.friends.is_empty() { + muted_label(ui, "no friends yet — add some!"); + } else { + muted_label(ui, "nobody is offline"); + } + } else { + for f in &offline { + friend_row(ui, f, net); + } + } +} + +fn render_pending(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + section_header(ui, "PENDING — INCOMING"); + if s.pending_friend_requests.is_empty() { + muted_label(ui, "no pending requests"); + } else { + for req in &s.pending_friend_requests { + pending::pending_row(ui, req, net); + } + } +} + +fn render_blocked(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + // Block-user input row + Frame::NONE + .inner_margin(egui::Margin::symmetric(12, 4)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let mut buf = s.block_input.clone().unwrap_or_default(); + let resp = ui.add( + egui::TextEdit::singleline(&mut buf) + .font(egui::FontId::monospace(11.0)) + .hint_text("user id to block") + .desired_width(140.0) + .text_color(t::TEXT_PRIMARY), + ); + let mut trigger = false; + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + trigger = true; + } + if ui + .add( + egui::Button::new( + RichText::new("Block") + .font(FontId::proportional(10.0)) + .color(t::ERROR), + ) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_SM) + .min_size(egui::vec2(50.0, 18.0)), + ) + .clicked() + { + trigger = true; + } + s.block_input = if buf.is_empty() { None } else { Some(buf) }; + if trigger + && let Some(target) = s.block_input.clone().filter(|s| !s.trim().is_empty()) + { + let net = net.clone(); + let target = target.trim().to_string(); + tokio::spawn(async move { + net.send(NetCommand::BlockUser { user_id: target }).await; + }); + s.block_input = None; + } + }); + }); + ui.add_space(8.0); + + section_header(ui, "BLOCKED"); + if s.blocked_users.is_empty() { + muted_label(ui, "no blocked users — enter a user ID above to block"); + return; + } + + // Fetch on first open of this tab. + if s.block_list_fetched_at.is_none() { + s.block_list_fetched_at = Some(std::time::Instant::now()); + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::BlockList).await; + }); + } + + for uid in s.blocked_users.clone() { + Frame::NONE + .fill(t::BG_SURFACE) + .corner_radius(t::R_MD) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let init: String = uid.chars().next().unwrap_or('?').to_uppercase().to_string(); + t::avatar(ui, &init, t::TEXT_DISABLED, 28.0); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&uid) + .font(FontId::monospace(11.0)) + .color(t::TEXT_PRIMARY), + ); + ui.label( + RichText::new("blocked") + .font(FontId::proportional(10.0)) + .color(t::TEXT_DISABLED), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new( + RichText::new("Unblock") + .font(FontId::proportional(11.0)) + .color(t::SUCCESS), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .on_hover_text("unblock this user") + .clicked() + { + let net = net.clone(); + let uidc = uid.clone(); + tokio::spawn(async move { + net.send(NetCommand::UnblockUser { user_id: uidc }).await; + }); + } + }); + }); + }); + } +} + +fn friend_row(ui: &mut egui::Ui, f: &FriendState, net: &NetHandle) { + Frame::NONE + .fill(t::BG_SURFACE) + .corner_radius(t::R_MD) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let init = f + .nickname + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + t::avatar(ui, &init, status_color(&f.status), 28.0); + + ui.add_space(8.0); + let (dot_r, _) = + ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter() + .circle_filled(dot_r.center(), 4.0, status_color(&f.status)); + + ui.add_space(6.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&f.nickname) + .font(FontId::proportional(13.0)) + .color(t::TEXT_PRIMARY), + ); + ui.label( + RichText::new(status_label(&f.status)) + .font(FontId::proportional(10.0)) + .color(t::TEXT_DISABLED), + ); + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if t::icon_btn_drawn( + ui, + crate::theme::icons::envelope, + "send message", + None, + 18.0, + ) + .clicked() + { + let net = net.clone(); + let uid = f.user_id.clone(); + tokio::spawn(async move { + net.send(NetCommand::DmStart { + target_user_id: uid, + }) + .await; + }); + } + if t::icon_btn_drawn( + ui, + crate::theme::icons::close, + "remove friend", + None, + 16.0, + ) + .clicked() + { + let net = net.clone(); + let uid = f.user_id.clone(); + tokio::spawn(async move { + net.send(NetCommand::FriendRemove { user_id: uid }).await; + }); + } + }); + }); + }); +} diff --git a/src/ui/chat/friends/mod.rs b/src/ui/chat/friends/mod.rs new file mode 100644 index 0000000..b451700 --- /dev/null +++ b/src/ui/chat/friends/mod.rs @@ -0,0 +1,209 @@ +mod list; +mod pending; + +use eframe::egui::{self, FontId, Frame, RichText}; +use vnox_client::net::{NetCommand, NetHandle}; + +use crate::state::UiState; +use crate::theme as t; + +pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(16, 0)) + .show(ui, |ui| { + ui.set_height(44.0); + ui.horizontal_centered(|ui| { + ui.label( + RichText::new("Friends") + .font(FontId::proportional(16.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let add_btn = egui::Button::new( + RichText::new("Add Friend") + .font(FontId::proportional(11.0)) + .color(t::ACCENT_SOFT), + ) + .fill(t::ACCENT) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(80.0, 26.0)); + if ui.add(add_btn).clicked() { + s.add_friend_open = true; + s.add_friend_input.clear(); + } + }); + }); + }); + + let sep = ui.max_rect(); + ui.painter().hline( + sep.x_range(), + sep.top() + 44.0, + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + ui.add_space(8.0); + + // Pending count badge for the Pending tab + let pending_count = s.pending_friend_requests.len(); + + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.spacing_mut().item_spacing.x = 4.0; + for (tab_key, label) in [ + ("online", "Online"), + ("all", "All"), + ("pending", "Pending"), + ("blocked", "Blocked"), + ] { + let selected = s.friends_tab == tab_key; + let display = if tab_key == "pending" && pending_count > 0 { + format!("{label} ({pending_count})") + } else { + label.to_string() + }; + let btn = egui::Button::new( + RichText::new(display) + .font(FontId::proportional(11.0)) + .color(if selected { + t::TEXT_PRIMARY + } else { + t::TEXT_MUTED + }), + ) + .fill(if selected { + t::BG_ELEVATED + } else { + egui::Color32::TRANSPARENT + }) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM); + if ui.add(btn).clicked() { + s.friends_tab = tab_key.to_string(); + } + } + }); + + ui.add_space(8.0); + + list::render_content(ui, s, net); + }); + + if s.add_friend_open { + show_add_friend_popup(ui.ctx(), s, net); + } +} + +fn show_add_friend_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) { + egui::Window::new("Add Friend") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(320.0); + ui.label( + RichText::new("Enter a user ID to send a friend request.") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ); + ui.add_space(8.0); + let resp = ui.add( + egui::TextEdit::singleline(&mut s.add_friend_input) + .font(FontId::monospace(13.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(RichText::new("user id (pubkey hex)").color(t::TEXT_DISABLED)) + .desired_width(280.0), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Cancel") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.add_friend_open = false; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let send_btn = egui::Button::new( + RichText::new("Send Request") + .font(FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ACCENT) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(110.0, 28.0)); + if ui.add(send_btn).clicked() && !s.add_friend_input.trim().is_empty() { + let net = net.clone(); + let target = s.add_friend_input.trim().to_string(); + tokio::spawn(async move { + net.send(NetCommand::FriendRequest { + target_user_id: target, + }) + .await; + }); + s.add_friend_open = false; + } + }); + }); + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + s.add_friend_open = false; + } + }); +} + +pub(super) fn section_header(ui: &mut egui::Ui, label: &str) { + ui.add_space(4.0); + ui.label( + RichText::new(label) + .font(FontId::proportional(10.0)) + .color(t::TEXT_MUTED) + .strong(), + ); + ui.add_space(2.0); +} + +pub(super) fn muted_label(ui: &mut egui::Ui, text: &str) { + ui.add_space(4.0); + ui.label( + RichText::new(text) + .font(FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); +} + +pub(super) fn status_color(status: &str) -> egui::Color32 { + match status { + "online" => t::SUCCESS, + "idle" => t::WARNING, + "dnd" => t::ERROR, + _ => t::TEXT_DISABLED, + } +} + +pub(super) fn status_label(status: &str) -> &str { + match status { + "online" => "online", + "idle" => "idle", + "dnd" => "do not disturb", + "offline" => "offline", + _ => status, + } +} diff --git a/src/ui/chat/friends/pending.rs b/src/ui/chat/friends/pending.rs new file mode 100644 index 0000000..1ede2cd --- /dev/null +++ b/src/ui/chat/friends/pending.rs @@ -0,0 +1,62 @@ +use eframe::egui::{self, FontId, Frame, RichText}; +use vnox_client::net::{NetCommand, NetHandle}; + +use crate::state::FriendState; +use crate::theme as t; + +pub(super) fn pending_row(ui: &mut egui::Ui, f: &FriendState, net: &NetHandle) { + Frame::NONE + .fill(t::BG_SURFACE) + .corner_radius(t::R_MD) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let init = f + .nickname + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + t::avatar(ui, &init, t::WARNING, 28.0); + ui.add_space(8.0); + ui.label( + RichText::new(&f.nickname) + .font(FontId::proportional(13.0)) + .color(t::TEXT_PRIMARY), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if t::icon_btn_drawn( + ui, + crate::theme::icons::check, + "accept", + Some(t::SUCCESS), + 18.0, + ) + .clicked() + { + let net = net.clone(); + let uid = f.user_id.clone(); + tokio::spawn(async move { + net.send(NetCommand::FriendAccept { user_id: uid }).await; + }); + } + if t::icon_btn_drawn( + ui, + crate::theme::icons::close, + "decline", + Some(t::ERROR), + 18.0, + ) + .clicked() + { + let net = net.clone(); + let uid = f.user_id.clone(); + tokio::spawn(async move { + net.send(NetCommand::FriendDecline { user_id: uid }).await; + }); + } + }); + }); + }); +} diff --git a/src/ui/chat/mod.rs b/src/ui/chat/mod.rs new file mode 100644 index 0000000..dac2e4b --- /dev/null +++ b/src/ui/chat/mod.rs @@ -0,0 +1,66 @@ +use eframe::egui::{self, Context, FontId, RichText}; + +use crate::state::UiState; +use crate::theme as t; +use vnox_client::{identity::Identity, net::NetHandle}; + +mod dm; +mod friends; +mod text; +mod voice; + +pub use friends::show as friends_panel; + +pub fn show( + ui: &mut egui::Ui, + ctx: &Context, + s: &mut UiState, + identity: &Identity, + net: &NetHandle, +) { + // If a DM conversation is active, show the DM chat view + if let Some(dm_id) = s.active_dm_id.clone() { + // Clear any active channel when showing DM + s.active_channel = None; + dm::dm_chat(ui, ctx, s, identity, net, &dm_id); + return; + } + + // Home screen — show Friends panel if no guild and no DM + if s.active_guild_id.is_none() { + friends::show(ui, s, net); + return; + } + + let ch_id = match s.active_channel.clone() { + Some(id) => id, + None => { + empty_state(ui, "pick a channel"); + return; + } + }; + + let ch = match s.channels.iter().find(|c| c.id == ch_id) { + Some(c) => c.clone(), + None => { + empty_state(ui, "channel not found"); + return; + } + }; + + if ch.kind == "voice" { + voice::voice_panel(ui, s, &ch); + } else { + text::text_chat(ui, ctx, s, net, identity, &ch_id, &ch.name); + } +} + +fn empty_state(ui: &mut egui::Ui, text: &str) { + ui.vertical_centered(|ui| { + ui.label( + RichText::new(text) + .font(FontId::monospace(12.0)) + .color(t::TEXT_DEAD), + ); + }); +} diff --git a/src/ui/chat/text/input.rs b/src/ui/chat/text/input.rs new file mode 100644 index 0000000..bbad55c --- /dev/null +++ b/src/ui/chat/text/input.rs @@ -0,0 +1,186 @@ +use crate::state::{ChatMessage, UiState}; +use crate::theme as t; +use eframe::egui::{self, Context, FontId, Frame, RichText}; +use std::collections::HashMap; +use std::time::Instant; +use vnox_client::{ + identity::Identity, + net::{NetCommand, NetHandle}, +}; + +pub(crate) fn chat_input_bar( + ui: &mut egui::Ui, + ctx: &Context, + s: &mut UiState, + net: &NetHandle, + ch_id: &str, + identity: &Identity, +) { + let r = ui.available_rect_before_wrap(); + ui.painter().hline( + r.x_range(), + r.top(), + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + // Reply / edit preview bar + if let Some((ref _reply_id, ref reply_sender)) = s.replying_to.clone() { + ui.horizontal(|ui| { + ui.label( + RichText::new("replying to") + .font(FontId::proportional(11.0)) + .color(t::ACCENT), + ); + let sender_nick = s.nick_for(reply_sender).to_string(); + ui.label( + RichText::new(sender_nick) + .font(FontId::proportional(11.0)) + .color(t::TEXT_SECONDARY), + ); + if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel reply", None, 14.0) + .clicked() + { + s.replying_to = None; + } + }); + } + if s.editing_message_id.is_some() { + ui.horizontal(|ui| { + ui.label( + RichText::new("editing") + .font(FontId::proportional(11.0)) + .color(t::WARNING), + ); + if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel edit", None, 14.0) + .clicked() + { + s.chat_input.clear(); + s.editing_message_id = None; + } + }); + } + ui.horizontal(|ui| { + let hint_text = if s.editing_message_id.is_some() { + "edit your message...".to_string() + } else if s.replying_to.is_some() { + "write your reply...".to_string() + } else { + format!("message in #{ch_id}") + }; + let input = egui::TextEdit::singleline(&mut s.chat_input) + .font(FontId::proportional(13.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(RichText::new(hint_text).color(t::TEXT_DISABLED)) + .frame(false) + .desired_width(ui.available_width() - 44.0); + + let enter = Frame::NONE + .fill(t::BG_INTERACTIVE) + .corner_radius(t::R_MD) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| ui.add(input)) + .inner; + + if !s.chat_input.is_empty() && s.last_typing_send.elapsed().as_secs() >= 3 { + s.last_typing_send = Instant::now(); + let net = net.clone(); + let c = ch_id.to_string(); + tokio::spawn(async move { + net.send(NetCommand::TypingStart { channel_id: c }).await; + }); + } + + let submit = (enter.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + || ui + .add( + egui::Button::new(RichText::new("→").color(t::ACCENT).size(14.0)) + .fill(t::ACCENT_10) + .stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT)) + .corner_radius(t::R_MD) + .min_size(egui::vec2(32.0, 32.0)), + ) + .clicked(); + + if submit && !s.chat_input.trim().is_empty() { + if let Some(edit_id) = s.editing_message_id.clone() { + let content = s.chat_input.trim().to_string(); + s.chat_input.clear(); + s.editing_message_id = None; + let net = net.clone(); + let cid = ch_id.to_string(); + tokio::spawn(async move { + net.send(NetCommand::MessageEdit { + channel_id: cid, + message_id: edit_id, + content, + }) + .await; + }); + } else { + let reply_to = s.replying_to.clone().map(|(id, _)| id); + send_chat(s, net, ch_id, &identity.pubkey_hex, reply_to); + s.replying_to = None; + } + ctx.request_repaint(); + } + }); + }); +} + +fn send_chat( + s: &mut UiState, + net: &NetHandle, + ch_id: &str, + sender_id: &str, + reply_to: Option, +) { + let content = s.chat_input.trim().to_string(); + if content.is_empty() { + return; + } + s.chat_input.clear(); + + s.messages + .entry(ch_id.to_string()) + .or_default() + .push(ChatMessage { + message_id: uuid::Uuid::new_v4().to_string(), + sender_id: sender_id.to_string(), + content: content.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + edited: false, + reactions: HashMap::new(), + reply_to: reply_to.clone(), + }); + s.scroll_bottom = true; + + let need_join = s.server_channel.as_deref() != Some(ch_id); + if need_join { + s.server_channel = Some(ch_id.to_string()); + } + + let net = net.clone(); + let ch = ch_id.to_string(); + tokio::spawn(async move { + if need_join { + net.send(NetCommand::JoinChannel { + channel_id: ch.clone(), + }) + .await; + } + net.send(NetCommand::SendChat { + channel_id: ch, + content, + reply_to, + }) + .await; + }); +} diff --git a/src/ui/chat/text/messages/list.rs b/src/ui/chat/text/messages/list.rs new file mode 100644 index 0000000..7c68bb8 --- /dev/null +++ b/src/ui/chat/text/messages/list.rs @@ -0,0 +1,75 @@ +use crate::state::{TYPING_TIMEOUT, UiState}; +use crate::theme as t; +use eframe::egui::{self, FontId, RichText, ScrollArea}; +use std::time::Instant; +use vnox_client::{identity::Identity, net::NetHandle}; + +use super::row::message_row; + +pub(crate) fn show( + ui: &mut egui::Ui, + s: &mut UiState, + net: &NetHandle, + identity: &Identity, + ch_id: &str, + max_h: f32, +) { + let msgs = s.messages.get(ch_id).cloned().unwrap_or_default(); + ScrollArea::vertical() + .max_height(max_h) + .auto_shrink([false; 2]) + .stick_to_bottom(s.scroll_bottom) + .show(ui, |ui| { + ui.add_space(12.0); + if msgs.is_empty() { + ui.label( + RichText::new("no messages yet") + .font(FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); + } else { + ui.label( + RichText::new("today") + .font(FontId::proportional(11.0)) + .color(t::TEXT_DISABLED), + ); + ui.add_space(8.0); + let mut prev: Option = None; + for msg in &msgs { + let group_start = prev.as_deref() != Some(&msg.sender_id); + message_row(ui, s, net, identity, msg, ch_id, group_start); + prev = Some(msg.sender_id.clone()); + } + } + typing_indicator(ui, s, ch_id); + ui.add_space(8.0); + }); + s.scroll_bottom = false; +} + +fn typing_indicator(ui: &mut egui::Ui, s: &mut UiState, ch_id: &str) { + let now = Instant::now(); + if let Some(typers) = s.typing_users.get_mut(ch_id) { + typers.retain(|(_, t)| now.duration_since(*t) < TYPING_TIMEOUT); + if typers.is_empty() { + s.typing_users.remove(ch_id); + return; + } + let names: Vec<&str> = typers.iter().map(|(n, _)| n.as_str()).collect(); + let label = if names.len() == 1 { + format!("{} is typing...", names[0]) + } else { + format!( + "{} and {} are typing...", + names[..names.len() - 1].join(", "), + names[names.len() - 1] + ) + }; + ui.add_space(4.0); + ui.label( + RichText::new(label) + .font(FontId::proportional(11.0)) + .color(t::TEXT_DISABLED), + ); + } +} diff --git a/src/ui/chat/text/messages/mod.rs b/src/ui/chat/text/messages/mod.rs new file mode 100644 index 0000000..7f68ad6 --- /dev/null +++ b/src/ui/chat/text/messages/mod.rs @@ -0,0 +1,130 @@ +pub mod list; +pub mod row; + +pub(crate) use list::show; +pub use row::message_row; + +use crate::state::{ChatMessage, UiState}; +use crate::theme as t; +use crate::theme::icons; +use eframe::egui::{self, RichText}; +use vnox_client::{ + identity::Identity, + net::{NetCommand, NetHandle}, +}; + +fn own_actions( + ui: &mut egui::Ui, + s: &mut UiState, + net: &NetHandle, + msg: &ChatMessage, + ch_id: &str, +) { + if t::icon_btn_drawn(ui, icons::pencil, "edit", None, 16.0).clicked() { + s.chat_input = msg.content.clone(); + s.editing_message_id = Some(msg.message_id.clone()); + } + if t::icon_btn_drawn(ui, icons::trash, "delete", None, 16.0).clicked() { + let net = net.clone(); + let mid = msg.message_id.clone(); + let cid = ch_id.to_string(); + tokio::spawn(async move { + net.send(NetCommand::MessageDelete { + channel_id: cid, + message_id: mid, + }) + .await; + }); + } +} + +fn reaction_buttons( + ui: &mut egui::Ui, + net: &NetHandle, + msg: &ChatMessage, + ch_id: &str, + identity: &Identity, +) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (emoji, users) in &msg.reactions { + let count = users.len(); + let has_reacted = users.iter().any(|u| u == &identity.pubkey_hex); + let bg = if has_reacted { + t::ACCENT_20 + } else { + t::BG_ELEVATED + }; + let border = if has_reacted { + t::BORDER_ACCENT + } else { + t::BORDER_SUBTLE + }; + let label = format!("{} {}", emoji, count); + if ui + .add( + egui::Button::new(RichText::new(&label).size(12.0).color(t::TEXT_SECONDARY)) + .fill(bg) + .stroke(egui::Stroke::new(1.0, border)) + .corner_radius(4.0) + .min_size(egui::Vec2::ZERO), + ) + .clicked() + { + let net = net.clone(); + let mid = msg.message_id.clone(); + let cid = ch_id.to_string(); + let e = emoji.clone(); + if has_reacted { + tokio::spawn(async move { + net.send(NetCommand::ReactionRemove { + channel_id: cid, + message_id: mid, + emoji: e, + }) + .await; + }); + } else { + tokio::spawn(async move { + net.send(NetCommand::ReactionAdd { + channel_id: cid, + message_id: mid, + emoji: e, + }) + .await; + }); + } + } + } + }); +} + +fn read_receipt_indicator(ui: &mut egui::Ui, s: &UiState) { + if let Some(ch) = &s.active_channel + && s.read_receipts.contains_key(ch) + { + ui.add_space(6.0); + let (rr, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover()); + icons::check(ui.painter(), rr, t::TEXT_DISABLED); + } +} + +fn fmt_ts(ts: i64) -> String { + let s = (ts / 1000).abs() % 86400; + format!("{:02}:{:02}", s / 3600, (s % 3600) / 60) +} + +fn nick_color(s: &UiState, sender_id: &str) -> egui::Color32 { + if let Some(color) = s.user_colors.get(sender_id) { + return *color; + } + let nick = s.nick_for(sender_id); + let c = [ + t::ACCENT, + t::SUCCESS, + t::WARNING, + t::INFO, + egui::Color32::from_rgb(200, 100, 180), + ]; + c[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % c.len()] +} diff --git a/src/ui/chat/text/messages/row.rs b/src/ui/chat/text/messages/row.rs new file mode 100644 index 0000000..c49b196 --- /dev/null +++ b/src/ui/chat/text/messages/row.rs @@ -0,0 +1,220 @@ +use crate::state::{ChatMessage, UiState}; +use crate::theme as t; +use eframe::egui::{self, FontId, Frame, RichText}; +use vnox_client::{ + identity::Identity, + net::{NetCommand, NetHandle}, +}; + +use super::{fmt_ts, nick_color, own_actions, reaction_buttons, read_receipt_indicator}; + +pub fn message_row( + ui: &mut egui::Ui, + s: &mut UiState, + net: &NetHandle, + identity: &Identity, + msg: &ChatMessage, + ch_id: &str, + group_start: bool, +) { + let nick = s.nick_for(&msg.sender_id).to_string(); + let is_own = msg.sender_id == identity.pubkey_hex; + let msg_id = msg.message_id.clone(); + let sender_id = msg.sender_id.clone(); + let content_clone = msg.content.clone(); + + let row_response = Frame::NONE + .inner_margin(egui::Margin { + left: 16, + right: 16, + top: if group_start { 6 } else { 1 }, + bottom: 0, + }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + if group_start { + let init = nick + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + t::avatar(ui, &init, nick_color(s, &msg.sender_id), 32.0); + } else { + ui.add_space(32.0); + } + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + if group_start { + ui.horizontal(|ui| { + ui.label( + RichText::new(&nick) + .font(FontId::proportional(13.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + ui.label( + RichText::new(fmt_ts(msg.timestamp)) + .font(FontId::proportional(11.0)) + .color(t::TEXT_DISABLED), + ); + if msg.edited { + ui.label( + RichText::new("(edited)") + .font(FontId::proportional(10.0)) + .color(t::TEXT_DISABLED), + ); + } + if is_own { + own_actions(ui, s, net, msg, ch_id); + } + }); + } + // Reply indicator + if let Some(reply_id) = &msg.reply_to { + let reply_snippet = s + .messages + .get(ch_id) + .and_then(|msgs| msgs.iter().find(|m| &m.message_id == reply_id)) + .map(|m| { + let reply_nick = s.nick_for(&m.sender_id).to_string(); + let snippet: String = m.content.chars().take(60).collect(); + format!("→ {reply_nick}: {snippet}") + }) + .unwrap_or_else(|| "→ (deleted message)".to_string()); + ui.label( + RichText::new(reply_snippet) + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED) + .italics(), + ); + } + ui.horizontal(|ui| { + ui.label( + RichText::new(&msg.content) + .font(FontId::proportional(13.0)) + .color(t::TEXT_SECONDARY), + ); + read_receipt_indicator(ui, s); + }); + if !msg.reactions.is_empty() { + reaction_buttons(ui, net, msg, ch_id, identity); + } + }); + }); + }) + .response; + + // Right-click context menu (egui 0.33 API: Response::context_menu). + row_response.context_menu(|ui| { + s.context_menu_message_id = Some(msg_id.clone()); + let quick_reactions = ["👍", "❤️", "😂", "🎉", "👀", "🤔"]; + ui.horizontal(|ui| { + for emoji in quick_reactions { + let has_reacted = msg + .reactions + .get(emoji) + .is_some_and(|users| users.contains(&identity.pubkey_hex)); + let btn_text = if has_reacted { + format!("{emoji} +") + } else { + emoji.to_string() + }; + if ui + .button(RichText::new(btn_text).font(FontId::proportional(14.0))) + .clicked() + { + let net = net.clone(); + let mid = msg_id.clone(); + let cid = ch_id.to_string(); + let e = emoji.to_string(); + if has_reacted { + tokio::spawn(async move { + net.send(NetCommand::ReactionRemove { + channel_id: cid, + message_id: mid, + emoji: e, + }) + .await; + }); + } else { + tokio::spawn(async move { + net.send(NetCommand::ReactionAdd { + channel_id: cid, + message_id: mid, + emoji: e, + }) + .await; + }); + } + ui.close(); + } + } + }); + ui.separator(); + if ui + .add( + egui::Button::new(RichText::new("Reply").font(FontId::proportional(12.0))) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.replying_to = Some((msg_id.clone(), sender_id.clone())); + s.editing_message_id = None; + ui.close(); + } + if ui + .add( + egui::Button::new(RichText::new("Copy text").font(FontId::proportional(12.0))) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + ui.ctx().copy_text(content_clone.clone()); + ui.close(); + } + if is_own { + ui.separator(); + if ui + .add( + egui::Button::new(RichText::new("Edit").font(FontId::proportional(12.0))) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.chat_input = content_clone.clone(); + s.editing_message_id = Some(msg_id.clone()); + s.replying_to = None; + ui.close(); + } + if ui + .add( + egui::Button::new( + RichText::new("Delete") + .font(FontId::proportional(12.0)) + .color(t::ERROR), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + let net = net.clone(); + let mid = msg_id.clone(); + let cid = ch_id.to_string(); + tokio::spawn(async move { + net.send(NetCommand::MessageDelete { + channel_id: cid, + message_id: mid, + }) + .await; + }); + ui.close(); + } + } + }); +} diff --git a/src/ui/chat/text/mod.rs b/src/ui/chat/text/mod.rs new file mode 100644 index 0000000..ab6886f --- /dev/null +++ b/src/ui/chat/text/mod.rs @@ -0,0 +1,77 @@ +mod input; +mod messages; + +use eframe::egui::{self, Context, FontId, Frame, RichText}; +use vnox_client::{identity::Identity, net::NetHandle}; + +use crate::state::UiState; +use crate::theme as t; + +pub(super) use messages::message_row; + +pub(crate) fn text_chat( + ui: &mut egui::Ui, + ctx: &Context, + s: &mut UiState, + net: &NetHandle, + identity: &Identity, + ch_id: &str, + ch_name: &str, +) { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(16, 0)) + .show(ui, |ui| { + ui.set_height(40.0); + ui.horizontal_centered(|ui| { + ui.label(RichText::new("#").color(t::TEXT_MUTED).size(16.0)); + ui.add_space(4.0); + ui.label( + RichText::new(ch_name) + .font(FontId::proportional(14.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + }); + }); + let r = ui.max_rect(); + ui.painter().hline( + r.x_range(), + r.top() + 40.0, + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + if let Some(elapsed) = s.voice_elapsed() { + voice_banner(ui, s, &elapsed); + } + + let input_h = 52.0; + messages::show(ui, s, net, identity, ch_id, ui.available_height() - input_h); + input::chat_input_bar(ui, ctx, s, net, ch_id, identity); + }); +} + +fn voice_banner(ui: &mut egui::Ui, s: &UiState, elapsed: &str) { + Frame::NONE + .fill(t::BG_ELEVATED) + .inner_margin(egui::Margin::symmetric(16, 5)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(egui::vec2(7.0, 7.0), egui::Sense::hover()); + ui.painter().circle_filled(dot.center(), 3.5, t::SUCCESS); + ui.label(RichText::new("voice").color(t::SUCCESS).size(12.0)); + ui.label(RichText::new("·").color(t::TEXT_DISABLED).size(12.0)); + if let Some(ch) = s.active_voice_channel() { + ui.label(RichText::new(&ch.name).color(t::TEXT_SECONDARY).size(12.0)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(elapsed) + .font(FontId::monospace(11.0)) + .color(t::TEXT_DISABLED), + ); + }); + }); + }); +} diff --git a/src/ui/chat/voice/mod.rs b/src/ui/chat/voice/mod.rs new file mode 100644 index 0000000..8b792d4 --- /dev/null +++ b/src/ui/chat/voice/mod.rs @@ -0,0 +1,147 @@ +mod panel; + +use eframe::egui::{self, FontId, Frame, RichText}; + +use crate::state::{Channel, UiState, VoiceUiState}; +use crate::theme as t; + +pub(crate) fn voice_panel(ui: &mut egui::Ui, s: &mut UiState, ch: &Channel) { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(16, 0)) + .show(ui, |ui| { + ui.set_height(40.0); + ui.horizontal_centered(|ui| { + ui.label(RichText::new("~").color(t::TEXT_MUTED).size(15.0)); + ui.add_space(4.0); + ui.label( + RichText::new(&ch.name) + .font(FontId::proportional(14.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + if let Some(elapsed) = s.voice_elapsed() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(elapsed) + .font(FontId::monospace(11.0)) + .color(t::TEXT_DISABLED), + ); + }); + } + }); + }); + + let r = ui.max_rect(); + ui.painter().hline( + r.x_range(), + r.top() + 40.0, + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + ui.add_space(16.0); + Frame::NONE + .inner_margin(egui::Margin::symmetric(20, 0)) + .show(ui, |ui| { + // Voice activity banner — shows when local or remote user is speaking + if s.local_speaking || s.remote_speaking { + voice_activity_banner(ui, s); + ui.add_space(8.0); + } + if ch.members.is_empty() { + ui.label( + RichText::new("no members yet") + .font(FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); + } + // Identify which member is currently speaking by sender_id. + let speaking_uid = if !s.last_remote_speaker_id.is_empty() { + Some(s.last_remote_speaker_id.clone()) + } else { + None + }; + for nick in &ch.members { + let user_id = s + .user_names + .iter() + .find(|(_, n)| *n == nick) + .map(|(id, _)| id.clone()) + .unwrap_or_else(|| nick.clone()); + let member_speaking = speaking_uid.as_deref() == Some(&user_id); + panel::member_card( + ui, + nick, + &user_id, + &mut s.per_user_volumes, + member_speaking, + ); + ui.add_space(6.0); + } + }); + + if let VoiceUiState::Error(ref err) = s.voice { + ui.add_space(16.0); + Frame::NONE + .fill(egui::Color32::from_rgba_premultiplied(217, 96, 74, 20)) + .corner_radius(t::R_MD) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_premultiplied(217, 96, 74, 60), + )) + .inner_margin(egui::Margin::same(12)) + .show(ui, |ui| { + ui.label( + RichText::new(format!("microphone error: {err}")) + .font(FontId::proportional(12.0)) + .color(t::ERROR), + ); + }); + } + + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + ui.add_space(16.0); + panel::voice_controls(ui, s); + }); + }); +} + +fn voice_activity_banner(ui: &mut egui::Ui, s: &UiState) { + let remote_nick: Option = if !s.last_remote_speaker_id.is_empty() { + Some(s.nick_for(&s.last_remote_speaker_id).to_string()) + } else { + None + }; + let (label, color) = match (s.local_speaking, s.remote_speaking, remote_nick.as_deref()) { + (true, true, Some(nick)) => (format!("you and {nick} are talking"), t::SUCCESS), + (true, true, None) => ("you and others are talking".to_string(), t::SUCCESS), + (true, false, _) => ("you are talking".to_string(), t::SUCCESS), + (false, true, Some(nick)) => (format!("{nick} is talking"), t::INFO), + (false, true, None) => ("someone is talking".to_string(), t::INFO), + _ => return, + }; + Frame::NONE + .fill(egui::Color32::from_rgba_premultiplied( + color.r(), + color.g(), + color.b(), + 20, + )) + .corner_radius(t::R_MD) + .stroke(egui::Stroke::new(1.0, color)) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Pulsating dot + let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 5.0, color); + ui.label( + RichText::new(label) + .font(FontId::proportional(12.0)) + .color(color) + .strong(), + ); + }); + }); +} diff --git a/src/ui/chat/voice/panel.rs b/src/ui/chat/voice/panel.rs new file mode 100644 index 0000000..ee0a3d0 --- /dev/null +++ b/src/ui/chat/voice/panel.rs @@ -0,0 +1,199 @@ +use eframe::egui::{self, FontId, Frame, Rect, RichText}; + +use crate::state::UiState; +use crate::theme as t; +use crate::theme::icons; + +pub(super) fn member_card( + ui: &mut egui::Ui, + nick: &str, + user_id: &str, + volumes: &mut std::collections::HashMap, + speaking: bool, +) { + let vol = volumes.entry(user_id.to_string()).or_insert(80.0); + let avatar_color = if speaking { + t::SUCCESS + } else { + nick_color(nick) + }; + Frame::NONE + .fill(if speaking { + egui::Color32::from_rgba_premultiplied( + t::SUCCESS.r(), + t::SUCCESS.g(), + t::SUCCESS.b(), + 15, + ) + } else { + t::BG_ELEVATED + }) + .corner_radius(t::R_MD) + .stroke(if speaking { + egui::Stroke::new(1.0, t::SUCCESS) + } else { + egui::Stroke::new(1.0, t::BORDER_DEFAULT) + }) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let init = nick + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + t::avatar(ui, &init, avatar_color, 28.0); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(nick) + .font(FontId::proportional(13.0)) + .color(if speaking { + t::SUCCESS + } else { + t::TEXT_PRIMARY + }) + .strong(), + ); + if speaking { + let (sr, _) = ui + .allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + icons::speaker(ui.painter(), sr, t::SUCCESS); + } + }); + ui.horizontal(|ui| { + ui.label( + RichText::new("vol") + .font(FontId::proportional(9.0)) + .color(t::TEXT_DISABLED), + ); + ui.add( + egui::Slider::new(vol, 0.0..=100.0) + .show_value(false) + .smallest_positive(0.0), + ); + }); + }); + }); + }); +} + +pub(super) fn voice_controls(ui: &mut egui::Ui, s: &mut UiState) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + + // Microphone button — drawn icon. + let mic_color = if s.mic_enabled { + t::TEXT_SECONDARY + } else { + t::ERROR + }; + let mic_fill = if s.mic_enabled { + t::BG_ELEVATED + } else { + egui::Color32::from_rgba_premultiplied(217, 96, 74, 20) + }; + let mic_stroke = if s.mic_enabled { + egui::Stroke::new(1.0, t::BORDER_DEFAULT) + } else { + egui::Stroke::new(1.0, egui::Color32::from_rgba_premultiplied(217, 96, 74, 80)) + }; + let (mic_rect, mic_resp) = + ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click()); + if ui.is_rect_visible(mic_rect) { + let painter = ui.painter(); + painter.rect_filled(mic_rect, t::R_FULL, mic_fill); + painter.rect_stroke(mic_rect, t::R_FULL, mic_stroke, egui::StrokeKind::Middle); + let mic_icon = if s.mic_enabled { + icons::microphone + } else { + icons::microphone_off + }; + let inner = inset(mic_rect, 10.0); + mic_icon(painter, inner, mic_color); + } + if mic_resp.clicked() { + s.mic_enabled = !s.mic_enabled; + } + mic_resp.on_hover_text("microphone"); + + // Deafen button — drawn icon. + let deaf_color = if s.deafen { + t::ERROR + } else { + t::TEXT_SECONDARY + }; + let deaf_fill = if s.deafen { + egui::Color32::from_rgba_premultiplied(217, 96, 74, 20) + } else { + t::BG_ELEVATED + }; + let (deaf_rect, deaf_resp) = + ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click()); + if ui.is_rect_visible(deaf_rect) { + let painter = ui.painter(); + painter.rect_filled(deaf_rect, t::R_FULL, deaf_fill); + painter.rect_stroke( + deaf_rect, + t::R_FULL, + egui::Stroke::new(1.0, t::BORDER_DEFAULT), + egui::StrokeKind::Middle, + ); + let deaf_icon = if s.deafen { + icons::headphones_off + } else { + icons::headphones + }; + let inner = inset(deaf_rect, 10.0); + deaf_icon(painter, inner, deaf_color); + } + if deaf_resp.clicked() { + s.deafen = !s.deafen; + } + deaf_resp.on_hover_text("deafen"); + + // Leave button — drawn X on red background. + let (leave_rect, leave_resp) = + ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click()); + if ui.is_rect_visible(leave_rect) { + let painter = ui.painter(); + painter.rect_filled(leave_rect, t::R_FULL, t::ERROR); + let inner = inset(leave_rect, 12.0); + icons::close(painter, inner, egui::Color32::from_rgb(26, 16, 8)); + } + if leave_resp.clicked() + && let Some(id) = s.active_channel.clone() + { + if s.channels.iter().any(|c| c.id == id && c.kind == "text") { + s.active_channel = Some("general".into()); + } else { + s.active_channel = s + .channels + .iter() + .find(|c| c.kind == "text") + .map(|c| c.id.clone()); + } + } + leave_resp.on_hover_text("leave voice channel"); + }); +} + +fn inset(rect: Rect, pad: f32) -> Rect { + Rect::from_min_max( + (rect.min.x + pad, rect.min.y + pad).into(), + (rect.max.x - pad, rect.max.y - pad).into(), + ) +} + +fn nick_color(nick: &str) -> egui::Color32 { + let colors = [ + t::ACCENT, + t::SUCCESS, + t::WARNING, + t::INFO, + egui::Color32::from_rgb(200, 100, 180), + ]; + colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()] +} diff --git a/src/ui/connect/action.rs b/src/ui/connect/action.rs new file mode 100644 index 0000000..ea753be --- /dev/null +++ b/src/ui/connect/action.rs @@ -0,0 +1,45 @@ +use eframe::egui::Color32; + +use crate::state::{ConnState, UiState}; +use crate::theme as t; +use vnox_client::net::{NetCommand, NetHandle}; + +pub fn disconnect(s: &mut UiState, net: &NetHandle) { + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::Disconnect).await; + }); + s.conn = ConnState::Disconnected; +} + +pub fn connect_to(s: &mut UiState, net: &NetHandle, addr: &str) { + if addr.is_empty() { + return; + } + if let Some(i) = s.bookmarks.iter().position(|b| b.address == addr) { + s.selected_bookmark = i; + } + s.connect_input = addr.to_string(); + s.conn = ConnState::Connecting; + let net = net.clone(); + let address = addr.to_string(); + let auto_reconnect = s.auto_reconnect; + tokio::spawn(async move { + net.send(NetCommand::Connect { + address, + auto_reconnect, + }) + .await; + }); +} + +pub(crate) fn msg_color(msg: &str) -> Color32 { + let l = msg.to_ascii_lowercase(); + if l.contains("error") || l.contains("fail") || l.contains("refuse") { + t::DANGER + } else if l.contains("connected") { + t::SUCCESS + } else { + t::TEXT_MUTED + } +} diff --git a/src/ui/connect/bookmarks.rs b/src/ui/connect/bookmarks.rs new file mode 100644 index 0000000..54fc28d --- /dev/null +++ b/src/ui/connect/bookmarks.rs @@ -0,0 +1,130 @@ +mod add; + +use eframe::egui::{self, Frame}; +use vnox_client::net::NetHandle; + +use crate::state::{ConnState, UiState}; +use crate::theme as t; + +use super::action::connect_to; + +pub fn server_list(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + Frame::NONE + .fill(t::BG_ELEVATED) + .corner_radius(t::R_LG) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .inner_margin(egui::Margin::same(16)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("SERVERS") + .font(egui::FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let add_btn = egui::Button::new( + egui::RichText::new("+") + .font(egui::FontId::proportional(16.0)) + .color(t::ACCENT_SOFT), + ) + .fill(t::ACCENT_10) + .stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT)) + .corner_radius(t::R_SM) + .min_size(egui::vec2(28.0, 28.0)); + if ui.add(add_btn).on_hover_text("add server").clicked() { + s.add_server_open = !s.add_server_open; + if s.add_server_open { + s.add_server_input = String::new(); + } + } + }); + }); + if s.add_server_open { + add::add_form(ui, s); + } + ui.add_space(4.0); + let busy = matches!( + s.conn, + ConnState::Connecting | ConnState::Reconnecting { .. } + ); + + if s.bookmarks.is_empty() { + ui.add_space(12.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new("no servers yet — click + to add one") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); + }); + ui.add_space(12.0); + } else { + for (i, bm) in s.bookmarks.clone().into_iter().enumerate() { + let active = s.connect_input == bm.address; + let bg = if active { + t::ACCENT_10 + } else { + egui::Color32::TRANSPARENT + }; + let stroke = if active { + egui::Stroke::new(1.0, t::BORDER_ACCENT) + } else { + egui::Stroke::NONE + }; + + let resp = Frame::NONE + .fill(bg) + .stroke(stroke) + .corner_radius(t::R_SM) + .inner_margin(egui::Margin::symmetric(8, 4)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("🖥").font(egui::FontId::proportional(13.0)), + ); + ui.add_space(6.0); + ui.vertical(|ui| { + ui.label( + egui::RichText::new(&bm.label) + .font(egui::FontId::proportional(13.0)) + .color(t::TEXT_PRIMARY), + ); + ui.label( + egui::RichText::new(&bm.address) + .font(egui::FontId::monospace(10.0)) + .color(t::TEXT_MUTED), + ); + }); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if t::icon_btn_drawn( + ui, + crate::theme::icons::close, + "remove bookmark", + None, + 16.0, + ) + .clicked() + { + s.remove_bookmark(i); + } + }, + ); + }); + }) + .response + .interact(egui::Sense::click()); + + if resp.clicked() && !busy { + s.connect_input = bm.address.clone(); + s.selected_bookmark = i; + if matches!(s.conn, ConnState::Disconnected) { + connect_to(s, net, &bm.address); + } + } + } + } + }); +} diff --git a/src/ui/connect/bookmarks/add.rs b/src/ui/connect/bookmarks/add.rs new file mode 100644 index 0000000..41a3110 --- /dev/null +++ b/src/ui/connect/bookmarks/add.rs @@ -0,0 +1,51 @@ +use crate::state::UiState; +use crate::theme as t; +use eframe::egui::{self, Frame}; + +pub fn add_form(ui: &mut egui::Ui, s: &mut UiState) { + ui.add_space(8.0); + let mut addr = s.add_server_input.clone(); + Frame::NONE + .fill(t::BG_INTERACTIVE) + .corner_radius(t::R_SM) + .stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut addr) + .font(egui::FontId::monospace(12.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text( + egui::RichText::new("ip:port or host:port").color(t::TEXT_DISABLED), + ) + .desired_width(ui.available_width() - 120.0) + .frame(false), + ); + let save_btn = egui::Button::new( + egui::RichText::new("Save") + .font(egui::FontId::proportional(11.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ) + .fill(t::BG_INTERACTIVE) + .stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT)) + .corner_radius(t::R_SM) + .min_size(egui::vec2(50.0, 24.0)); + if ui.add(save_btn).clicked() && !addr.trim().is_empty() { + s.connect_input = addr.trim().to_string(); + s.add_bookmark_from_input(); + s.add_server_open = false; + s.add_server_input = String::new(); + s.connect_input = String::new(); + } + if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel", None, 18.0).clicked() + { + s.add_server_open = false; + s.add_server_input = String::new(); + } + }); + }); + s.add_server_input = addr; + ui.add_space(6.0); +} diff --git a/src/ui/connect/mod.rs b/src/ui/connect/mod.rs new file mode 100644 index 0000000..7e96c1f --- /dev/null +++ b/src/ui/connect/mod.rs @@ -0,0 +1,149 @@ +use eframe::egui::{self, Frame}; +use vnox_client::{identity::Identity, net::NetHandle}; + +use crate::state::{ConnState, UiState}; +use crate::theme as t; + +mod action; +mod bookmarks; + +pub use action::{connect_to, disconnect}; + +pub fn show(ui: &mut egui::Ui, s: &mut UiState, _identity: &Identity, net: &NetHandle) { + ui.vertical_centered(|ui| { + ui.set_max_width(520.0); + ui.add_space(40.0); + + ui.label( + egui::RichText::new("VNOX") + .font(egui::FontId::proportional(32.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + ui.add_space(2.0); + ui.label( + egui::RichText::new("self-hosted voice & chat") + .font(egui::FontId::proportional(13.0)) + .color(t::TEXT_MUTED), + ); + ui.add_space(32.0); + + bookmarks::server_list(ui, s, net); + + ui.add_space(20.0); + + ui.label( + egui::RichText::new("Quick Connect") + .font(egui::FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + ui.add_space(6.0); + + Frame::NONE + .fill(t::BG_INTERACTIVE) + .corner_radius(t::R_MD) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(">") + .font(egui::FontId::monospace(12.0)) + .color(t::ACCENT_SOFT), + ); + let w = ui.available_width().max(80.0); + ui.add( + egui::TextEdit::singleline(&mut s.connect_input) + .font(egui::FontId::monospace(13.0)) + .text_color(t::TEXT_PRIMARY) + .frame(false) + .hint_text( + egui::RichText::new("127.0.0.1:7600").color(t::TEXT_DISABLED), + ) + .desired_width(w), + ); + }); + }); + + let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.add_space(8.0); + + let label = match &s.conn { + ConnState::Disconnected => "Connect", + ConnState::Connecting | ConnState::Reconnecting { .. } => "Cancel", + ConnState::Connected { .. } => "Disconnect", + }; + let btn = egui::Button::new( + egui::RichText::new(label) + .font(egui::FontId::proportional(13.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT)) + .corner_radius(t::R_MD) + .min_size(egui::vec2(200.0, 38.0)); + + if ui.add(btn).clicked() || enter_pressed { + match &s.conn { + ConnState::Disconnected => { + let addr = s.connect_input.trim().to_owned(); + if !addr.is_empty() { + connect_to(s, net, &addr); + } + } + _ => disconnect(s, net), + } + } + + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.checkbox(&mut s.auto_reconnect, ""); + ui.label( + egui::RichText::new("auto-reconnect on drop") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ); + }); + + if let Some(ref msg) = s.status_msg { + ui.add_space(8.0); + ui.label( + egui::RichText::new(msg) + .font(egui::FontId::proportional(11.0)) + .color(action::msg_color(msg)), + ); + } + + ui.add_space(28.0); + let sep = ui.available_rect_before_wrap(); + let painter = ui.painter(); + painter.hline( + sep.x_range(), + sep.top(), + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + ui.add_space(12.0); + + ui.horizontal(|ui| { + ui.label(egui::RichText::new("💡").font(egui::FontId::proportional(13.0))); + ui.label( + egui::RichText::new("Host your own —") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ); + ui.label( + egui::RichText::new("github.com/loki5512344/Vnox") + .font(egui::FontId::proportional(12.0)) + .color(t::ACCENT_SOFT), + ); + ui.label( + egui::RichText::new(" · self-host in 5 min") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); + }); + + ui.add_space(20.0); + }); +} diff --git a/src/ui/keybind/bindings.rs b/src/ui/keybind/bindings.rs new file mode 100644 index 0000000..ad49ea8 --- /dev/null +++ b/src/ui/keybind/bindings.rs @@ -0,0 +1,133 @@ +use eframe::egui::Key; + +pub(crate) fn name_from_key(key: Key) -> Option<&'static str> { + match key { + Key::Space => Some("space"), + Key::A => Some("a"), + Key::B => Some("b"), + Key::C => Some("c"), + Key::D => Some("d"), + Key::E => Some("e"), + Key::F => Some("f"), + Key::G => Some("g"), + Key::H => Some("h"), + Key::I => Some("i"), + Key::J => Some("j"), + Key::K => Some("k"), + Key::L => Some("l"), + Key::M => Some("m"), + Key::N => Some("n"), + Key::O => Some("o"), + Key::P => Some("p"), + Key::Q => Some("q"), + Key::R => Some("r"), + Key::S => Some("s"), + Key::T => Some("t"), + Key::U => Some("u"), + Key::V => Some("v"), + Key::W => Some("w"), + Key::X => Some("x"), + Key::Y => Some("y"), + Key::Z => Some("z"), + Key::F1 => Some("f1"), + Key::F2 => Some("f2"), + Key::F3 => Some("f3"), + Key::F4 => Some("f4"), + Key::F5 => Some("f5"), + Key::F6 => Some("f6"), + Key::F7 => Some("f7"), + Key::F8 => Some("f8"), + Key::F9 => Some("f9"), + Key::F10 => Some("f10"), + Key::F11 => Some("f11"), + Key::F12 => Some("f12"), + _ => None, + } +} + +pub(crate) fn key_from_name(name: &str) -> Option { + match name { + "a" => Some(Key::A), + "b" => Some(Key::B), + "c" => Some(Key::C), + "d" => Some(Key::D), + "e" => Some(Key::E), + "f" => Some(Key::F), + "g" => Some(Key::G), + "h" => Some(Key::H), + "i" => Some(Key::I), + "j" => Some(Key::J), + "k" => Some(Key::K), + "l" => Some(Key::L), + "m" => Some(Key::M), + "n" => Some(Key::N), + "o" => Some(Key::O), + "p" => Some(Key::P), + "q" => Some(Key::Q), + "r" => Some(Key::R), + "s" => Some(Key::S), + "t" => Some(Key::T), + "u" => Some(Key::U), + "v" => Some(Key::V), + "w" => Some(Key::W), + "x" => Some(Key::X), + "y" => Some(Key::Y), + "z" => Some(Key::Z), + "space" => Some(Key::Space), + "f1" => Some(Key::F1), + "f2" => Some(Key::F2), + "f3" => Some(Key::F3), + "f4" => Some(Key::F4), + "f5" => Some(Key::F5), + "f6" => Some(Key::F6), + "f7" => Some(Key::F7), + "f8" => Some(Key::F8), + "f9" => Some(Key::F9), + "f10" => Some(Key::F10), + "f11" => Some(Key::F11), + "f12" => Some(Key::F12), + _ => None, + } +} + +pub(crate) const ALL_KEYS: &[Key] = &[ + Key::A, + Key::B, + Key::C, + Key::D, + Key::E, + Key::F, + Key::G, + Key::H, + Key::I, + Key::J, + Key::K, + Key::L, + Key::M, + Key::N, + Key::O, + Key::P, + Key::Q, + Key::R, + Key::S, + Key::T, + Key::U, + Key::V, + Key::W, + Key::X, + Key::Y, + Key::Z, + Key::Space, + Key::F1, + Key::F2, + Key::F3, + Key::F4, + Key::F5, + Key::F6, + Key::F7, + Key::F8, + Key::F9, + Key::F10, + Key::F11, + Key::F12, +]; diff --git a/src/ui/keybind/mod.rs b/src/ui/keybind/mod.rs new file mode 100644 index 0000000..23dc066 --- /dev/null +++ b/src/ui/keybind/mod.rs @@ -0,0 +1,114 @@ +//! Global hotkey detection (egui input snapshot). + +mod bindings; + +use eframe::egui::{self, Key, PointerButton}; + +use bindings::{ALL_KEYS, key_from_name, name_from_key}; + +/// True while the binding is held (push-to-talk). +pub fn binding_held(ctx: &egui::Context, binding: &str) -> bool { + let b = binding.trim().to_lowercase(); + ctx.input(|i| match b.as_str() { + "mouse4" | "mb4" | "x1" => i.pointer.button_down(PointerButton::Extra1), + "mouse5" | "mb5" | "x2" => i.pointer.button_down(PointerButton::Extra2), + "lmb" | "mouse1" => i.pointer.button_down(PointerButton::Primary), + "rmb" | "mouse2" => i.pointer.button_down(PointerButton::Secondary), + "mmb" | "mouse3" => i.pointer.button_down(PointerButton::Middle), + "space" => i.key_down(Key::Space), + _ => combo_down(&b, i), + }) +} + +/// Rising edge (toggle mute/deafen, etc.). +pub fn binding_pressed(ctx: &egui::Context, binding: &str) -> bool { + let b = binding.trim().to_lowercase(); + ctx.input(|i| match b.as_str() { + "mouse4" | "mb4" | "x1" => i.pointer.button_pressed(PointerButton::Extra1), + "mouse5" | "mb5" | "x2" => i.pointer.button_pressed(PointerButton::Extra2), + "ctrl+m" => i.modifiers.ctrl && i.key_pressed(Key::M), + "ctrl+d" => i.modifiers.ctrl && i.key_pressed(Key::D), + "ctrl+shift+o" => i.modifiers.ctrl && i.modifiers.shift && i.key_pressed(Key::O), + _ => combo_pressed(&b, i), + }) +} + +/// Listen for any key/mouse press and return a binding string like "ctrl+m" or "mouse4". +pub fn listen_pressed(ctx: &egui::Context) -> Option { + ctx.input(|i| { + for &key in ALL_KEYS { + if i.key_pressed(key) { + let mut parts: Vec<&str> = Vec::new(); + if i.modifiers.ctrl { + parts.push("ctrl"); + } + if i.modifiers.shift { + parts.push("shift"); + } + if i.modifiers.alt { + parts.push("alt"); + } + if let Some(name) = name_from_key(key) { + parts.push(name); + return Some(parts.join("+")); + } + } + } + if i.pointer.button_pressed(PointerButton::Extra1) { + return Some("mouse4".into()); + } + if i.pointer.button_pressed(PointerButton::Extra2) { + return Some("mouse5".into()); + } + if i.pointer.button_pressed(PointerButton::Middle) { + return Some("mouse3".into()); + } + None + }) +} + +fn combo_down(binding: &str, i: &egui::InputState) -> bool { + let Some((mods, key)) = parse_combo(binding) else { + return false; + }; + mods.ctrl == i.modifiers.ctrl + && mods.shift == i.modifiers.shift + && mods.alt == i.modifiers.alt + && i.key_down(key) +} + +fn combo_pressed(binding: &str, i: &egui::InputState) -> bool { + let Some((mods, key)) = parse_combo(binding) else { + return false; + }; + mods.ctrl == i.modifiers.ctrl + && mods.shift == i.modifiers.shift + && mods.alt == i.modifiers.alt + && i.key_pressed(key) +} + +#[derive(Default)] +struct Mods { + ctrl: bool, + shift: bool, + alt: bool, +} + +fn parse_combo(binding: &str) -> Option<(Mods, Key)> { + let parts: Vec<&str> = binding.split('+').map(str::trim).collect(); + if parts.is_empty() { + return None; + } + let mut mods = Mods::default(); + let key_name = *parts.last()?; + for p in &parts[..parts.len().saturating_sub(1)] { + match *p { + "ctrl" | "control" => mods.ctrl = true, + "shift" => mods.shift = true, + "alt" => mods.alt = true, + _ => {} + } + } + let key = key_from_name(key_name)?; + Some((mods, key)) +} diff --git a/src/ui/members/mod.rs b/src/ui/members/mod.rs new file mode 100644 index 0000000..b779b07 --- /dev/null +++ b/src/ui/members/mod.rs @@ -0,0 +1,161 @@ +//! Right-hand member list panel — shows online/offline members +//! of the currently active channel. + +use eframe::egui::{ + self, Color32, Context, FontId, Frame, RichText, ScrollArea, SidePanel, Stroke, +}; + +use crate::state::{Channel, UiState}; +use crate::theme as t; + +const PANEL_WIDTH: f32 = 220.0; + +pub fn show(ctx: &Context, s: &mut UiState) { + // Only show for guild text channels (not DMs, not voice-only view, not home/friends). + let ch_id = match &s.active_channel { + Some(id) => id.clone(), + None => return, + }; + if s.active_guild_id.is_none() { + return; + } + if s.active_dm_id.is_some() { + return; + } + let channel = match s.channels.iter().find(|c| c.id == ch_id) { + Some(c) => c.clone(), + None => return, + }; + if channel.kind == "voice" { + return; + } + + SidePanel::right("members") + .exact_width(PANEL_WIDTH) + .resizable(false) + .frame(Frame::NONE.fill(t::BG_SURFACE)) + .show_separator_line(false) + .show(ctx, |ui| { + let r = ui.max_rect(); + ui.painter() + .vline(r.left(), r.y_range(), Stroke::new(1.0, t::BORDER_SUBTLE)); + + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + ui.add_space(12.0); + render_section(ui, s, &channel, true); + ui.add_space(8.0); + render_section(ui, s, &channel, false); + }); + }); +} + +fn render_section(ui: &mut egui::Ui, s: &UiState, channel: &Channel, online: bool) { + // Members currently in this channel + let mut members: Vec<&str> = channel.members.iter().map(|m| s.nick_for(m)).collect(); + + // Add current user if not present + let self_nick = "you"; + if !members.contains(&self_nick) { + members.push(self_nick); + } + + // Sort alphabetically (case-insensitive) + members.sort_by_key(|a| a.to_lowercase()); + + let visible: Vec<&str> = if online { + members.clone() + } else { + Vec::new() // we don't track offline members per channel yet + }; + + if online && visible.is_empty() { + return; + } + + let header = if online { + format!("ONLINE — {}", visible.len()) + } else { + "OFFLINE — 0".to_string() + }; + + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label( + RichText::new(header) + .font(FontId::proportional(10.0)) + .color(t::TEXT_MUTED) + .strong(), + ); + }); + ui.add_space(4.0); + + ScrollArea::vertical() + .auto_shrink([false, true]) + .show(ui, |ui| { + for nick in &visible { + member_row(ui, nick, online); + } + }); +} + +fn member_row(ui: &mut egui::Ui, nick: &str, online: bool) { + let (dot_color, text_color) = if online { + (t::SUCCESS, t::TEXT_PRIMARY) + } else { + (t::TEXT_DISABLED, t::TEXT_DISABLED) + }; + + Frame::NONE + .inner_margin(egui::Margin { + left: 12, + right: 8, + top: 2, + bottom: 2, + }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let init = nick + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + let avatar_color = if online { + nick_avatar_color(nick) + } else { + t::BG_ELEVATED + }; + let (r, _) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 12.0, avatar_color); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + &init, + egui::FontId::proportional(11.0), + Color32::from_rgb(15, 15, 15), + ); + // Status dot + let (sd, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); + ui.painter().circle_filled(sd.center(), 4.0, dot_color); + + ui.label( + RichText::new(nick) + .font(FontId::proportional(12.0)) + .color(text_color), + ); + }); + }); +} + +fn nick_avatar_color(nick: &str) -> egui::Color32 { + let colors = [ + t::ACCENT, + t::SUCCESS, + t::WARNING, + t::INFO, + Color32::from_rgb(200, 100, 180), + Color32::from_rgb(120, 190, 180), + ]; + colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()] +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..04dd215 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,12 @@ +mod app; +pub mod chat; +pub mod connect; +pub mod keybind; +pub mod members; +pub mod settings; +pub mod sidebar; +pub mod state; +pub mod theme; +pub mod titlebar; + +pub use app::VnoxApp; diff --git a/src/ui/settings/mod.rs b/src/ui/settings/mod.rs new file mode 100644 index 0000000..fec7a3b --- /dev/null +++ b/src/ui/settings/mod.rs @@ -0,0 +1,78 @@ +mod nav; +mod network; +mod tabs; +mod widgets; + +use crate::state::{SettingsPage, UiState}; +use crate::theme as t; +use eframe::egui::{self, Align, Context, Frame, Layout, ScrollArea, Vec2, Window}; +use vnox_client::identity::Identity; + +pub use nav::show_nav; + +pub fn show_window(ctx: &Context, s: &mut UiState, identity: &Identity) { + let open_id = egui::Id::new("settings_open_flag"); + if !s.settings_open { + ctx.data_mut(|d| d.remove_temp::(open_id)); + return; + } + if ctx.data_mut(|d| d.get_temp::(open_id)) != Some(true) { + ctx.data_mut(|d| d.insert_temp(open_id, true)); + s.force_refresh_audio_devices(); + } + s.refresh_audio_devices(); + Window::new("Settings") + .collapsible(false) + .resizable(true) + .default_size([720.0, 520.0]) + .min_size([520.0, 380.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG) + .shadow(egui::epaint::Shadow::NONE), + ) + .show(ctx, |ui| { + ui.visuals_mut().override_text_color = Some(t::TEXT_PRIMARY); + let panel_h = ui.available_height().max(200.0); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout( + Vec2::new(168.0, panel_h), + Layout::top_down(Align::LEFT), + |ui| nav::nav_panel(ui, s), + ); + ui.separator(); + ui.allocate_ui_with_layout( + Vec2::new(ui.available_width().max(280.0), panel_h), + Layout::top_down(Align::LEFT), + |ui| { + ScrollArea::vertical() + .id_salt("settings_content") + .max_height(panel_h) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.set_min_width(320.0); + ui.add_space(12.0); + show_content(ui, s, identity); + ui.add_space(12.0); + }); + }, + ); + }); + }); +} + +pub fn show_content(ui: &mut egui::Ui, s: &mut UiState, id: &Identity) { + match &s.settings_page.clone() { + SettingsPage::Voice => tabs::voice(ui, s), + SettingsPage::AudioOutput => tabs::output(ui, s), + SettingsPage::Identity => tabs::show(ui, id, s), + SettingsPage::Network => network::network(ui, s), + SettingsPage::Overlay => network::overlay(ui, s), + SettingsPage::Appearance => tabs::appearance(ui, s), + SettingsPage::Keybinds => tabs::keybinds(ui, s), + SettingsPage::Plugins => tabs::plugins(ui), + SettingsPage::Advanced => tabs::advanced(ui, s), + } +} diff --git a/src/ui/settings/nav.rs b/src/ui/settings/nav.rs new file mode 100644 index 0000000..baad353 --- /dev/null +++ b/src/ui/settings/nav.rs @@ -0,0 +1,125 @@ +use crate::state::{SettingsPage, UiState}; +use crate::theme as t; +use eframe::egui::{self, Align, Context, FontId, Frame, Layout, ScrollArea, Vec2}; + +use super::widgets; + +pub(super) fn nav_panel(ui: &mut egui::Ui, s: &mut UiState) { + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(8, 12)) + .show(ui, |ui| { + ui.vertical(|ui| { + ScrollArea::vertical() + .id_salt("settings_nav") + .max_height((ui.available_height() - 30.0).max(80.0)) + .auto_shrink([false, true]) + .show(ui, |ui| { + nav_group(ui, s, "account", &[(SettingsPage::Identity, "identity")]); + nav_group( + ui, + s, + "audio", + &[ + (SettingsPage::Voice, "voice"), + (SettingsPage::AudioOutput, "audio output"), + ], + ); + nav_group( + ui, + s, + "network", + &[ + (SettingsPage::Network, "network"), + (SettingsPage::Overlay, "overlay"), + ], + ); + nav_group( + ui, + s, + "app", + &[ + (SettingsPage::Appearance, "appearance"), + (SettingsPage::Keybinds, "keybinds"), + (SettingsPage::Plugins, "plugins"), + ], + ); + nav_group(ui, s, "debug", &[(SettingsPage::Advanced, "advanced")]); + }); + if back_btn(ui) { + s.settings_open = false; + } + }); + }); +} + +pub fn show_nav(ctx: &Context, s: &mut UiState) { + egui::SidePanel::left("settings_nav") + .exact_width(160.0) + .resizable(false) + .frame(Frame::new().fill(t::BG_STRIP)) + .show(ctx, |ui| { + ui.style_mut().spacing.item_spacing = Vec2::new(0.0, 1.0); + ui.add_space(10.0); + nav_group(ui, s, "account", &[(SettingsPage::Identity, "identity")]); + nav_group( + ui, + s, + "audio", + &[ + (SettingsPage::Voice, "voice"), + (SettingsPage::AudioOutput, "audio output"), + ], + ); + nav_group( + ui, + s, + "network", + &[ + (SettingsPage::Network, "network"), + (SettingsPage::Overlay, "overlay"), + ], + ); + nav_group( + ui, + s, + "app", + &[ + (SettingsPage::Appearance, "appearance"), + (SettingsPage::Keybinds, "keybinds"), + (SettingsPage::Plugins, "plugins"), + ], + ); + nav_group(ui, s, "debug", &[(SettingsPage::Advanced, "advanced")]); + ui.with_layout(Layout::bottom_up(Align::LEFT), |ui| { + ui.add_space(8.0); + if back_btn(ui) { + s.settings_open = false; + } + }); + }); +} + +fn nav_group(ui: &mut egui::Ui, s: &mut UiState, section: &str, items: &[(SettingsPage, &str)]) { + ui.add_space(4.0); + widgets::nav_section(ui, section); + for (page, label) in items { + if widgets::nav_item(ui, &s.settings_page == page, label) { + s.settings_page = page.clone(); + } + } +} + +fn back_btn(ui: &mut egui::Ui) -> bool { + ui.add_sized( + [ui.available_width().max(120.0), 28.0], + egui::Button::new( + egui::RichText::new("← back") + .font(FontId::monospace(11.0)) + .color(t::TEXT_GHOST), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() +} diff --git a/src/ui/settings/network.rs b/src/ui/settings/network.rs new file mode 100644 index 0000000..2908e35 --- /dev/null +++ b/src/ui/settings/network.rs @@ -0,0 +1,132 @@ +use super::widgets::{badge, group_label, page_header, s_row, toggle}; +use crate::state::UiState; +use crate::theme as t; +use eframe::egui::{self, Align, FontId, Frame, Layout, Margin, RichText, Stroke}; + +pub fn network(ui: &mut egui::Ui, s: &mut UiState) { + page_header(ui, "network", "LNEx protocol · relay · connection"); + + // Live status card + Frame::new() + .fill(t::BG_STRIP) + .stroke(Stroke::new(1.0, t::BORDER_FAINT)) + .corner_radius(6u8) + .inner_margin(Margin::symmetric(12, 10)) + .show(ui, |ui| { + status_row(ui, "protocol", |ui| badge(ui, "LNEx v1", t::ACCENT_SOFT)); + status_row(ui, "voice transport", |ui| badge(ui, "UDP", t::BLUE)); + status_row(ui, "control transport", |ui| badge(ui, "TCP", t::WARN)); + status_row(ui, "encryption", |ui| { + badge(ui, "on · chacha20", t::SUCCESS) + }); + status_row(ui, "rtt", |ui| { + let rtt = s + .rtt_ms + .map(|ms| format!("{ms}ms")) + .unwrap_or_else(|| "—".into()); + let color = s + .rtt_ms + .map(|ms| { + if ms < 50 { + t::SUCCESS + } else if ms < 120 { + t::WARN + } else { + t::DANGER + } + }) + .unwrap_or(t::TEXT_GHOST); + ui.label( + RichText::new(rtt) + .font(FontId::monospace(10.0)) + .color(color), + ); + }); + status_row(ui, "packet loss", |ui| { + ui.label( + RichText::new("0.1%") + .font(FontId::monospace(10.0)) + .color(t::SUCCESS), + ); + }); + }); + ui.add_space(12.0); + + group_label(ui, "relay"); + s_row( + ui, + "use relay node", + Some("route through relay if direct fails"), + |ui| { + toggle(ui, &mut s.relay_enabled); + }, + ); + s_row(ui, "relay address", None, |ui| { + ui.add( + egui::TextEdit::singleline(&mut s.relay_address) + .font(FontId::monospace(10.0)) + .text_color(t::TEXT_MUTED) + .desired_width(140.0), + ); + }); + s_row( + ui, + "auto relay selection", + Some("pick nearest by latency"), + |ui| { + toggle(ui, &mut s.auto_relay); + }, + ); + ui.add_space(8.0); + + group_label(ui, "connection"); + s_row( + ui, + "auto reconnect", + Some("retry with backoff after disconnect"), + |ui| { + toggle(ui, &mut s.auto_reconnect); + }, + ); + ui.add_space(8.0); + + group_label(ui, "advanced"); + s_row(ui, "UDP port", None, |ui| { + ui.add( + egui::TextEdit::singleline(&mut s.udp_port) + .font(FontId::monospace(10.0)) + .text_color(t::TEXT_MUTED) + .desired_width(70.0), + ); + }); + s_row(ui, "force relay only", Some("disable direct p2p"), |ui| { + toggle(ui, &mut s.force_relay); + }); +} + +fn status_row(ui: &mut egui::Ui, key: &str, val: impl FnOnce(&mut egui::Ui)) { + ui.horizontal(|ui| { + ui.label( + RichText::new(key) + .font(FontId::monospace(10.0)) + .color(t::TEXT_DEAD), + ); + ui.with_layout(Layout::right_to_left(Align::Center), val); + }); + ui.add_space(4.0); +} + +pub fn overlay(ui: &mut egui::Ui, s: &mut UiState) { + page_header(ui, "overlay", "in-game HUD · speaking indicators"); + + group_label(ui, "general"); + s_row(ui, "enable overlay", None, |ui| { + toggle(ui, &mut s.overlay_enabled); + }); + s_row(ui, "show speaking users", None, |ui| { + toggle(ui, &mut s.overlay_speakers); + }); + s_row(ui, "show latency", None, |ui| { + toggle(ui, &mut s.overlay_latency); + }); +} diff --git a/src/ui/settings/tabs/app.rs b/src/ui/settings/tabs/app.rs new file mode 100644 index 0000000..9c0cceb --- /dev/null +++ b/src/ui/settings/tabs/app.rs @@ -0,0 +1,167 @@ +use crate::state::UiState; +use crate::theme as t; +use crate::ui::settings::widgets::{ + badge, group_label, page_header, s_row, slider_row, small_select, toggle, +}; +use eframe::egui::{self, FontId, RichText}; + +pub fn appearance(ui: &mut egui::Ui, s: &mut UiState) { + page_header(ui, "appearance", "theme · font · ui scale"); + group_label(ui, "theme"); + s_row(ui, "color scheme", None, |ui| { + let mut v = 0usize; + small_select( + ui, + "scheme", + &mut v, + &["hifi dark", "terminal green", "void"], + ); + }); + s_row(ui, "ui scale", None, |ui| { + slider_row(ui, &mut s.ui_scale, 80.0, 130.0, |v| { + format!("{}%", v as u32) + }); + }); + s_row(ui, "font", None, |ui| { + let mut v = 0usize; + small_select( + ui, + "font", + &mut v, + &["IBM Plex Mono", "Geist Mono", "Fira Code"], + ); + }); +} + +pub fn plugins(ui: &mut egui::Ui) { + page_header(ui, "plugins", "installed · runtime: deno"); + group_label(ui, "installed"); + s_row(ui, "relay-switcher", Some("v0.1.2 · raven"), |ui| { + let mut v = true; + toggle(ui, &mut v); + }); + s_row(ui, "music-bot", Some("v0.3.0 · community"), |ui| { + let mut v = false; + toggle(ui, &mut v); + }); +} + +pub fn advanced(ui: &mut egui::Ui, s: &mut UiState) { + page_header(ui, "advanced", "debug · logging · protocol internals"); + group_label(ui, "logging"); + s_row(ui, "log level", None, |ui| { + small_select( + ui, + "log_level", + &mut s.log_level, + &["info", "debug", "trace"], + ); + }); + s_row(ui, "log voice packets", Some("verbose · high cpu"), |ui| { + toggle(ui, &mut s.log_voice_packets); + }); + ui.add_space(8.0); + group_label(ui, "protocol"); + s_row(ui, "LNEx version", None, |ui| { + badge(ui, "v1", t::ACCENT_SOFT); + }); + s_row(ui, "show packet stats", None, |ui| { + toggle(ui, &mut s.show_packet_stats); + }); + s_row( + ui, + "disable encryption", + Some("debug only · dangerous"), + |ui| { + ui.label( + RichText::new("!") + .font(FontId::monospace(10.0)) + .color(t::DANGER), + ); + ui.add_space(4.0); + toggle(ui, &mut s.disable_encryption); + }, + ); +} + +pub fn keybinds(ui: &mut egui::Ui, s: &mut UiState) { + page_header(ui, "keybinds", "global hotkeys"); + group_label(ui, "voice"); + kb_row( + ui, + "push-to-talk", + &mut s.kb_ptt, + &mut s.kb_listening, + "ptt", + ); + kb_row( + ui, + "mute toggle", + &mut s.kb_mute, + &mut s.kb_listening, + "mute", + ); + kb_row( + ui, + "deafen toggle", + &mut s.kb_deafen, + &mut s.kb_listening, + "deafen", + ); + ui.add_space(8.0); + group_label(ui, "overlay"); + kb_row( + ui, + "toggle overlay", + &mut s.kb_overlay, + &mut s.kb_listening, + "overlay", + ); +} + +fn kb_row( + ui: &mut egui::Ui, + label: &str, + val: &mut String, + listening: &mut Option, + listen_key: &str, +) { + s_row(ui, label, None, |ui| { + let is_listening = listening.as_deref() == Some(listen_key); + if is_listening { + if let Some(binding) = crate::keybind::listen_pressed(ui.ctx()) { + *val = binding; + *listening = None; + } + let btn = egui::Button::new( + egui::RichText::new("Listening… press a key") + .font(egui::FontId::monospace(10.0)) + .color(egui::Color32::WHITE), + ) + .fill(crate::theme::ACCENT) + .corner_radius(crate::theme::R_SM) + .min_size(egui::vec2(120.0, 20.0)); + if ui.add(btn).clicked() { + *listening = None; + } + } else { + ui.add( + egui::TextEdit::singleline(val) + .font(egui::FontId::monospace(10.0)) + .text_color(crate::theme::TEXT_MUTED) + .desired_width(90.0), + ); + let rec = egui::Button::new( + egui::RichText::new("●") + .font(egui::FontId::monospace(9.0)) + .color(crate::theme::ERROR), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false) + .min_size(egui::vec2(16.0, 16.0)); + if ui.add(rec).on_hover_text("record keybind").clicked() { + *listening = Some(listen_key.to_string()); + } + } + }); +} diff --git a/src/ui/settings/tabs/audio.rs b/src/ui/settings/tabs/audio.rs new file mode 100644 index 0000000..009e5e3 --- /dev/null +++ b/src/ui/settings/tabs/audio.rs @@ -0,0 +1,150 @@ +use crate::state::UiState; +use crate::theme as t; +use crate::ui::settings::widgets::{ + badge, device_meta_row, group_label, page_header, s_row, slider_row, small_select, + small_select_labels, toggle, +}; +use eframe::egui::{self, FontId, RichText, Stroke, Vec2}; +use vnox_client::audio::devices::DeviceLabel; +fn selected_meta(index: usize, metas: &[DeviceLabel]) -> Option<&DeviceLabel> { + if index == 0 { + return None; + } + metas.get(index.saturating_sub(1)) +} + +pub fn voice(ui: &mut egui::Ui, s: &mut UiState) { + page_header(ui, "voice", "input device, encoding, push-to-talk"); + group_label(ui, "input device"); + s_row(ui, "microphone", None, |ui| { + small_select_labels(ui, "mic_dev", &mut s.mic_device, &s.input_device_labels); + }); + device_meta_row(ui, selected_meta(s.mic_device, &s.input_device_metas)); + s_row(ui, "input volume", None, |ui| { + slider_row(ui, &mut s.input_volume, 0.0, 100.0, |v| { + format!("{}%", v as u32) + }); + }); + s_row(ui, "noise suppression", Some("rnnoise · cpu ~2%"), |ui| { + toggle(ui, &mut s.noise_suppress); + }); + s_row(ui, "echo cancellation", None, |ui| { + toggle(ui, &mut s.echo_cancel); + }); + ui.add_space(8.0); + group_label(ui, "activation"); + s_row(ui, "mode", None, |ui| { + small_select( + ui, + "vad_mode", + &mut s.vad_mode, + &["push-to-talk", "voice activity", "always on"], + ); + }); + if s.vad_mode == 1 { + s_row( + ui, + "vad threshold", + Some("voice activity sensitivity"), + |ui| { + slider_row(ui, &mut s.vad_threshold, 0.0, 100.0, |v| { + format!("{}%", v as u32) + }); + }, + ); + } + ui.add_space(8.0); + group_label(ui, "codec"); + s_row(ui, "encoder", Some("opus · low latency mode"), |ui| { + badge(ui, "opus", t::ACCENT_SOFT); + }); + s_row(ui, "bitrate", Some("opus 8–128 kbps"), |ui| { + ui.horizontal(|ui| { + slider_row(ui, &mut s.voice_bitrate, 8.0, 128.0, |v| { + format!("{}k", v as u32) + }); + let presets = [32, 64, 96, 128usize]; + for &p in &presets { + let pf = p as f32; + let selected = (s.voice_bitrate - pf).abs() < 1.0; + let resp = ui.add_sized( + Vec2::new(28.0, 20.0), + egui::Button::new( + RichText::new(format!("{}", p)) + .font(FontId::monospace(8.0)) + .color(if selected { t::ACCENT } else { t::TEXT_MUTED }), + ) + .fill(if selected { + egui::Color32::from_rgba_premultiplied( + t::ACCENT.r(), + t::ACCENT.g(), + t::ACCENT.b(), + 25, + ) + } else { + egui::Color32::from_rgb(23, 23, 23) + }) + .stroke(Stroke::new(1.0, t::BORDER_SUBTLE)) + .corner_radius(3u8), + ); + if resp.clicked() { + s.voice_bitrate = pf; + } + } + }); + let effective = (s.voice_bitrate as u32).clamp(8, 128); + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label( + RichText::new(format!("effective: {} kbps", effective)) + .font(FontId::monospace(9.0)) + .color(t::TEXT_DEAD), + ); + }); + }); + s_row( + ui, + "packet interval", + Some("lower = less latency, more cpu"), + |ui| { + small_select( + ui, + "pkt_interval", + &mut s.packet_interval, + &["10ms", "20ms", "40ms"], + ); + }, + ); +} + +pub fn output(ui: &mut egui::Ui, s: &mut UiState) { + page_header(ui, "audio output", "output device · volume · jitter buffer"); + group_label(ui, "output device"); + s_row(ui, "device", None, |ui| { + small_select_labels(ui, "out_dev", &mut s.output_device, &s.output_device_labels); + }); + device_meta_row(ui, selected_meta(s.output_device, &s.output_device_metas)); + s_row(ui, "output volume", None, |ui| { + slider_row(ui, &mut s.output_volume, 0.0, 100.0, |v| { + format!("{}%", v as u32) + }); + }); + ui.add_space(8.0); + group_label(ui, "jitter buffer"); + s_row( + ui, + "buffer size", + Some("lower = less delay, more glitches"), + |ui| { + small_select(ui, "jitter", &mut s.jitter_size, &["20ms", "40ms", "80ms"]); + }, + ); + s_row( + ui, + "adaptive buffer", + Some("auto-adjust on packet loss"), + |ui| { + toggle(ui, &mut s.adaptive_buffer); + }, + ); +} diff --git a/src/ui/settings/tabs/identity.rs b/src/ui/settings/tabs/identity.rs new file mode 100644 index 0000000..b3f189f --- /dev/null +++ b/src/ui/settings/tabs/identity.rs @@ -0,0 +1,629 @@ +use crate::identity::{self, Identity}; +use crate::theme as t; +use crate::ui::settings::widgets::{group_label, id_button, page_header, s_row, toggle}; +use crate::ui::state::UiState; +use eframe::egui::{self, Color32, FontId, Frame, Margin, RichText, Stroke, Vec2}; + +pub fn show(ui: &mut egui::Ui, identity: &Identity, s: &mut UiState) { + page_header(ui, "identity", "your local keypair · no account required"); + Frame::new() + .fill(t::BG_PANEL) + .stroke(Stroke::new(1.0, t::BORDER)) + .corner_radius(7u8) + .inner_margin(Margin::same(12)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let init: String = identity + .nickname + .chars() + .take(2) + .collect::() + .to_uppercase(); + let av = egui::Button::new( + RichText::new(&init) + .font(FontId::monospace(11.0)) + .color(t::ACCENT), + ) + .fill(Color32::from_rgba_premultiplied(255, 107, 53, 18)) + .stroke(Stroke::new( + 1.0, + Color32::from_rgba_premultiplied(255, 107, 53, 38), + )) + .corner_radius(8u8) + .min_size(Vec2::splat(36.0)); + ui.add_enabled(false, av); + ui.add_space(6.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&identity.nickname) + .font(FontId::monospace(12.0)) + .color(t::TEXT_SECONDARY) + .strong(), + ); + ui.label( + RichText::new(format!("pub: {}…", &identity.pubkey_hex[..16])) + .font(FontId::monospace(9.0)) + .color(t::TEXT_GHOST), + ); + }); + }); + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + if id_button(ui, "copy pubkey") { + ui.ctx().copy_text(identity.pubkey_hex.clone()); + } + if id_button(ui, "set passphrase") { + s.vault_set_open = true; + s.vault_passphrase_input.clear(); + s.vault_passphrase_confirm.clear(); + s.vault_error = None; + } + if id_button(ui, "remove passphrase") { + s.vault_remove_open = true; + s.vault_passphrase_input.clear(); + s.vault_error = None; + } + if id_button(ui, "export keyfile") { + s.export_keyfile_open = true; + s.export_keyfile_pass.clear(); + s.export_keyfile_confirm.clear(); + s.export_keyfile_output.clear(); + s.export_keyfile_error = None; + } + if id_button(ui, "import keyfile") { + s.import_keyfile_open = true; + s.import_keyfile_input.clear(); + s.import_keyfile_pass.clear(); + s.import_keyfile_error = None; + } + id_button(ui, "change nickname"); + id_button(ui, "rotate keypair"); + }); + }); + + // Vault status block. + ui.add_space(10.0); + group_label(ui, "vault status"); + let is_encrypted = identity::is_vault_encrypted(); + Frame::new() + .fill(if is_encrypted { t::BG_PANEL } else { t::BG_ELEVATED }) + .stroke(Stroke::new( + 1.0, + if is_encrypted { t::SUCCESS } else { t::BORDER }, + )) + .corner_radius(7u8) + .inner_margin(Margin::same(12)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (label, color) = if is_encrypted { + ("encrypted (Argon2id + ChaCha20-Poly1305)", t::SUCCESS) + } else { + ("plain JSON — passphrase not set", t::WARNING) + }; + // Drawn lock/unlock icon. + let (icon_rect, _) = ui.allocate_exact_size( + egui::vec2(18.0, 18.0), + egui::Sense::hover(), + ); + let icon_fn = if is_encrypted { + crate::theme::icons::lock + } else { + crate::theme::icons::unlock + }; + icon_fn(ui.painter(), icon_rect, color); + ui.label( + RichText::new(label) + .font(FontId::proportional(12.0)) + .color(color) + .strong(), + ); + }); + ui.add_space(4.0); + ui.label( + RichText::new(if is_encrypted { + "Your private key is encrypted at rest. The vault is unlocked in memory while VNOX is running." + } else { + "Anyone with filesystem access can read your private key. Set a passphrase to encrypt it." + }) + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + }); + + ui.add_space(10.0); + group_label(ui, "backup"); + s_row( + ui, + "seed phrase backup", + Some("24-word recovery · never share"), + |ui| { + let mut v = false; + toggle(ui, &mut v); + }, + ); + s_row( + ui, + "export to file", + Some(".vnox keyfile · encrypted"), + |ui| { + let _ = id_button(ui, "export"); + }, + ); + + ui.add_space(8.0); + group_label(ui, "permissions"); + s_row( + ui, + "show identity to strangers", + Some("pubkey visible on federated nodes"), + |ui| { + let mut v = true; + toggle(ui, &mut v); + }, + ); + + // Set-passphrase modal. + if s.vault_set_open { + show_set_passphrase_modal(ui.ctx(), identity, s); + } + // Remove-passphrase modal. + if s.vault_remove_open { + show_remove_passphrase_modal(ui.ctx(), identity, s); + } + // Export keyfile modal. + if s.export_keyfile_open { + show_export_keyfile_modal(ui.ctx(), identity, s); + } + // Import keyfile modal. + if s.import_keyfile_open { + show_import_keyfile_modal(ui.ctx(), s); + } +} + +fn show_set_passphrase_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) { + egui::Window::new("Set Identity Passphrase") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(360.0); + ui.label( + RichText::new("Pick a strong passphrase. It will be used to encrypt your identity keypair at rest (Argon2id + ChaCha20-Poly1305).") + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + ui.add_space(4.0); + ui.label( + RichText::new("! There is no recovery — if you forget the passphrase, the keypair is lost.") + .font(FontId::proportional(11.0)) + .color(t::WARNING), + ); + ui.add_space(10.0); + ui.label(RichText::new("Passphrase").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY)); + let p1 = ui.add( + egui::TextEdit::singleline(&mut s.vault_passphrase_input) + .password(true) + .font(FontId::monospace(12.0)) + .desired_width(320.0), + ); + ui.add_space(6.0); + ui.label(RichText::new("Confirm").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY)); + let p2 = ui.add( + egui::TextEdit::singleline(&mut s.vault_passphrase_confirm) + .password(true) + .font(FontId::monospace(12.0)) + .desired_width(320.0), + ); + ui.add_space(10.0); + let mismatch = !s.vault_passphrase_input.is_empty() + && s.vault_passphrase_input != s.vault_passphrase_confirm; + if mismatch { + ui.label( + RichText::new("passphrases do not match") + .font(FontId::proportional(11.0)) + .color(t::ERROR), + ); + } + if s.vault_passphrase_input.len() < 8 && !s.vault_passphrase_input.is_empty() { + ui.label( + RichText::new("! use at least 8 characters") + .font(FontId::proportional(11.0)) + .color(t::WARNING), + ); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Cancel") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.vault_set_open = false; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_apply = !s.vault_passphrase_input.is_empty() + && s.vault_passphrase_input == s.vault_passphrase_confirm + && s.vault_passphrase_input.len() >= 8; + let btn = egui::Button::new( + RichText::new("Encrypt & Save") + .font(FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ACCENT) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(120.0, 28.0)); + if ui.add_enabled(can_apply, btn).clicked() { + match identity::save(identity, Some(&s.vault_passphrase_input)) { + Ok(()) => { + s.vault_set_open = false; + s.vault_passphrase_input.clear(); + s.vault_passphrase_confirm.clear(); + } + Err(e) => { + s.vault_error = Some(format!("save failed: {e}")); + } + } + } + }); + }); + if let Some(ref err) = s.vault_error { + ui.add_space(4.0); + ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR)); + } + if (p1.lost_focus() || p2.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) + { + s.vault_set_open = false; + } + }); +} + +fn show_remove_passphrase_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) { + egui::Window::new("Remove Passphrase") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(360.0); + ui.label( + RichText::new("! This will store your identity as plain JSON on disk. Anyone with filesystem access will be able to read your private key.") + .font(FontId::proportional(11.0)) + .color(t::WARNING), + ); + ui.add_space(8.0); + ui.label( + RichText::new("Type REMOVE to confirm.") + .font(FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY), + ); + ui.add_space(4.0); + let resp = ui.add( + egui::TextEdit::singleline(&mut s.vault_passphrase_input) + .font(FontId::monospace(12.0)) + .desired_width(320.0), + ); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Cancel") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.vault_remove_open = false; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_apply = s.vault_passphrase_input == "REMOVE"; + let btn = egui::Button::new( + RichText::new("Remove Passphrase") + .font(FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ERROR) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(140.0, 28.0)); + if ui.add_enabled(can_apply, btn).clicked() { + match identity::save(identity, None) { + Ok(()) => { + s.vault_remove_open = false; + s.vault_passphrase_input.clear(); + } + Err(e) => { + s.vault_error = Some(format!("save failed: {e}")); + } + } + } + }); + }); + if let Some(ref err) = s.vault_error { + ui.add_space(4.0); + ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR)); + } + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + s.vault_remove_open = false; + } + }); +} + +fn show_export_keyfile_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) { + egui::Window::new("Export Identity Keyfile") + .collapsible(false) + .resizable(true) + .default_size([440.0, 380.0]) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(420.0); + ui.label( + RichText::new("Pick a passphrase to encrypt the keyfile. Anyone with the keyfile and the passphrase can impersonate you — share carefully.") + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + ui.add_space(8.0); + ui.label(RichText::new("Passphrase (optional — empty = plain JSON)").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY)); + ui.add( + egui::TextEdit::singleline(&mut s.export_keyfile_pass) + .password(true) + .font(FontId::monospace(12.0)) + .desired_width(380.0), + ); + ui.add_space(6.0); + ui.label(RichText::new("Confirm").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY)); + ui.add( + egui::TextEdit::singleline(&mut s.export_keyfile_confirm) + .password(true) + .font(FontId::monospace(12.0)) + .desired_width(380.0), + ); + let mismatch = !s.export_keyfile_pass.is_empty() + && s.export_keyfile_pass != s.export_keyfile_confirm; + if mismatch { + ui.label( + RichText::new("passphrases do not match") + .font(FontId::proportional(11.0)) + .color(t::ERROR), + ); + } + ui.add_space(8.0); + let can_generate = !mismatch; + let gen_btn = egui::Button::new( + RichText::new("Generate keyfile") + .font(FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ACCENT) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(120.0, 28.0)); + if ui.add_enabled(can_generate, gen_btn).clicked() { + let pass = if s.export_keyfile_pass.is_empty() { + None + } else { + Some(s.export_keyfile_pass.as_str()) + }; + match identity::export_keyfile(identity, pass) { + Ok(text) => s.export_keyfile_output = text, + Err(e) => s.export_keyfile_error = Some(format!("export failed: {e}")), + } + } + if let Some(ref err) = s.export_keyfile_error { + ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR)); + } + ui.add_space(6.0); + if !s.export_keyfile_output.is_empty() { + ui.label( + RichText::new("Keyfile (copy and save to a .vnoxkey file):") + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + egui::ScrollArea::vertical() + .max_height(160.0) + .show(ui, |ui| { + let mut buf = s.export_keyfile_output.clone(); + ui.add( + egui::TextEdit::multiline(&mut buf) + .font(FontId::monospace(10.0)) + .desired_width(380.0) + .interactive(true), + ); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Copy") + .font(FontId::proportional(11.0)) + .color(t::ACCENT), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + ui.ctx().copy_text(s.export_keyfile_output.clone()); + } + if ui + .add( + egui::Button::new( + RichText::new("Save to disk") + .font(FontId::proportional(11.0)) + .color(t::ACCENT), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + let path = dirs::document_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(format!("vnox-{}-{}.vnoxkey", identity.nickname, &identity.pubkey_hex[..8])); + match std::fs::write(&path, &s.export_keyfile_output) { + Ok(()) => { + s.export_keyfile_error = Some(format!("saved to: {}", path.display())); + } + Err(e) => { + s.export_keyfile_error = Some(format!("save failed: {e}")); + } + } + } + }); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Close") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.export_keyfile_open = false; + } + }); + }); +} + +fn show_import_keyfile_modal(ctx: &egui::Context, s: &mut UiState) { + egui::Window::new("Import Identity Keyfile") + .collapsible(false) + .resizable(true) + .default_size([440.0, 360.0]) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(420.0); + ui.label( + RichText::new("! Importing a keyfile will replace your current identity. Back up your current keypair first if you want to keep it.") + .font(FontId::proportional(11.0)) + .color(t::WARNING), + ); + ui.add_space(8.0); + ui.label( + RichText::new("Paste keyfile JSON here:") + .font(FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY), + ); + egui::ScrollArea::vertical() + .max_height(140.0) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut s.import_keyfile_input) + .font(FontId::monospace(10.0)) + .desired_width(380.0) + .hint_text(RichText::new(r#"{"version":1,"scheme":"..."}"#).color(t::TEXT_DISABLED)), + ); + }); + ui.add_space(6.0); + ui.label( + RichText::new("Passphrase (leave empty for plain keyfiles)") + .font(FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY), + ); + ui.add( + egui::TextEdit::singleline(&mut s.import_keyfile_pass) + .password(true) + .font(FontId::monospace(12.0)) + .desired_width(380.0), + ); + ui.add_space(8.0); + let can_import = !s.import_keyfile_input.trim().is_empty(); + let import_btn = egui::Button::new( + RichText::new("Import & Replace Identity") + .font(FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ERROR) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(180.0, 28.0)); + if ui.add_enabled(can_import, import_btn).clicked() { + let pass = if s.import_keyfile_pass.is_empty() { + None + } else { + Some(s.import_keyfile_pass.as_str()) + }; + match identity::import_keyfile(s.import_keyfile_input.trim(), pass) { + Ok(identity) => { + // Persist imported identity (without passphrase for now — + // the user can set one separately). + match identity::save(&identity, None) { + Ok(()) => { + s.import_keyfile_error = Some( + "imported — restart VNOX to use the new identity".into(), + ); + } + Err(e) => { + s.import_keyfile_error = Some(format!("save failed: {e}")); + } + } + } + Err(e) => { + s.import_keyfile_error = Some(format!("import failed: {e}")); + } + } + } + if let Some(ref err) = s.import_keyfile_error { + ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::TEXT_MUTED)); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Close") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.import_keyfile_open = false; + } + }); + }); +} diff --git a/src/ui/settings/tabs/mod.rs b/src/ui/settings/tabs/mod.rs new file mode 100644 index 0000000..5118249 --- /dev/null +++ b/src/ui/settings/tabs/mod.rs @@ -0,0 +1,7 @@ +mod app; +mod audio; +mod identity; + +pub use app::{advanced, appearance, keybinds, plugins}; +pub use audio::{output, voice}; +pub use identity::show; diff --git a/src/ui/settings/widgets/controls.rs b/src/ui/settings/widgets/controls.rs new file mode 100644 index 0000000..0eceddd --- /dev/null +++ b/src/ui/settings/widgets/controls.rs @@ -0,0 +1,128 @@ +use crate::theme as t; +use eframe::egui::{self, Color32, FontId, Frame, Margin, RichText, Sense, Stroke, Vec2}; + +pub fn toggle(ui: &mut egui::Ui, value: &mut bool) { + let (rect, resp) = ui.allocate_exact_size(Vec2::new(30.0, 16.0), Sense::click()); + if resp.clicked() { + *value = !*value; + } + let painter = ui.painter(); + let bg = if *value { + Color32::from_rgba_premultiplied(255, 107, 53, 38) + } else { + t::BG_ELEVATED + }; + let bdr = if *value { + Color32::from_rgb(100, 50, 25) + } else { + t::BORDER_SUBTLE + }; + painter.rect( + rect, + 8u8, + bg, + Stroke::new(1.0, bdr), + egui::StrokeKind::Inside, + ); + let kx = if *value { + rect.max.x - 7.0 + } else { + rect.min.x + 7.0 + }; + let kc = if *value { + t::ACCENT + } else { + Color32::from_rgb(45, 45, 45) + }; + painter.circle_filled(egui::pos2(kx, rect.center().y), 5.0, kc); +} + +pub fn small_select_labels(ui: &mut egui::Ui, id: &str, value: &mut usize, options: &[String]) { + let fallback = "?".to_string(); + let selected = options.get(*value).unwrap_or(&fallback); + egui::ComboBox::from_id_salt(id) + .selected_text( + RichText::new(selected.as_str()) + .font(FontId::monospace(10.0)) + .color(t::TEXT_MUTED), + ) + .width(220.0) + .show_ui(ui, |ui| { + for (i, opt) in options.iter().enumerate() { + ui.selectable_value( + value, + i, + RichText::new(opt.as_str()) + .font(FontId::monospace(10.0)) + .color(t::TEXT_SECONDARY), + ); + } + }); +} + +pub fn small_select(ui: &mut egui::Ui, id: &str, value: &mut usize, options: &[&str]) { + egui::ComboBox::from_id_salt(id) + .selected_text( + RichText::new(options.get(*value).copied().unwrap_or("?")) + .font(FontId::monospace(10.0)) + .color(t::TEXT_MUTED), + ) + .width(110.0) + .show_ui(ui, |ui| { + for (i, opt) in options.iter().enumerate() { + ui.selectable_value( + value, + i, + RichText::new(*opt) + .font(FontId::monospace(10.0)) + .color(t::TEXT_SECONDARY), + ); + } + }); +} + +pub fn slider_row( + ui: &mut egui::Ui, + val: &mut f32, + min: f32, + max: f32, + fmt: impl Fn(f32) -> String, +) { + ui.horizontal(|ui| { + ui.spacing_mut().slider_width = 90.0; + ui.add( + egui::Slider::new(val, min..=max) + .show_value(false) + .trailing_fill(true), + ); + ui.add_space(4.0); + ui.label( + RichText::new(fmt(*val)) + .font(FontId::monospace(10.0)) + .color(t::TEXT_DIM), + ); + }); +} + +pub fn badge(ui: &mut egui::Ui, text: &str, color: Color32) { + Frame::new() + .fill(Color32::from_rgba_premultiplied( + color.r(), + color.g(), + color.b(), + 25, + )) + .stroke(Stroke::new( + 1.0, + Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), 50), + )) + .corner_radius(3u8) + .inner_margin(Margin::symmetric(6, 2)) + .show(ui, |ui| { + ui.label( + RichText::new(text) + .font(FontId::monospace(9.0)) + .color(color), + ); + }); +} diff --git a/src/ui/settings/widgets/layout.rs b/src/ui/settings/widgets/layout.rs new file mode 100644 index 0000000..852f793 --- /dev/null +++ b/src/ui/settings/widgets/layout.rs @@ -0,0 +1,90 @@ +use crate::theme as t; +use eframe::egui::{self, Align, Color32, FontId, Layout, RichText, Stroke}; + +pub fn page_header(ui: &mut egui::Ui, title: &str, sub: &str) { + ui.label( + RichText::new(title) + .font(FontId::monospace(13.0)) + .color(t::TEXT_SECONDARY) + .strong(), + ); + ui.add_space(2.0); + ui.label( + RichText::new(sub) + .font(FontId::monospace(9.0)) + .color(t::TEXT_DEAD), + ); + ui.add_space(14.0); +} + +pub fn group_label(ui: &mut egui::Ui, label: &str) { + ui.add_space(2.0); + ui.label( + RichText::new(label.to_uppercase()) + .font(FontId::monospace(9.0)) + .color(t::TEXT_DEAD), + ); + ui.add(egui::Separator::default().spacing(6.0)); + ui.add_space(2.0); +} + +pub fn s_row( + ui: &mut egui::Ui, + label: &str, + desc: Option<&str>, + content: impl FnOnce(&mut egui::Ui), +) { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_min_width(200.0); + ui.label( + RichText::new(label) + .font(FontId::monospace(11.0)) + .color(t::TEXT_DIM), + ); + if let Some(d) = desc { + ui.label( + RichText::new(d) + .font(FontId::monospace(9.0)) + .color(t::TEXT_DEAD), + ); + } + }); + ui.with_layout(Layout::right_to_left(Align::Center), content); + }); + ui.add_space(4.0); + ui.add(egui::Separator::default().horizontal().spacing(0.0)); + ui.add_space(4.0); +} + +pub fn device_meta_row(ui: &mut egui::Ui, meta: Option<&vnox_client::audio::devices::DeviceLabel>) { + if let Some(m) = meta { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(4.0); + super::controls::badge(ui, &m.host_api, Color32::from_rgb(120, 180, 255)); + if m.channels > 0 { + super::controls::badge( + ui, + &format!("{}ch", m.channels), + Color32::from_rgb(180, 200, 120), + ); + } + }); + ui.add_space(2.0); + } +} + +pub fn id_button(ui: &mut egui::Ui, label: &str) -> bool { + ui.add( + egui::Button::new( + RichText::new(label) + .font(FontId::monospace(9.0)) + .color(t::TEXT_MUTED), + ) + .fill(Color32::from_rgb(23, 23, 23)) + .stroke(Stroke::new(1.0, t::BORDER_SUBTLE)) + .corner_radius(4u8), + ) + .clicked() +} diff --git a/src/ui/settings/widgets/mod.rs b/src/ui/settings/widgets/mod.rs new file mode 100644 index 0000000..3882cd4 --- /dev/null +++ b/src/ui/settings/widgets/mod.rs @@ -0,0 +1,7 @@ +mod controls; +mod layout; +mod nav; + +pub use controls::{badge, slider_row, small_select, small_select_labels, toggle}; +pub use layout::{device_meta_row, group_label, id_button, page_header, s_row}; +pub use nav::{nav_item, nav_section}; diff --git a/src/ui/settings/widgets/nav.rs b/src/ui/settings/widgets/nav.rs new file mode 100644 index 0000000..d782665 --- /dev/null +++ b/src/ui/settings/widgets/nav.rs @@ -0,0 +1,46 @@ +use crate::theme as t; +use eframe::egui::{self, FontId, RichText}; + +pub fn nav_item(ui: &mut egui::Ui, is_active: bool, label: &str) -> bool { + let text_color = if is_active { + t::ACCENT_SOFT + } else { + t::TEXT_GHOST + }; + let fill = if is_active { + t::ACCENT_10 + } else { + egui::Color32::TRANSPARENT + }; + let stroke = if is_active { + egui::Stroke::new(1.0, t::BORDER_ACCENT) + } else { + egui::Stroke::NONE + }; + + ui.add_sized( + [ui.available_width().max(120.0), 26.0], + egui::Button::new( + RichText::new(label) + .font(FontId::monospace(11.0)) + .color(text_color), + ) + .fill(fill) + .stroke(stroke) + .corner_radius(t::R_SM), + ) + .clicked() +} + +pub fn nav_section(ui: &mut egui::Ui, label: &str) { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label( + RichText::new(label.to_uppercase()) + .font(FontId::monospace(9.0)) + .color(t::TEXT_DEAD), + ); + }); + ui.add_space(2.0); +} diff --git a/src/ui/sidebar/channel_list/items.rs b/src/ui/sidebar/channel_list/items.rs new file mode 100644 index 0000000..03e25e2 --- /dev/null +++ b/src/ui/sidebar/channel_list/items.rs @@ -0,0 +1,142 @@ +use eframe::egui::{self, Frame}; + +use crate::theme as t; +use vnox_client::net::{NetCommand, NetHandle}; + +/// Channel row with optional context menu for deletion. +/// When `net` is provided and the channel is not a default one, a "Delete channel" +/// entry appears in the right-click context menu. +pub(super) fn ch_row_with_menu( + ui: &mut egui::Ui, + icon: &str, + name: &str, + active: bool, + badge: Option<&str>, + live: bool, + net: Option<&NetHandle>, +) -> bool { + let bg = if active { + t::ACCENT_10 + } else { + egui::Color32::TRANSPARENT + }; + let stroke = if active { + egui::Stroke::new(1.0, t::BORDER_ACCENT) + } else { + egui::Stroke::NONE + }; + let text_c = if active { + t::ACCENT_SOFT + } else { + t::TEXT_MUTED + }; + + let resp = Frame::NONE + .fill(bg) + .stroke(stroke) + .corner_radius(t::R_SM) + .inner_margin(egui::Margin::symmetric(8, 3)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(icon) + .color(if active { t::ACCENT } else { t::TEXT_MUTED }) + .size(13.0), + ); + ui.label(egui::RichText::new(name).color(text_c).size(13.0)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if live { + let (r, _) = + ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover()); + ui.painter().circle_filled(r.center(), 3.0, t::SUCCESS); + } else if let Some(b) = badge { + ui.label( + egui::RichText::new(b) + .font(egui::FontId::proportional(10.0)) + .color(t::TEXT_DISABLED), + ); + } + }); + }); + }) + .response + .interact(egui::Sense::click()); + + // Right-click context menu — offer deletion for non-default channels. + if let Some(net) = net + && name != "general" + && name != "voice" + { + resp.context_menu(|ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("Delete channel") + .font(egui::FontId::proportional(12.0)) + .color(t::ERROR), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + let net = net.clone(); + let ch_id = name.to_string(); + tokio::spawn(async move { + net.send(NetCommand::ChannelDelete { channel_id: ch_id }) + .await; + }); + ui.close(); + } + }); + } + + resp.clicked() +} + +pub(super) fn member_row(ui: &mut egui::Ui, nick: &str) { + Frame::NONE + .inner_margin(egui::Margin { + left: 28, + right: 8, + top: 4, + bottom: 4, + }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let init = nick + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + let (r, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + ui.painter() + .circle_filled(r.center(), 8.0, nick_color(nick)); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + &init, + egui::FontId::proportional(8.0), + egui::Color32::from_rgb(15, 15, 15), + ); + ui.label( + egui::RichText::new(nick) + .font(egui::FontId::proportional(11.5)) + .color(t::TEXT_MUTED), + ); + }); + }); +} + +fn nick_color(nick: &str) -> egui::Color32 { + let colors = [ + t::ACCENT, + t::SUCCESS, + t::WARNING, + t::INFO, + egui::Color32::from_rgb(200, 100, 180), + ]; + colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()] +} diff --git a/src/ui/sidebar/channel_list/mod.rs b/src/ui/sidebar/channel_list/mod.rs new file mode 100644 index 0000000..227f0dd --- /dev/null +++ b/src/ui/sidebar/channel_list/mod.rs @@ -0,0 +1,327 @@ +mod items; + +use eframe::egui::{self, Frame}; +use vnox_client::net::{NetCommand, NetHandle}; + +use crate::state::{Channel, UiState, VoiceUiState}; +use crate::theme as t; + +pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + if matches!(s.conn, crate::state::ConnState::Connected { .. }) && s.channels.is_empty() { + s.seed_default_channels(); + if s.active_channel.is_none() { + s.active_channel = Some("general".into()); + } + } + + if let Some(guild_id) = &s.active_guild_id.clone() + && let Some(g) = s.guilds.iter().find(|g| g.guild_id == *guild_id) + { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&g.name) + .font(egui::FontId::proportional(14.0)) + .color(t::TEXT_PRIMARY), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + Frame::NONE + .inner_margin(egui::Margin::symmetric(4, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(format!("{} members", g.member_count)) + .font(egui::FontId::proportional(10.0)) + .color(t::TEXT_MUTED), + ); + }); + // Create channel button ("+"). + if ui + .add( + egui::Button::new( + egui::RichText::new("+") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_SM) + .min_size(egui::vec2(20.0, 18.0)), + ) + .on_hover_text("create a channel") + .clicked() + { + s.create_channel_open = true; + s.create_channel_input.clear(); + } + let guild_id = guild_id.clone(); + if ui + .add( + egui::Button::new( + egui::RichText::new("Leave") + .font(egui::FontId::proportional(10.0)) + .color(t::TEXT_MUTED), + ) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_SM), + ) + .on_hover_text("leave this guild") + .clicked() + { + s.active_guild_id = None; + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::GuildLeave { guild_id }).await; + }); + } + }); + }); + ui.add_space(2.0); + ui.separator(); + ui.add_space(4.0); + } else { + // Home (no guild) — show a "Create Channel" button in the channel header. + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("CHANNELS") + .font(egui::FontId::proportional(10.0)) + .color(t::TEXT_MUTED) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("+") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_SM) + .min_size(egui::vec2(20.0, 18.0)), + ) + .on_hover_text("create a channel") + .clicked() + { + s.create_channel_open = true; + s.create_channel_input.clear(); + } + }); + }); + ui.add_space(2.0); + ui.separator(); + ui.add_space(4.0); + } + + if s.create_channel_open { + show_create_channel_popup(ui.ctx(), s, net); + } + + let text_chs: Vec = s + .channels + .iter() + .filter(|c| c.kind == "text") + .cloned() + .collect(); + let voice_chs: Vec = s + .channels + .iter() + .filter(|c| c.kind == "voice") + .cloned() + .collect(); + + if !text_chs.is_empty() { + cat_label(ui, s, "text"); + if !s.collapsed_categories.contains("text") { + for ch in &text_chs { + let active = s.active_channel.as_deref() == Some(&ch.id); + if items::ch_row_with_menu(ui, "#", &ch.name, active, None, false, Some(net)) { + join(s, net, &ch.id); + } + } + } + } + + if !voice_chs.is_empty() { + ui.add_space(4.0); + cat_label(ui, s, "voice"); + if !s.collapsed_categories.contains("voice") { + for ch in &voice_chs { + let active = s.active_channel.as_deref() == Some(&ch.id); + let live = + matches!(&s.voice, VoiceUiState::Active { channel } if channel == &ch.id); + let badge = (!ch.members.is_empty()).then(|| ch.members.len().to_string()); + if items::ch_row_with_menu( + ui, + "~", + &ch.name, + active, + badge.as_deref(), + live, + Some(net), + ) { + join(s, net, &ch.id); + } + if live || ch.name == "lobby" { + for member in &ch.members { + items::member_row(ui, member); + } + } + } + } + } + + if s.channels.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new("waiting for channels…") + .font(egui::FontId::proportional(11.0)) + .color(t::TEXT_DISABLED), + ); + } +} + +fn cat_label(ui: &mut egui::Ui, s: &mut UiState, text: &str) { + ui.add_space(2.0); + let collapsed = s.collapsed_categories.contains(text); + let arrow = if collapsed { "▶" } else { "▼" }; + let resp = Frame::NONE + .inner_margin(egui::Margin { + left: 12, + right: 8, + top: 2, + bottom: 2, + }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(arrow) + .font(egui::FontId::proportional(8.0)) + .color(t::TEXT_MUTED), + ); + ui.label( + egui::RichText::new(text.to_uppercase()) + .font(egui::FontId::proportional(10.0)) + .color(t::TEXT_MUTED), + ); + }); + }) + .response + .interact(egui::Sense::click()); + if resp.clicked() { + if collapsed { + s.collapsed_categories.remove(text); + } else { + s.collapsed_categories.insert(text.to_string()); + } + } +} + +fn join(s: &mut UiState, net: &NetHandle, channel_id: &str) { + s.active_channel = Some(channel_id.to_string()); + s.server_channel = Some(channel_id.to_string()); + let net = net.clone(); + let id = channel_id.to_string(); + tokio::spawn(async move { + net.send(NetCommand::JoinChannel { channel_id: id }).await; + }); +} + +fn show_create_channel_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) { + egui::Window::new("Create a Channel") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(300.0); + ui.label( + egui::RichText::new("Channel Name") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY), + ); + ui.add_space(8.0); + let resp = ui.add( + egui::TextEdit::singleline(&mut s.create_channel_input) + .font(egui::FontId::proportional(14.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(egui::RichText::new("e.g. dev-talk").color(t::TEXT_DISABLED)) + .desired_width(260.0), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Type") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY), + ); + ui.add_space(4.0); + let kind_text = match s.create_channel_kind.as_str() { + "voice" => "Voice", + _ => "Text", + }; + egui::ComboBox::from_id_salt("channel_kind") + .selected_text(kind_text) + .show_ui(ui, |ui| { + ui.selectable_value(&mut s.create_channel_kind, "text".into(), "Text"); + ui.selectable_value(&mut s.create_channel_kind, "voice".into(), "Voice"); + }); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("Cancel") + .font(egui::FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.create_channel_open = false; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let create_btn = egui::Button::new( + egui::RichText::new("Create") + .font(egui::FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ACCENT) + .stroke(egui::Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(80.0, 28.0)); + if ui.add(create_btn).clicked() && !s.create_channel_input.trim().is_empty() { + let name = s.create_channel_input.trim().to_string(); + let kind = s.create_channel_kind.clone(); + // Send server-side ChannelCreate — gateway will broadcast + // back a ChannelCreated event that updates the sidebar. + let net_clone = net.clone(); + let ch_id = name.clone(); + let ch_name = name.clone(); + let ch_kind = kind.clone(); + tokio::spawn(async move { + net_clone + .send(NetCommand::ChannelCreate { + channel_id: ch_id, + channel_name: ch_name, + kind: ch_kind, + }) + .await; + }); + s.create_channel_open = false; + } + }); + }); + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + s.create_channel_open = false; + } + }); +} diff --git a/src/ui/sidebar/guild_bar/icons.rs b/src/ui/sidebar/guild_bar/icons.rs new file mode 100644 index 0000000..b2f429f --- /dev/null +++ b/src/ui/sidebar/guild_bar/icons.rs @@ -0,0 +1,82 @@ +use eframe::egui::{self, FontId, Rect, RichText, Stroke}; + +use crate::theme as t; + +pub const GUILD_ICON_SIZE: f32 = 40.0; + +pub fn guild_icon( + ui: &mut egui::Ui, + label: &str, + tooltip: &str, + active: bool, + onclick: impl FnOnce(), +) { + let (bg, stroke) = if active { + (t::ACCENT_10, Stroke::new(1.5, t::ACCENT)) + } else { + (t::BG_ELEVATED, Stroke::new(1.0, t::BORDER_DEFAULT)) + }; + + let text_color = if active { + t::ACCENT_SOFT + } else { + t::TEXT_MUTED + }; + + let btn = egui::Button::new( + RichText::new(label) + .font(FontId::proportional(14.0)) + .color(text_color), + ) + .fill(bg) + .stroke(stroke) + .corner_radius(t::R_FULL) + .min_size(egui::vec2(GUILD_ICON_SIZE, GUILD_ICON_SIZE)); + + if ui.add(btn).on_hover_text(tooltip).clicked() { + onclick(); + } +} + +/// Guild icon that renders a vector-drawn glyph instead of a text label. +pub fn guild_icon_drawn( + ui: &mut egui::Ui, + draw_fn: fn(&egui::Painter, Rect, eframe::egui::Color32), + tooltip: &str, + active: bool, + onclick: impl FnOnce(), +) { + let (bg, stroke) = if active { + (t::ACCENT_10, Stroke::new(1.5, t::ACCENT)) + } else { + (t::BG_ELEVATED, Stroke::new(1.0, t::BORDER_DEFAULT)) + }; + let color = if active { + t::ACCENT_SOFT + } else { + t::TEXT_MUTED + }; + + let (rect, resp) = ui.allocate_exact_size( + egui::vec2(GUILD_ICON_SIZE, GUILD_ICON_SIZE), + egui::Sense::click(), + ); + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + painter.rect_filled(rect, t::R_FULL, bg); + painter.rect_stroke(rect, t::R_FULL, stroke, egui::StrokeKind::Middle); + // Inset for the icon. + let pad = 8.0; + let inner = Rect::from_min_max( + (rect.min.x + pad, rect.min.y + pad).into(), + (rect.max.x - pad, rect.max.y - pad).into(), + ); + draw_fn(painter, inner, color); + } + if resp.clicked() { + onclick(); + } + if !tooltip.is_empty() { + resp.on_hover_text(tooltip); + } +} diff --git a/src/ui/sidebar/guild_bar/mod.rs b/src/ui/sidebar/guild_bar/mod.rs new file mode 100644 index 0000000..37f473b --- /dev/null +++ b/src/ui/sidebar/guild_bar/mod.rs @@ -0,0 +1,685 @@ +use crate::theme::icons as vec_icons; +use crate::{state::UiState, theme as t}; +use eframe::egui::{self, FontId, Frame, RichText, Stroke}; +use vnox_client::net::{NetCommand, NetHandle}; + +mod icons; +const BAR_WIDTH: f32 = 56.0; + +pub fn show(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) { + egui::SidePanel::left("guild_bar") + .exact_width(BAR_WIDTH) + .resizable(false) + .frame(Frame::NONE.fill(t::BG_STRIP)) + .show_separator_line(false) + .show(ctx, |ui| { + let r = ui.max_rect(); + ui.painter() + .vline(r.right(), r.y_range(), Stroke::new(1.0, t::BORDER_SUBTLE)); + + ui.with_layout( + egui::Layout::top_down_justified(egui::Align::Center), + |ui| { + ui.add_space(8.0); + + let home_active = s.active_guild_id.is_none(); + icons::guild_icon_drawn(ui, vec_icons::home, "Home", home_active, || { + s.active_guild_id = None; + s.active_dm_id = s.dm_conversations.first().map(|c| c.dm_id.clone()); + }); + ui.add_space(4.0); + + ui.painter().hline( + egui::Rangef::new(r.left() + 12.0, r.right() - 12.0), + ui.cursor().top() + 2.0, + Stroke::new(1.0, t::BORDER_SUBTLE), + ); + ui.add_space(6.0); + + for guild in &s.guilds { + let active = s.active_guild_id.as_deref() == Some(&guild.guild_id); + let initial = guild + .name + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + icons::guild_icon(ui, &initial, &guild.name, active, || { + s.active_guild_id = Some(guild.guild_id.clone()); + let net = net.clone(); + let gid = guild.guild_id.clone(); + tokio::spawn(async move { + net.send(vnox_client::net::NetCommand::GuildList).await; + let _ = gid; + }); + }); + ui.add_space(2.0); + } + + ui.add_space(4.0); + if t::icon_btn_drawn( + ui, + vec_icons::plus, + "create a guild", + None, + icons::GUILD_ICON_SIZE, + ) + .clicked() + { + s.create_guild_open = true; + s.create_guild_input.clear(); + } + + ui.add_space(4.0); + if t::icon_btn_drawn( + ui, + vec_icons::link, + "join with invite code", + None, + icons::GUILD_ICON_SIZE, + ) + .clicked() + { + s.invite_accept_open = true; + s.invite_accept_code.clear(); + } + + ui.add_space(4.0); + let has_guild = s.active_guild_id.is_some(); + let audit_resp = t::icon_btn_drawn( + ui, + vec_icons::clipboard, + "view audit log (admins only)", + None, + icons::GUILD_ICON_SIZE, + ); + if has_guild + && audit_resp.clicked() + && let Some(gid) = s.active_guild_id.clone() + { + s.audit_log_open = true; + s.audit_log_guild_id = Some(gid.clone()); + s.audit_log_entries.clear(); + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::GuildAuditLogFetch { + guild_id: gid, + limit: 50, + }) + .await; + }); + } + + ui.add_space(4.0); + let members_resp = t::icon_btn_drawn( + ui, + vec_icons::people, + "guild members", + None, + icons::GUILD_ICON_SIZE, + ); + let has_guild = s.active_guild_id.is_some(); + if has_guild + && members_resp.clicked() + && let Some(gid) = s.active_guild_id.clone() + { + s.guild_members_open = true; + s.guild_member_list_guild_id = Some(gid.clone()); + s.guild_member_list.clear(); + s.guild_role_list.clear(); + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::GuildMemberListFetch { + guild_id: gid.clone(), + }) + .await; + net.send(NetCommand::GuildRoleListFetch { guild_id: gid }) + .await; + }); + } + }, + ); + + if s.create_guild_open { + show_create_guild_popup(ctx, s, net); + } + if s.invite_accept_open { + show_invite_accept_popup(ctx, s, net); + } + if s.audit_log_open { + show_audit_log_popup(ctx, s, net); + } + if s.guild_members_open { + show_members_popup(ctx, s, net); + } + }); +} + +fn show_create_guild_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) { + egui::Window::new("Create a Guild") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(280.0); + ui.label( + RichText::new("Guild Name") + .font(FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY), + ); + ui.add_space(8.0); + let resp = ui.add( + egui::TextEdit::singleline(&mut s.create_guild_input) + .font(FontId::proportional(14.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(RichText::new("e.g. My Awesome Guild").color(t::TEXT_DISABLED)) + .desired_width(240.0), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Cancel") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.create_guild_open = false; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let create_btn = egui::Button::new( + RichText::new("Create") + .font(FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ACCENT) + .stroke(Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(80.0, 28.0)); + if ui.add(create_btn).clicked() && !s.create_guild_input.trim().is_empty() { + let net = net.clone(); + let name = s.create_guild_input.trim().to_string(); + tokio::spawn(async move { + net.send(vnox_client::net::NetCommand::GuildCreate { name }) + .await; + }); + s.create_guild_open = false; + } + }); + }); + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + s.create_guild_open = false; + } + }); +} + +fn show_invite_accept_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) { + egui::Window::new("Join a Guild") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(280.0); + ui.label( + RichText::new("Invite Code") + .font(FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY), + ); + ui.add_space(8.0); + let resp = ui.add( + egui::TextEdit::singleline(&mut s.invite_accept_code) + .font(FontId::monospace(14.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(RichText::new("e.g. a1b2c3d4").color(t::TEXT_DISABLED)) + .desired_width(240.0), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui + .add( + egui::Button::new( + RichText::new("Cancel") + .font(FontId::proportional(12.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.invite_accept_open = false; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let join_btn = egui::Button::new( + RichText::new("Join") + .font(FontId::proportional(12.0)) + .color(egui::Color32::WHITE), + ) + .fill(t::ACCENT) + .stroke(Stroke::NONE) + .corner_radius(t::R_SM) + .min_size(egui::vec2(80.0, 28.0)); + if ui.add(join_btn).clicked() && !s.invite_accept_code.trim().is_empty() { + let net = net.clone(); + let code = s.invite_accept_code.trim().to_string(); + tokio::spawn(async move { + net.send(NetCommand::InviteAccept { code }).await; + }); + s.invite_accept_open = false; + } + }); + }); + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + s.invite_accept_open = false; + } + }); +} + +fn show_audit_log_popup(ctx: &egui::Context, s: &mut UiState, _net: &NetHandle) { + egui::Window::new("Audit Log") + .collapsible(false) + .resizable(true) + .default_size([520.0, 420.0]) + .min_size([400.0, 300.0]) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(500.0); + ui.horizontal(|ui| { + ui.label( + RichText::new("Last 50 admin actions for this guild") + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if t::icon_btn_drawn(ui, vec_icons::close, "close", None, 18.0).clicked() { + s.audit_log_open = false; + } + }); + }); + ui.add_space(6.0); + ui.separator(); + ui.add_space(4.0); + + if s.audit_log_entries.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(40.0); + ui.label( + RichText::new("no audit log entries") + .font(FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); + ui.add_space(4.0); + ui.label( + RichText::new("(loading or empty — admin actions like kick, role change, invite create will appear here)") + .font(FontId::proportional(10.0)) + .color(t::TEXT_DISABLED), + ); + }); + return; + } + + egui::ScrollArea::vertical() + .auto_shrink([false, true]) + .show(ui, |ui| { + for entry in s.audit_log_entries.clone() { + Frame::NONE + .fill(t::BG_SURFACE) + .corner_radius(t::R_SM) + .stroke(egui::Stroke::new(1.0, t::BORDER_SUBTLE)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (icon, color) = action_icon(&entry.action); + ui.label( + RichText::new(icon) + .font(FontId::proportional(14.0)) + .color(color), + ); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(&entry.action) + .font(FontId::proportional(12.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + ui.label( + RichText::new(format!("· by {}…", &entry.actor_id[..8.min(entry.actor_id.len())])) + .font(FontId::monospace(10.0)) + .color(t::TEXT_DISABLED), + ); + }); + if let Some(t_id) = &entry.target_id { + ui.label( + RichText::new(format!("target: {}…", &t_id[..8.min(t_id.len())])) + .font(FontId::monospace(10.0)) + .color(t::TEXT_MUTED), + ); + } + if let Some(reason) = &entry.reason { + ui.label( + RichText::new(format!("reason: {reason}")) + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED) + .italics(), + ); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(fmt_audit_ts(entry.created_at)) + .font(FontId::monospace(10.0)) + .color(t::TEXT_DISABLED), + ); + }); + }); + }); + ui.add_space(2.0); + } + }); + }); +} + +fn action_icon(action: &str) -> (&'static str, egui::Color32) { + match action { + "guild_create" | "GUILD_CREATE" => ("[+]", t::SUCCESS), + "guild_delete" | "GUILD_DELETE" => ("[-]", t::ERROR), + "member_kick" | "MEMBER_KICK" => ("[kick]", t::ERROR), + "member_leave" | "MEMBER_LEAVE" => ("[leave]", t::WARNING), + "role_create" | "ROLE_CREATE" => ("[role+]", t::INFO), + "role_delete" | "ROLE_DELETE" => ("[role-]", t::ERROR), + "role_assign" | "ROLE_ASSIGN" => ("[role=]", t::INFO), + "role_unassign" | "ROLE_UNASSIGN" => ("[role!=]", t::WARNING), + "invite_create" | "INVITE_CREATE" => ("[invite+]", t::SUCCESS), + "invite_delete" | "INVITE_DELETE" => ("[invite-]", t::ERROR), + "invite_accept" | "INVITE_ACCEPT" => ("[invite=]", t::SUCCESS), + _ => ("[*]", t::TEXT_MUTED), + } +} + +fn fmt_audit_ts(ts: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let diff = (now - ts / 1000).max(0); + if diff < 60 { + format!("{diff}s ago") + } else if diff < 3600 { + format!("{}m ago", diff / 60) + } else if diff < 86400 { + format!("{}h ago", diff / 3600) + } else { + format!("{}d ago", diff / 86400) + } +} + +fn show_members_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) { + egui::Window::new("Guild Members") + .collapsible(false) + .resizable(true) + .default_size([520.0, 460.0]) + .min_size([400.0, 320.0]) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame( + Frame::window(&ctx.style()) + .fill(t::BG_ELEVATED) + .stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT)) + .corner_radius(t::R_LG), + ) + .show(ctx, |ui| { + ui.set_width(500.0); + ui.horizontal(|ui| { + let count = s.guild_member_list.len(); + ui.label( + RichText::new(format!("{count} members")) + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if t::icon_btn_drawn(ui, vec_icons::refresh, "refresh", None, 18.0).clicked() + && let Some(gid) = s.guild_member_list_guild_id.clone() + { + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::GuildMemberListFetch { + guild_id: gid.clone(), + }) + .await; + net.send(NetCommand::GuildRoleListFetch { guild_id: gid }) + .await; + }); + } + if t::icon_btn_drawn(ui, vec_icons::close, "close", None, 18.0).clicked() { + s.guild_members_open = false; + } + }); + }); + ui.add_space(6.0); + ui.separator(); + ui.add_space(4.0); + + if s.guild_member_list.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(40.0); + ui.label( + RichText::new("no members") + .font(FontId::proportional(12.0)) + .color(t::TEXT_DISABLED), + ); + }); + return; + } + + // Determine if current user is owner (kick allowed). + // For simplicity, we allow kick UI for everyone; gateway enforces permission. + let roles_clone = s.guild_role_list.clone(); + let guild_id = s.guild_member_list_guild_id.clone().unwrap_or_default(); + + egui::ScrollArea::vertical() + .auto_shrink([false, true]) + .show(ui, |ui| { + for member in s.guild_member_list.clone() { + let role_color = + parse_hex_color(&member.role_color).unwrap_or(t::TEXT_PRIMARY); + Frame::NONE + .fill(t::BG_SURFACE) + .corner_radius(t::R_SM) + .stroke(egui::Stroke::new(1.0, t::BORDER_SUBTLE)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Avatar with role-colored ring + let init = member + .nickname + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(); + let (r, _) = ui.allocate_exact_size( + egui::vec2(28.0, 28.0), + egui::Sense::hover(), + ); + ui.painter().circle_filled(r.center(), 14.0, role_color); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + &init, + egui::FontId::proportional(12.0), + egui::Color32::from_rgb(15, 15, 15), + ); + + ui.add_space(8.0); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(&member.nickname) + .font(FontId::proportional(13.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + if member.is_owner { + let (cr, _) = ui.allocate_exact_size( + egui::vec2(14.0, 14.0), + egui::Sense::hover(), + ); + vec_icons::crown(ui.painter(), cr, t::WARNING); + } + }); + ui.horizontal(|ui| { + let (dot_r, _) = ui.allocate_exact_size( + egui::vec2(8.0, 8.0), + egui::Sense::hover(), + ); + ui.painter().circle_filled( + dot_r.center(), + 4.0, + role_color, + ); + ui.label( + RichText::new(&member.role_name) + .font(FontId::proportional(10.0)) + .color(t::TEXT_MUTED), + ); + ui.label( + RichText::new(format!( + "· {}…", + &member.user_id[..8.min(member.user_id.len())] + )) + .font(FontId::monospace(10.0)) + .color(t::TEXT_DISABLED), + ); + }); + }); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // Role-assign combo (only shows if user has roles defined). + if !roles_clone.is_empty() && !member.is_owner { + let combo_text = "+ role".to_string(); + egui::ComboBox::from_id_salt(format!( + "role_combo_{}", + member.user_id + )) + .selected_text(combo_text) + .width(120.0) + .show_ui(ui, |ui| { + for role in &roles_clone { + if ui + .add( + egui::Button::new( + RichText::new(&role.name) + .font(FontId::proportional( + 11.0, + )) + .color( + parse_hex_color( + &role.color, + ) + .unwrap_or( + t::TEXT_PRIMARY, + ), + ), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + let net = net.clone(); + let gid = guild_id.clone(); + let uid = member.user_id.clone(); + let rid = role.id.clone(); + tokio::spawn(async move { + net.send( + NetCommand::GuildRoleAssign { + guild_id: gid, + user_id: uid, + role_id: rid, + }, + ) + .await; + }); + } + } + }); + } + // Kick button (hidden for owner). + if !member.is_owner + && t::icon_btn_drawn( + ui, + vec_icons::boot, + "kick (admins only)", + Some(t::ERROR), + 18.0, + ) + .clicked() + { + let net_kick = net.clone(); + let net_refresh = net.clone(); + let gid = guild_id.clone(); + let uid = member.user_id.clone(); + tokio::spawn(async move { + net_kick + .send(NetCommand::GuildMemberKick { + guild_id: gid.clone(), + user_id: uid, + }) + .await; + tokio::time::sleep( + std::time::Duration::from_millis(200), + ) + .await; + net_refresh + .send(NetCommand::GuildMemberListFetch { + guild_id: gid, + }) + .await; + }); + } + }, + ); + }); + }); + ui.add_space(2.0); + } + }); + }); +} + +fn parse_hex_color(hex: &str) -> Option { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(egui::Color32::from_rgb(r, g, b)) +} diff --git a/src/ui/sidebar/mod.rs b/src/ui/sidebar/mod.rs new file mode 100644 index 0000000..0fd8900 --- /dev/null +++ b/src/ui/sidebar/mod.rs @@ -0,0 +1,94 @@ +//! Unified left sidebar (design from aiupdate). + +mod channel_list; +mod guild_bar; +mod sections; + +pub use guild_bar::show as guild_bar; + +use eframe::egui::{self, Context, Frame, SidePanel}; +use vnox_client::{identity::Identity, net::NetHandle}; + +use crate::{ + connect, + state::{ConnState, UiState}, + theme as t, +}; + +pub fn show(ctx: &Context, s: &mut UiState, identity: &Identity, net: &NetHandle) { + SidePanel::left("sidebar") + .exact_width(220.0) + .resizable(false) + .frame(Frame::NONE.fill(t::BG_SURFACE)) + .show_separator_line(false) + .show(ctx, |ui| { + let r = ui.max_rect(); + ui.painter().vline( + r.right(), + r.y_range(), + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { + sections::header::show(ui, s); + bookmark_row(ui, s, net); + + let userbar_h = 52.0; + let avail = (ui.available_height() - userbar_h).max(0.0); + egui::ScrollArea::vertical() + .max_height(avail) + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.add_space(6.0); + channel_list::show(ui, s, net); + ui.add_space(4.0); + sections::dm::show(ui, s, net); + ui.add_space(6.0); + }); + + sections::userbar::show(ui, s, identity, net); + }); + }); +} + +fn bookmark_row(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + let busy = matches!( + s.conn, + ConnState::Connecting | ConnState::Reconnecting { .. } + ); + if s.bookmarks.is_empty() { + return; + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); + ui.add_space(8.0); + for (i, bm) in s.bookmarks.clone().into_iter().enumerate() { + let active = s.selected_bookmark == i; + let label = bm.label.chars().take(8).collect::(); + let btn = egui::Button::new( + egui::RichText::new(label) + .font(egui::FontId::proportional(10.0)) + .color(if active { + t::ACCENT_SOFT + } else { + t::TEXT_MUTED + }), + ) + .fill(if active { t::ACCENT_10 } else { t::BG_ELEVATED }) + .stroke(egui::Stroke::new( + 1.0, + if active { + t::BORDER_ACCENT + } else { + t::BORDER_DEFAULT + }, + )) + .corner_radius(t::R_SM); + if ui.add(btn).on_hover_text(&bm.address).clicked() && !busy { + s.selected_bookmark = i; + connect::connect_to(s, net, &bm.address); + } + } + }); + ui.add_space(4.0); +} diff --git a/src/ui/sidebar/sections/dm.rs b/src/ui/sidebar/sections/dm.rs new file mode 100644 index 0000000..608da42 --- /dev/null +++ b/src/ui/sidebar/sections/dm.rs @@ -0,0 +1,170 @@ +use eframe::egui::{self, Frame}; +use vnox_client::net::{NetCommand, NetHandle}; + +use crate::state::{ConnState, UiState}; +use crate::theme as t; + +pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + if !matches!(s.conn, ConnState::Connected { .. }) { + return; + } + Frame::NONE + .inner_margin(egui::Margin { + left: 12, + right: 8, + top: 2, + bottom: 2, + }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("DIRECT MESSAGES") + .font(egui::FontId::proportional(10.0)) + .color(t::TEXT_MUTED), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("+") + .font(egui::FontId::proportional(12.0)) + .color(t::ACCENT), + ) + .frame(false) + .min_size(egui::vec2(16.0, 16.0)), + ) + .on_hover_text("new DM") + .clicked() + { + s.new_dm_input = Some(String::new()); + } + }); + }); + }); + new_dm_input(ui, s, net); + for conv in s.dm_conversations.clone() { + let active = s.active_dm_id.as_deref() == Some(&conv.dm_id); + let bg = if active { + t::ACCENT_10 + } else { + egui::Color32::TRANSPARENT + }; + let stroke = if active { + egui::Stroke::new(1.0, t::BORDER_ACCENT) + } else { + egui::Stroke::NONE + }; + let text_c = if active { + t::ACCENT_SOFT + } else { + t::TEXT_MUTED + }; + + let resp = Frame::NONE + .fill(bg) + .stroke(stroke) + .corner_radius(t::R_SM) + .inner_margin(egui::Margin::symmetric(8, 3)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("@").color(text_c).size(13.0)); + ui.label( + egui::RichText::new(&conv.other_nickname) + .color(text_c) + .size(13.0), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if conv.unread_count > 0 { + let text = if conv.unread_count > 99 { + "99+".to_string() + } else { + conv.unread_count.to_string() + }; + let (r, _) = ui + .allocate_exact_size(egui::vec2(18.0, 14.0), egui::Sense::hover()); + ui.painter() + .rect_filled(r, egui::CornerRadius::same(4), t::ACCENT); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + &text, + egui::FontId::monospace(10.0), + egui::Color32::WHITE, + ); + } + }); + }); + }) + .response + .interact(egui::Sense::click()); + + if resp.clicked() { + let clicked_dm_id = conv.dm_id.clone(); + s.active_dm_id = Some(clicked_dm_id.clone()); + if let Some(c) = s + .dm_conversations + .iter_mut() + .find(|c| c.dm_id == conv.dm_id) + { + c.unread_count = 0; + } + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::DmReadAck { + dm_id: clicked_dm_id, + }) + .await; + }); + } + } +} + +pub fn new_dm_input(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + let mut input_buf = s.new_dm_input.clone().unwrap_or_default(); + let mut should_start = false; + if s.new_dm_input.is_some() { + Frame::NONE + .inner_margin(egui::Margin::symmetric(12, 2)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let resp = ui.add_sized( + egui::vec2(120.0, 20.0), + egui::TextEdit::singleline(&mut input_buf) + .font(egui::FontId::proportional(11.0)) + .text_color(t::TEXT_PRIMARY) + .hint_text(egui::RichText::new("user id").color(t::TEXT_DISABLED)) + .frame(true), + ); + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) + || ui + .add( + egui::Button::new( + egui::RichText::new("Start") + .font(egui::FontId::proportional(10.0)) + .color(t::ACCENT), + ) + .fill(t::ACCENT_10) + .stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT)) + .corner_radius(t::R_SM) + .min_size(egui::vec2(40.0, 18.0)), + ) + .clicked() + && !input_buf.trim().is_empty() + { + should_start = true; + } + }); + }); + } + if should_start { + let target = input_buf.trim().to_string(); + let net = net.clone(); + tokio::spawn(async move { + net.send(NetCommand::DmStart { + target_user_id: target, + }) + .await; + }); + s.new_dm_input = None; + } +} diff --git a/src/ui/sidebar/sections/header.rs b/src/ui/sidebar/sections/header.rs new file mode 100644 index 0000000..cbbeb65 --- /dev/null +++ b/src/ui/sidebar/sections/header.rs @@ -0,0 +1,95 @@ +use eframe::egui::{self, Frame}; + +use crate::state::{ConnState, UiState}; +use crate::theme as t; + +pub fn show(ui: &mut egui::Ui, s: &UiState) { + let title = if let Some(gid) = &s.active_guild_id + && let Some(g) = s.guilds.iter().find(|g| &g.guild_id == gid) + { + g.name.clone() + } else { + match &s.conn { + ConnState::Connected { node_name, .. } => node_name.clone(), + ConnState::Reconnecting { attempt } => format!("reconnect #{attempt}"), + ConnState::Connecting => "connecting…".into(), + ConnState::Disconnected => "not connected".into(), + } + }; + + let is_guild = s.active_guild_id.is_some(); + + Frame::NONE + .fill(t::BG_SURFACE) + .inner_margin(egui::Margin::symmetric(12, 0)) + .show(ui, |ui| { + ui.set_height(38.0); + ui.horizontal_centered(|ui| { + if is_guild { + let (r, _) = + ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover()); + ui.painter().rect_filled(r, t::R_FULL, t::ACCENT); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + title + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .to_string(), + egui::FontId::monospace(11.0), + egui::Color32::WHITE, + ); + } else { + let (r, _) = + ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover()); + ui.painter().rect_filled(r, t::R_SM, t::ACCENT_20); + ui.painter().rect_stroke( + r, + t::R_SM, + egui::Stroke::new(1.0, t::BORDER_ACCENT), + egui::StrokeKind::Inside, + ); + ui.painter().text( + r.center(), + egui::Align2::CENTER_CENTER, + "V", + egui::FontId::monospace(11.0), + t::TEXT_PRIMARY, + ); + } + + ui.add_space(8.0); + ui.label( + egui::RichText::new(title) + .font(egui::FontId::proportional(13.0)) + .color(t::TEXT_PRIMARY) + .strong(), + ); + + if s.private_mode { + ui.add_space(6.0); + Frame::NONE + .fill(t::WARNING) + .corner_radius(t::R_SM) + .inner_margin(egui::Margin::symmetric(4, 1)) + .show(ui, |ui| { + ui.label( + egui::RichText::new("PRIVATE") + .font(egui::FontId::monospace(8.0)) + .color(egui::Color32::from_rgb(15, 15, 15)) + .strong(), + ); + }); + } + }); + }); + + let sep = ui.max_rect(); + ui.painter().hline( + sep.x_range(), + sep.top() + 38.0, + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); +} diff --git a/src/ui/sidebar/sections/mod.rs b/src/ui/sidebar/sections/mod.rs new file mode 100644 index 0000000..44064bd --- /dev/null +++ b/src/ui/sidebar/sections/mod.rs @@ -0,0 +1,3 @@ +pub mod dm; +pub mod header; +pub mod userbar; diff --git a/src/ui/sidebar/sections/userbar.rs b/src/ui/sidebar/sections/userbar.rs new file mode 100644 index 0000000..9d5e3ae --- /dev/null +++ b/src/ui/sidebar/sections/userbar.rs @@ -0,0 +1,281 @@ +use eframe::egui::{self, FontId, Frame, RichText}; +use vnox_client::{ + identity::Identity, + net::{NetCommand, NetHandle}, +}; + +use crate::state::UiState; +use crate::theme as t; +use crate::theme::icons; + +pub fn show(ui: &mut egui::Ui, s: &mut UiState, identity: &Identity, net: &NetHandle) { + let r = ui.available_rect_before_wrap(); + ui.painter().hline( + r.x_range(), + r.top(), + egui::Stroke::new(1.0, t::BORDER_SUBTLE), + ); + + Frame::NONE + .fill(t::BG_ELEVATED) + .inner_margin(egui::Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let init = identity + .nickname + .chars() + .next() + .unwrap_or('U') + .to_uppercase() + .to_string(); + t::avatar( + ui, + &init, + avatar_color_for(&identity.nickname, s.local_speaking), + 30.0, + ); + ui.add_space(8.0); + + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&identity.nickname) + .font(egui::FontId::proportional(12.5)) + .color(if s.local_speaking { + t::SUCCESS + } else { + t::TEXT_PRIMARY + }) + .strong(), + ); + if s.local_speaking { + let (r, _) = ui + .allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); + icons::speaker(ui.painter(), r, t::SUCCESS); + } + }); + + // Status text — shows voice state, custom status, or activity. + let status_text = if let Some(ch) = s.active_voice_channel() { + if s.remote_speaking { + format!("in voice · {} · talking", ch.name) + } else { + format!("in voice · {}", ch.name) + } + } else if let Some(ref custom) = s.custom_status { + custom.clone() + } else if let (Some(_kind), Some(text)) = (&s.activity_type, &s.activity_text) { + text.clone() + } else if s.overlay_latency { + match s.rtt_ms { + Some(ms) => format!("online · {ms}ms"), + None => "online".into(), + } + } else { + "online".into() + }; + + let status_label = ui.add( + egui::Button::new( + RichText::new(status_text) + .font(FontId::proportional(10.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ); + status_label.context_menu(|ui| { + status_picker(ui, s, net); + ui.separator(); + custom_status_editor(ui, s, net); + ui.separator(); + activity_editor(ui, s, net); + }); + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 1.0; + if t::icon_btn_drawn(ui, icons::gear, "settings", None, 22.0).clicked() { + s.settings_open = true; + } + let deaf_c = if s.deafen { Some(t::ERROR) } else { None }; + let deaf_icon = if s.deafen { + icons::headphones_off + } else { + icons::headphones + }; + if t::icon_btn_drawn(ui, deaf_icon, "deafen", deaf_c, 22.0).clicked() { + s.deafen = !s.deafen; + } + let mic_icon = if s.mic_enabled { + icons::microphone + } else { + icons::microphone_off + }; + let mic_c = if !s.mic_enabled { + Some(t::ERROR) + } else if s.local_speaking { + Some(t::SUCCESS) + } else { + None + }; + if t::icon_btn_drawn(ui, mic_icon, "microphone", mic_c, 22.0).clicked() { + s.mic_enabled = !s.mic_enabled; + } + }); + }); + }); +} + +fn status_picker(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + ui.label( + RichText::new("Status") + .font(FontId::proportional(10.0)) + .color(t::TEXT_MUTED) + .strong(), + ); + let options = [ + ("online", "Online"), + ("idle", "Idle"), + ("dnd", "Do Not Disturb"), + ("invisible", "Invisible"), + ]; + for (key, label) in options { + let selected = s.presence_status.as_deref() == Some(key); + let btn = egui::Button::new(RichText::new(label).font(FontId::proportional(12.0))) + .fill(if selected { + t::ACCENT_10 + } else { + egui::Color32::TRANSPARENT + }) + .frame(false); + if ui.add(btn).clicked() { + s.presence_status = Some(key.to_string()); + send_presence(s, net); + ui.close(); + } + } +} + +fn custom_status_editor(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + ui.label( + RichText::new("Custom status") + .font(FontId::proportional(10.0)) + .color(t::TEXT_MUTED) + .strong(), + ); + let mut buf = s.custom_status.clone().unwrap_or_default(); + let resp = ui.add( + egui::TextEdit::singleline(&mut buf) + .font(FontId::proportional(12.0)) + .hint_text("what's on your mind?") + .desired_width(180.0) + .text_color(t::TEXT_PRIMARY), + ); + if resp.changed() { + s.custom_status = if buf.trim().is_empty() { + None + } else { + Some(buf) + }; + } + if resp.lost_focus() { + send_presence(s, net); + } + if ui + .add( + egui::Button::new( + RichText::new("Clear") + .font(FontId::proportional(11.0)) + .color(t::TEXT_MUTED), + ) + .fill(egui::Color32::TRANSPARENT) + .frame(false), + ) + .clicked() + { + s.custom_status = None; + send_presence(s, net); + ui.close(); + } +} + +fn activity_editor(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) { + ui.label( + RichText::new("Activity") + .font(FontId::proportional(10.0)) + .color(t::TEXT_MUTED) + .strong(), + ); + let kinds = [ + ("playing", "Playing"), + ("listening", "Listening"), + ("watching", "Watching"), + ("streaming", "Streaming"), + ]; + let mut current_kind = s.activity_type.clone(); + let combo = egui::ComboBox::from_id_salt("activity_kind") + .selected_text(current_kind.clone().unwrap_or_else(|| "— none —".into())) + .width(160.0); + combo.show_ui(ui, |ui| { + for (k, label) in kinds { + ui.selectable_value(&mut current_kind, Some(k.to_string()), label); + } + ui.selectable_value(&mut current_kind, None, "— none —"); + }); + let mut buf = s.activity_text.clone().unwrap_or_default(); + let resp = ui.add( + egui::TextEdit::singleline(&mut buf) + .font(FontId::proportional(12.0)) + .hint_text("doing what?") + .desired_width(180.0) + .text_color(t::TEXT_PRIMARY), + ); + let mut changed = false; + if resp.changed() { + s.activity_text = if buf.trim().is_empty() { + None + } else { + Some(buf) + }; + changed = true; + } + if current_kind != s.activity_type { + s.activity_type = current_kind; + changed = true; + } + if changed && (resp.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter))) { + send_presence(s, net); + } +} + +fn send_presence(s: &UiState, net: &NetHandle) { + let net = net.clone(); + let status = s.presence_status.clone().unwrap_or_else(|| "online".into()); + let custom = s.custom_status.clone(); + let activity = s.activity_type.clone(); + let activity_text = s.activity_text.clone(); + tokio::spawn(async move { + net.send(NetCommand::PresenceUpdate { + status, + custom_status: custom, + activity, + activity_text, + }) + .await; + }); +} + +fn avatar_color_for(nick: &str, speaking: bool) -> egui::Color32 { + if speaking { + return t::SUCCESS; + } + let colors = [ + t::ACCENT_10, + egui::Color32::from_rgb(50, 70, 100), + egui::Color32::from_rgb(60, 80, 50), + egui::Color32::from_rgb(80, 60, 80), + ]; + colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()] +} diff --git a/src/ui/state/default.rs b/src/ui/state/default.rs new file mode 100644 index 0000000..7c28ced --- /dev/null +++ b/src/ui/state/default.rs @@ -0,0 +1,152 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use super::models::{default_bookmarks, now_utc_hms}; +use super::types::{ConnState, UiState, VoiceUiState}; + +impl Default for UiState { + fn default() -> Self { + Self { + conn: ConnState::Disconnected, + connect_input: "127.0.0.1:7600".into(), + bookmarks: default_bookmarks(), + selected_bookmark: 0, + status_msg: None, + channels: Vec::new(), + active_channel: None, + messages: Default::default(), + chat_input: String::new(), + editing_message_id: None, + replying_to: None, + context_menu_message_id: None, + scroll_bottom: false, + voice: VoiceUiState::Off, + voice_joined_at: None, + user_names: Default::default(), + server_channel: None, + rtt_ms: None, + + settings_open: false, + settings_page: Default::default(), + + mic_enabled: true, + deafen: false, + noise_suppress: true, + echo_cancel: true, + vad_mode: 0, + vad_threshold: 40.0, + voice_bitrate: 64.0, + packet_interval: 1, + mic_device: 0, + input_volume: 80.0, + input_device_labels: vec!["Default".into()], + input_device_metas: Vec::new(), + output_device_labels: vec!["Default".into()], + output_device_metas: Vec::new(), + last_device_scan: None, + + output_device: 0, + output_volume: 90.0, + jitter_size: 1, + adaptive_buffer: true, + + overlay_enabled: false, + overlay_speakers: true, + overlay_latency: true, + + relay_enabled: true, + relay_address: "relay.nightcore.lnex".into(), + auto_relay: true, + force_relay: false, + udp_port: "7700".into(), + auto_reconnect: true, + + ui_scale: 100.0, + + kb_ptt: "mouse4".into(), + kb_mute: "ctrl+m".into(), + kb_deafen: "ctrl+d".into(), + kb_overlay: "ctrl+shift+o".into(), + + log_level: 0, + log_voice_packets: false, + show_packet_stats: false, + disable_encryption: false, + + guilds: Vec::new(), + active_guild_id: None, + presences: Default::default(), + presence_status: Some("online".into()), + custom_status: None, + activity_type: None, + activity_text: None, + friends: Vec::new(), + pending_friend_requests: Vec::new(), + blocked_users: Vec::new(), + block_input: None, + block_list_fetched_at: None, + dm_conversations: Vec::new(), + dm_messages: Default::default(), + active_dm_id: None, + new_dm_input: None, + + add_server_open: false, + add_server_input: String::new(), + invite_accept_open: false, + invite_accept_code: String::new(), + create_guild_open: false, + create_guild_input: String::new(), + + create_channel_open: false, + create_channel_input: String::new(), + create_channel_kind: "text".into(), + + vault_set_open: false, + vault_remove_open: false, + vault_passphrase_input: String::new(), + vault_passphrase_confirm: String::new(), + vault_error: None, + + export_keyfile_open: false, + export_keyfile_pass: String::new(), + export_keyfile_confirm: String::new(), + export_keyfile_output: String::new(), + export_keyfile_error: None, + import_keyfile_open: false, + import_keyfile_input: String::new(), + import_keyfile_pass: String::new(), + import_keyfile_error: None, + + audit_log_open: false, + audit_log_guild_id: None, + audit_log_entries: Vec::new(), + + guild_members_open: false, + guild_member_list_guild_id: None, + guild_member_list: Vec::new(), + guild_role_list_guild_id: None, + guild_role_list: Vec::new(), + + add_friend_open: false, + add_friend_input: String::new(), + + dm_search_query: String::new(), + user_colors: HashMap::new(), + per_user_volumes: Default::default(), + kb_listening: None, + + private_mode: false, + + collapsed_categories: HashSet::new(), + startup_time_str: now_utc_hms(), + typing_users: HashMap::new(), + last_typing_send: Instant::now(), + read_receipts: HashMap::new(), + friends_tab: "all".into(), + local_speaking: false, + remote_speaking: false, + last_remote_speaker_id: String::new(), + last_voice_packet_ms: 0, + } + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs new file mode 100644 index 0000000..9eedaf6 --- /dev/null +++ b/src/ui/state/mod.rs @@ -0,0 +1,131 @@ +mod default; +mod models; +mod types; + +pub use models::{ + Channel, ChatMessage, DmConversation, FriendState, GuildState, NodeBookmark, SettingsPage, +}; +pub use types::{ConnState, TYPING_TIMEOUT, UiState, VoiceUiState}; + +use std::time::{Duration, Instant}; + +use models::bookmark_label_from_address; + +impl UiState { + pub fn refresh_audio_devices(&mut self) { + let now = Instant::now(); + if let Some(last) = self.last_device_scan + && now.duration_since(last) < Duration::from_millis(2500) + { + return; + } + self.last_device_scan = Some(now); + + match vnox_client::audio::devices::enumerate() { + Ok(lists) => { + self.input_device_labels = lists.input_labels; + self.input_device_metas = lists.input_devices; + self.output_device_labels = lists.output_labels; + self.output_device_metas = lists.output_devices; + } + Err(e) => { + tracing::warn!("audio device enumeration: {e}"); + } + } + + if self.input_device_labels.is_empty() { + self.input_device_labels.push("Default".into()); + } + if self.output_device_labels.is_empty() { + self.output_device_labels.push("Default".into()); + } + + self.mic_device = self + .mic_device + .min(self.input_device_labels.len().saturating_sub(1)); + self.output_device = self + .output_device + .min(self.output_device_labels.len().saturating_sub(1)); + } + + pub fn force_refresh_audio_devices(&mut self) { + self.last_device_scan = None; + self.refresh_audio_devices(); + } + + pub fn remove_bookmark(&mut self, index: usize) { + if index < self.bookmarks.len() { + self.bookmarks.remove(index); + if self.selected_bookmark >= self.bookmarks.len() && !self.bookmarks.is_empty() { + self.selected_bookmark = self.bookmarks.len() - 1; + } + } + } + + pub fn add_bookmark_from_input(&mut self) -> bool { + let address = self.connect_input.trim().to_string(); + if address.is_empty() || self.bookmarks.iter().any(|b| b.address == address) { + return false; + } + let label = bookmark_label_from_address(&address); + self.bookmarks.push(NodeBookmark { label, address }); + self.selected_bookmark = self.bookmarks.len().saturating_sub(1); + true + } + + pub fn voice_elapsed(&self) -> Option { + let secs = self.voice_joined_at?.elapsed().as_secs(); + Some(format!("{}:{:02}", secs / 60, secs % 60)) + } + + pub fn nick_for<'a>(&'a self, sender_id: &'a str) -> &'a str { + self.user_names + .get(sender_id) + .map(|s| s.as_str()) + .unwrap_or(sender_id) + } + + pub fn active_voice_channel(&self) -> Option<&Channel> { + self.channels + .iter() + .find(|c| matches!(&self.voice, VoiceUiState::Active { channel } if channel == &c.id)) + } + + pub fn seed_default_channels(&mut self) { + if !self.channels.is_empty() { + return; + } + self.channels = vec![ + Channel { + id: "general".into(), + name: "general".into(), + kind: "text".into(), + members: Vec::new(), + }, + Channel { + id: "dev-talk".into(), + name: "dev-talk".into(), + kind: "text".into(), + members: Vec::new(), + }, + Channel { + id: "plugins".into(), + name: "plugins".into(), + kind: "text".into(), + members: Vec::new(), + }, + Channel { + id: "lobby".into(), + name: "lobby".into(), + kind: "voice".into(), + members: Vec::new(), + }, + Channel { + id: "gaming".into(), + name: "gaming".into(), + kind: "voice".into(), + members: Vec::new(), + }, + ]; + } +} diff --git a/src/ui/state/models.rs b/src/ui/state/models.rs new file mode 100644 index 0000000..5a02528 --- /dev/null +++ b/src/ui/state/models.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Default)] +pub enum SettingsPage { + Identity, + #[default] + Voice, + AudioOutput, + Network, + Overlay, + Appearance, + Keybinds, + Plugins, + Advanced, +} + +#[derive(Debug, Clone)] +pub struct Channel { + pub id: String, + pub name: String, + pub kind: String, + pub members: Vec, +} + +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub message_id: String, + pub sender_id: String, + pub content: String, + pub timestamp: i64, + pub edited: bool, + pub reactions: HashMap>, + /// Optional message_id this message is replying to. + #[cfg_attr(not(test), allow(dead_code))] + pub reply_to: Option, +} + +#[derive(Debug, Clone)] +pub struct DmConversation { + pub dm_id: String, + pub other_user_id: String, + pub other_nickname: String, + pub unread_count: u32, +} + +#[derive(Debug, Clone)] +pub struct GuildState { + pub guild_id: String, + pub name: String, + pub owner_id: String, + pub member_count: u32, +} + +#[derive(Debug, Clone)] +pub struct FriendState { + pub user_id: String, + pub nickname: String, + pub status: String, + pub since: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeBookmark { + pub label: String, + pub address: String, +} + +pub(crate) fn default_bookmarks() -> Vec { + Vec::new() +} + +pub(crate) fn bookmark_label_from_address(address: &str) -> String { + address + .split(':') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or(address) + .chars() + .take(12) + .collect() +} + +pub(crate) fn now_utc_hms() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let h = (secs % 86400) / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + format!("{h:02}:{m:02}:{s:02}") +} diff --git a/src/ui/state/types.rs b/src/ui/state/types.rs new file mode 100644 index 0000000..2939974 --- /dev/null +++ b/src/ui/state/types.rs @@ -0,0 +1,205 @@ +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +pub const TYPING_TIMEOUT: Duration = Duration::from_secs(4); + +#[derive(Debug, Clone, PartialEq, Default)] +pub enum VoiceUiState { + #[default] + Off, + Active { + channel: String, + }, + Error(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConnState { + Disconnected, + Connecting, + Reconnecting { + attempt: u32, + }, + Connected { + session_id: String, + node_name: String, + }, +} + +pub struct UiState { + pub conn: ConnState, + pub connect_input: String, + pub bookmarks: Vec, + pub selected_bookmark: usize, + pub status_msg: Option, + + pub channels: Vec, + pub active_channel: Option, + pub messages: HashMap>, + pub chat_input: String, + pub editing_message_id: Option, + /// When set, the next sent message will be a reply to this message_id. + pub replying_to: Option<(String, String)>, // (message_id, sender_id) + /// Message currently shown in the context menu (right-click). + pub context_menu_message_id: Option, + pub scroll_bottom: bool, + + pub voice: VoiceUiState, + pub voice_joined_at: Option, + + pub user_names: HashMap, + pub server_channel: Option, + pub rtt_ms: Option, + + pub settings_open: bool, + pub settings_page: super::SettingsPage, + + pub mic_enabled: bool, + pub deafen: bool, + pub noise_suppress: bool, + pub echo_cancel: bool, + pub vad_mode: usize, + pub vad_threshold: f32, + pub voice_bitrate: f32, + pub packet_interval: usize, + pub mic_device: usize, + pub input_volume: f32, + pub input_device_labels: Vec, + pub output_device_labels: Vec, + pub input_device_metas: Vec, + pub output_device_metas: Vec, + pub last_device_scan: Option, + + pub output_device: usize, + pub output_volume: f32, + pub jitter_size: usize, + pub adaptive_buffer: bool, + + pub overlay_enabled: bool, + pub overlay_speakers: bool, + pub overlay_latency: bool, + + pub relay_enabled: bool, + pub relay_address: String, + pub auto_relay: bool, + pub force_relay: bool, + pub udp_port: String, + pub auto_reconnect: bool, + + pub ui_scale: f32, + + pub kb_ptt: String, + pub kb_mute: String, + pub kb_deafen: String, + pub kb_overlay: String, + + pub log_level: usize, + pub log_voice_packets: bool, + pub show_packet_stats: bool, + pub disable_encryption: bool, + + pub private_mode: bool, + + pub guilds: Vec, + pub active_guild_id: Option, + + pub presences: HashMap, + pub presence_status: Option, + /// Free-form custom status text ("making tea"). + pub custom_status: Option, + /// Activity type ("playing", "listening", "watching", "streaming"). + pub activity_type: Option, + /// Activity text (e.g. "Counter-Strike 2"). + pub activity_text: Option, + + pub friends: Vec, + pub pending_friend_requests: Vec, + /// Blocked user IDs. + pub blocked_users: Vec, + /// Input buffer for the block-user text field. + pub block_input: Option, + /// When the blocked list was last fetched from the server. + pub block_list_fetched_at: Option, + + pub dm_conversations: Vec, + pub dm_messages: HashMap>, + pub active_dm_id: Option, + pub new_dm_input: Option, + pub dm_search_query: String, + + pub user_colors: HashMap, + + pub per_user_volumes: HashMap, + + pub add_server_open: bool, + pub add_server_input: String, + pub invite_accept_open: bool, + pub invite_accept_code: String, + + pub create_guild_open: bool, + pub create_guild_input: String, + + /// "Create a Channel" popup state. + pub create_channel_open: bool, + pub create_channel_input: String, + pub create_channel_kind: String, + + /// Vault UI state — set/remove passphrase modals. + pub vault_set_open: bool, + pub vault_remove_open: bool, + pub vault_passphrase_input: String, + pub vault_passphrase_confirm: String, + pub vault_error: Option, + + /// Export/import keyfile UI state. + pub export_keyfile_open: bool, + pub export_keyfile_pass: String, + pub export_keyfile_confirm: String, + pub export_keyfile_output: String, + pub export_keyfile_error: Option, + pub import_keyfile_open: bool, + pub import_keyfile_input: String, + pub import_keyfile_pass: String, + pub import_keyfile_error: Option, + + /// Audit log viewer state. + pub audit_log_open: bool, + pub audit_log_guild_id: Option, + pub audit_log_entries: Vec, + + /// Guild member list viewer state. + pub guild_members_open: bool, + pub guild_member_list_guild_id: Option, + pub guild_member_list: Vec, + + /// Guild role list state. + pub guild_role_list_guild_id: Option, + pub guild_role_list: Vec, + + pub add_friend_open: bool, + pub add_friend_input: String, + + pub kb_listening: Option, + + pub collapsed_categories: HashSet, + + pub startup_time_str: String, + + pub typing_users: HashMap>, + pub last_typing_send: Instant, + + pub read_receipts: HashMap>, + + /// Active tab in the Friends panel ("online" / "all" / "pending" / "blocked"). + pub friends_tab: String, + + /// True when local user is transmitting voice (PTT held or VAD above threshold). + pub local_speaking: bool, + /// True when a remote peer's voice packet arrived in the last ~500ms. + pub remote_speaking: bool, + /// Most recently received voice-packet sender's user_id (hex pubkey). + /// Empty when no remote user has spoken recently. + pub last_remote_speaker_id: String, + /// UNIX millisecond timestamp of the last voice packet from any remote peer. + pub last_voice_packet_ms: u64, +} diff --git a/src/ui/theme/icons.rs b/src/ui/theme/icons.rs new file mode 100644 index 0000000..493778b --- /dev/null +++ b/src/ui/theme/icons.rs @@ -0,0 +1,710 @@ +//! Hand-drawn vector icons rendered via `egui::Painter`. +//! +//! Each icon is a function `draw_(painter, rect, color)` that fills +//! the given `egui::Rect` with a monochrome glyph of the given `Color32`. +//! +//! All glyphs are designed on a 24×24 grid normalized to the rect. +//! Replace emoji usage throughout the UI with these for a cleaner, more +//! professional look that doesn't depend on system emoji fonts. + +use eframe::egui::{self, Color32, Pos2, Rect, Shape, Stroke, Vec2}; + +/// Draw an icon centered in the given rect, scaled to fit. +pub fn draw_icon( + ui: &mut egui::Ui, + size: f32, + draw_fn: impl FnOnce(&egui::Painter, Rect, Color32), +) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + let s = rect.size().min_elem(); + let center = rect.center(); + let half = s / 2.0; + let scaled = + Rect::from_min_size(Pos2::new(center.x - half, center.y - half), Vec2::splat(s)); + draw_fn(painter, scaled, super::TEXT_MUTED); + } + resp +} + +/// Helper to convert a 0..24 coordinate into the scaled rect's coordinate. +fn p(rect: Rect, x: f32, y: f32) -> Pos2 { + let sx = rect.width() / 24.0; + let sy = rect.height() / 24.0; + Pos2::new(rect.min.x + x * sx, rect.min.y + y * sy) +} + +/// Microphone icon. +pub fn microphone(painter: &egui::Painter, rect: Rect, color: Color32) { + // Mic body (rounded capsule). + let body = Rect::from_center_size( + p(rect, 12.0, 9.0), + Vec2::new(rect.width() * 0.18, rect.height() * 0.42), + ); + painter.rect_filled(body, egui::CornerRadius::same(4), color); + // Arc below (the cradle). + let arc_center = p(rect, 12.0, 14.0); + let radius = rect.width() * 0.22; + painter.add(Shape::circle_stroke( + arc_center, + radius, + Stroke::new(rect.width() * 0.05, color), + )); + // Stand. + painter.line_segment( + [p(rect, 12.0, 16.0), p(rect, 12.0, 20.0)], + Stroke::new(rect.width() * 0.06, color), + ); + painter.line_segment( + [p(rect, 9.0, 20.0), p(rect, 15.0, 20.0)], + Stroke::new(rect.width() * 0.06, color), + ); +} + +/// Microphone-slash icon (mic with a diagonal line through it). +pub fn microphone_off(painter: &egui::Painter, rect: Rect, color: Color32) { + microphone(painter, rect, Color32::from_rgb(80, 80, 80)); + painter.line_segment( + [p(rect, 5.0, 5.0), p(rect, 19.0, 19.0)], + Stroke::new(rect.width() * 0.08, color), + ); +} + +/// Headphones icon (for deafen). +pub fn headphones(painter: &egui::Painter, rect: Rect, color: Color32) { + // Top arc. + painter.add(Shape::circle_stroke( + p(rect, 12.0, 13.0), + rect.width() * 0.30, + Stroke::new(rect.width() * 0.06, color), + )); + // Cover bottom half of the arc so only top shows. + painter.rect_filled( + Rect::from_min_max(p(rect, 4.0, 13.0), p(rect, 20.0, 21.0)), + egui::CornerRadius::same(0), + super::BG_ELEVATED, + ); + // Left ear cup. + painter.rect_filled( + Rect::from_center_size( + p(rect, 5.5, 13.0), + Vec2::new(rect.width() * 0.18, rect.height() * 0.28), + ), + egui::CornerRadius::same(2), + color, + ); + // Right ear cup. + painter.rect_filled( + Rect::from_center_size( + p(rect, 18.5, 13.0), + Vec2::new(rect.width() * 0.18, rect.height() * 0.28), + ), + egui::CornerRadius::same(2), + color, + ); +} + +/// Headphones-off (with slash). +pub fn headphones_off(painter: &egui::Painter, rect: Rect, color: Color32) { + headphones(painter, rect, Color32::from_rgb(80, 80, 80)); + painter.line_segment( + [p(rect, 5.0, 5.0), p(rect, 19.0, 19.0)], + Stroke::new(rect.width() * 0.08, color), + ); +} + +/// Gear / settings icon (cog). +pub fn gear(painter: &egui::Painter, rect: Rect, color: Color32) { + let center = p(rect, 12.0, 12.0); + let outer_r = rect.width() * 0.40; + let inner_r = rect.width() * 0.28; + let hole_r = rect.width() * 0.12; + // Draw 8 teeth as small rounded rects around the circle. + for i in 0..8 { + let angle = i as f32 * std::f32::consts::TAU / 8.0; + let tooth_center = Pos2::new( + center.x + angle.cos() * (outer_r - rect.width() * 0.04), + center.y + angle.sin() * (outer_r - rect.width() * 0.04), + ); + painter.rect_filled( + Rect::from_center_size(tooth_center, Vec2::splat(rect.width() * 0.10)), + egui::CornerRadius::same(1), + color, + ); + } + // Outer ring. + painter.add(Shape::circle_stroke( + center, + inner_r, + Stroke::new(rect.width() * 0.07, color), + )); + // Center hole. + painter.circle_filled(center, hole_r, color); +} + +/// Plus icon. +pub fn plus(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.08; + let h = rect.height() * 0.6; + let v = rect.width() * 0.6; + let hv = rect.height() * 0.08; + painter.rect_filled( + Rect::from_center_size(rect.center(), Vec2::new(w, h)), + egui::CornerRadius::same(1), + color, + ); + painter.rect_filled( + Rect::from_center_size(rect.center(), Vec2::new(v, hv)), + egui::CornerRadius::same(1), + color, + ); +} + +/// X / close icon. +pub fn close(painter: &egui::Painter, rect: Rect, color: Color32) { + let stroke = Stroke::new(rect.width() * 0.08, color); + painter.line_segment([p(rect, 6.0, 6.0), p(rect, 18.0, 18.0)], stroke); + painter.line_segment([p(rect, 18.0, 6.0), p(rect, 6.0, 18.0)], stroke); +} + +/// Trash icon. +pub fn trash(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Lid. + painter.line_segment( + [p(rect, 5.0, 7.0), p(rect, 19.0, 7.0)], + Stroke::new(w, color), + ); + // Handle. + painter.line_segment( + [p(rect, 10.0, 5.0), p(rect, 14.0, 5.0)], + Stroke::new(w, color), + ); + // Body. + painter.line_segment( + [p(rect, 7.0, 7.0), p(rect, 8.5, 19.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 17.0, 7.0), p(rect, 15.5, 19.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 8.5, 19.0), p(rect, 15.5, 19.0)], + Stroke::new(w, color), + ); + // Ribs. + painter.line_segment( + [p(rect, 11.0, 9.0), p(rect, 11.0, 17.0)], + Stroke::new(w * 0.7, color), + ); + painter.line_segment( + [p(rect, 13.0, 9.0), p(rect, 13.0, 17.0)], + Stroke::new(w * 0.7, color), + ); +} + +/// Lock icon (closed padlock). +pub fn lock(painter: &egui::Painter, rect: Rect, color: Color32) { + // Shackle (arc). + painter.add(Shape::circle_stroke( + p(rect, 12.0, 9.0), + rect.width() * 0.18, + Stroke::new(rect.width() * 0.07, color), + )); + // Cover the bottom of the shackle arc. + painter.rect_filled( + Rect::from_min_max(p(rect, 9.0, 9.0), p(rect, 15.0, 13.0)), + egui::CornerRadius::same(0), + super::BG_ELEVATED, + ); + // Body. + painter.rect_filled( + Rect::from_center_size( + p(rect, 12.0, 15.0), + Vec2::new(rect.width() * 0.42, rect.height() * 0.28), + ), + egui::CornerRadius::same(2), + color, + ); +} + +/// Unlock icon (open padlock). +pub fn unlock(painter: &egui::Painter, rect: Rect, color: Color32) { + // Shackle (open — arc shifted right). + painter.add(Shape::circle_stroke( + p(rect, 14.0, 9.0), + rect.width() * 0.18, + Stroke::new(rect.width() * 0.07, color), + )); + // Cover bottom of shackle. + painter.rect_filled( + Rect::from_min_max(p(rect, 11.0, 9.0), p(rect, 17.0, 13.0)), + egui::CornerRadius::same(0), + super::BG_ELEVATED, + ); + // Body. + painter.rect_filled( + Rect::from_center_size( + p(rect, 10.0, 15.0), + Vec2::new(rect.width() * 0.42, rect.height() * 0.28), + ), + egui::CornerRadius::same(2), + color, + ); +} + +/// Refresh / rotate icon (circular arrow). +pub fn refresh(painter: &egui::Painter, rect: Rect, color: Color32) { + let center = p(rect, 12.0, 12.0); + let r = rect.width() * 0.32; + let stroke = Stroke::new(rect.width() * 0.07, color); + // Arc (3/4 circle). + let steps = 24; + let start = -std::f32::consts::FRAC_PI_2; + let end = start + std::f32::consts::TAU * 0.75; + let mut prev: Option = None; + for i in 0..=steps { + let t = start + (end - start) * (i as f32 / steps as f32); + let pt = Pos2::new(center.x + t.cos() * r, center.y + t.sin() * r); + if let Some(p0) = prev { + painter.line_segment([p0, pt], stroke); + } + prev = Some(pt); + } + // Arrowhead at the end. + let arrow_end = prev.unwrap(); + let perp = end + std::f32::consts::FRAC_PI_2; + let ah1 = Pos2::new( + arrow_end.x + perp.cos() * rect.width() * 0.10, + arrow_end.y + perp.sin() * rect.height() * 0.10, + ); + let ah2 = Pos2::new( + arrow_end.x - perp.cos() * rect.width() * 0.10, + arrow_end.y - perp.sin() * rect.height() * 0.10, + ); + painter.line_segment([arrow_end, ah1], stroke); + painter.line_segment([arrow_end, ah2], stroke); +} + +/// Clipboard / list icon (for audit log). +pub fn clipboard(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Outer rect. + painter.rect_stroke( + Rect::from_min_max(p(rect, 5.0, 4.0), p(rect, 19.0, 20.0)), + egui::CornerRadius::same(2), + Stroke::new(w, color), + egui::StrokeKind::Middle, + ); + // Clip at top. + painter.rect_filled( + Rect::from_min_max(p(rect, 9.0, 3.0), p(rect, 15.0, 6.0)), + egui::CornerRadius::same(1), + color, + ); + // Lines. + painter.line_segment( + [p(rect, 8.0, 10.0), p(rect, 16.0, 10.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 8.0, 13.0), p(rect, 16.0, 13.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 8.0, 16.0), p(rect, 14.0, 16.0)], + Stroke::new(w, color), + ); +} + +/// People / members icon (two overlapping circles + shoulders). +pub fn people(painter: &egui::Painter, rect: Rect, color: Color32) { + let r1 = rect.width() * 0.16; + let r2 = rect.width() * 0.13; + // Two heads. + painter.circle_filled(p(rect, 9.0, 8.0), r1, color); + painter.circle_filled(p(rect, 16.0, 9.0), r2, color); + // Shoulders. + painter.rect_filled( + Rect::from_min_max(p(rect, 4.0, 13.0), p(rect, 14.0, 21.0)), + egui::CornerRadius::same(4), + color, + ); + painter.rect_filled( + Rect::from_min_max(p(rect, 12.0, 14.0), p(rect, 20.0, 21.0)), + egui::CornerRadius::same(4), + color, + ); +} + +/// Crown icon (for owner). +pub fn crown(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Base bar. + painter.line_segment( + [p(rect, 5.0, 18.0), p(rect, 19.0, 18.0)], + Stroke::new(w * 1.5, color), + ); + // Three peaks. + painter.line_segment( + [p(rect, 5.0, 18.0), p(rect, 7.0, 8.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 7.0, 8.0), p(rect, 12.0, 14.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 12.0, 14.0), p(rect, 17.0, 8.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 17.0, 8.0), p(rect, 19.0, 18.0)], + Stroke::new(w, color), + ); + // Gems. + painter.circle_filled(p(rect, 7.0, 7.0), rect.width() * 0.04, color); + painter.circle_filled(p(rect, 17.0, 7.0), rect.width() * 0.04, color); + painter.circle_filled(p(rect, 12.0, 13.0), rect.width() * 0.04, color); +} + +/// Boot icon (for kick). +pub fn boot(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.07; + // Boot outline. + painter.line_segment( + [p(rect, 8.0, 5.0), p(rect, 8.0, 14.0)], + Stroke::new(w * 1.5, color), + ); + painter.line_segment( + [p(rect, 8.0, 14.0), p(rect, 8.0, 17.0)], + Stroke::new(w * 1.5, color), + ); + painter.line_segment( + [p(rect, 7.0, 17.0), p(rect, 18.0, 17.0)], + Stroke::new(w * 1.5, color), + ); + painter.line_segment( + [p(rect, 18.0, 17.0), p(rect, 18.0, 14.0)], + Stroke::new(w * 1.5, color), + ); + painter.line_segment( + [p(rect, 18.0, 14.0), p(rect, 12.0, 14.0)], + Stroke::new(w * 1.5, color), + ); + painter.line_segment( + [p(rect, 12.0, 14.0), p(rect, 12.0, 5.0)], + Stroke::new(w * 1.5, color), + ); + // Top opening. + painter.line_segment( + [p(rect, 8.0, 5.0), p(rect, 12.0, 5.0)], + Stroke::new(w, color), + ); +} + +/// Speaker (sound waves) icon — used for voice activity. +pub fn speaker(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Speaker box. + painter.line_segment( + [p(rect, 4.0, 10.0), p(rect, 8.0, 10.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 4.0, 14.0), p(rect, 8.0, 14.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 4.0, 10.0), p(rect, 4.0, 14.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 8.0, 10.0), p(rect, 12.0, 6.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 8.0, 14.0), p(rect, 12.0, 18.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 12.0, 6.0), p(rect, 12.0, 18.0)], + Stroke::new(w, color), + ); + // Sound waves. + painter.add(Shape::circle_stroke( + p(rect, 15.0, 12.0), + rect.width() * 0.10, + Stroke::new(w * 0.8, color), + )); + painter.add(Shape::circle_stroke( + p(rect, 17.0, 12.0), + rect.width() * 0.16, + Stroke::new(w * 0.6, color), + )); +} + +/// Speaker-muted icon (speaker with X). +pub fn speaker_off(painter: &egui::Painter, rect: Rect, color: Color32) { + speaker(painter, rect, Color32::from_rgb(80, 80, 80)); + let w = rect.width() * 0.07; + painter.line_segment( + [p(rect, 14.0, 8.0), p(rect, 20.0, 16.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 20.0, 8.0), p(rect, 14.0, 16.0)], + Stroke::new(w, color), + ); +} + +/// Envelope icon (for DM). +pub fn envelope(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Outer rect. + painter.rect_stroke( + Rect::from_min_max(p(rect, 4.0, 6.0), p(rect, 20.0, 18.0)), + egui::CornerRadius::same(1), + Stroke::new(w, color), + egui::StrokeKind::Middle, + ); + // Flap. + painter.line_segment( + [p(rect, 4.0, 6.0), p(rect, 12.0, 13.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 20.0, 6.0), p(rect, 12.0, 13.0)], + Stroke::new(w, color), + ); +} + +/// Pencil / edit icon. +pub fn pencil(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Diagonal pencil body. + painter.line_segment( + [p(rect, 5.0, 19.0), p(rect, 16.0, 8.0)], + Stroke::new(w * 1.5, color), + ); + // Tip. + painter.line_segment( + [p(rect, 4.0, 20.0), p(rect, 6.0, 18.0)], + Stroke::new(w * 1.5, color), + ); + // Eraser end. + painter.line_segment( + [p(rect, 15.0, 7.0), p(rect, 18.0, 10.0)], + Stroke::new(w * 1.5, color), + ); + painter.line_segment( + [p(rect, 16.0, 6.0), p(rect, 19.0, 9.0)], + Stroke::new(w * 1.5, color), + ); +} + +/// Reply arrow icon. +pub fn reply(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.07; + // Curved arrow (drawn as two segments). + painter.line_segment( + [p(rect, 16.0, 8.0), p(rect, 8.0, 8.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 8.0, 8.0), p(rect, 8.0, 16.0)], + Stroke::new(w, color), + ); + // Arrowhead. + painter.line_segment( + [p(rect, 5.0, 11.0), p(rect, 8.0, 8.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 8.0, 8.0), p(rect, 11.0, 11.0)], + Stroke::new(w, color), + ); +} + +/// Copy icon (two overlapping squares). +pub fn copy(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Back square. + painter.rect_stroke( + Rect::from_min_max(p(rect, 8.0, 4.0), p(rect, 20.0, 16.0)), + egui::CornerRadius::same(1), + Stroke::new(w, color), + egui::StrokeKind::Middle, + ); + // Front square. + painter.rect_stroke( + Rect::from_min_max(p(rect, 4.0, 8.0), p(rect, 16.0, 20.0)), + egui::CornerRadius::same(1), + Stroke::new(w, color), + egui::StrokeKind::Middle, + ); +} + +/// User-plus icon (for "add friend"). +pub fn user_plus(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Head. + painter.circle_filled(p(rect, 9.0, 8.0), rect.width() * 0.14, color); + // Shoulders. + painter.rect_filled( + Rect::from_min_max(p(rect, 4.0, 13.0), p(rect, 14.0, 21.0)), + egui::CornerRadius::same(4), + color, + ); + // Plus. + painter.line_segment( + [p(rect, 16.0, 11.0), p(rect, 20.0, 11.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 18.0, 9.0), p(rect, 18.0, 13.0)], + Stroke::new(w, color), + ); +} + +/// Home icon. +pub fn home(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Roof. + painter.line_segment( + [p(rect, 4.0, 12.0), p(rect, 12.0, 5.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 12.0, 5.0), p(rect, 20.0, 12.0)], + Stroke::new(w, color), + ); + // Body. + painter.line_segment( + [p(rect, 6.0, 12.0), p(rect, 6.0, 19.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 18.0, 12.0), p(rect, 18.0, 19.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 6.0, 19.0), p(rect, 18.0, 19.0)], + Stroke::new(w, color), + ); + // Door. + painter.line_segment( + [p(rect, 10.0, 19.0), p(rect, 10.0, 14.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 14.0, 19.0), p(rect, 14.0, 14.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 10.0, 14.0), p(rect, 14.0, 14.0)], + Stroke::new(w, color), + ); +} + +/// Link icon (for invites). +pub fn link(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.07; + // Left chain. + painter.rect_stroke( + Rect::from_min_max(p(rect, 3.0, 8.0), p(rect, 12.0, 16.0)), + egui::CornerRadius::same(4), + Stroke::new(w, color), + egui::StrokeKind::Middle, + ); + // Right chain. + painter.rect_stroke( + Rect::from_min_max(p(rect, 12.0, 8.0), p(rect, 21.0, 16.0)), + egui::CornerRadius::same(4), + Stroke::new(w, color), + egui::StrokeKind::Middle, + ); + // Connector. + painter.line_segment( + [p(rect, 9.0, 12.0), p(rect, 15.0, 12.0)], + Stroke::new(w, color), + ); +} + +/// Tag icon (for roles). +pub fn tag(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.06; + // Tag body. + let pts = [ + p(rect, 4.0, 12.0), + p(rect, 8.0, 6.0), + p(rect, 19.0, 6.0), + p(rect, 19.0, 18.0), + p(rect, 8.0, 18.0), + ]; + for i in 0..pts.len() - 1 { + painter.line_segment([pts[i], pts[i + 1]], Stroke::new(w, color)); + } + painter.line_segment([pts[pts.len() - 1], pts[0]], Stroke::new(w, color)); + // Hole. + painter.circle_filled(p(rect, 15.0, 12.0), rect.width() * 0.04, color); +} + +/// Check / tick icon. +pub fn check(painter: &egui::Painter, rect: Rect, color: Color32) { + painter.line_segment( + [p(rect, 5.0, 12.0), p(rect, 10.0, 17.0)], + Stroke::new(rect.width() * 0.10, color), + ); + painter.line_segment( + [p(rect, 10.0, 17.0), p(rect, 19.0, 6.0)], + Stroke::new(rect.width() * 0.10, color), + ); +} + +/// Hash icon (for text channel). +pub fn hash(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.07; + painter.line_segment( + [p(rect, 8.0, 4.0), p(rect, 6.0, 20.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 16.0, 4.0), p(rect, 14.0, 20.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 4.0, 9.0), p(rect, 18.0, 9.0)], + Stroke::new(w, color), + ); + painter.line_segment( + [p(rect, 3.0, 15.0), p(rect, 17.0, 15.0)], + Stroke::new(w, color), + ); +} + +/// Waveform icon (for voice channel). +pub fn waveform(painter: &egui::Painter, rect: Rect, color: Color32) { + let w = rect.width() * 0.07; + let cy = rect.center().y; + let h = rect.height(); + let bars = [ + (6.0_f32, 0.25_f32), + (9.0, 0.55), + (12.0, 0.85), + (15.0, 0.55), + (18.0, 0.25), + ]; + for (x, frac) in bars { + let bh = h * frac * 0.7; + painter.line_segment( + [ + Pos2::new(rect.min.x + x * rect.width() / 24.0, cy - bh / 2.0), + Pos2::new(rect.min.x + x * rect.width() / 24.0, cy + bh / 2.0), + ], + Stroke::new(w, color), + ); + } +} diff --git a/src/ui/theme/mod.rs b/src/ui/theme/mod.rs new file mode 100644 index 0000000..d6d45b8 --- /dev/null +++ b/src/ui/theme/mod.rs @@ -0,0 +1,144 @@ +pub mod icons; +pub mod widgets; + +pub use widgets::{avatar, icon_btn, icon_btn_drawn}; + +use eframe::egui::{self, Color32, CornerRadius, FontDefinitions, Stroke, Visuals}; + +pub const BG_BASE: Color32 = Color32::from_rgb(13, 13, 13); +pub const BG_SURFACE: Color32 = Color32::from_rgb(22, 22, 22); +pub const BG_ELEVATED: Color32 = Color32::from_rgb(28, 28, 28); +pub const BG_INTERACTIVE: Color32 = Color32::from_rgb(30, 30, 30); + +pub const ACCENT: Color32 = Color32::from_rgb(230, 126, 34); +pub const ACCENT_HOVER: Color32 = Color32::from_rgb(245, 145, 55); +pub const ACCENT_SOFT: Color32 = Color32::from_rgb(200, 110, 30); +pub const ACCENT_10: Color32 = Color32::from_rgba_premultiplied(230, 126, 34, 25); +pub const ACCENT_20: Color32 = Color32::from_rgba_premultiplied(230, 126, 34, 50); +pub const BORDER_ACCENT: Color32 = Color32::from_rgba_premultiplied(230, 126, 34, 60); + +pub const SUCCESS: Color32 = Color32::from_rgb(80, 180, 90); +pub const WARNING: Color32 = Color32::from_rgb(220, 170, 60); +pub const ERROR: Color32 = Color32::from_rgb(210, 90, 80); +pub const INFO: Color32 = Color32::from_rgb(90, 155, 210); + +pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(224, 224, 224); +pub const TEXT_SECONDARY: Color32 = Color32::from_rgb(170, 170, 170); +pub const TEXT_MUTED: Color32 = Color32::from_rgb(136, 136, 136); +pub const TEXT_DISABLED: Color32 = Color32::from_rgb(90, 90, 90); +pub const TEXT_DEAD: Color32 = Color32::from_rgb(55, 55, 55); + +pub const BORDER_SUBTLE: Color32 = Color32::from_rgb(42, 42, 42); +pub const BORDER_DEFAULT: Color32 = Color32::from_rgb(51, 51, 51); +pub const BORDER_STRONG: Color32 = Color32::from_rgb(64, 64, 64); + +pub const BG_STRIP: Color32 = BG_SURFACE; +pub const BG_PANEL: Color32 = BG_SURFACE; +pub const BORDER: Color32 = BORDER_DEFAULT; +pub const BORDER_FAINT: Color32 = BORDER_SUBTLE; +pub const BORDER_MID: Color32 = BORDER_STRONG; +pub const WARN: Color32 = WARNING; +pub const DANGER: Color32 = ERROR; +pub const BLUE: Color32 = INFO; +pub const TEXT_GHOST: Color32 = TEXT_DISABLED; +pub const TEXT_DIM: Color32 = TEXT_MUTED; +pub const ACCENT_DIM: Color32 = Color32::from_rgb(160, 90, 25); +pub const TEXT_FAINT: Color32 = TEXT_DEAD; + +pub const R_SM: CornerRadius = CornerRadius::same(6); +pub const R_MD: CornerRadius = CornerRadius::same(10); +pub const R_LG: CornerRadius = CornerRadius::same(14); +pub const R_FULL: CornerRadius = CornerRadius::same(255); + +const NAME_PALETTE: [Color32; 6] = [ + Color32::from_rgb(170, 170, 170), + Color32::from_rgb(106, 158, 207), + Color32::from_rgb(124, 184, 122), + Color32::from_rgb(220, 170, 60), + Color32::from_rgb(190, 140, 200), + Color32::from_rgb(120, 190, 180), +]; + +pub fn user_color(user_id: &str) -> Color32 { + if user_id.is_empty() { + return TEXT_SECONDARY; + } + let hash: usize = user_id + .bytes() + .fold(0usize, |acc, b| acc.wrapping_add(b as usize)); + NAME_PALETTE[hash % NAME_PALETTE.len()] +} + +pub fn apply_visuals(ctx: &egui::Context) { + let mut v = Visuals::dark(); + + v.window_fill = BG_BASE; + v.panel_fill = BG_BASE; + v.extreme_bg_color = BG_INTERACTIVE; + v.faint_bg_color = BG_ELEVATED; + v.code_bg_color = BG_SURFACE; + v.hyperlink_color = ACCENT; + v.override_text_color = Some(TEXT_PRIMARY); + + v.widgets.noninteractive.bg_fill = BG_SURFACE; + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, BORDER_SUBTLE); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, TEXT_MUTED); + v.widgets.noninteractive.corner_radius = R_MD; + + v.widgets.inactive.bg_fill = Color32::TRANSPARENT; + v.widgets.inactive.bg_stroke = Stroke::new(1.0, BORDER_DEFAULT); + v.widgets.inactive.fg_stroke = Stroke::new(1.0, TEXT_MUTED); + v.widgets.inactive.corner_radius = R_MD; + + v.widgets.hovered.bg_fill = BG_INTERACTIVE; + v.widgets.hovered.bg_stroke = Stroke::new(1.0, BORDER_STRONG); + v.widgets.hovered.fg_stroke = Stroke::new(1.0, TEXT_PRIMARY); + v.widgets.hovered.corner_radius = R_MD; + + v.widgets.active.bg_fill = BG_INTERACTIVE; + v.widgets.active.bg_stroke = Stroke::new(1.0, BORDER_ACCENT); + v.widgets.active.fg_stroke = Stroke::new(1.0, TEXT_PRIMARY); + v.widgets.active.corner_radius = R_MD; + + v.widgets.open.bg_fill = BG_INTERACTIVE; + v.widgets.open.bg_stroke = Stroke::new(1.0, BORDER_STRONG); + v.widgets.open.fg_stroke = Stroke::new(1.0, TEXT_PRIMARY); + v.widgets.open.corner_radius = R_MD; + + v.selection.bg_fill = ACCENT_10; + v.selection.stroke = Stroke::new(0.0, Color32::TRANSPARENT); + + v.window_corner_radius = R_LG; + v.window_shadow = egui::epaint::Shadow::NONE; + v.popup_shadow = egui::epaint::Shadow::NONE; + v.window_stroke = Stroke::new(1.0, BORDER_DEFAULT); + + ctx.set_visuals(v); + set_fonts(ctx); + set_spacing(ctx); +} + +fn set_fonts(ctx: &egui::Context) { + let fonts = FontDefinitions::default(); + ctx.set_fonts(fonts); +} + +fn set_spacing(ctx: &egui::Context) { + let mut s = (*ctx.style()).clone(); + s.text_styles = [ + (egui::TextStyle::Small, egui::FontId::proportional(11.0)), + (egui::TextStyle::Body, egui::FontId::proportional(13.0)), + (egui::TextStyle::Button, egui::FontId::proportional(13.0)), + (egui::TextStyle::Heading, egui::FontId::proportional(15.0)), + (egui::TextStyle::Monospace, egui::FontId::monospace(12.0)), + ] + .into(); + s.spacing.item_spacing = egui::vec2(6.0, 4.0); + s.spacing.button_padding = egui::vec2(10.0, 5.0); + s.spacing.window_margin = egui::Margin::same(0); + s.spacing.menu_margin = egui::Margin::same(4); + s.spacing.indent = 16.0; + s.spacing.interact_size = egui::vec2(40.0, 28.0); + s.spacing.scroll.bar_width = 4.0; + ctx.set_style(s); +} diff --git a/src/ui/theme/widgets.rs b/src/ui/theme/widgets.rs new file mode 100644 index 0000000..f74d2d3 --- /dev/null +++ b/src/ui/theme/widgets.rs @@ -0,0 +1,76 @@ +use eframe::egui::{self, Color32, Rect, Stroke}; + +use super::*; + +pub fn avatar(ui: &mut egui::Ui, initials: &str, color: Color32, size: f32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + painter.circle_filled(rect.center(), size / 2.0, color); + painter.circle_stroke(rect.center(), size / 2.0, Stroke::new(1.0, BORDER_DEFAULT)); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + initials, + egui::FontId::proportional(size * 0.38), + TEXT_PRIMARY, + ); + } + resp +} + +pub fn icon_btn( + ui: &mut egui::Ui, + icon: &str, + tooltip: &str, + active_color: Option, +) -> egui::Response { + let color = active_color.unwrap_or(TEXT_MUTED); + let btn = egui::Button::new(egui::RichText::new(icon).color(color).size(14.0)) + .frame(false) + .min_size(egui::vec2(26.0, 26.0)); + let resp = ui.add(btn); + if !tooltip.is_empty() { + resp.clone().on_hover_text(tooltip); + } + resp +} + +/// Draw a vector icon into a button-sized area. +/// `draw_fn` is one of the functions from `crate::theme::icons` (e.g. `icons::gear`). +/// The icon is rendered with the given color; pass `None` to use TEXT_MUTED. +pub fn icon_btn_drawn( + ui: &mut egui::Ui, + draw_fn: fn(&egui::Painter, Rect, Color32), + tooltip: &str, + active_color: Option, + size: f32, +) -> egui::Response { + let color = active_color.unwrap_or(TEXT_MUTED); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + // Inset to give padding. + let pad = size * 0.10; + let inner = Rect::from_min_max( + (rect.min.x + pad, rect.min.y + pad).into(), + (rect.max.x - pad, rect.max.y - pad).into(), + ); + draw_fn(painter, inner, color); + } + if ui.is_rect_visible(rect) && resp.hovered() { + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(4), BG_ELEVATED); + // Re-draw the icon on top of the hover background. + let pad = size * 0.10; + let inner = Rect::from_min_max( + (rect.min.x + pad, rect.min.y + pad).into(), + (rect.max.x - pad, rect.max.y - pad).into(), + ); + draw_fn(painter, inner, color); + } + if !tooltip.is_empty() { + resp.clone().on_hover_text(tooltip); + } + resp +} diff --git a/src/ui/titlebar.rs b/src/ui/titlebar.rs new file mode 100644 index 0000000..294db68 --- /dev/null +++ b/src/ui/titlebar.rs @@ -0,0 +1,93 @@ +use eframe::egui::{self, Context, Frame, TopBottomPanel}; + +use crate::state::{ConnState, UiState}; +use crate::theme as t; + +pub const TITLEBAR_H: f32 = 30.0; + +pub(crate) fn show_titlebar(ctx: &Context, s: &mut UiState) { + TopBottomPanel::top("titlebar") + .exact_height(TITLEBAR_H) + .frame(Frame::NONE.fill(t::BG_SURFACE)) + .show_separator_line(false) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.add_space(12.0); + + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center).with_main_justify(true), + |ui| { + let w = ui.available_width(); + if w.is_finite() && w > 0.0 { + ui.set_width(w); + } + let title = match &s.conn { + ConnState::Connected { node_name, .. } => { + format!("VNOX · {node_name}") + } + ConnState::Connecting => "VNOX · connecting…".into(), + ConnState::Reconnecting { attempt } => { + format!("VNOX · reconnect #{attempt}") + } + ConnState::Disconnected => "VNOX · offline".into(), + }; + ui.label( + egui::RichText::new(title) + .font(egui::FontId::monospace(11.0)) + .color(t::TEXT_MUTED), + ); + }, + ); + + if matches!(s.conn, ConnState::Connected { .. }) + && s.overlay_latency + && let Some(rtt) = s.rtt_ms + { + let color = if rtt < 80 { + t::SUCCESS + } else if rtt < 200 { + t::WARNING + } else { + t::ERROR + }; + ui.label( + egui::RichText::new(format!("{rtt} ms")) + .font(egui::FontId::monospace(10.0)) + .color(color), + ); + ui.add_space(8.0); + } + + let transport = match &s.conn { + ConnState::Connected { .. } => "quic/v1", + ConnState::Connecting => "connecting…", + ConnState::Reconnecting { .. } => "reconnecting…", + ConnState::Disconnected => "idle", + }; + ui.label( + egui::RichText::new(transport) + .font(egui::FontId::monospace(10.0)) + .color(t::TEXT_DISABLED), + ); + ui.add_space(10.0); + + let settings_color = if s.settings_open { + Some(t::ACCENT) + } else { + None + }; + if t::icon_btn_drawn( + ui, + crate::theme::icons::gear, + "Settings", + settings_color, + 18.0, + ) + .clicked() + { + s.settings_open = !s.settings_open; + } + ui.add_space(12.0); + }); + }); +}