diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4c5d022 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +jobs: + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Системные пакеты + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libgl1-mesa-dev + - uses: dtolnay/rust-toolchain@stable + - run: cargo test + - run: cargo build --release + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test + - run: cargo build --release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ec710d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target + +# Никогда не коммитить хранилища паролей и их копии +*.qulyp +*.qulyp.bak +*.qulyp.tmp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6891785 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# Qulyp — памятка для ИИ-ассистента + +Менеджер паролей на Rust + egui (eframe 0.36, рендер glow). Windows и Linux. +Один зашифрованный файл `.qulyp`, работает офлайн, синхронизация через облачную папку. + +## Заметки проекта (`notes/`) — читать в начале сессии +| Файл | Что внутри | Лимит строк | +|---|---|---| +| `notes/STATUS.md` | Где проект сейчас, что работает | 40 | +| `notes/PLAN.md` | Планы: Сейчас / Дальше / Потом | 60 | +| `notes/DEBT.md` | Хвосты: баги, техдолг, недоделки | 60 | +| `notes/LOG.md` | Журнал сессий, новые сверху | 80 | +| `notes/DECISIONS.md` | Ключевые решения и почему | 80 | +| `notes/DATA.md` | Формат файла и схема данных («БД») | 80 | + +Лимиты проверяет `cargo test --test notes` (заголовок `` в каждом файле). +`notes/archive/` — старое, **не читать** без необходимости. + +## Правила ведения заметок +- В конце каждой рабочей сессии: 1–4 строки в `LOG.md` (дата, что сделано), обновить `STATUS.md`. +- Закрытый хвост — удалить из `DEBT.md` (не зачёркивать). Новый — добавить одной строкой. +- Пишем сжато: одна мысль — одна строка, без пересказа кода и git-истории. +- Упёрлись в лимит `LOG.md` → старые записи перенести в `notes/archive/LOG-<год>.md`. +- В других файлах при лимите — сократить формулировки, а не архивировать. + +## Код +- `src/crypto.rs` — формат файла, Argon2id + XChaCha20-Poly1305 +- `src/vault.rs` — модель данных и слияние версий (синхронизация) +- `src/storage.rs` — сессия: атомарная запись, `.bak`, автослияние внешних изменений +- `src/app.rs` — экраны и UI; `src/widgets.rs`, `src/theme.rs` — виджеты и стиль +- `src/devshots.rs` — dev-сценарий со скриншотами (feature `dev-shots`) + +## Команды +- `cargo test` — все тесты; `cargo run` — запуск; `cargo build --release` — релиз +- `cargo check --target x86_64-unknown-linux-gnu` — проверка сборки под Linux +- Скриншоты UI: см. шапку `src/devshots.rs`. **Не** эмулировать системные нажатия клавиш/мыши + для проверки GUI — они могут уйти в чужое окно. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5b5cfc2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3855 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[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", + "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 = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.2", + "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.2", + "polling", + "rustix 1.1.5", + "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.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "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 = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[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.2", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[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 = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[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 = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-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 = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "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 = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +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.2", + "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 = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +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 = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "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 = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "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 = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[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 2.0.119", +] + +[[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.2", + "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.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "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.2", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "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.2", + "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.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "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.2", + "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.2", + "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.2", + "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-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[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.2", + "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 = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[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.5", + "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", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "qulyp" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "argon2", + "chacha20poly1305", + "chrono", + "dirs", + "eframe", + "hmac", + "png 0.17.16", + "rand", + "rfd", + "serde", + "serde_json", + "sha1", + "sha2", + "uuid", + "zeroize", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[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.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "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 = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[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.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[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.2", + "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.2", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "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 = "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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[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.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[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-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[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 = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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", + "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.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "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.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "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.2", + "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.5", + "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.2", + "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.2", + "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.2", + "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.2", + "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.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "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 = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "hashbrown 0.17.1", + "libloading", + "log", + "naga", + "naga-types", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "static_assertions", + "thiserror 2.0.21", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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", +] + +[[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", + "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_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.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.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.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.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.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.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.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "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 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5068366 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "qulyp" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Qulyp — локальный менеджер паролей СЃ шифрованным файлом Рё синхронизацией" + +[dependencies] +anyhow = "1" +arboard = { version = "3.6", default-features = false, features = ["windows-sys"] } +argon2 = "0.5" +chacha20poly1305 = "0.10" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +dirs = "6" +eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "links", "wayland", "x11"] } +hmac = "0.12" +rand = "0.8" +rfd = "0.17" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha1 = "0.10" +sha2 = "0.10" +uuid = { version = "1", features = ["v4", "serde"] } +zeroize = "1.8" + +# Argon2 РІ debug-СЃР±РѕСЂРєРµ без оптимизаций работает очень медленно +[profile.dev.package."*"] +opt-level = 3 + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true + +# РќР° Linux (Wayland) буферу обмена нужен отдельный бэкенд +[target.'cfg(target_os = "linux")'.dependencies] +arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } + +[dependencies.png] +version = "0.17" +optional = true + +[features] +# Только для разработки: сценарий + скриншоты РѕРєРЅР° (СЃРј. src/devshots.rs) +dev-shots = ["dep:png"] diff --git a/README.md b/README.md index b82b8e7..92833e8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,56 @@ -# AspansQulyp +# Qulyp -AspansQulyp \ No newline at end of file +Локальный менеджер паролей для Windows и Linux — простая альтернатива KeePass. +Все данные лежат в одном зашифрованном файле `.qulyp`, работает без интернета и без серверов. + +## Возможности +- **Шифрование:** ключ из мастер-пароля через Argon2id (64 МиБ, 3 прохода), данные — XChaCha20-Poly1305 +- **Записи:** логин, пароль, сайт, коды 2FA (TOTP), заметки, папки, избранное, история паролей +- **Генератор паролей** и индикатор стойкости +- **Буфер обмена** очищается сам через 20 с (на Windows пароль не попадает в журнал Win+V) +- **Автоблокировка** при бездействии и (по желанию) при сворачивании окна +- **Синхронизация** между устройствами через любую облачную папку +- Тёмная и светлая тема, горячие клавиши + +## Синхронизация +Положите файл хранилища в папку Dropbox, OneDrive, Google Drive, Syncthing или Nextcloud. +Qulyp каждые 2 секунды проверяет, не изменился ли файл, и **сливает** изменения, а не затирает их: +для каждой записи побеждает более свежая версия, старый пароль попадает в историю, удаления тоже синхронизируются. + +Кнопка 🔄 вручную объединяет с любым другим файлом `.qulyp` — например, с копией на флешке +или с конфликтной копией, которую создало облако. + +## Горячие клавиши +| | | +|---|---| +| `Ctrl+F` | поиск | +| `Ctrl+N` | новая запись | +| `Ctrl+C` / двойной клик | копировать пароль | +| `Ctrl+B` | копировать логин | +| `Ctrl+G` | генератор паролей | +| `Ctrl+L` | заблокировать | +| `↑` `↓`, `Del`, `Esc` | навигация, удаление, отмена | + +## Сборка +Нужен Rust 1.85+ (). + +```bash +cargo build --release +``` + +Готовый файл: `target/release/qulyp.exe` (Windows) или `target/release/qulyp` (Linux). + +Для Linux (Debian/Ubuntu) нужны пакеты: + +```bash +sudo apt install libxkbcommon-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libgl1-mesa-dev +``` + +Тесты: `cargo test`. + +## Важно +Мастер-пароль **нельзя восстановить**. Без него данные не расшифровать — ни вам, ни кому-либо ещё. +Делайте резервные копии файла: рядом с ним Qulyp хранит прошлую версию `*.qulyp.bak`. + +## Для разработчиков +Состояние проекта, планы и хвосты — в [`notes/`](notes/), правила ведения — в [`CLAUDE.md`](CLAUDE.md). diff --git a/notes/DATA.md b/notes/DATA.md new file mode 100644 index 0000000..712ea63 --- /dev/null +++ b/notes/DATA.md @@ -0,0 +1,26 @@ + +# Данные + +## Файл `.qulyp` +| Байты | Поле | +|---|---| +| 0–7 | magic `QULYPv1\0` | +| 8–19 | Argon2id: m_kib, t, p (u32 LE) | +| 20–35 | salt (16) | +| 36–59 | nonce XChaCha20 (24) | +| 60– | шифротекст JSON + тег Poly1305 (16) | + +Рядом при сохранении: `.qulyp.bak` (прошлая версия), временный `.qulyp.tmp` (атомарная замена). + +## JSON хранилища (`Vault`) +- `format` (1), `name`, `created` (мс) +- `entries[]`: `id` (uuid), `title`, `username`, `password`, `url`, `totp`, `notes`, + `group` (uuid|null), `favorite`, `created`, `modified` (мс), `history[] {password, changed}` +- `groups[]`: `id`, `name`, `modified` +- `tombstones {uuid: мс}` — удалённые записи и папки + +Все поля с `serde(default)` — старые файлы читаются новыми версиями. + +## Настройки (не секретные) +`%APPDATA%\Qulyp\config.json` / `~/.config/qulyp/config.json` (путь меняется `QULYP_CONFIG`): +недавние файлы, тема, автоблокировка, очистка буфера, опции генератора. diff --git a/notes/DEBT.md b/notes/DEBT.md new file mode 100644 index 0000000..821f654 --- /dev/null +++ b/notes/DEBT.md @@ -0,0 +1,20 @@ + +# Хвосты (баги, техдолг, недоделки) + +## Безопасность +- Строки пароля в egui `TextEdit` могут оставлять копии в памяти — zeroize только «по возможности» +- Слияние по времени изменения (LWW): при сильно сбитых часах на устройстве может победить не та версия +- Надгробия (tombstones) удалённых записей никогда не чистятся — файл медленно растёт + +## Платформы +- Linux: буфер обмена на X11 очищается при закрытии приложения; на GNOME Wayland нет data-control → идёт через XWayland +- Linux: шрифты ищутся по фиксированным путям (Inter/Noto/DejaVu), иначе встроенные egui +- Автоблокировка «при сворачивании» зависит от того, сообщает ли WM о minimized + +## UX +- Иконки — глифы шрифтов; часть эмодзи отсутствует (проверять dev-шагом `glyphs:`) +- Оценка стойкости пароля грубая (без словаря) +- Переключение записи при открытом редакторе молча отбрасывает несохранённые правки + +## Тесты +- UI покрыт только ручными скриншотами (dev-shots), автотестов интерфейса нет diff --git a/notes/DECISIONS.md b/notes/DECISIONS.md new file mode 100644 index 0000000..23fa0c9 --- /dev/null +++ b/notes/DECISIONS.md @@ -0,0 +1,25 @@ + +# Решения + +**egui/eframe вместо Tauri/Qt.** Чистый Rust, один бинарник, без WebView и Node; одинаково на Windows и Linux. +Рендер `glow` (OpenGL) — легче wgpu и быстрее собирается. + +**Свой формат, а не .kdbx.** KDBX сложен (XML, много версий KDF/шифров). Свой формат — 60 байт заголовка ++ зашифрованный JSON, легко проверить. Импорт KDBX — отдельной задачей (см. PLAN). + +**Argon2id 64 МиБ / 3 прохода / p=4.** Рекомендация OWASP-уровня, ~0.5 с на разблокировку. +Параметры пишутся в заголовок — можно усилить позже без смены формата. + +**XChaCha20-Poly1305.** 24-байтный случайный nonce на каждое сохранение — без риска повтора. +Весь заголовок — AAD, подмена параметров ломает расшифровку. + +**Синхронизация = файл в облачной папке.** Никакого своего сервера. Перед записью файл на диске +сливается с памятью; раз в 2 с проверяется внешнее изменение. Слияние — по записям, свежая побеждает, +старый пароль уходит в историю, удаления — через надгробия. + +**Автосохранение после каждого изменения.** Нет кнопки «Сохранить» и риска забыть. Старая версия → `.qulyp.bak`. + +**RustCrypto старых стабильных версий** (argon2 0.5, chacha20poly1305 0.10, hmac 0.12): новые мажорные +версии меняют API, а эти широко проверены. + +**Язык интерфейса — русский.** Локализация — если понадобится. diff --git a/notes/LOG.md b/notes/LOG.md new file mode 100644 index 0000000..e9687e5 --- /dev/null +++ b/notes/LOG.md @@ -0,0 +1,9 @@ + +# Журнал (новые сверху) + +## 2026-09-30 +- Создан проект: крипто (Argon2id + XChaCha20-Poly1305), модель данных, слияние, атомарная запись +- UI на egui 0.36: экраны приветствия/создания/разблокировки, основной экран, генератор, настройки +- Синхронизация через файл в облачной папке + ручное слияние, обработка смены пароля на другом устройстве +- 12 unit-тестов, dev-shots для скриншотов UI, проверка `cargo check` под Linux +- Система заметок `notes/` с лимитами строк (тест `tests/notes.rs`) diff --git a/notes/PLAN.md b/notes/PLAN.md new file mode 100644 index 0000000..a7e443a --- /dev/null +++ b/notes/PLAN.md @@ -0,0 +1,19 @@ + +# План + +## Сейчас +- [ ] Запустить и проверить на живом Linux (X11 и Wayland), поправить шрифты/буфер +- [ ] CI: сборка + тесты на Windows и Linux (workflow добавлен, проверить на сервере) + +## Дальше +- [ ] Импорт/экспорт CSV (форматы Bitwarden, Chrome, KeePass) +- [ ] Сборка дистрибутивов: .msi/.exe-установщик, AppImage/.deb +- [ ] Ассоциация файлов `.qulyp` (двойной клик открывает хранилище) +- [ ] Автоподхват конфликтных копий облака (`… (conflicted copy).qulyp`) + +## Потом +- [ ] Импорт .kdbx (KeePass) +- [ ] Вложения и произвольные поля +- [ ] Вложенные папки / теги +- [ ] Свои векторные иконки вместо шрифтовых глифов +- [ ] Расширение браузера для автозаполнения diff --git a/notes/STATUS.md b/notes/STATUS.md new file mode 100644 index 0000000..30bead6 --- /dev/null +++ b/notes/STATUS.md @@ -0,0 +1,19 @@ + +# Статус + +**Версия:** 0.1.0 · **Обновлено:** 2026-09-30 + +## Работает +- Создание/открытие хранилища, мастер-пароль (мин. 8 символов), смена пароля +- Записи: название, логин, пароль, сайт, TOTP (2FA), заметки, папка, избранное +- История паролей (до 20 на запись), поиск, папки, горячие клавиши +- Генератор паролей + индикатор стойкости +- Буфер обмена с автоочисткой (на Windows — мимо журнала Win+V и облака) +- Автоблокировка по бездействию и при сворачивании +- Синхронизация: автоподхват изменений файла (облачная папка) + ручное слияние с другим файлом +- Обработка конфликта «пароль сменили на другом устройстве» +- Тёмная/светлая/системная тема + +## Проверено +- Windows 11: сборка, 12 unit-тестов, UI вручную по скриншотам (dev-shots) +- Linux: только `cargo check` (запуск на живом Linux ещё не делали) diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d59cd51 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,2037 @@ +//! Состояние приложения, экраны и фоновые задачи. + +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, channel}; +use std::time::{Duration, Instant}; + +use anyhow::bail; +use eframe::egui::{ + self, Align, Align2, CentralPanel, Color32, ComboBox, CornerRadius, Event, Frame, Id, Key, KeyboardShortcut, + Layout, Margin, Modifiers, Panel, RichText, ScrollArea, Stroke, TextEdit, ThemePreference, Ui, ViewportCommand, + vec2, +}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::config::{Config, ThemeMode}; +use crate::crypto::VaultKey; +use crate::generator; +use crate::storage::{self, ForeignKey, Session}; +use crate::theme::{self, Pal, bold}; +use crate::totp::Totp; +use crate::vault::{Entry, MergeReport, Vault}; +use crate::widgets::*; + +type Secret = Zeroizing; + +fn secret() -> Secret { + Zeroizing::new(String::new()) +} + +const MIN_PASSWORD: usize = 8; + +// ─── Фоновые задачи (Argon2 занимает ~0.5 с, UI не должен замирать) ─────────── + +enum Job { + Opened(anyhow::Result), + OtherLoaded { path: PathBuf, write_back: bool, result: anyhow::Result<(Vault, VaultKey)> }, + NewKey(anyhow::Result), +} + +// ─── Уведомления ───────────────────────────────────────────────────────────── + +#[derive(Clone, Copy)] +enum Kind { + Info, + Ok, + Err, +} + +#[derive(Default)] +struct Toasts(Vec<(String, Kind, Instant)>); + +impl Toasts { + fn push(&mut self, kind: Kind, text: impl Into) { + let secs = if matches!(kind, Kind::Err) { 6 } else { 3 }; + self.0.push((text.into(), kind, Instant::now() + Duration::from_secs(secs))); + if self.0.len() > 4 { + self.0.remove(0); + } + } + fn ok(&mut self, t: impl Into) { + self.push(Kind::Ok, t) + } + fn info(&mut self, t: impl Into) { + self.push(Kind::Info, t) + } + fn err(&mut self, t: impl Into) { + self.push(Kind::Err, t) + } + + fn show(&mut self, ctx: &egui::Context, pal: &Pal) { + let now = Instant::now(); + self.0.retain(|t| t.2 > now); + if self.0.is_empty() { + return; + } + ctx.request_repaint_after(Duration::from_millis(250)); + egui::Area::new(Id::new("toasts")) + .anchor(Align2::CENTER_BOTTOM, vec2(0.0, -46.0)) + .order(egui::Order::Foreground) + .interactable(false) + .show(ctx, |ui| { + for (text, kind, _) in &self.0 { + let dot = match kind { + Kind::Info => pal.accent, + Kind::Ok => pal.success, + Kind::Err => pal.danger, + }; + Frame::new() + .fill(pal.card) + .stroke(Stroke::new(1.0, pal.border)) + .corner_radius(CornerRadius::same(10)) + .inner_margin(Margin::symmetric(14, 9)) + .shadow(ui.visuals().popup_shadow) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("●").color(dot).size(11.0)); + ui.label(RichText::new(text).color(pal.text)); + }); + }); + ui.add_space(6.0); + } + }); + } +} + +// ─── Буфер обмена с автоочисткой ───────────────────────────────────────────── + +struct Clip { + board: Option, + pending: Option<(Secret, Instant)>, +} + +impl Clip { + fn new() -> Self { + Self { board: arboard::Clipboard::new().ok(), pending: None } + } + + fn copy(&mut self, text: &str, clear_after: u32) -> bool { + let Some(board) = self.board.as_mut() else { return false }; + #[cfg(windows)] + let ok = { + use arboard::SetExtWindows; + // Не попадать в журнал Win+V и облачный буфер. + board.set().exclude_from_history().exclude_from_cloud().text(text).is_ok() + }; + #[cfg(not(windows))] + let ok = board.set_text(text).is_ok(); + self.pending = (ok && clear_after > 0) + .then(|| (Zeroizing::new(text.to_owned()), Instant::now() + Duration::from_secs(clear_after.into()))); + ok + } + + /// Очистить буфер, если срок вышел (или `force`) и там всё ещё наше значение. + fn tick(&mut self, force: bool) { + let due = self.pending.as_ref().is_some_and(|(_, at)| force || Instant::now() >= *at); + if !due { + return; + } + let (value, _) = self.pending.take().unwrap(); + if let Some(b) = self.board.as_mut() + && b.get_text().is_ok_and(|t| t == *value) + { + let _ = b.clear(); + } + } +} + +// ─── Экраны ────────────────────────────────────────────────────────────────── + +struct CreateForm { + name: String, + path: PathBuf, + pw: Secret, + pw2: Secret, + show: bool, + error: Option, + busy: bool, +} + +impl CreateForm { + fn new() -> Self { + let dir = Config::default_vault_dir(); + let mut path = dir.join("Qulyp.qulyp"); + let mut n = 2; + while path.exists() { + path = dir.join(format!("Qulyp {n}.qulyp")); + n += 1; + } + Self { + name: "Мои пароли".into(), path, pw: secret(), pw2: secret(), show: false, error: None, busy: false + } + } +} + +struct UnlockForm { + path: PathBuf, + pw: Secret, + show: bool, + error: Option, + busy: bool, +} + +impl UnlockForm { + fn new(path: PathBuf) -> Self { + Self { path, pw: secret(), show: false, error: None, busy: false } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Filter { + All, + Favorites, + Group(Uuid), +} + +struct Editor { + draft: Entry, + is_new: bool, + show_pw: bool, +} + +struct SyncDialog { + path: String, + pw: Secret, + show: bool, + write_back: bool, + error: Option, + busy: bool, +} + +#[derive(Default)] +struct PwDialog { + current: Secret, + new: Secret, + confirm: Secret, + show: bool, + error: Option, + busy: bool, +} + +enum GroupDialog { + New(String), + Rename(Uuid, String), +} + +struct MainView { + s: Session, + filter: Filter, + search: String, + selected: Option, + editor: Option, + reveal: bool, + last_activity: Instant, + last_poll: Instant, + dirty: bool, + conflict: bool, + saved_at: Option, + focus_search: bool, + confirm_delete: Option, + confirm_delete_group: Option, + confirm_overwrite: bool, + group_dialog: Option, + sync: Option, + pw: Option, +} + +impl MainView { + fn new(s: Session) -> Self { + Self { + s, + filter: Filter::All, + search: String::new(), + selected: None, + editor: None, + reveal: false, + last_activity: Instant::now(), + last_poll: Instant::now(), + dirty: false, + conflict: false, + saved_at: None, + focus_search: false, + confirm_delete: None, + confirm_delete_group: None, + confirm_overwrite: false, + group_dialog: None, + sync: None, + pw: None, + } + } + + fn modal_open(&self) -> bool { + self.confirm_delete.is_some() + || self.confirm_delete_group.is_some() + || self.confirm_overwrite + || self.group_dialog.is_some() + || self.sync.is_some() + || self.pw.is_some() + } + + /// id видимых записей с учётом фильтра и поиска, по алфавиту. + fn visible(&self) -> Vec { + let q = self.search.trim().to_lowercase(); + let mut v: Vec<&Entry> = self + .s + .vault + .entries + .iter() + .filter(|e| match self.filter { + Filter::All => true, + Filter::Favorites => e.favorite, + Filter::Group(g) => e.group == Some(g), + }) + .filter(|e| e.matches(&q)) + .collect(); + v.sort_by_cached_key(|e| e.title.to_lowercase()); + v.into_iter().map(|e| e.id).collect() + } + + fn now_label() -> String { + chrono::Local::now().format("%H:%M:%S").to_string() + } + + /// Сохранить изменения (с автослиянием внешних правок). + fn persist(&mut self, toasts: &mut Toasts) { + self.dirty = true; + if self.conflict { + return; + } + match self.s.save() { + Ok(r) => { + self.dirty = false; + self.saved_at = Some(Self::now_label()); + if r.any() { + toasts.info(format!("🔄 Подтянуты изменения с другого устройства: {}", describe(&r))); + } + } + Err(e) if e.is::() => self.conflict = true, + Err(e) => toasts.err(format!("Не удалось сохранить: {e:#}")), + } + } + + /// Раз в пару секунд: подтянуть изменения файла, сделанные извне. + fn poll(&mut self, toasts: &mut Toasts) { + if self.last_poll.elapsed() < Duration::from_secs(2) || self.conflict { + return; + } + self.last_poll = Instant::now(); + if self.dirty { + return self.persist(toasts); + } + match self.s.poll() { + Ok(Some(r)) => { + self.saved_at = Some(Self::now_label()); + if r.any() { + toasts.info(format!("🔄 Синхронизировано: {}", describe(&r))); + } + } + Err(e) if e.is::() => self.conflict = true, + // Файл может быть недописан облачным клиентом — попробуем позже. + _ => {} + } + } +} + +fn describe(r: &MergeReport) -> String { + let mut parts = Vec::new(); + if r.added > 0 { + parts.push(format!("+{} новых", r.added)); + } + if r.updated > 0 { + parts.push(format!("{} обновлено", r.updated)); + } + if r.removed > 0 { + parts.push(format!("{} удалено", r.removed)); + } + parts.join(", ") +} + +enum Screen { + Welcome, + Create(CreateForm), + Unlock(UnlockForm), + Main(Box), +} + +// ─── Приложение ────────────────────────────────────────────────────────────── + +pub struct QulypApp { + cfg: Config, + screen: Screen, + toasts: Toasts, + clip: Clip, + job: Option>, + gen_open: bool, + gen_value: Secret, + settings_open: bool, + title: String, + #[cfg(feature = "dev-shots")] + dev: Option, +} + +impl QulypApp { + pub fn new(cc: &eframe::CreationContext<'_>, open: Option) -> Self { + theme::install(&cc.egui_ctx); + let cfg = Config::load(); + let start = open.or_else(|| cfg.recent.iter().find(|p| p.exists()).cloned()); + let screen = match start { + Some(p) => Screen::Unlock(UnlockForm::new(p)), + None => Screen::Welcome, + }; + let gen_value = Zeroizing::new(generator::generate(&cfg.generator)); + Self { + cfg, + screen, + toasts: Toasts::default(), + clip: Clip::new(), + job: None, + gen_open: false, + gen_value, + settings_open: false, + title: String::new(), + #[cfg(feature = "dev-shots")] + dev: crate::devshots::DevScript::from_env(), + } + } + + fn spawn(&mut self, ctx: &egui::Context, f: impl FnOnce() -> Job + Send + 'static) { + let (tx, rx) = channel(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(f()); + ctx.request_repaint(); + }); + self.job = Some(rx); + } + + fn copy(&mut self, value: &str, what: &str) { + if value.is_empty() { + return; + } + if self.clip.copy(value, self.cfg.clipboard_sec) { + let tail = if self.cfg.clipboard_sec > 0 { + format!(" · очистится через {} с", self.cfg.clipboard_sec) + } else { + String::new() + }; + self.toasts.ok(format!("{what} скопирован{tail}")); + } else { + self.toasts.err("Буфер обмена недоступен"); + } + } + + fn lock(&mut self, mv: &mut MainView) -> Screen { + if mv.dirty { + mv.persist(&mut self.toasts); + } + self.clip.tick(true); + self.gen_open = false; + Screen::Unlock(UnlockForm::new(mv.s.path.clone())) + } + + fn handle_job(&mut self, job: Job) { + match job { + Job::Opened(Ok(session)) => { + self.cfg.push_recent(session.path.clone()); + self.toasts.ok(format!("Открыто: {}", session.file_name())); + self.screen = Screen::Main(Box::new(MainView::new(session))); + } + Job::Opened(Err(e)) => match &mut self.screen { + Screen::Unlock(UnlockForm { busy, error, .. }) | Screen::Create(CreateForm { busy, error, .. }) => { + *busy = false; + *error = Some(format!("{e:#}")); + } + _ => {} + }, + Job::OtherLoaded { path, write_back, result } => { + if let Screen::Main(mv) = &mut self.screen { + apply_other(&mut self.toasts, mv, path, write_back, result); + } + } + Job::NewKey(result) => { + let Screen::Main(mv) = &mut self.screen else { return }; + match result.and_then(|k| mv.s.change_key(k)) { + Ok(()) => { + mv.pw = None; + mv.saved_at = Some(MainView::now_label()); + self.toasts.ok("Мастер-пароль изменён"); + } + Err(e) => { + if let Some(d) = mv.pw.as_mut() { + d.busy = false; + d.error = Some(format!("{e:#}")); + } + } + } + } + } + } + + fn apply_theme(&mut self, ctx: &egui::Context) { + let pref = match self.cfg.theme { + ThemeMode::System => ThemePreference::System, + ThemeMode::Dark => ThemePreference::Dark, + ThemeMode::Light => ThemePreference::Light, + }; + if ctx.options(|o| o.theme_preference) != pref { + ctx.set_theme(pref); + } + } + + fn set_title(&mut self, ctx: &egui::Context, title: String) { + if self.title != title { + ctx.send_viewport_cmd(ViewportCommand::Title(title.clone())); + self.title = title; + } + } +} + +/// Результат чтения другого файла для синхронизации → слить и сообщить. +fn apply_other( + toasts: &mut Toasts, + mv: &mut MainView, + path: PathBuf, + write_back: bool, + result: anyhow::Result<(Vault, VaultKey)>, +) { + match result.and_then(|(other, key)| mv.s.merge_other(&other, &path, &key, write_back)) { + Ok(r) => { + mv.sync = None; + mv.conflict = false; + mv.dirty = false; + mv.saved_at = Some(MainView::now_label()); + let d = describe(&r); + toasts.ok(if d.is_empty() { + "Синхронизировано: изменений нет".into() + } else { + format!("Синхронизировано: {d}") + }); + } + Err(e) => { + if let Some(d) = mv.sync.as_mut() { + d.busy = false; + d.error = Some(format!("{e:#}")); + } + } + } +} + +impl eframe::App for QulypApp { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if let Some(job) = self.job.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.job = None; + self.handle_job(job); + } + self.clip.tick(false); + if self.clip.pending.is_some() { + ctx.request_repaint_after(Duration::from_secs(1)); + } + + let should_lock = match &mut self.screen { + Screen::Main(mv) => { + mv.poll(&mut self.toasts); + ctx.request_repaint_after(Duration::from_secs(1)); + let minimized = ctx.input(|i| i.viewport().minimized.unwrap_or(false)); + let limit = Duration::from_secs(self.cfg.auto_lock_min as u64 * 60); + let idle = self.cfg.auto_lock_min > 0 && mv.last_activity.elapsed() >= limit; + idle || (self.cfg.lock_on_minimize && minimized) + } + _ => false, + }; + if should_lock && let Screen::Main(mut mv) = std::mem::replace(&mut self.screen, Screen::Welcome) { + self.screen = self.lock(&mut mv); + self.toasts.info("Хранилище заблокировано"); + } + } + + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + #[cfg(feature = "dev-shots")] + self.dev_step(&ctx); + self.apply_theme(&ctx); + let pal = theme::pal(ui.visuals().dark_mode); + + let mut screen = std::mem::replace(&mut self.screen, Screen::Welcome); + let next = match &mut screen { + Screen::Welcome => { + self.set_title(&ctx, "Qulyp".into()); + self.ui_welcome(ui, &pal) + } + Screen::Create(f) => { + self.set_title(&ctx, "Qulyp — новое хранилище".into()); + self.ui_create(ui, &pal, f) + } + Screen::Unlock(f) => { + self.set_title(&ctx, "Qulyp — заблокировано".into()); + self.ui_unlock(ui, &pal, f) + } + Screen::Main(mv) => { + self.set_title(&ctx, format!("Qulyp — {}", mv.s.vault.name)); + self.ui_main(ui, &pal, mv) + } + }; + self.screen = next.unwrap_or(screen); + + self.ui_settings(&ctx, &pal); + self.toasts.show(&ctx, &pal); + } + + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + if let Screen::Main(mv) = &mut self.screen + && mv.dirty + { + mv.persist(&mut self.toasts); + } + self.clip.tick(true); + } +} + +// ─── Экраны до разблокировки ───────────────────────────────────────────────── + +/// Карточка по центру окна. +fn centered(ui: &mut Ui, pal: &Pal, width: f32, add: impl FnOnce(&mut Ui)) { + CentralPanel::default().frame(Frame::new().fill(pal.bg)).show(ui, |ui| { + ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + let h = ui.available_height(); + let key = Id::new("centered_h"); + let last: f32 = ui.ctx().data(|d| d.get_temp(key)).unwrap_or(480.0); + ui.add_space(((h - last) / 2.0).max(20.0)); + ui.vertical_centered(|ui| { + let r = frame_card(pal) + .inner_margin(Margin::same(32)) + .corner_radius(CornerRadius::same(18)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.set_width(width); + add(ui) + }); + let hh = r.response.rect.height(); + if (hh - last).abs() > 1.0 { + ui.ctx().data_mut(|d| d.insert_temp(key, hh)); + ui.ctx().request_repaint(); + } + }); + }); + }); +} + +fn left(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + ui.with_layout(Layout::top_down(Align::Min), add); +} + +fn pick_vault_file() -> Option { + rfd::FileDialog::new() + .set_title("Открыть хранилище") + .add_filter("Хранилище Qulyp", &["qulyp"]) + .add_filter("Все файлы", &["*"]) + .set_directory(Config::default_vault_dir()) + .pick_file() +} + +impl QulypApp { + fn ui_welcome(&mut self, ui: &mut Ui, pal: &Pal) -> Option { + let mut next = None; + let mut remove = None; + let recent = self.cfg.recent.clone(); + centered(ui, pal, 380.0, |ui| { + logo(ui, 64.0); + ui.add_space(10.0); + ui.label(RichText::new("Qulyp").font(egui::FontId::new(30.0, bold())).color(pal.text)); + ui.label( + RichText::new("Локальный менеджер паролей.\nОдин зашифрованный файл — данные остаются у вас.") + .color(pal.weak), + ); + ui.add_space(20.0); + if primary(ui, pal, "Создать хранилище", 380.0).clicked() { + next = Some(Screen::Create(CreateForm::new())); + } + ui.add_space(2.0); + if secondary(ui, "Открыть файл…", 380.0).clicked() + && let Some(p) = pick_vault_file() + { + next = Some(Screen::Unlock(UnlockForm::new(p))); + } + if !recent.is_empty() { + ui.add_space(16.0); + left(ui, |ui| { + section_label(ui, pal, "НЕДАВНИЕ"); + for (i, p) in recent.iter().enumerate() { + let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let dir = p.parent().map(|d| d.display().to_string()).unwrap_or_default(); + ui.horizontal(|ui| { + let r = entry_row_small(ui, pal, &name, &dir, p.exists(), 380.0 - 40.0); + if r.clicked() { + next = Some(Screen::Unlock(UnlockForm::new(p.clone()))); + } + if icon_btn(ui, "×", "Убрать из списка").clicked() { + remove = Some(i); + } + }); + } + }); + } + ui.add_space(14.0); + ui.label(RichText::new("XChaCha20-Poly1305 · Argon2id · работает офлайн").size(11.5).color(pal.weak)); + }); + if let Some(i) = remove { + self.cfg.recent.remove(i); + self.cfg.save(); + } + next + } + + fn ui_unlock(&mut self, ui: &mut Ui, pal: &Pal, f: &mut UnlockForm) -> Option { + let mut next = None; + let mut submit = false; + let name = f.path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + centered(ui, pal, 360.0, |ui| { + logo(ui, 56.0); + ui.add_space(8.0); + ui.label(RichText::new(&name).font(egui::FontId::new(22.0, bold())).color(pal.text)); + ui.add(egui::Label::new(RichText::new(f.path.display().to_string()).size(12.0).color(pal.weak)).truncate()); + ui.add_space(16.0); + left(ui, |ui| { + ui.add_enabled_ui(!f.busy, |ui| { + let r = password_field(ui, &mut f.pw, &mut f.show, "Мастер-пароль", |_| {}); + if !f.busy && ui.memory(|m| m.focused().is_none()) { + r.request_focus(); + } + if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + submit = true; + } + }); + if let Some(e) = &f.error { + ui.label(RichText::new(e).color(pal.danger).size(13.0)); + } + }); + ui.add_space(10.0); + if f.busy { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Проверяем пароль…").color(pal.weak)); + }); + } else if primary(ui, pal, "Разблокировать", 360.0).clicked() { + submit = true; + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.link("Другой файл").clicked() { + next = Some(Screen::Welcome); + } + ui.label(RichText::new("·").color(pal.weak)); + if ui.link("Создать новое").clicked() { + next = Some(Screen::Create(CreateForm::new())); + } + }); + }); + if submit && !f.busy && !f.pw.is_empty() { + f.busy = true; + f.error = None; + let (path, pw) = (f.path.clone(), f.pw.clone()); + f.pw = secret(); + self.spawn(ui.ctx(), move || Job::Opened(Session::open(path, &pw))); + } + next + } + + fn ui_create(&mut self, ui: &mut Ui, pal: &Pal, f: &mut CreateForm) -> Option { + let mut next = None; + let mut submit = false; + centered(ui, pal, 400.0, |ui| { + logo(ui, 48.0); + ui.add_space(6.0); + ui.label(RichText::new("Новое хранилище").font(egui::FontId::new(22.0, bold())).color(pal.text)); + ui.add_space(12.0); + left(ui, |ui| { + ui.add_enabled_ui(!f.busy, |ui| { + field_label(ui, pal, "Название"); + text_field(ui, &mut f.name, "Например, Личное"); + field_label(ui, pal, "Файл"); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 30.0), Layout::right_to_left(Align::Center), |ui| { + let change = ui.button("Изменить…").clicked(); + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let path = f.path.display().to_string(); + ui.add(egui::Label::new(RichText::new(path).size(12.5).color(pal.text)).truncate()); + }); + if change { + let file = f.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if let Some(mut p) = rfd::FileDialog::new() + .set_title("Где сохранить хранилище") + .add_filter("Хранилище Qulyp", &["qulyp"]) + .set_directory(f.path.parent().map(PathBuf::from).unwrap_or_else(Config::default_vault_dir)) + .set_file_name(file) + .save_file() + { + if p.extension().is_none() { + p.set_extension("qulyp"); + } + f.path = p; + } + } + }); + ui.label( + RichText::new("Совет: папка Dropbox / OneDrive / Google Drive / Syncthing даст синхронизацию между устройствами.") + .size(11.5) + .color(pal.weak), + ); + field_label(ui, pal, "Мастер-пароль"); + let mut show = f.show; + password_field(ui, &mut f.pw, &mut show, "Минимум 8 символов", |_| {}); + strength_bar(ui, pal, &f.pw); + field_label(ui, pal, "Повторите пароль"); + let r = password_field(ui, &mut f.pw2, &mut show, "Ещё раз", |_| {}); + f.show = show; + if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + submit = true; + } + }); + ui.add_space(4.0); + ui.label( + RichText::new("⚠ Мастер-пароль нельзя восстановить. Без него данные не расшифровать — запомните или запишите его.") + .size(12.0) + .color(pal.warn), + ); + if let Some(e) = &f.error { + ui.label(RichText::new(e).color(pal.danger).size(13.0)); + } + }); + ui.add_space(12.0); + if f.busy { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Создаём ключ…").color(pal.weak)); + }); + } else { + if primary(ui, pal, "Создать", 400.0).clicked() { + submit = true; + } + if ui.link("Назад").clicked() { + next = Some(Screen::Welcome); + } + } + }); + if submit && !f.busy { + f.error = if f.pw.chars().count() < MIN_PASSWORD { + Some(format!("Пароль должен быть не короче {MIN_PASSWORD} символов")) + } else if *f.pw != *f.pw2 { + Some("Пароли не совпадают".into()) + } else if f.path.exists() { + Some("Такой файл уже есть — выберите другое имя или откройте его".into()) + } else { + None + }; + if f.error.is_none() { + f.busy = true; + let name = + if f.name.trim().is_empty() { "Мои пароли".to_owned() } else { f.name.trim().to_owned() }; + let (path, pw) = (f.path.clone(), f.pw.clone()); + self.spawn(ui.ctx(), move || Job::Opened(Session::create(path, &name, &pw))); + } + } + next + } +} + +fn entry_row_small(ui: &mut Ui, pal: &Pal, name: &str, dir: &str, exists: bool, width: f32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(width, 42.0), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(8), pal.hover); + } + let clip = ui.painter().with_clip_rect(rect.shrink(4.0)); + let x = rect.left() + 10.0; + let c = if exists { pal.text } else { pal.weak }; + clip.text( + egui::pos2(x, rect.center().y - 8.0), + Align2::LEFT_CENTER, + name.to_owned(), + egui::FontId::proportional(14.0), + c, + ); + let sub = if exists { dir.to_owned() } else { format!("{dir} · файл не найден") }; + clip.text( + egui::pos2(x, rect.center().y + 10.0), + Align2::LEFT_CENTER, + sub, + egui::FontId::proportional(11.5), + pal.weak, + ); + resp +} + +// ─── Основной экран ────────────────────────────────────────────────────────── + +impl QulypApp { + fn ui_main(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) -> Option { + let ctx = ui.ctx().clone(); + let mut next = None; + if ctx.input(|i| !i.events.is_empty()) { + mv.last_activity = Instant::now(); + } + self.shortcuts(&ctx, mv, &mut next); + + Panel::top("top") + .frame(Frame::new().fill(pal.sidebar).inner_margin(Margin::symmetric(16, 10))) + .show_separator_line(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + logo(ui, 28.0); + ui.add_space(4.0); + ui.label(RichText::new(&mv.s.vault.name).font(egui::FontId::new(16.0, bold())).color(pal.text)); + ui.label(RichText::new(mv.s.file_name()).size(12.5).color(pal.weak)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if secondary(ui, "🔒 Заблокировать", 0.0).on_hover_text("Ctrl+L").clicked() { + next = Some(self.lock(mv)); + } + if icon_btn(ui, "⚙", "Настройки").clicked() { + self.settings_open = !self.settings_open; + } + if icon_btn(ui, "🎲", "Генератор паролей (Ctrl+G)").clicked() { + self.gen_open = !self.gen_open; + } + if icon_btn(ui, "🔄", "Синхронизация с другим файлом").clicked() { + mv.sync = Some(SyncDialog { + path: String::new(), + pw: secret(), + show: false, + write_back: true, + error: None, + busy: false, + }); + } + }); + }); + }); + + Panel::bottom("status") + .frame(Frame::new().fill(pal.sidebar).inner_margin(Margin::symmetric(16, 5))) + .show_separator_line(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (dot, text) = if mv.conflict { + (pal.danger, "Конфликт — требуется действие".to_owned()) + } else if mv.dirty { + (pal.warn, "Есть несохранённые изменения".to_owned()) + } else { + ( + pal.success, + mv.saved_at + .as_ref() + .map_or("Всё сохранено".into(), |t| format!("Сохранено в {t}")), + ) + }; + ui.label(RichText::new("●").color(dot).size(10.0)); + ui.label(RichText::new(text).size(12.0).color(pal.weak)); + ui.label(RichText::new("·").color(pal.weak)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(format!("Записей: {}", mv.s.vault.entries.len())).size(12.0).color(pal.weak), + ); + ui.add_space(12.0); + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let path = mv.s.path.display().to_string(); + ui.add(egui::Label::new(RichText::new(path).size(12.0).color(pal.weak)).truncate()); + }); + }); + }); + }); + + Panel::left("sidebar") + .resizable(false) + .default_size(210.0) + .frame(Frame::new().fill(pal.sidebar).inner_margin(Margin::symmetric(10, 12))) + .show_separator_line(false) + .show(ui, |ui| self.sidebar(ui, pal, mv)); + + Panel::left("list") + .resizable(true) + .default_size(320.0) + .size_range(250.0..=560.0) + .frame(Frame::new().fill(pal.list).inner_margin(Margin::symmetric(4, 12))) + .show(ui, |ui| self.entry_list(ui, pal, mv)); + + CentralPanel::default().frame(Frame::new().fill(pal.bg).inner_margin(Margin::same(24))).show(ui, |ui| { + if mv.conflict { + self.conflict_banner(ui, pal, mv); + } + if mv.editor.is_some() { + self.editor(ui, pal, mv); + } else if let Some(id) = mv.selected.filter(|id| mv.s.vault.entry(*id).is_some()) { + self.details(ui, pal, mv, id); + } else { + empty_state(ui, pal, mv.s.vault.entries.is_empty()); + } + }); + + self.dialogs(&ctx, pal, mv); + self.ui_generator(&ctx, pal, mv); + next + } + + fn shortcuts(&mut self, ctx: &egui::Context, mv: &mut MainView, next: &mut Option) { + if mv.modal_open() { + return; + } + let typing = ctx.egui_wants_keyboard_input(); + let sc = |m, k| KeyboardShortcut::new(m, k); + let (find, new, lock, genr, user, pw, up, down, del, esc) = ctx.input_mut(|i| { + ( + i.consume_shortcut(&sc(Modifiers::COMMAND, Key::F)), + i.consume_shortcut(&sc(Modifiers::COMMAND, Key::N)), + i.consume_shortcut(&sc(Modifiers::COMMAND, Key::L)), + i.consume_shortcut(&sc(Modifiers::COMMAND, Key::G)), + i.consume_shortcut(&sc(Modifiers::COMMAND, Key::B)), + !typing && i.events.iter().any(|e| matches!(e, Event::Copy)), + !typing && i.consume_key(Modifiers::NONE, Key::ArrowUp), + !typing && i.consume_key(Modifiers::NONE, Key::ArrowDown), + !typing && i.consume_key(Modifiers::NONE, Key::Delete), + i.consume_key(Modifiers::NONE, Key::Escape), + ) + }); + if lock { + *next = Some(self.lock(mv)); + return; + } + if find { + mv.focus_search = true; + } + if genr { + self.gen_open = !self.gen_open; + } + if new && mv.editor.is_none() { + start_new(mv); + } + if esc { + if mv.editor.is_some() { + mv.editor = None; + } else { + mv.search.clear(); + } + } + if mv.editor.is_some() { + return; + } + let sel = mv.selected.and_then(|id| mv.s.vault.entry(id)); + if user { + if let Some(v) = sel.map(|e| Zeroizing::new(e.username.clone())) { + self.copy(&v, "Логин"); + } + } else if pw && let Some(v) = sel.map(|e| Zeroizing::new(e.password.clone())) { + self.copy(&v, "Пароль"); + } + if del && sel.is_some() { + mv.confirm_delete = mv.selected; + } + if up || down { + let ids = mv.visible(); + if !ids.is_empty() { + let pos = mv.selected.and_then(|s| ids.iter().position(|x| *x == s)); + let i = match (pos, down) { + (None, _) => 0, + (Some(p), true) => (p + 1).min(ids.len() - 1), + (Some(p), false) => p.saturating_sub(1), + }; + mv.selected = Some(ids[i]); + mv.reveal = false; + } + } + } + + fn sidebar(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) { + let v = &mv.s.vault; + let fav = v.entries.iter().filter(|e| e.favorite).count(); + section_label(ui, pal, " ХРАНИЛИЩЕ"); + if nav_item(ui, pal, "☰", "Все записи", v.entries.len(), mv.filter == Filter::All).clicked() { + mv.filter = Filter::All; + } + if nav_item(ui, pal, "★", "Избранное", fav, mv.filter == Filter::Favorites).clicked() { + mv.filter = Filter::Favorites; + } + ui.add_space(12.0); + ui.horizontal(|ui| { + section_label(ui, pal, " ПАПКИ"); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if icon_btn(ui, "+", "Новая папка").clicked() { + mv.group_dialog = Some(GroupDialog::New(String::new())); + } + }); + }); + let mut groups: Vec<_> = mv.s.vault.groups.iter().map(|g| (g.id, g.name.clone())).collect(); + groups.sort_by_cached_key(|g| g.1.to_lowercase()); + if groups.is_empty() { + ui.label(RichText::new(" Папок пока нет").size(12.5).color(pal.weak)); + } + ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + for (id, name) in groups { + let count = mv.s.vault.entries.iter().filter(|e| e.group == Some(id)).count(); + let r = nav_item(ui, pal, "🗀", &name, count, mv.filter == Filter::Group(id)); + if r.clicked() { + mv.filter = Filter::Group(id); + } + r.context_menu(|ui| { + if ui.button("✏ Переименовать").clicked() { + mv.group_dialog = Some(GroupDialog::Rename(id, name.clone())); + ui.close(); + } + if ui.button("🗑 Удалить папку").clicked() { + mv.confirm_delete_group = Some(id); + ui.close(); + } + }); + } + }); + } + + fn entry_list(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) { + ui.horizontal(|ui| { + ui.add_space(8.0); + let r = ui.add( + TextEdit::singleline(&mut mv.search) + .hint_text("🔍 Поиск Ctrl+F") + .desired_width(ui.available_width() - 8.0) + .margin(Margin::symmetric(10, 7)), + ); + if mv.focus_search { + r.request_focus(); + mv.focus_search = false; + } + }); + ui.horizontal(|ui| { + ui.add_space(8.0); + if primary(ui, pal, "+ Новая запись", ui.available_width() - 8.0).on_hover_text("Ctrl+N").clicked() + { + start_new(mv); + } + }); + ui.add_space(4.0); + + let ids = mv.visible(); + if ids.is_empty() { + ui.add_space(30.0); + ui.vertical_centered(|ui| { + let msg = if !mv.search.is_empty() { + "Ничего не найдено" + } else if mv.s.vault.entries.is_empty() { + "Здесь появятся ваши записи" + } else { + "В этом разделе пусто" + }; + ui.label(RichText::new(msg).color(pal.weak)); + }); + return; + } + let mut copy: Option<(Secret, &'static str)> = None; + ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + for id in ids { + let Some(e) = mv.s.vault.entry(id).cloned() else { continue }; + let sub = if !e.username.is_empty() { e.username.clone() } else { display_url(&e.url).to_owned() }; + let (title, fav) = (e.title.clone(), e.favorite); + let r = entry_row(ui, pal, &title, &sub, fav, mv.selected == Some(id)); + if r.clicked() { + mv.selected = Some(id); + mv.editor = None; + mv.reveal = false; + } + if r.double_clicked() { + copy = Some((Zeroizing::new(e.password.clone()), "Пароль")); + } + r.context_menu(|ui| { + if ui.button("👤 Копировать логин").clicked() { + copy = Some((Zeroizing::new(e.username.clone()), "Логин")); + ui.close(); + } + if ui.button("🔑 Копировать пароль").clicked() { + copy = Some((Zeroizing::new(e.password.clone()), "Пароль")); + ui.close(); + } + if !e.url.is_empty() && ui.button("🌐 Открыть сайт").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(full_url(&e.url))); + ui.close(); + } + ui.separator(); + if ui.button("✏ Изменить").clicked() { + mv.selected = Some(id); + mv.editor = Some(Editor { draft: e.clone(), is_new: false, show_pw: false }); + ui.close(); + } + if ui.button("🗑 Удалить").clicked() { + mv.confirm_delete = Some(id); + ui.close(); + } + }); + } + }); + if let Some((v, what)) = copy { + self.copy(&v, what); + } + } + + fn conflict_banner(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) { + Frame::new() + .fill(pal.warn.gamma_multiply(0.15)) + .stroke(Stroke::new(1.0, pal.warn.gamma_multiply(0.6))) + .corner_radius(CornerRadius::same(12)) + .inner_margin(Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label( + RichText::new("⚠ Файл изменён с другим мастер-паролем") + .font(egui::FontId::new(15.0, bold())) + .color(pal.text), + ); + ui.label( + RichText::new( + "Похоже, пароль сменили на другом устройстве. Автосинхронизация приостановлена, \ + ваши последние правки пока только в памяти.", + ) + .color(pal.weak), + ); + ui.horizontal(|ui| { + if primary(ui, pal, "Ввести новый пароль и объединить", 0.0).clicked() { + mv.sync = Some(SyncDialog { + path: mv.s.path.display().to_string(), + pw: secret(), + show: false, + write_back: false, + error: None, + busy: false, + }); + } + if secondary(ui, "Перезаписать моей версией", 0.0).clicked() { + mv.confirm_overwrite = true; + } + }); + }); + ui.add_space(16.0); + } + + fn details(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView, id: Uuid) { + let e = mv.s.vault.entry(id).unwrap().clone(); + let group = mv.s.vault.group_name(e.group).map(str::to_owned); + let mut copy: Option<(Secret, &'static str)> = None; + + ui.horizontal(|ui| { + avatar(ui, if e.title.is_empty() { "?" } else { &e.title }, 52.0); + ui.add_space(6.0); + ui.vertical(|ui| { + ui.add( + egui::Label::new( + RichText::new(if e.title.is_empty() { "Без названия" } else { &e.title }) + .font(egui::FontId::new(22.0, bold())) + .color(pal.text), + ) + .truncate(), + ); + ui.label( + RichText::new(format!("🗀 {}", group.as_deref().unwrap_or("Без папки"))).size(12.5).color(pal.weak), + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if icon_btn(ui, "🗑", "Удалить (Del)").clicked() { + mv.confirm_delete = Some(id); + } + if icon_btn(ui, if e.favorite { "★" } else { "☆" }, "Избранное").clicked() { + mv.s.vault.toggle_favorite(id); + mv.persist(&mut self.toasts); + } + if secondary(ui, "✏ Изменить", 0.0).clicked() { + mv.editor = Some(Editor { draft: e.clone(), is_new: false, show_pw: false }); + } + }); + }); + ui.add_space(14.0); + + ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + frame_card(pal).show(ui, |ui| { + ui.set_width(ui.available_width()); + let mut any = false; + if !e.username.is_empty() { + any = true; + value_row(ui, pal, "Логин", |ui| { + if icon_btn(ui, "🗐", "Копировать (Ctrl+B)").clicked() { + copy = Some((Zeroizing::new(e.username.clone()), "Логин")); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(&e.username).size(15.0).color(pal.text)).truncate()); + }); + }); + } + if !e.password.is_empty() { + any = true; + value_row(ui, pal, "Пароль", |ui| { + if icon_btn(ui, "🗐", "Копировать (Ctrl+C)").clicked() { + copy = Some((Zeroizing::new(e.password.clone()), "Пароль")); + } + if toggle_btn(ui, "👁", mv.reveal, "Показать/скрыть").clicked() { + mv.reveal = !mv.reveal; + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let text = if mv.reveal { + RichText::new(&e.password).monospace().size(15.0) + } else { + RichText::new("•".repeat(e.password.chars().count().min(16))).size(15.0) + }; + ui.add(egui::Label::new(text.color(pal.text)).truncate()); + }); + }); + strength_bar(ui, pal, &e.password); + ui.add_space(4.0); + } + if !e.url.is_empty() { + any = true; + value_row(ui, pal, "Сайт", |ui| { + if icon_btn(ui, "🗐", "Копировать").clicked() { + copy = Some((Zeroizing::new(e.url.clone()), "Адрес")); + } + if icon_btn(ui, "↗", "Открыть в браузере").clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(full_url(&e.url))); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let r = ui.add( + egui::Label::new(RichText::new(display_url(&e.url)).size(15.0).color(pal.accent)) + .truncate() + .sense(egui::Sense::click()), + ); + if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + ui.ctx().open_url(egui::OpenUrl::new_tab(full_url(&e.url))); + } + }); + }); + } + if let Some(t) = Totp::parse(&e.totp) { + any = true; + let (code, left) = t.now(); + value_row(ui, pal, "Код 2FA", |ui| { + if icon_btn(ui, "🗐", "Копировать код").clicked() { + copy = Some((Zeroizing::new(code.clone()), "Код")); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let (a, b) = code.split_at(code.len() / 2); + ui.label(RichText::new(format!("{a} {b}")).monospace().size(20.0).color(pal.text)); + ui.add_space(8.0); + let frac = left as f32 / t.period() as f32; + let color = if left <= 5 { pal.danger } else { pal.accent }; + ui.add(egui::ProgressBar::new(frac).desired_width(60.0).desired_height(5.0).fill(color)); + ui.label(RichText::new(format!("{left} с")).size(12.0).color(pal.weak)); + }); + }); + } + if !e.notes.is_empty() { + any = true; + field_label(ui, pal, "Заметки"); + ui.add(egui::Label::new(RichText::new(&e.notes).color(pal.text)).wrap().selectable(true)); + } + if !any { + ui.label(RichText::new("Запись пустая — нажмите «Изменить», чтобы заполнить.").color(pal.weak)); + } + }); + + ui.add_space(10.0); + ui.label( + RichText::new(format!("Создано {} · Изменено {}", format_ms(e.created), format_ms(e.modified))) + .size(12.0) + .color(pal.weak), + ); + if !e.history.is_empty() { + ui.add_space(6.0); + egui::CollapsingHeader::new( + RichText::new(format!("История паролей ({})", e.history.len())).color(pal.weak), + ) + .id_salt(("history", id)) + .show(ui, |ui| { + for h in &e.history { + ui.horizontal(|ui| { + if icon_btn(ui, "🗐", "Копировать").clicked() { + copy = Some((Zeroizing::new(h.password.clone()), "Старый пароль")); + } + let shown = if mv.reveal { h.password.clone() } else { "•".repeat(12) }; + ui.label(RichText::new(shown).monospace().color(pal.text)); + ui.label(RichText::new(format!("до {}", format_ms(h.changed))).size(12.0).color(pal.weak)); + }); + } + }); + } + }); + if let Some((v, what)) = copy { + self.copy(&v, what); + } + } + + fn editor(&mut self, ui: &mut Ui, pal: &Pal, mv: &mut MainView) { + let groups: Vec<(Uuid, String)> = mv.s.vault.groups.iter().map(|g| (g.id, g.name.clone())).collect(); + let gen_opts = self.cfg.generator.clone(); + let mut save = ui.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::S))); + let mut cancel = false; + let ed = mv.editor.as_mut().unwrap(); + + ui.label( + RichText::new(if ed.is_new { "Новая запись" } else { "Редактирование" }) + .font(egui::FontId::new(22.0, bold())) + .color(pal.text), + ); + ui.add_space(10.0); + ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { + frame_card(pal).show(ui, |ui| { + ui.set_width(ui.available_width()); + let d = &mut ed.draft; + field_label(ui, pal, "Название"); + let r = text_field(ui, &mut d.title, "Например, Почта"); + if ed.is_new && d.title.is_empty() && d.username.is_empty() && !ui.ctx().egui_wants_keyboard_input() { + r.request_focus(); + } + field_label(ui, pal, "Логин / e-mail"); + text_field(ui, &mut d.username, "user@example.com"); + field_label(ui, pal, "Пароль"); + let mut generate = false; + password_field(ui, &mut d.password, &mut ed.show_pw, "Пароль", |ui| { + if icon_btn(ui, "🎲", "Сгенерировать надёжный пароль").clicked() { + generate = true; + } + }); + if generate { + d.password = generator::generate(&gen_opts); + ed.show_pw = true; + } + strength_bar(ui, pal, &d.password); + field_label(ui, pal, "Сайт"); + text_field(ui, &mut d.url, "example.com"); + field_label(ui, pal, "Папка"); + let current = groups + .iter() + .find(|g| Some(g.0) == d.group) + .map_or("Без папки".to_owned(), |g| g.1.clone()); + ComboBox::from_id_salt("group").selected_text(current).width(ui.available_width().min(320.0)).show_ui( + ui, + |ui| { + ui.selectable_value(&mut d.group, None, "Без папки"); + for (gid, name) in &groups { + ui.selectable_value(&mut d.group, Some(*gid), name); + } + }, + ); + field_label(ui, pal, "Код 2FA (TOTP) — необязательно"); + text_field(ui, &mut d.totp, "Секрет base32 или ссылка otpauth://…"); + if !d.totp.trim().is_empty() { + match Totp::parse(&d.totp) { + Some(t) => { + ui.label( + RichText::new(format!("✓ Текущий код: {}", t.now().0)).size(12.0).color(pal.success), + ); + } + None => { + ui.label(RichText::new("Не похоже на секрет TOTP").size(12.0).color(pal.warn)); + } + } + } + field_label(ui, pal, "Заметки"); + ui.add( + TextEdit::multiline(&mut d.notes) + .desired_rows(4) + .desired_width(f32::INFINITY) + .margin(Margin::symmetric(10, 8)) + .hint_text("Секретные вопросы, PIN-коды, что угодно — всё шифруется"), + ); + ui.add_space(4.0); + ui.checkbox(&mut d.favorite, "★ В избранном"); + }); + ui.add_space(14.0); + ui.horizontal(|ui| { + if primary(ui, pal, "Сохранить", 140.0).on_hover_text("Ctrl+S").clicked() { + save = true; + } + if secondary(ui, "Отмена", 110.0).on_hover_text("Esc").clicked() { + cancel = true; + } + }); + }); + + if cancel { + mv.editor = None; + } else if save { + let mut ed = mv.editor.take().unwrap(); + let d = &mut ed.draft; + if d.title.trim().is_empty() { + d.title = if !d.url.trim().is_empty() { + display_url(&d.url).split('/').next().unwrap_or_default().to_owned() + } else if !d.username.trim().is_empty() { + d.username.trim().to_owned() + } else { + "Без названия".into() + }; + } + d.totp = d.totp.trim().to_owned(); + let id = d.id; + mv.s.vault.upsert(ed.draft.clone()); + mv.selected = Some(id); + mv.reveal = false; + mv.persist(&mut self.toasts); + self.toasts.ok(if ed.is_new { + "Запись добавлена" + } else { + "Изменения сохранены" + }); + } + } + + fn dialogs(&mut self, ctx: &egui::Context, pal: &Pal, mv: &mut MainView) { + let modal = |id: &str| egui::Modal::new(Id::new(id)).frame(frame_card(pal).inner_margin(Margin::same(22))); + + if let Some(id) = mv.confirm_delete { + let title = mv.s.vault.entry(id).map(|e| e.title.clone()).unwrap_or_default(); + let mut close = false; + let r = modal("del").show(ctx, |ui| { + ui.set_width(340.0); + ui.label(RichText::new(format!("Удалить «{title}»?")).font(egui::FontId::new(17.0, bold()))); + ui.label(RichText::new("Запись исчезнет и на других устройствах после синхронизации.").color(pal.weak)); + ui.add_space(10.0); + ui.horizontal(|ui| { + if danger(ui, pal, "Удалить").clicked() { + mv.s.vault.delete_entry(id); + if mv.selected == Some(id) { + mv.selected = None; + } + mv.persist(&mut self.toasts); + close = true; + } + if secondary(ui, "Отмена", 100.0).clicked() { + close = true; + } + }); + }); + if close || r.should_close() { + mv.confirm_delete = None; + } + } + + if let Some(id) = mv.confirm_delete_group { + let name = mv.s.vault.group_name(Some(id)).unwrap_or_default().to_owned(); + let mut close = false; + let r = modal("delgroup").show(ctx, |ui| { + ui.set_width(340.0); + ui.label(RichText::new(format!("Удалить папку «{name}»?")).font(egui::FontId::new(17.0, bold()))); + ui.label(RichText::new("Записи из неё не удалятся — они останутся без папки.").color(pal.weak)); + ui.add_space(10.0); + ui.horizontal(|ui| { + if danger(ui, pal, "Удалить").clicked() { + mv.s.vault.delete_group(id); + if mv.filter == Filter::Group(id) { + mv.filter = Filter::All; + } + mv.persist(&mut self.toasts); + close = true; + } + if secondary(ui, "Отмена", 100.0).clicked() { + close = true; + } + }); + }); + if close || r.should_close() { + mv.confirm_delete_group = None; + } + } + + if mv.confirm_overwrite { + let mut close = false; + let r = modal("overwrite").show(ctx, |ui| { + ui.set_width(380.0); + ui.label(RichText::new("Перезаписать файл своей версией?").font(egui::FontId::new(17.0, bold()))); + ui.label( + RichText::new( + "Изменения, сделанные на другом устройстве, и новый пароль будут потеряны. \ + Предыдущая версия файла сохранится рядом как .qulyp.bak.", + ) + .color(pal.weak), + ); + ui.add_space(10.0); + ui.horizontal(|ui| { + if danger(ui, pal, "Перезаписать").clicked() { + match mv.s.force_save() { + Ok(()) => { + mv.conflict = false; + mv.dirty = false; + mv.saved_at = Some(MainView::now_label()); + self.toasts.ok("Файл перезаписан"); + } + Err(e) => self.toasts.err(format!("{e:#}")), + } + close = true; + } + if secondary(ui, "Отмена", 100.0).clicked() { + close = true; + } + }); + }); + if close || r.should_close() { + mv.confirm_overwrite = false; + } + } + + if let Some(dlg) = mv.group_dialog.as_mut() { + let (title, name) = match dlg { + GroupDialog::New(n) => ("Новая папка", n), + GroupDialog::Rename(_, n) => ("Переименовать папку", n), + }; + let mut done = false; + let mut close = false; + let r = modal("group").show(ctx, |ui| { + ui.set_width(320.0); + ui.label(RichText::new(title).font(egui::FontId::new(17.0, bold()))); + ui.add_space(6.0); + let r = text_field(ui, name, "Название папки"); + r.request_focus(); + if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + done = true; + } + ui.add_space(8.0); + ui.horizontal(|ui| { + if primary(ui, pal, "Готово", 100.0).clicked() { + done = true; + } + if secondary(ui, "Отмена", 100.0).clicked() { + close = true; + } + }); + }); + if done && !name.trim().is_empty() { + let name = name.trim().to_owned(); + match dlg { + GroupDialog::New(_) => { + let id = mv.s.vault.add_group(&name); + mv.filter = Filter::Group(id); + } + GroupDialog::Rename(id, _) => mv.s.vault.rename_group(*id, &name), + } + mv.persist(&mut self.toasts); + close = true; + } + if close || r.should_close() { + mv.group_dialog = None; + } + } + + if mv.sync.is_some() { + self.sync_dialog(ctx, pal, mv); + } + if mv.pw.is_some() { + self.password_dialog(ctx, pal, mv); + } + } + + fn sync_dialog(&mut self, ctx: &egui::Context, pal: &Pal, mv: &mut MainView) { + let mut submit = false; + let mut close = false; + let dlg = mv.sync.as_mut().unwrap(); + let r = egui::Modal::new(Id::new("sync")).frame(frame_card(pal).inner_margin(Margin::same(22))).show(ctx, |ui| { + ui.set_width(440.0); + ui.label(RichText::new("Синхронизация").font(egui::FontId::new(18.0, bold()))); + ui.label( + RichText::new( + "Объединяет записи с другим файлом .qulyp — копией на флешке, конфликтной копией облака \ + или файлом с другого устройства. Для каждой записи побеждает более свежая версия, \ + старые пароли попадают в историю.", + ) + .size(12.5) + .color(pal.weak), + ); + ui.add_space(4.0); + ui.label( + RichText::new("Автоматически: если файл лежит в облачной папке, Qulyp сам подхватывает изменения каждые 2 секунды.") + .size(12.5) + .color(pal.accent), + ); + ui.add_enabled_ui(!dlg.busy, |ui| { + field_label(ui, pal, "Файл"); + ui.horizontal(|ui| { + ui.add(TextEdit::singleline(&mut dlg.path).desired_width(330.0).margin(Margin::symmetric(10, 7)).hint_text("Путь к файлу")); + if ui.button("Обзор…").clicked() + && let Some(p) = pick_vault_file() { + dlg.path = p.display().to_string(); + } + }); + field_label(ui, pal, "Пароль того файла"); + let r = password_field(ui, &mut dlg.pw, &mut dlg.show, "Пусто — если пароль тот же", |_| {}); + if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + submit = true; + } + if !storage::same_file(std::path::Path::new(dlg.path.trim()), &mv.s.path) { + ui.checkbox(&mut dlg.write_back, "Записать объединённый результат и в тот файл"); + } + }); + if let Some(e) = &dlg.error { + ui.label(RichText::new(e).color(pal.danger).size(13.0)); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + if dlg.busy { + ui.spinner(); + ui.label(RichText::new("Расшифровываем…").color(pal.weak)); + } else { + if primary(ui, pal, "Синхронизировать", 170.0).clicked() { + submit = true; + } + if secondary(ui, "Отмена", 100.0).clicked() { + close = true; + } + } + }); + }); + if (close || r.should_close()) && !dlg.busy { + mv.sync = None; + return; + } + if !submit || dlg.busy { + return; + } + let path = PathBuf::from(dlg.path.trim()); + if dlg.path.trim().is_empty() { + dlg.error = Some("Укажите файл".into()); + return; + } + let write_back = dlg.write_back && !storage::same_file(&path, &mv.s.path); + if dlg.pw.is_empty() { + let result = mv.s.load_with_own_key(&path).map(|v| (v, mv.s.key.clone())); + apply_other(&mut self.toasts, mv, path, write_back, result); + } else { + dlg.busy = true; + dlg.error = None; + let pw = dlg.pw.clone(); + self.spawn(ctx, move || { + let result = storage::load(&path, &pw).map(|(v, k, _)| (v, k)); + Job::OtherLoaded { path, write_back, result } + }); + } + } + + fn password_dialog(&mut self, ctx: &egui::Context, pal: &Pal, mv: &mut MainView) { + let mut submit = false; + let mut close = false; + let dlg = mv.pw.as_mut().unwrap(); + let r = egui::Modal::new(Id::new("pw")).frame(frame_card(pal).inner_margin(Margin::same(22))).show(ctx, |ui| { + ui.set_width(380.0); + ui.label(RichText::new("Смена мастер-пароля").font(egui::FontId::new(18.0, bold()))); + ui.add_enabled_ui(!dlg.busy, |ui| { + let mut show = dlg.show; + field_label(ui, pal, "Текущий пароль"); + password_field(ui, &mut dlg.current, &mut show, "", |_| {}); + field_label(ui, pal, "Новый пароль"); + password_field(ui, &mut dlg.new, &mut show, "Минимум 8 символов", |_| {}); + strength_bar(ui, pal, &dlg.new); + field_label(ui, pal, "Повторите новый"); + password_field(ui, &mut dlg.confirm, &mut show, "", |_| {}); + dlg.show = show; + }); + ui.label( + RichText::new("На других устройствах после синхронизации понадобится новый пароль.") + .size(12.0) + .color(pal.weak), + ); + if let Some(e) = &dlg.error { + ui.label(RichText::new(e).color(pal.danger).size(13.0)); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + if dlg.busy { + ui.spinner(); + ui.label(RichText::new("Создаём новый ключ…").color(pal.weak)); + } else { + if primary(ui, pal, "Сменить", 130.0).clicked() { + submit = true; + } + if secondary(ui, "Отмена", 100.0).clicked() { + close = true; + } + } + }); + }); + if (close || r.should_close()) && !dlg.busy { + mv.pw = None; + return; + } + if !submit || dlg.busy { + return; + } + dlg.error = if dlg.new.chars().count() < MIN_PASSWORD { + Some(format!("Новый пароль должен быть не короче {MIN_PASSWORD} символов")) + } else if *dlg.new != *dlg.confirm { + Some("Новые пароли не совпадают".into()) + } else { + None + }; + if dlg.error.is_some() { + return; + } + dlg.busy = true; + let (cur_pw, new_pw, cur_key) = (dlg.current.clone(), dlg.new.clone(), mv.s.key.clone()); + self.spawn(ctx, move || { + Job::NewKey((|| { + let check = VaultKey::derive(&cur_pw, cur_key.salt, cur_key.params)?; + if !check.same_as(&cur_key) { + bail!("Текущий пароль неверный"); + } + VaultKey::generate(&new_pw) + })()) + }); + } + + fn ui_generator(&mut self, ctx: &egui::Context, pal: &Pal, mv: &mut MainView) { + if !self.gen_open { + return; + } + let mut open = true; + let mut copy = false; + let before = self.cfg.generator.clone(); + egui::Window::new(RichText::new("Генератор паролей").font(egui::FontId::new(16.0, bold()))) + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(380.0) + .anchor(Align2::RIGHT_TOP, vec2(-20.0, 64.0)) + .show(ctx, |ui| { + ui.set_width(380.0); + Frame::new().fill(pal.field).corner_radius(CornerRadius::same(10)).inner_margin(Margin::same(12)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + ui.add( + egui::Label::new( + RichText::new(self.gen_value.as_str()).monospace().size(17.0).color(pal.text), + ) + .wrap() + .selectable(true), + ); + }, + ); + strength_bar(ui, pal, &self.gen_value); + ui.horizontal(|ui| { + if primary(ui, pal, "🗐 Копировать", 0.0).clicked() { + copy = true; + } + if secondary(ui, "🔄 Ещё", 0.0).clicked() { + self.gen_value = Zeroizing::new(generator::generate(&self.cfg.generator)); + } + if let Some(ed) = mv.editor.as_mut() + && secondary(ui, "Вставить в запись", 0.0).clicked() + { + ed.draft.password = self.gen_value.to_string(); + ed.show_pw = true; + } + }); + ui.add_space(6.0); + let o = &mut self.cfg.generator; + ui.horizontal(|ui| { + ui.label("Длина"); + ui.add(egui::Slider::new(&mut o.length, 8..=64)); + }); + ui.horizontal_wrapped(|ui| { + ui.checkbox(&mut o.upper, "A–Z"); + ui.checkbox(&mut o.lower, "a–z"); + ui.checkbox(&mut o.digits, "0–9"); + ui.checkbox(&mut o.symbols, "!@#$"); + }); + ui.checkbox(&mut o.avoid_ambiguous, "Без похожих символов (l, 1, O, 0)"); + }); + if self.cfg.generator != before { + self.gen_value = Zeroizing::new(generator::generate(&self.cfg.generator)); + self.cfg.save(); + } + if copy { + let v = self.gen_value.clone(); + self.copy(&v, "Пароль"); + } + self.gen_open = open; + } + + fn ui_settings(&mut self, ctx: &egui::Context, pal: &Pal) { + if !self.settings_open { + return; + } + let mut open = true; + let before = (self.cfg.theme, self.cfg.auto_lock_min, self.cfg.clipboard_sec, self.cfg.lock_on_minimize); + let mut change_pw = false; + let in_main = matches!(self.screen, Screen::Main(_)); + egui::Window::new(RichText::new("Настройки").font(egui::FontId::new(16.0, bold()))) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(Align2::RIGHT_TOP, vec2(-20.0, 64.0)) + .show(ctx, |ui| { + ui.set_width(360.0); + field_label(ui, pal, "Тема"); + ui.horizontal(|ui| { + ui.selectable_value(&mut self.cfg.theme, ThemeMode::System, "Как в системе"); + ui.selectable_value(&mut self.cfg.theme, ThemeMode::Dark, "Тёмная"); + ui.selectable_value(&mut self.cfg.theme, ThemeMode::Light, "Светлая"); + }); + field_label(ui, pal, "Автоблокировка при бездействии"); + let lock_label = |m: u32| if m == 0 { "Никогда".to_owned() } else { format!("{m} мин") }; + ComboBox::from_id_salt("autolock").selected_text(lock_label(self.cfg.auto_lock_min)).show_ui(ui, |ui| { + for m in [1, 5, 10, 15, 30, 60, 0] { + ui.selectable_value(&mut self.cfg.auto_lock_min, m, lock_label(m)); + } + }); + ui.checkbox(&mut self.cfg.lock_on_minimize, "Блокировать при сворачивании окна"); + field_label(ui, pal, "Очищать буфер обмена через"); + let clip_label = |s: u32| if s == 0 { "Не очищать".to_owned() } else { format!("{s} с") }; + ComboBox::from_id_salt("clip").selected_text(clip_label(self.cfg.clipboard_sec)).show_ui(ui, |ui| { + for s in [10, 20, 30, 60, 0] { + ui.selectable_value(&mut self.cfg.clipboard_sec, s, clip_label(s)); + } + }); + if in_main { + ui.add_space(8.0); + if secondary(ui, "🔑 Сменить мастер-пароль…", 0.0).clicked() { + change_pw = true; + } + } + ui.add_space(8.0); + ui.separator(); + ui.label( + RichText::new(format!( + "Qulyp {} · XChaCha20-Poly1305 + Argon2id (64 МиБ, 3 прохода).\nВсё хранится локально в одном файле.", + env!("CARGO_PKG_VERSION") + )) + .size(12.0) + .color(pal.weak), + ); + }); + if before != (self.cfg.theme, self.cfg.auto_lock_min, self.cfg.clipboard_sec, self.cfg.lock_on_minimize) { + self.cfg.save(); + } + if change_pw { + if let Screen::Main(mv) = &mut self.screen { + mv.pw = Some(PwDialog::default()); + } + open = false; + } + self.settings_open = open; + } +} + +fn start_new(mv: &mut MainView) { + let mut draft = Entry::default(); + match mv.filter { + Filter::Group(g) => draft.group = Some(g), + Filter::Favorites => draft.favorite = true, + Filter::All => {} + } + mv.selected = Some(draft.id); + mv.editor = Some(Editor { draft, is_new: true, show_pw: false }); +} + +fn value_row(ui: &mut Ui, pal: &Pal, label: &str, add: impl FnOnce(&mut Ui)) { + field_label(ui, pal, label); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 30.0), Layout::right_to_left(Align::Center), add); + ui.add_space(6.0); +} + +fn empty_state(ui: &mut Ui, pal: &Pal, vault_empty: bool) { + ui.add_space(ui.available_height() * 0.28); + ui.vertical_centered(|ui| { + logo(ui, 56.0); + ui.add_space(10.0); + if vault_empty { + ui.label(RichText::new("Хранилище пустое").font(egui::FontId::new(20.0, bold())).color(pal.text)); + ui.label(RichText::new("Нажмите «Новая запись» или Ctrl+N, чтобы добавить первый пароль.").color(pal.weak)); + } else { + ui.label(RichText::new("Выберите запись").font(egui::FontId::new(20.0, bold())).color(pal.text)); + ui.label( + RichText::new("↑ ↓ — навигация · Ctrl+C — пароль · Ctrl+B — логин · двойной клик — копировать пароль") + .color(pal.weak), + ); + } + ui.add_space(4.0); + ui.label( + RichText::new("Ctrl+F — поиск · Ctrl+G — генератор · Ctrl+L — заблокировать") + .size(12.0) + .color(Color32::GRAY), + ); + }); +} + +#[cfg(feature = "dev-shots")] +impl QulypApp { + /// Выполнить шаг dev-сценария (см. `devshots.rs`). + fn dev_step(&mut self, ctx: &egui::Context) { + let Some(step) = self.dev.as_mut().and_then(|d| d.tick(ctx)) else { return }; + let (cmd, arg) = step.split_once(':').unwrap_or((step.as_str(), "")); + let arg = arg.to_owned(); + match (cmd, &mut self.screen) { + ("unlock", Screen::Unlock(f)) => { + f.busy = true; + let path = f.path.clone(); + self.spawn(ctx, move || Job::Opened(Session::open(path, &arg))); + } + ("theme", _) => self.cfg.theme = if arg == "light" { ThemeMode::Light } else { ThemeMode::Dark }, + ("welcome", _) => self.screen = Screen::Welcome, + ("create", _) => self.screen = Screen::Create(CreateForm::new()), + ("settings", _) => self.settings_open = !self.settings_open, + ("gen", _) => self.gen_open = !self.gen_open, + ("lock", Screen::Main(_)) => { + if let Screen::Main(mut mv) = std::mem::replace(&mut self.screen, Screen::Welcome) { + self.screen = self.lock(&mut mv); + } + } + (_, Screen::Main(mv)) => match cmd { + "select" => { + let ids = mv.visible(); + mv.selected = ids.get(arg.parse::().unwrap_or(0)).copied(); + mv.editor = None; + } + "reveal" => mv.reveal = true, + "search" => mv.search = arg, + "fav" => mv.filter = Filter::Favorites, + "all" => mv.filter = Filter::All, + "group" => { + let mut g: Vec<_> = mv.s.vault.groups.iter().map(|g| (g.name.to_lowercase(), g.id)).collect(); + g.sort(); + if let Some((_, id)) = g.get(arg.parse::().unwrap_or(0)) { + mv.filter = Filter::Group(*id); + } + } + "edit" => { + if let Some(e) = mv.selected.and_then(|id| mv.s.vault.entry(id)).cloned() { + mv.editor = Some(Editor { draft: e, is_new: false, show_pw: false }); + } + } + "new" => start_new(mv), + "sync" => { + mv.sync = Some(SyncDialog { + path: String::new(), + pw: secret(), + show: false, + write_back: true, + error: None, + busy: false, + }) + } + "delete" => mv.confirm_delete = mv.selected, + "conflict" => mv.conflict = true, + "close" => { + mv.sync = None; + mv.confirm_delete = None; + mv.editor = None; + mv.conflict = false; + } + "glyphs" => { + let font = egui::FontId::proportional(14.0); + for c in arg.chars() { + let ok = ctx.fonts_mut(|f| f.has_glyph(&font, c)); + eprintln!("[dev] glyph {c} U+{:04X} {}", c as u32, if ok { "ok" } else { "MISSING" }); + } + } + _ => eprintln!("[dev] unknown step {step}"), + }, + _ => eprintln!("[dev] step {step} not applicable"), + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a832ad0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,88 @@ +//! Настройки приложения (не секретные): недавние файлы, тема, таймеры. +//! Windows: %APPDATA%\Qulyp\config.json, Linux: ~/.config/qulyp/config.json. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::generator::GenOptions; + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Default)] +pub enum ThemeMode { + #[default] + System, + Dark, + Light, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(default)] +pub struct Config { + pub recent: Vec, + pub theme: ThemeMode, + /// Автоблокировка при бездействии, минут (0 — никогда). + pub auto_lock_min: u32, + /// Очистка буфера обмена после копирования, секунд (0 — не очищать). + pub clipboard_sec: u32, + pub lock_on_minimize: bool, + pub generator: GenOptions, +} + +impl Default for Config { + fn default() -> Self { + Self { + recent: Vec::new(), + theme: ThemeMode::System, + auto_lock_min: 10, + clipboard_sec: 20, + lock_on_minimize: false, + generator: GenOptions::default(), + } + } +} + +fn path() -> Option { + // Переопределение — для тестов и «портативного» режима. + if let Some(p) = std::env::var_os("QULYP_CONFIG") { + return Some(PathBuf::from(p)); + } + let dir = if cfg!(windows) { "Qulyp" } else { "qulyp" }; + Some(dirs::config_dir()?.join(dir).join("config.json")) +} + +impl Config { + pub fn load() -> Self { + path().and_then(|p| std::fs::read(p).ok()).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + } + + pub fn save(&self) { + let Some(p) = path() else { return }; + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(json) = serde_json::to_vec_pretty(self) { + let _ = std::fs::write(p, json); + } + } + + pub fn push_recent(&mut self, file: PathBuf) { + let file = file.canonicalize().map(strip_unc).unwrap_or(file); + self.recent.retain(|p| p != &file); + self.recent.insert(0, file); + self.recent.truncate(8); + self.save(); + } + + pub fn default_vault_dir() -> PathBuf { + dirs::document_dir().or_else(dirs::home_dir).unwrap_or_else(|| PathBuf::from(".")) + } +} + +/// `canonicalize` на Windows возвращает `\\?\C:\...` — убираем префикс для читаемости. +fn strip_unc(p: PathBuf) -> PathBuf { + let s = p.to_string_lossy(); + match s.strip_prefix(r"\\?\") { + Some(rest) if !rest.starts_with("UNC") => PathBuf::from(rest), + _ => p, + } +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..5b293a6 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,172 @@ +//! Формат файла и шифрование. +//! +//! Файл `.qulyp`: +//! ```text +//! magic 8 байт "QULYPv1\0" +//! m_kib 4 байта LE — память Argon2id, КиБ +//! t 4 байта LE — число проходов +//! p 4 байта LE — параллелизм +//! salt 16 байт +//! nonce 24 байта — XChaCha20-Poly1305 +//! data ... — зашифрованный JSON хранилища + 16 байт тега +//! ``` +//! Весь заголовок передаётся как AAD, поэтому его подмена ломает расшифровку. + +use anyhow::{Result, anyhow, bail}; +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use rand::RngCore; +use rand::rngs::OsRng; +use zeroize::Zeroizing; + +pub const MAGIC: &[u8; 8] = b"QULYPv1\0"; +pub const HEADER_LEN: usize = 8 + 12 + 16 + 24; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KdfParams { + pub m_kib: u32, + pub t: u32, + pub p: u32, +} + +impl Default for KdfParams { + fn default() -> Self { + // 64 МиБ, 3 прохода — примерно 0.3–0.7 с на обычном ПК. + Self { m_kib: 64 * 1024, t: 3, p: 4 } + } +} + +impl KdfParams { + fn validate(&self) -> Result<()> { + // Защита от файлов с абсурдными параметрами (DoS при открытии). + if !(8 * 1024..=2 * 1024 * 1024).contains(&self.m_kib) + || !(1..=64).contains(&self.t) + || !(1..=16).contains(&self.p) + { + bail!("Недопустимые параметры KDF в файле"); + } + Ok(()) + } +} + +pub struct Header { + pub params: KdfParams, + pub salt: [u8; 16], +} + +pub fn read_header(data: &[u8]) -> Result
{ + if data.len() < HEADER_LEN + 16 || &data[..8] != MAGIC { + bail!("Это не файл хранилища Qulyp"); + } + let u32_at = |i: usize| u32::from_le_bytes(data[i..i + 4].try_into().unwrap()); + let params = KdfParams { m_kib: u32_at(8), t: u32_at(12), p: u32_at(16) }; + params.validate()?; + let mut salt = [0u8; 16]; + salt.copy_from_slice(&data[20..36]); + Ok(Header { params, salt }) +} + +/// Ключ, полученный из мастер-пароля. Держится в памяти, пока хранилище открыто. +#[derive(Clone)] +pub struct VaultKey { + key: Zeroizing<[u8; 32]>, + pub salt: [u8; 16], + pub params: KdfParams, +} + +impl VaultKey { + pub fn derive(password: &str, salt: [u8; 16], params: KdfParams) -> Result { + params.validate()?; + let p = Params::new(params.m_kib, params.t, params.p, Some(32)).map_err(|e| anyhow!("Argon2: {e}"))?; + let mut key = Zeroizing::new([0u8; 32]); + Argon2::new(Algorithm::Argon2id, Version::V0x13, p) + .hash_password_into(password.as_bytes(), &salt, key.as_mut()) + .map_err(|e| anyhow!("Argon2: {e}"))?; + Ok(Self { key, salt, params }) + } + + /// Новый ключ со свежей случайной солью. + pub fn generate(password: &str) -> Result { + let mut salt = [0u8; 16]; + OsRng.fill_bytes(&mut salt); + Self::derive(password, salt, KdfParams::default()) + } + + /// Подходит ли этот ключ к файлу с данным заголовком (та же соль и параметры). + pub fn matches(&self, h: &Header) -> bool { + self.salt == h.salt && self.params == h.params + } + + /// Сравнение ключей без раннего выхода. + pub fn same_as(&self, other: &VaultKey) -> bool { + let mut diff = 0u8; + for (a, b) in self.key.iter().zip(other.key.iter()) { + diff |= a ^ b; + } + diff == 0 && self.salt == other.salt && self.params == other.params + } +} + +pub fn encrypt(plain: &[u8], key: &VaultKey) -> Result> { + let mut nonce = [0u8; 24]; + OsRng.fill_bytes(&mut nonce); + + let mut out = Vec::with_capacity(HEADER_LEN + plain.len() + 16); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&key.params.m_kib.to_le_bytes()); + out.extend_from_slice(&key.params.t.to_le_bytes()); + out.extend_from_slice(&key.params.p.to_le_bytes()); + out.extend_from_slice(&key.salt); + out.extend_from_slice(&nonce); + + let cipher = XChaCha20Poly1305::new(Key::from_slice(key.key.as_slice())); + let ct = cipher + .encrypt(XNonce::from_slice(&nonce), Payload { msg: plain, aad: &out[..HEADER_LEN] }) + .map_err(|_| anyhow!("Ошибка шифрования"))?; + out.extend_from_slice(&ct); + Ok(out) +} + +pub fn decrypt(data: &[u8], key: &VaultKey) -> Result>> { + let header = read_header(data)?; + if !key.matches(&header) { + bail!("Ключ не подходит к файлу"); + } + let nonce = XNonce::from_slice(&data[36..HEADER_LEN]); + let cipher = XChaCha20Poly1305::new(Key::from_slice(key.key.as_slice())); + let plain = cipher + .decrypt(nonce, Payload { msg: &data[HEADER_LEN..], aad: &data[..HEADER_LEN] }) + .map_err(|_| anyhow!("Неверный пароль или файл повреждён"))?; + Ok(Zeroizing::new(plain)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fast_key(pw: &str) -> VaultKey { + VaultKey::derive(pw, [7; 16], KdfParams { m_kib: 8 * 1024, t: 1, p: 1 }).unwrap() + } + + #[test] + fn roundtrip() { + let k = fast_key("secret"); + let ct = encrypt(b"hello", &k).unwrap(); + assert_eq!(&decrypt(&ct, &k).unwrap()[..], b"hello"); + } + + #[test] + fn wrong_password_fails() { + let ct = encrypt(b"hello", &fast_key("a")).unwrap(); + assert!(decrypt(&ct, &fast_key("b")).is_err()); + } + + #[test] + fn tampered_header_fails() { + let k = fast_key("a"); + let mut ct = encrypt(b"hello", &k).unwrap(); + ct[40] ^= 1; // nonce + assert!(decrypt(&ct, &k).is_err()); + } +} diff --git a/src/devshots.rs b/src/devshots.rs new file mode 100644 index 0000000..d56b077 --- /dev/null +++ b/src/devshots.rs @@ -0,0 +1,84 @@ +//! Только для разработки (feature `dev-shots`): сценарий действий и скриншоты +//! окна изнутри приложения — без эмуляции системных нажатий клавиш. +//! +//! ```text +//! QULYP_CONFIG=tmp/config.json QULYP_DEV_DIR=tmp QULYP_DEV_STEPS="unlock:demo-password;shot:main;select:0;shot:details" \ +//! cargo run --features dev-shots -- tmp/demo.qulyp +//! ``` + +use std::collections::VecDeque; +use std::path::PathBuf; + +use eframe::egui::{self, Event, UserData, ViewportCommand}; + +pub struct DevScript { + steps: VecDeque, + dir: PathBuf, + wait: u32, + pending_shot: Option, +} + +impl DevScript { + pub fn from_env() -> Option { + let steps = std::env::var("QULYP_DEV_STEPS").ok()?; + let dir = PathBuf::from(std::env::var("QULYP_DEV_DIR").unwrap_or_else(|_| ".".into())); + Some(Self { + steps: steps.split(';').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(), + dir, + wait: 20, + pending_shot: None, + }) + } + + /// Вызывается каждый кадр. Возвращает следующий шаг, который должно выполнить приложение. + pub fn tick(&mut self, ctx: &egui::Context) -> Option { + ctx.request_repaint(); + if let Some(name) = self.pending_shot.clone() { + let img = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + }); + if let Some(img) = img { + let path = self.dir.join(format!("{name}.png")); + save_png(&path, &img); + eprintln!("[dev] saved {}", path.display()); + self.pending_shot = None; + self.wait = 3; + } + return None; + } + if self.wait > 0 { + self.wait -= 1; + return None; + } + let step = match self.steps.pop_front() { + Some(s) => s, + None => { + ctx.send_viewport_cmd(ViewportCommand::Close); + return None; + } + }; + if let Some(name) = step.strip_prefix("shot:") { + ctx.send_viewport_cmd(ViewportCommand::Screenshot(UserData::default())); + self.pending_shot = Some(name.to_owned()); + return None; + } + if let Some(n) = step.strip_prefix("wait:") { + self.wait = n.parse().unwrap_or(10); + return None; + } + self.wait = 12; + Some(step) + } +} + +fn save_png(path: &std::path::Path, img: &egui::ColorImage) { + let file = std::fs::File::create(path).expect("create png"); + let mut enc = png::Encoder::new(std::io::BufWriter::new(file), img.size[0] as u32, img.size[1] as u32); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let data: Vec = img.pixels.iter().flat_map(|c| c.to_array()).collect(); + enc.write_header().and_then(|mut w| w.write_image_data(&data)).expect("write png"); +} diff --git a/src/generator.rs b/src/generator.rs new file mode 100644 index 0000000..63f2a6e --- /dev/null +++ b/src/generator.rs @@ -0,0 +1,127 @@ +//! Генератор паролей и оценка их стойкости. + +use rand::Rng; +use rand::rngs::OsRng; +use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; + +const LOWER: &str = "abcdefghijklmnopqrstuvwxyz"; +const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS: &str = "0123456789"; +const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.?/~"; +const AMBIGUOUS: &str = "Il1O0o|"; + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[serde(default)] +pub struct GenOptions { + pub length: usize, + pub lower: bool, + pub upper: bool, + pub digits: bool, + pub symbols: bool, + pub avoid_ambiguous: bool, +} + +impl Default for GenOptions { + fn default() -> Self { + Self { length: 20, lower: true, upper: true, digits: true, symbols: true, avoid_ambiguous: true } + } +} + +pub fn generate(o: &GenOptions) -> String { + let pick = + |set: &str| -> Vec { set.chars().filter(|c| !o.avoid_ambiguous || !AMBIGUOUS.contains(*c)).collect() }; + let mut sets: Vec> = [(o.lower, LOWER), (o.upper, UPPER), (o.digits, DIGITS), (o.symbols, SYMBOLS)] + .into_iter() + .filter(|(on, _)| *on) + .map(|(_, s)| pick(s)) + .collect(); + if sets.is_empty() { + sets.push(pick(LOWER)); + } + let all: Vec = sets.concat(); + let len = o.length.clamp(4, 128); + let mut rng = OsRng; + + // По одному символу из каждого включённого набора, остальное — из общего. + let mut out: Vec = sets.iter().map(|s| s[rng.gen_range(0..s.len())]).collect(); + while out.len() < len { + out.push(all[rng.gen_range(0..all.len())]); + } + out.truncate(len); + out.shuffle(&mut rng); + out.into_iter().collect() +} + +#[derive(Clone, Copy)] +pub struct Strength { + /// 0 — очень слабый … 4 — отличный. + pub score: u8, + pub bits: f64, +} + +impl Strength { + pub fn label(&self) -> &'static str { + ["Очень слабый", "Слабый", "Средний", "Хороший", "Отличный"][self.score as usize] + } +} + +/// Грубая оценка энтропии: размер алфавита × длина, со штрафом за повторы. +pub fn strength(pw: &str) -> Strength { + if pw.is_empty() { + return Strength { score: 0, bits: 0.0 }; + } + let mut pool = 0u32; + if pw.chars().any(|c| c.is_ascii_lowercase()) { + pool += 26; + } + if pw.chars().any(|c| c.is_ascii_uppercase()) { + pool += 26; + } + if pw.chars().any(|c| c.is_ascii_digit()) { + pool += 10; + } + if pw.chars().any(|c| c.is_ascii_punctuation() || c == ' ') { + pool += 33; + } + if !pw.is_ascii() { + pool += 66; // кириллица и прочее + } + let mut unique: Vec = pw.chars().collect(); + unique.sort_unstable(); + unique.dedup(); + let len = pw.chars().count() as f64; + // Повторяющиеся символы почти не добавляют стойкости. + let effective = (unique.len() as f64).max(len * 0.5).min(len); + let bits = effective * (pool.max(1) as f64).log2(); + let score = match bits { + b if b < 28.0 => 0, + b if b < 40.0 => 1, + b if b < 60.0 => 2, + b if b < 80.0 => 3, + _ => 4, + }; + Strength { score, bits } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn respects_options() { + let o = GenOptions { length: 32, symbols: false, ..Default::default() }; + for _ in 0..50 { + let p = generate(&o); + assert_eq!(p.chars().count(), 32); + assert!(p.chars().all(|c| c.is_ascii_alphanumeric())); + assert!(p.chars().any(|c| c.is_ascii_digit())); + assert!(!p.chars().any(|c| AMBIGUOUS.contains(c))); + } + } + + #[test] + fn strength_ordering() { + assert!(strength("123456").score < strength("Tr0ub4dor&3xK!9qZ").score); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6cf5ec0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,32 @@ +// В релизной сборке на Windows не показывать консольное окно. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod app; +mod config; +mod crypto; +#[cfg(feature = "dev-shots")] +mod devshots; +mod generator; +mod storage; +mod theme; +mod totp; +mod vault; +mod widgets; + +use eframe::egui; + +fn main() -> eframe::Result<()> { + // Путь к хранилищу можно передать аргументом (ассоциация файлов .qulyp). + let open = std::env::args_os().nth(1).map(std::path::PathBuf::from).filter(|p| p.is_file()); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("Qulyp") + .with_app_id("qulyp") + .with_inner_size([1140.0, 740.0]) + .with_min_inner_size([860.0, 540.0]) + .with_icon(theme::app_icon()), + ..Default::default() + }; + eframe::run_native("Qulyp", options, Box::new(|cc| Ok(Box::new(app::QulypApp::new(cc, open))))) +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..42432fd --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,304 @@ +//! Открытое хранилище: чтение/запись файла и синхронизация. +//! +//! Синхронизация рассчитана на «папку в облаке» (Dropbox, OneDrive, Google Drive, +//! Syncthing, Nextcloud…): если файл на диске изменился извне, перед записью +//! он расшифровывается тем же ключом и сливается с текущей версией. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use anyhow::{Context, Result, anyhow}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::crypto::{self, VaultKey}; +use crate::vault::{MergeReport, Vault}; + +/// Файл на диске зашифрован другим мастер-паролем (сменили на другом устройстве). +#[derive(Debug)] +pub struct ForeignKey; + +impl std::fmt::Display for ForeignKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Файл хранилища изменён с другим мастер-паролем") + } +} + +impl std::error::Error for ForeignKey {} + +#[derive(Clone, Copy, PartialEq)] +struct Stamp { + len: u64, + mtime: Option, +} + +fn stamp(path: &Path) -> Option { + let m = fs::metadata(path).ok()?; + Some(Stamp { len: m.len(), mtime: m.modified().ok() }) +} + +fn hash(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +fn parse_vault(plain: &[u8]) -> Result { + serde_json::from_slice(plain).context("Не удалось прочитать содержимое хранилища") +} + +fn serialize(vault: &Vault, key: &VaultKey) -> Result> { + let plain = Zeroizing::new(serde_json::to_vec(vault)?); + crypto::encrypt(&plain, key) +} + +/// Атомарная запись: старая версия → `.bak`, новая пишется во временный файл +/// и переименовывается поверх. Сбой посреди записи не портит хранилище. +pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("qulyp.tmp"); + { + let mut f = fs::File::create(&tmp).with_context(|| format!("Не удалось записать {}", tmp.display()))?; + f.write_all(data)?; + f.sync_all()?; + } + if path.exists() { + let _ = fs::copy(path, path.with_extension("qulyp.bak")); + } + fs::rename(&tmp, path).with_context(|| format!("Не удалось сохранить {}", path.display()))?; + Ok(()) +} + +/// Прочитать и расшифровать файл паролем. Медленно (Argon2) — вызывать не из UI-потока. +pub fn load(path: &Path, password: &str) -> Result<(Vault, VaultKey, Vec)> { + let bytes = fs::read(path).with_context(|| format!("Не удалось открыть {}", path.display()))?; + let header = crypto::read_header(&bytes)?; + let key = VaultKey::derive(password, header.salt, header.params)?; + let plain = crypto::decrypt(&bytes, &key)?; + Ok((parse_vault(&plain)?, key, bytes)) +} + +pub fn same_file(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +pub struct Session { + pub path: PathBuf, + pub key: VaultKey, + pub vault: Vault, + disk_stamp: Option, + disk_hash: [u8; 32], +} + +impl Session { + pub fn open(path: PathBuf, password: &str) -> Result { + let (vault, key, bytes) = load(&path, password)?; + Ok(Self { disk_stamp: stamp(&path), disk_hash: hash(&bytes), path, key, vault }) + } + + pub fn create(path: PathBuf, name: &str, password: &str) -> Result { + let key = VaultKey::generate(password)?; + let mut s = Self { path, key, vault: Vault::new(name), disk_stamp: None, disk_hash: [0; 32] }; + s.write()?; + Ok(s) + } + + pub fn file_name(&self) -> String { + self.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() + } + + fn write(&mut self) -> Result<()> { + self.vault.normalize(); + let data = serialize(&self.vault, &self.key)?; + write_atomic(&self.path, &data)?; + self.disk_stamp = stamp(&self.path); + self.disk_hash = hash(&data); + Ok(()) + } + + /// Байты файла, если он изменился с момента последнего чтения/записи. + fn changed_on_disk(&mut self) -> Result>> { + let now = stamp(&self.path); + if now.is_none() || now == self.disk_stamp { + return Ok(None); + } + let bytes = fs::read(&self.path)?; + if hash(&bytes) == self.disk_hash { + self.disk_stamp = now; // тронули mtime, но содержимое то же + return Ok(None); + } + Ok(Some(bytes)) + } + + fn decrypt_foreign(&self, bytes: &[u8]) -> Result { + let header = crypto::read_header(bytes)?; + if !self.key.matches(&header) { + return Err(ForeignKey.into()); + } + parse_vault(&crypto::decrypt(bytes, &self.key)?) + } + + /// Сохранить, предварительно влив изменения, сделанные в файле извне. + pub fn save(&mut self) -> Result { + let mut report = MergeReport::default(); + if let Some(bytes) = self.changed_on_disk()? { + let other = self.decrypt_foreign(&bytes)?; + report = self.vault.merge(&other); + } + self.write()?; + Ok(report) + } + + /// Перезаписать файл текущей версией без слияния. + pub fn force_save(&mut self) -> Result<()> { + self.write() + } + + /// Проверить файл на внешние изменения и подтянуть их. + /// `Ok(None)` — ничего не менялось. + pub fn poll(&mut self) -> Result> { + let Some(bytes) = self.changed_on_disk()? else { return Ok(None) }; + let mut other = self.decrypt_foreign(&bytes)?; + let report = self.vault.merge(&other); + other.normalize(); + if self.vault == other { + self.disk_stamp = stamp(&self.path); + self.disk_hash = hash(&bytes); + } else { + // У нас есть что-то, чего нет в файле, — записываем объединённую версию. + self.write()?; + } + Ok(Some(report)) + } + + /// Слить с другим файлом, уже расшифрованным его ключом. + /// Если это наш же файл (сменили пароль на другом устройстве) — переходим на его ключ. + pub fn merge_other( + &mut self, + other: &Vault, + other_path: &Path, + other_key: &VaultKey, + write_back: bool, + ) -> Result { + let report = self.vault.merge(other); + if same_file(other_path, &self.path) { + self.key = other_key.clone(); + self.write()?; + } else { + self.save()?; + if write_back { + write_atomic(other_path, &serialize(&self.vault, other_key)?)?; + } + } + Ok(report) + } + + /// Прочитать другой файл нашим ключом (когда у копии тот же пароль и соль). + pub fn load_with_own_key(&self, path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("Не удалось открыть {}", path.display()))?; + self.decrypt_foreign(&bytes).map_err(|e| { + if e.is::() { + anyhow!("У этого файла другой пароль — введите его") + } else { + e + } + }) + } + + pub fn change_key(&mut self, key: VaultKey) -> Result<()> { + self.save()?; + self.key = key; + self.write() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::Entry; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("qulyp-test-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d.join("v.qulyp") + } + + #[test] + fn two_devices_sync_through_one_file() { + let path = tmp("sync"); + let mut a = Session::create(path.clone(), "t", "pw").unwrap(); + let mut b = Session::open(path.clone(), "pw").unwrap(); + + let mut e = Entry::default(); + e.title = "from A".into(); + a.vault.upsert(e); + a.save().unwrap(); + + let mut e = Entry::default(); + e.title = "from B".into(); + b.vault.upsert(e); + // B не видел изменений A — при сохранении должен их влить, а не затереть. + let r = b.save().unwrap(); + assert_eq!(r.added, 1); + assert_eq!(b.vault.entries.len(), 2); + + let r = a.poll().unwrap().unwrap(); + assert_eq!(r.added, 1); + assert_eq!(a.vault.entries.len(), 2); + + let reopened = Session::open(path, "pw").unwrap(); + assert_eq!(reopened.vault.entries.len(), 2); + } + + /// Демо-хранилище для ручной проверки UI (пароль `demo-password`): + /// `QULYP_DEMO=путь.qulyp cargo test make_demo -- --ignored` + #[test] + #[ignore] + fn make_demo() { + let path = PathBuf::from(std::env::var("QULYP_DEMO").expect("укажите QULYP_DEMO")); + let _ = fs::remove_file(&path); + let mut s = Session::create(path, "Личное", "demo-password").unwrap(); + let work = s.vault.add_group("Работа"); + let social = s.vault.add_group("Соцсети"); + let items = [ + ("GitHub", "dev@example.com", "gh_Tq8#vLz2!mPx", "github.com", Some(work), true, "JBSWY3DPEHPK3PXP"), + ("Почта Gmail", "me@gmail.com", "Kx9$wQ2mZr7!", "mail.google.com", None, true, ""), + ("Jira", "ivanov", "qwerty123", "company.atlassian.net", Some(work), false, ""), + ("Telegram", "+7 700 000 00 00", "Tg-2026-secure!", "web.telegram.org", Some(social), false, ""), + ("Instagram", "my.photos", "InstaPass!42", "instagram.com", Some(social), false, ""), + ("Wi-Fi дома", "", "HomeNet_5G_2026", "", None, false, ""), + ("Kaspi", "+7 701 111 22 33", "K@spi!Bank#77", "kaspi.kz", None, true, ""), + ]; + for (t, u, p, url, g, fav, totp) in items { + let mut e = Entry::default(); + e.title = t.into(); + e.username = u.into(); + e.password = p.into(); + e.url = url.into(); + e.group = g; + e.favorite = fav; + e.totp = totp.into(); + if t == "Wi-Fi дома" { + e.notes = "Роутер в прихожей, админка 192.168.1.1".into(); + } + s.vault.upsert(e); + } + s.save().unwrap(); + } + + #[test] + fn password_change_elsewhere_is_detected() { + let path = tmp("foreign"); + let mut a = Session::create(path.clone(), "t", "old").unwrap(); + let mut b = Session::open(path.clone(), "old").unwrap(); + b.change_key(VaultKey::generate("new").unwrap()).unwrap(); + let err = a.poll().unwrap_err(); + assert!(err.is::()); + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..9f488a0 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,232 @@ +//! Цвета, стиль виджетов, шрифты и иконка окна. + +use std::sync::Arc; + +use eframe::egui::{ + self, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, IconData, Margin, Shadow, Stroke, + Style, TextStyle, Theme, Visuals, vec2, +}; + +#[derive(Clone, Copy)] +pub struct Pal { + pub bg: Color32, + pub sidebar: Color32, + pub list: Color32, + pub card: Color32, + pub field: Color32, + pub border: Color32, + pub text: Color32, + pub weak: Color32, + pub accent: Color32, + pub accent_soft: Color32, + pub hover: Color32, + pub danger: Color32, + pub success: Color32, + pub warn: Color32, +} + +pub const ACCENT: Color32 = Color32::from_rgb(0x5B, 0x8C, 0xFF); + +pub fn pal(dark: bool) -> Pal { + if dark { + Pal { + bg: Color32::from_rgb(0x12, 0x14, 0x19), + sidebar: Color32::from_rgb(0x0E, 0x10, 0x14), + list: Color32::from_rgb(0x16, 0x19, 0x1F), + card: Color32::from_rgb(0x1C, 0x20, 0x28), + field: Color32::from_rgb(0x23, 0x27, 0x31), + border: Color32::from_rgb(0x2A, 0x2F, 0x3A), + text: Color32::from_rgb(0xE8, 0xEA, 0xF0), + weak: Color32::from_rgb(0x8A, 0x91, 0xA2), + accent: ACCENT, + accent_soft: Color32::from_rgba_unmultiplied(0x5B, 0x8C, 0xFF, 40), + hover: Color32::from_rgb(0x20, 0x24, 0x2D), + danger: Color32::from_rgb(0xFF, 0x5C, 0x6C), + success: Color32::from_rgb(0x3C, 0xCB, 0x7F), + warn: Color32::from_rgb(0xF5, 0xB9, 0x42), + } + } else { + Pal { + bg: Color32::from_rgb(0xF6, 0xF7, 0xF9), + sidebar: Color32::from_rgb(0xEC, 0xEE, 0xF2), + list: Color32::from_rgb(0xFB, 0xFB, 0xFC), + card: Color32::WHITE, + field: Color32::from_rgb(0xF0, 0xF2, 0xF5), + border: Color32::from_rgb(0xDD, 0xE1, 0xE7), + text: Color32::from_rgb(0x1C, 0x1F, 0x26), + weak: Color32::from_rgb(0x6B, 0x72, 0x80), + accent: Color32::from_rgb(0x3B, 0x6E, 0xF0), + accent_soft: Color32::from_rgba_unmultiplied(0x3B, 0x6E, 0xF0, 30), + hover: Color32::from_rgb(0xE9, 0xEC, 0xF1), + danger: Color32::from_rgb(0xE0, 0x3A, 0x4B), + success: Color32::from_rgb(0x1F, 0xA8, 0x62), + warn: Color32::from_rgb(0xC9, 0x8A, 0x10), + } + } +} + +pub fn bold() -> FontFamily { + FontFamily::Name("bold".into()) +} + +fn visuals(dark: bool) -> Visuals { + let p = pal(dark); + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; + v.panel_fill = p.bg; + v.window_fill = p.card; + v.faint_bg_color = p.list; + v.extreme_bg_color = p.field; + v.text_edit_bg_color = Some(p.field); + v.window_stroke = Stroke::new(1.0, p.border); + v.window_corner_radius = CornerRadius::same(14); + v.menu_corner_radius = CornerRadius::same(10); + v.window_shadow = + Shadow { offset: [0, 10], blur: 30, spread: 0, color: Color32::from_black_alpha(if dark { 110 } else { 40 }) }; + v.popup_shadow = + Shadow { offset: [0, 6], blur: 18, spread: 0, color: Color32::from_black_alpha(if dark { 90 } else { 30 }) }; + v.hyperlink_color = p.accent; + v.selection.bg_fill = p.accent.gamma_multiply(0.45); + v.selection.stroke = Stroke::new(1.0, p.accent); + v.error_fg_color = p.danger; + v.warn_fg_color = p.warn; + + let r = CornerRadius::same(8); + let w = &mut v.widgets; + for s in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + s.corner_radius = r; + s.expansion = 0.0; + } + w.noninteractive.bg_stroke = Stroke::new(1.0, p.border); + w.noninteractive.fg_stroke = Stroke::new(1.0, p.text); + w.inactive.weak_bg_fill = p.field; + w.inactive.bg_fill = p.field; + w.inactive.bg_stroke = Stroke::new(1.0, p.border); + w.inactive.fg_stroke = Stroke::new(1.0, p.text); + w.hovered.weak_bg_fill = p.hover; + w.hovered.bg_fill = p.hover; + w.hovered.bg_stroke = Stroke::new(1.0, p.accent.gamma_multiply(0.6)); + w.hovered.fg_stroke = Stroke::new(1.5, p.text); + w.active.weak_bg_fill = p.accent_soft; + w.active.bg_fill = p.accent_soft; + w.active.bg_stroke = Stroke::new(1.0, p.accent); + w.active.fg_stroke = Stroke::new(1.5, p.text); + w.open.weak_bg_fill = p.hover; + v +} + +pub fn install(ctx: &egui::Context) { + install_fonts(ctx); + for (theme, dark) in [(Theme::Dark, true), (Theme::Light, false)] { + let mut style = Style::default(); + style.visuals = visuals(dark); + style.spacing.item_spacing = vec2(8.0, 8.0); + style.spacing.button_padding = vec2(12.0, 6.0); + style.spacing.interact_size.y = 30.0; + style.spacing.window_margin = Margin::same(18); + style.spacing.menu_margin = Margin::same(8); + style.spacing.scroll.bar_width = 8.0; + style.spacing.scroll.floating = true; + style.text_styles = [ + (TextStyle::Small, FontId::proportional(12.0)), + (TextStyle::Body, FontId::proportional(14.5)), + (TextStyle::Button, FontId::proportional(14.5)), + (TextStyle::Heading, FontId::new(22.0, bold())), + (TextStyle::Monospace, FontId::monospace(14.0)), + ] + .into(); + ctx.set_style_of(theme, style); + } +} + +/// Системные шрифты с кириллицей, если есть; иначе — встроенные в egui. +fn install_fonts(ctx: &egui::Context) { + const REGULAR: &[&str] = &[ + "C:\\Windows\\Fonts\\segoeui.ttf", + "/usr/share/fonts/truetype/inter/Inter-Regular.ttf", + "/usr/share/fonts/inter/Inter-Regular.otf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/google-noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + ]; + const BOLD: &[&str] = &[ + "C:\\Windows\\Fonts\\seguisb.ttf", + "/usr/share/fonts/truetype/inter/Inter-SemiBold.ttf", + "/usr/share/fonts/inter/Inter-SemiBold.otf", + "/usr/share/fonts/truetype/noto/NotoSans-SemiBold.ttf", + "/usr/share/fonts/noto/NotoSans-SemiBold.ttf", + "/usr/share/fonts/google-noto/NotoSans-SemiBold.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + ]; + const MONO: &[&str] = &[ + "C:\\Windows\\Fonts\\consola.ttf", + "/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + ]; + let first = |list: &[&str]| list.iter().find_map(|p| std::fs::read(p).ok()); + + let mut fonts = FontDefinitions::default(); + + if let Some(b) = first(REGULAR) { + fonts.font_data.insert("ui".into(), Arc::new(FontData::from_owned(b))); + fonts.families.get_mut(&FontFamily::Proportional).unwrap().insert(0, "ui".into()); + } + let mut bold_family = fonts.families[&FontFamily::Proportional].clone(); + if let Some(b) = first(BOLD) { + fonts.font_data.insert("ui-bold".into(), Arc::new(FontData::from_owned(b))); + bold_family.insert(0, "ui-bold".into()); + } + fonts.families.insert(bold(), bold_family); + if let Some(b) = first(MONO) { + fonts.font_data.insert("mono".into(), Arc::new(FontData::from_owned(b))); + let mono = fonts.families.get_mut(&FontFamily::Monospace).unwrap(); + mono.insert(0, "mono".into()); + } + ctx.set_fonts(fonts); +} + +/// Иконка окна: скруглённый квадрат с замком, рисуется попиксельно. +pub fn app_icon() -> IconData { + const N: usize = 64; + let mut rgba = vec![0u8; N * N * 4]; + let inside_rrect = |x: f32, y: f32, x0: f32, y0: f32, x1: f32, y1: f32, r: f32| { + let cx = x.clamp(x0 + r, x1 - r); + let cy = y.clamp(y0 + r, y1 - r); + (x - cx).powi(2) + (y - cy).powi(2) <= r * r + }; + for py in 0..N { + for px in 0..N { + let (x, y) = (px as f32 + 0.5, py as f32 + 0.5); + let i = (py * N + px) * 4; + if !inside_rrect(x, y, 2.0, 2.0, 62.0, 62.0, 14.0) { + continue; + } + // Градиент фона + let t = (x + y) / 128.0; + let (r, g, b) = ( + (0x5B as f32 * (1.0 - t) + 0x8B as f32 * t) as u8, + (0x8C as f32 * (1.0 - t) + 0x5C as f32 * t) as u8, + (0xFF as f32 * (1.0 - t) + 0xF6 as f32 * t) as u8, + ); + let mut c = [r, g, b, 255]; + // Дужка замка + let d = ((x - 32.0).powi(2) + (y - 27.0).powi(2)).sqrt(); + let shackle = y <= 30.0 && (8.5..=13.0).contains(&d); + let legs = (19.0..=23.5).contains(&x) || (40.5..=45.0).contains(&x); + let shackle = shackle || (legs && (27.0..=32.0).contains(&y)); + // Корпус с замочной скважиной + let body = inside_rrect(x, y, 15.0, 30.0, 49.0, 54.0, 5.0); + let hole = ((x - 32.0).powi(2) + (y - 39.5).powi(2)).sqrt() <= 3.5 + || ((30.5..=33.5).contains(&x) && (39.5..=47.0).contains(&y)); + if (shackle || body) && !hole { + c = [255, 255, 255, 255]; + } + rgba[i..i + 4].copy_from_slice(&c); + } + } + IconData { rgba, width: N as u32, height: N as u32 } +} diff --git a/src/totp.rs b/src/totp.rs new file mode 100644 index 0000000..821141e --- /dev/null +++ b/src/totp.rs @@ -0,0 +1,145 @@ +//! Одноразовые коды 2FA (TOTP, RFC 6238). + +use hmac::{Hmac, Mac}; + +#[derive(Clone, Copy)] +enum Algo { + Sha1, + Sha256, + Sha512, +} + +pub struct Totp { + secret: Vec, + digits: u32, + period: u64, + algo: Algo, +} + +impl Totp { + /// Принимает base32-секрет или ссылку `otpauth://totp/...?secret=...`. + pub fn parse(input: &str) -> Option { + let input = input.trim(); + if input.is_empty() { + return None; + } + let mut t = Totp { secret: Vec::new(), digits: 6, period: 30, algo: Algo::Sha1 }; + if input.to_ascii_lowercase().starts_with("otpauth://") { + let query = input.split_once('?')?.1; + for pair in query.split('&') { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + let v = percent_decode(v); + match k.to_ascii_lowercase().as_str() { + "secret" => t.secret = base32_decode(&v)?, + "digits" => t.digits = v.parse().ok().filter(|d| (6..=8).contains(d))?, + "period" => t.period = v.parse().ok().filter(|p| *p > 0)?, + "algorithm" => { + t.algo = match v.to_ascii_uppercase().as_str() { + "SHA256" => Algo::Sha256, + "SHA512" => Algo::Sha512, + _ => Algo::Sha1, + } + } + _ => {} + } + } + } else { + t.secret = base32_decode(input)?; + } + (!t.secret.is_empty()).then_some(t) + } + + pub fn code_at(&self, unix: u64) -> String { + let counter = (unix / self.period).to_be_bytes(); + let digest = match self.algo { + Algo::Sha1 => hmac_sha1(&self.secret, &counter), + Algo::Sha256 => hmac_sha256(&self.secret, &counter), + Algo::Sha512 => hmac_sha512(&self.secret, &counter), + }; + let off = (digest[digest.len() - 1] & 0x0f) as usize; + let bin = u32::from_be_bytes([digest[off] & 0x7f, digest[off + 1], digest[off + 2], digest[off + 3]]); + let code = bin % 10u32.pow(self.digits); + format!("{code:0width$}", width = self.digits as usize) + } + + pub fn now(&self) -> (String, u64) { + let unix = chrono::Utc::now().timestamp().max(0) as u64; + (self.code_at(unix), self.period - unix % self.period) + } + + pub fn period(&self) -> u64 { + self.period + } +} + +macro_rules! hmac_fn { + ($name:ident, $d:ty) => { + fn $name(key: &[u8], msg: &[u8]) -> Vec { + let mut mac = Hmac::<$d>::new_from_slice(key).expect("HMAC принимает ключ любой длины"); + mac.update(msg); + mac.finalize().into_bytes().to_vec() + } + }; +} +hmac_fn!(hmac_sha1, sha1::Sha1); +hmac_fn!(hmac_sha256, sha2::Sha256); +hmac_fn!(hmac_sha512, sha2::Sha512); + +fn base32_decode(s: &str) -> Option> { + let mut out = Vec::new(); + let (mut buf, mut bits) = (0u32, 0u32); + for c in s.chars().filter(|c| !c.is_whitespace() && *c != '=' && *c != '-') { + let v = match c.to_ascii_uppercase() { + c @ 'A'..='Z' => c as u32 - 'A' as u32, + c @ '2'..='7' => c as u32 - '2' as u32 + 26, + _ => return None, + }; + buf = (buf << 5) | v; + bits += 5; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Some(out) +} + +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' && i + 2 < b.len() { + let hex = std::str::from_utf8(&b[i + 1..i + 3]).ok(); + if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(v); + i += 3; + continue; + } + } + out.push(if b[i] == b'+' { b' ' } else { b[i] }); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc6238_sha1_vector() { + // Секрет "12345678901234567890" в base32. + let t = Totp::parse("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ").unwrap(); + let t8 = Totp { digits: 8, ..t }; + assert_eq!(t8.code_at(59), "94287082"); + assert_eq!(t8.code_at(1111111109), "07081804"); + } + + #[test] + fn otpauth_uri() { + let t = Totp::parse("otpauth://totp/X:me?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=X&digits=6").unwrap(); + assert_eq!(t.code_at(59), "287082"); + } +} diff --git a/src/vault.rs b/src/vault.rs new file mode 100644 index 0000000..6007bcd --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,349 @@ +//! Модель данных хранилища и слияние двух версий (синхронизация). +//! +//! Слияние: у каждой записи и папки есть `id` и `modified`. Для одинаковых `id` +//! побеждает более свежая версия, пароль проигравшей уходит в историю. +//! Удаления хранятся как «надгробия» (`tombstones`), чтобы удалённая на одном +//! устройстве запись не воскресла после синхронизации с другим. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroize; + +const HISTORY_LIMIT: usize = 20; + +pub fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Default)] +pub struct OldPassword { + pub password: String, + /// Когда этот пароль перестал быть актуальным (мс). + pub changed: i64, +} + +impl Drop for OldPassword { + fn drop(&mut self) { + self.password.zeroize(); + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub struct Entry { + #[serde(default)] + pub id: Uuid, + #[serde(default)] + pub title: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub url: String, + /// TOTP: base32-секрет или ссылка `otpauth://`. + #[serde(default)] + pub totp: String, + #[serde(default)] + pub notes: String, + #[serde(default)] + pub group: Option, + #[serde(default)] + pub favorite: bool, + #[serde(default)] + pub created: i64, + #[serde(default)] + pub modified: i64, + #[serde(default)] + pub history: Vec, +} + +impl Default for Entry { + fn default() -> Self { + let now = now_ms(); + Self { + id: Uuid::new_v4(), + title: String::new(), + username: String::new(), + password: String::new(), + url: String::new(), + totp: String::new(), + notes: String::new(), + group: None, + favorite: false, + created: now, + modified: now, + history: Vec::new(), + } + } +} + +impl Drop for Entry { + fn drop(&mut self) { + self.password.zeroize(); + self.totp.zeroize(); + self.notes.zeroize(); + self.username.zeroize(); + } +} + +impl Entry { + pub fn matches(&self, query: &str) -> bool { + if query.is_empty() { + return true; + } + [&self.title, &self.username, &self.url, &self.notes].iter().any(|f| f.to_lowercase().contains(query)) + } + + /// Добавить пароли `other` (текущий и историю) в историю `self`, без дублей. + fn absorb_history(&mut self, other: &Entry) { + let add = |pw: &str, changed: i64, this: &mut Entry| { + if pw.is_empty() || pw == this.password || this.history.iter().any(|h| h.password == pw) { + return; + } + this.history.push(OldPassword { password: pw.to_owned(), changed }); + }; + add(&other.password, other.modified, self); + for h in &other.history { + add(&h.password, h.changed, self); + } + self.history.sort_by(|a, b| b.changed.cmp(&a.changed)); + self.history.truncate(HISTORY_LIMIT); + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(default)] +pub struct Group { + pub id: Uuid, + pub name: String, + pub modified: i64, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[serde(default)] +pub struct Vault { + pub format: u32, + pub name: String, + pub created: i64, + pub entries: Vec, + pub groups: Vec, + /// id удалённого объекта → время удаления (мс). + pub tombstones: HashMap, +} + +impl Default for Vault { + fn default() -> Self { + Self { + format: 1, + name: String::new(), + created: now_ms(), + entries: Vec::new(), + groups: Vec::new(), + tombstones: HashMap::new(), + } + } +} + +#[derive(Default, Debug, Clone, Copy)] +pub struct MergeReport { + pub added: usize, + pub updated: usize, + pub removed: usize, +} + +impl MergeReport { + pub fn any(&self) -> bool { + self.added + self.updated + self.removed > 0 + } +} + +impl Vault { + pub fn new(name: &str) -> Self { + Self { name: name.to_owned(), ..Default::default() } + } + + pub fn entry(&self, id: Uuid) -> Option<&Entry> { + self.entries.iter().find(|e| e.id == id) + } + + pub fn group_name(&self, id: Option) -> Option<&str> { + let id = id?; + self.groups.iter().find(|g| g.id == id).map(|g| g.name.as_str()) + } + + /// Сохранить запись (новую или изменённую). Старый пароль уходит в историю. + pub fn upsert(&mut self, mut e: Entry) { + e.modified = now_ms(); + if let Some(old) = self.entries.iter_mut().find(|x| x.id == e.id) { + if !old.password.is_empty() && old.password != e.password { + let pw = old.password.clone(); + e.history.insert(0, OldPassword { password: pw, changed: e.modified }); + e.history.truncate(HISTORY_LIMIT); + } + *old = e; + } else { + self.entries.push(e); + } + } + + pub fn delete_entry(&mut self, id: Uuid) { + self.entries.retain(|e| e.id != id); + self.tombstones.insert(id, now_ms()); + } + + pub fn toggle_favorite(&mut self, id: Uuid) { + if let Some(e) = self.entries.iter_mut().find(|e| e.id == id) { + e.favorite = !e.favorite; + e.modified = now_ms(); + } + } + + pub fn add_group(&mut self, name: &str) -> Uuid { + let g = Group { id: Uuid::new_v4(), name: name.to_owned(), modified: now_ms() }; + let id = g.id; + self.groups.push(g); + id + } + + pub fn rename_group(&mut self, id: Uuid, name: &str) { + if let Some(g) = self.groups.iter_mut().find(|g| g.id == id) { + g.name = name.to_owned(); + g.modified = now_ms(); + } + } + + /// Удалить папку; её записи остаются, но без папки. + pub fn delete_group(&mut self, id: Uuid) { + let now = now_ms(); + self.groups.retain(|g| g.id != id); + self.tombstones.insert(id, now); + for e in self.entries.iter_mut().filter(|e| e.group == Some(id)) { + e.group = None; + e.modified = now; + } + } + + /// Канонический порядок — чтобы одинаковые хранилища сравнивались равными. + pub fn normalize(&mut self) { + self.entries.sort_by_key(|e| e.id); + self.groups.sort_by_key(|g| g.id); + } + + /// Влить в себя другую версию хранилища. + pub fn merge(&mut self, other: &Vault) -> MergeReport { + let mut r = MergeReport::default(); + + for (id, &t) in &other.tombstones { + let mine = self.tombstones.entry(*id).or_insert(t); + *mine = (*mine).max(t); + } + + for oe in &other.entries { + match self.entries.iter_mut().find(|e| e.id == oe.id) { + Some(me) if oe.modified > me.modified => { + let mut newer = oe.clone(); + newer.absorb_history(me); + *me = newer; + r.updated += 1; + } + Some(me) => me.absorb_history(oe), + None if self.tombstones.get(&oe.id).is_some_and(|&t| t >= oe.modified) => {} + None => { + self.entries.push(oe.clone()); + r.added += 1; + } + } + } + + for og in &other.groups { + match self.groups.iter_mut().find(|g| g.id == og.id) { + Some(mg) if og.modified > mg.modified => *mg = og.clone(), + Some(_) => {} + None => self.groups.push(og.clone()), + } + } + + // Применяем удаления: объект умирает, если удалён позже последней правки. + let tomb = &self.tombstones; + let dead = |id: &Uuid, modified: i64| tomb.get(id).is_some_and(|&t| t >= modified); + let before = self.entries.len(); + self.entries.retain(|e| !dead(&e.id, e.modified)); + r.removed = before - self.entries.len(); + self.groups.retain(|g| !dead(&g.id, g.modified)); + + let groups: Vec = self.groups.iter().map(|g| g.id).collect(); + for e in &mut self.entries { + if e.group.is_some_and(|g| !groups.contains(&g)) { + e.group = None; + } + } + + self.normalize(); + r + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(title: &str, pw: &str, modified: i64) -> Entry { + let mut e = Entry::default(); + e.title = title.into(); + e.password = pw.into(); + e.modified = modified; + e + } + + #[test] + fn newer_wins_and_old_password_goes_to_history() { + let e = entry("mail", "old", 100); + let mut a = Vault::new("a"); + a.entries.push(e.clone()); + let mut b = a.clone(); + b.entries[0].password = "new".into(); + b.entries[0].modified = 200; + + let r = a.merge(&b); + assert_eq!(r.updated, 1); + assert_eq!(a.entries[0].password, "new"); + assert_eq!(a.entries[0].history[0].password, "old"); + } + + #[test] + fn deletion_propagates_but_later_edit_survives() { + let mut a = Vault::new("a"); + a.entries.push(entry("x", "1", 100)); + let id = a.entries[0].id; + + let mut b = a.clone(); + b.entries.clear(); + b.tombstones.insert(id, 150); + + let mut a2 = a.clone(); + a2.merge(&b); + assert!(a2.entries.is_empty()); + + // Правка после удаления — запись остаётся. + a.entries[0].modified = 300; + a.merge(&b); + assert_eq!(a.entries.len(), 1); + } + + #[test] + fn merge_is_symmetric_for_disjoint_changes() { + let base = Vault::new("v"); + let mut a = base.clone(); + let mut b = base.clone(); + a.entries.push(entry("a", "1", 10)); + b.entries.push(entry("b", "2", 20)); + let mut ab = a.clone(); + ab.merge(&b); + let mut ba = b.clone(); + ba.merge(&a); + assert_eq!(ab.entries.len(), 2); + assert!(ab.entries == ba.entries); + } +} diff --git a/src/widgets.rs b/src/widgets.rs new file mode 100644 index 0000000..0e20187 --- /dev/null +++ b/src/widgets.rs @@ -0,0 +1,257 @@ +//! Небольшие переиспользуемые виджеты. + +use std::f32::consts::PI; + +use eframe::egui::{ + self, Align, Align2, Button, Color32, CornerRadius, FontId, Frame, Layout, Margin, Response, RichText, Sense, + Shape, Stroke, TextEdit, Ui, Vec2, pos2, vec2, +}; + +use crate::generator; +use crate::theme::{Pal, bold}; + +pub fn frame_card(pal: &Pal) -> Frame { + Frame::new() + .fill(pal.card) + .stroke(Stroke::new(1.0, pal.border)) + .corner_radius(CornerRadius::same(14)) + .inner_margin(Margin::same(18)) +} + +/// Логотип: скруглённый квадрат с замком. +pub fn logo(ui: &mut Ui, size: f32) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let p = ui.painter(); + let s = size / 64.0; + p.rect_filled(rect, CornerRadius::same((14.0 * s) as u8), crate::theme::ACCENT); + let o = rect.min; + let at = |x: f32, y: f32| pos2(o.x + x * s, o.y + y * s); + let arc: Vec<_> = (0..=24) + .map(|i| { + let a = PI + PI * i as f32 / 24.0; + at(32.0 + 10.8 * a.cos(), 29.0 + 10.8 * a.sin()) + }) + .collect(); + let mut pts = vec![at(21.2, 33.0)]; + pts.extend(arc); + pts.push(at(42.8, 33.0)); + p.add(Shape::line(pts, Stroke::new(4.6 * s, Color32::WHITE))); + let body = egui::Rect::from_min_max(at(15.0, 31.0), at(49.0, 54.0)); + p.rect_filled(body, CornerRadius::same((5.0 * s) as u8), Color32::WHITE); + p.circle_filled(at(32.0, 40.0), 3.6 * s, crate::theme::ACCENT); + p.rect_filled(egui::Rect::from_min_max(at(30.4, 40.0), at(33.6, 47.5)), 1.0, crate::theme::ACCENT); +} + +pub fn avatar_color(seed: &str) -> Color32 { + let h = seed.to_lowercase().bytes().fold(2166136261u32, |h, b| (h ^ b as u32).wrapping_mul(16777619)); + egui::ecolor::Hsva::new((h % 360) as f32 / 360.0, 0.55, 0.78, 1.0).into() +} + +pub fn initial(title: &str) -> String { + title.chars().find(|c| c.is_alphanumeric()).map(|c| c.to_uppercase().collect()).unwrap_or_else(|| "•".into()) +} + +pub fn avatar(ui: &mut Ui, title: &str, size: f32) { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + paint_avatar(ui, rect.center(), size, title); +} + +fn paint_avatar(ui: &Ui, center: egui::Pos2, size: f32, title: &str) { + let p = ui.painter(); + p.rect_filled( + egui::Rect::from_center_size(center, Vec2::splat(size)), + CornerRadius::same((size * 0.28) as u8), + avatar_color(title), + ); + p.text(center, Align2::CENTER_CENTER, initial(title), FontId::new(size * 0.46, bold()), Color32::WHITE); +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { s.to_owned() } else { s.chars().take(max - 1).chain(['…']).collect() } +} + +/// Строка в списке записей. +pub fn entry_row(ui: &mut Ui, pal: &Pal, title: &str, subtitle: &str, favorite: bool, selected: bool) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 56.0), Sense::click()); + if ui.is_rect_visible(rect) { + let inner = rect.shrink2(vec2(8.0, 2.0)); + let bg = if selected { + pal.accent_soft + } else if resp.hovered() { + pal.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(inner, CornerRadius::same(10), bg); + let av = pos2(inner.left() + 26.0, inner.center().y); + paint_avatar(ui, av, 34.0, if title.is_empty() { "?" } else { title }); + + let right = inner.right() - if favorite { 30.0 } else { 10.0 }; + let text_rect = egui::Rect::from_min_max(pos2(inner.left() + 52.0, inner.top()), pos2(right, inner.bottom())); + let chars = ((text_rect.width() / 7.6) as usize).max(4); + let painter = ui.painter().with_clip_rect(text_rect); + let x = text_rect.left(); + painter.text( + pos2(x, inner.center().y - 9.0), + Align2::LEFT_CENTER, + truncate(if title.is_empty() { "Без названия" } else { title }, chars), + FontId::new(14.5, bold()), + pal.text, + ); + painter.text( + pos2(x, inner.center().y + 10.0), + Align2::LEFT_CENTER, + truncate(subtitle, chars + 4), + FontId::proportional(12.5), + pal.weak, + ); + if favorite { + ui.painter().text( + pos2(inner.right() - 16.0, inner.center().y), + Align2::CENTER_CENTER, + "★", + FontId::proportional(14.0), + pal.warn, + ); + } + } + resp +} + +/// Пункт бокового меню. +pub fn nav_item(ui: &mut Ui, pal: &Pal, icon: &str, label: &str, count: usize, selected: bool) -> Response { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); + if ui.is_rect_visible(rect) { + let bg = if selected { + pal.accent_soft + } else if resp.hovered() { + pal.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(8), bg); + let color = if selected { pal.accent } else { pal.weak }; + let y = rect.center().y; + ui.painter().text(pos2(rect.left() + 18.0, y), Align2::CENTER_CENTER, icon, FontId::proportional(14.0), color); + let clip = egui::Rect::from_min_max(rect.min, pos2(rect.right() - 34.0, rect.bottom())); + ui.painter().with_clip_rect(clip).text( + pos2(rect.left() + 36.0, y), + Align2::LEFT_CENTER, + label, + FontId::proportional(14.0), + if selected { pal.text } else { pal.text.gamma_multiply(0.9) }, + ); + ui.painter().text( + pos2(rect.right() - 10.0, y), + Align2::RIGHT_CENTER, + count.to_string(), + FontId::proportional(12.0), + pal.weak, + ); + } + resp +} + +pub fn section_label(ui: &mut Ui, pal: &Pal, text: &str) { + ui.label(RichText::new(text).size(11.0).color(pal.weak).strong()); +} + +pub fn field_label(ui: &mut Ui, pal: &Pal, text: &str) { + ui.add_space(2.0); + ui.label(RichText::new(text).size(12.5).color(pal.weak)); +} + +pub fn primary(ui: &mut Ui, pal: &Pal, text: &str, width: f32) -> Response { + let btn = Button::new(RichText::new(text).color(Color32::WHITE).font(FontId::new(14.5, bold()))) + .fill(pal.accent) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(9)) + .min_size(vec2(width, 38.0)); + ui.add(btn) +} + +pub fn secondary(ui: &mut Ui, text: &str, width: f32) -> Response { + ui.add(Button::new(text).corner_radius(CornerRadius::same(9)).min_size(vec2(width, 38.0))) +} + +pub fn danger(ui: &mut Ui, pal: &Pal, text: &str) -> Response { + let btn = Button::new(RichText::new(text).color(Color32::WHITE).strong()) + .fill(pal.danger) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(9)) + .min_size(vec2(110.0, 36.0)); + ui.add(btn) +} + +pub fn icon_btn(ui: &mut Ui, icon: &str, tip: &str) -> Response { + ui.add(Button::new(RichText::new(icon).size(15.0)).frame_when_inactive(false).min_size(vec2(32.0, 30.0))) + .on_hover_text(tip) +} + +pub fn toggle_btn(ui: &mut Ui, icon: &str, on: bool, tip: &str) -> Response { + ui.add(Button::new(RichText::new(icon).size(15.0)).selected(on).frame_when_inactive(on).min_size(vec2(32.0, 30.0))) + .on_hover_text(tip) +} + +pub fn text_field(ui: &mut Ui, value: &mut String, hint: &str) -> Response { + ui.add(TextEdit::singleline(value).hint_text(hint).desired_width(f32::INFINITY).margin(Margin::symmetric(10, 7))) +} + +/// Поле пароля с кнопкой показать/скрыть. Дополнительные кнопки — через `extra`. +pub fn password_field( + ui: &mut Ui, + value: &mut String, + show: &mut bool, + hint: &str, + extra: impl FnOnce(&mut Ui), +) -> Response { + ui.allocate_ui_with_layout(vec2(ui.available_width(), 34.0), Layout::right_to_left(Align::Center), |ui| { + extra(ui); + if toggle_btn(ui, "👁", *show, if *show { "Скрыть" } else { "Показать" }).clicked() { + *show = !*show; + } + let edit = TextEdit::singleline(value) + .password(!*show) + .hint_text(hint) + .desired_width(f32::INFINITY) + .margin(Margin::symmetric(10, 7)); + let edit = if *show { edit.font(egui::TextStyle::Monospace) } else { edit }; + ui.add(edit) + }) + .inner +} + +pub fn strength_bar(ui: &mut Ui, pal: &Pal, pw: &str) { + let s = generator::strength(pw); + let colors = [pal.danger, pal.danger, pal.warn, pal.success, pal.success]; + let color = colors[s.score as usize]; + let filled = if pw.is_empty() { 0 } else { s.score as usize + 1 }; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for i in 0..5 { + let (rect, _) = ui.allocate_exact_size(vec2(34.0, 5.0), Sense::hover()); + ui.painter().rect_filled(rect, CornerRadius::same(3), if i < filled { color } else { pal.border }); + } + ui.add_space(6.0); + if !pw.is_empty() { + ui.label(RichText::new(format!("{} · ~{:.0} бит", s.label(), s.bits)).size(12.0).color(color)); + } + }); +} + +pub fn format_ms(ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(ms) + .map(|d| d.with_timezone(&chrono::Local).format("%d.%m.%Y %H:%M").to_string()) + .unwrap_or_default() +} + +pub fn display_url(url: &str) -> &str { + let u = url.trim(); + let u = u.strip_prefix("https://").or_else(|| u.strip_prefix("http://")).unwrap_or(u); + u.strip_suffix('/').unwrap_or(u) +} + +pub fn full_url(url: &str) -> String { + let u = url.trim(); + if u.contains("://") { u.to_owned() } else { format!("https://{u}") } +} diff --git a/tests/notes.rs b/tests/notes.rs new file mode 100644 index 0000000..daececc --- /dev/null +++ b/tests/notes.rs @@ -0,0 +1,41 @@ +//! Заметки проекта не должны разрастаться: каждый файл в `notes/` объявляет +//! лимит строк в первой строке (``), тест следит за ним. + +use std::fs; +use std::path::Path; + +#[test] +fn notes_fit_their_line_limits() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("notes"); + let mut problems = Vec::new(); + let mut checked = 0; + + for entry in fs::read_dir(&dir).expect("папка notes/") { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|e| e != "md") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let text = fs::read_to_string(&path).unwrap(); + let first = text.lines().next().unwrap_or_default(); + let limit = first + .trim() + .strip_prefix("")) + .and_then(|s| s.trim().parse::().ok()); + let Some(limit) = limit else { + problems.push(format!("{name}: нет заголовка ``")); + continue; + }; + let lines = text.lines().count(); + if lines > limit { + problems.push(format!( + "{name}: {lines} строк при лимите {limit} — сократите (LOG.md: перенесите старое в notes/archive/)" + )); + } + checked += 1; + } + + assert!(checked > 0, "в notes/ нет ни одного .md"); + assert!(problems.is_empty(), "\n{}", problems.join("\n")); +}