diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 17885e80..9c00d591 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,7 +89,7 @@ jobs: - name: Build debug binary run: cargo build --locked - - uses: actions/upload-artifact@v7.0.0 + - uses: actions/upload-artifact@v7.0.2 with: name: bins-${{ matrix.build }} # Two paths, the first for x86_64 jobs, the second for aarch64. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b9af7499..4e43b70e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -77,7 +77,7 @@ jobs: cp "target/${{ matrix.target }}/release/$BIN_NAME" "dist/" fi - - uses: actions/upload-artifact@v7.0.0 + - uses: actions/upload-artifact@v7.0.2 with: name: cotp-${{ matrix.build }} path: dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40c9d161..8b6b3078 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,10 +23,10 @@ jobs: - name: Install NodeJS uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 26 - name: Install Semantic Release globally - run: npm install -g semantic-release @semantic-release/git @semantic-release/exec @semantic-release/changelog + run: npm install -g semantic-release@25.0.9 @semantic-release/git@11.0.1 @semantic-release/exec@7.1.0 @semantic-release/changelog@7.0.0 # Semantic Release configuration executes gh CLI which triggers the deploy workflow - name: Release with semantic versioning diff --git a/Cargo.lock b/Cargo.lock index ec1c1a93..6c5f879a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,34 +29,34 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] name = "aes-gcm" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead", "aes", "cipher", "ctr", + "ctutils", "ghash", - "subtle", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -132,17 +132,11 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "assert_cmd" @@ -184,9 +178,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "backtrace" @@ -211,9 +205,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" @@ -238,17 +232,16 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2b_simd" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" dependencies = [ - "arrayref", "arrayvec", "constant_time_eq", ] @@ -279,20 +272,20 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "by_address" @@ -302,9 +295,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder-lite" @@ -327,11 +320,21 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -341,13 +344,13 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -375,9 +378,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -385,9 +388,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -397,21 +400,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "clipboard-win" @@ -464,9 +467,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -530,7 +533,7 @@ dependencies = [ "aes-gcm", "assert_cmd", "assert_fs", - "base64 0.23.0", + "base64 0.23.1", "chacha20poly1305", "clap", "color-eyre", @@ -555,9 +558,10 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", "test-case", + "tui-input", "url", "urlencoding", "zeroize", @@ -580,9 +584,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -595,9 +599,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -605,18 +609,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crossterm" @@ -624,13 +628,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook", "signal-hook-mio", "winapi", @@ -687,9 +691,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", ] @@ -706,12 +710,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -730,15 +734,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -754,20 +758,20 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "deltae" @@ -777,12 +781,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive_builder" @@ -867,9 +868,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -888,13 +889,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -923,9 +924,9 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "either" -version = "1.16.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "enum_dispatch" @@ -941,9 +942,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -957,19 +958,20 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] [[package]] name = "eyre" -version = "0.6.12" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" dependencies = [ + "autocfg", "indenter", "once_cell", ] @@ -984,17 +986,11 @@ dependencies = [ "regex", ] -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -1007,11 +1003,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + [[package]] name = "finl_unicode" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" [[package]] name = "fixedbitset" @@ -1049,6 +1051,30 @@ 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" @@ -1069,17 +1095,6 @@ dependencies = [ "winapi", ] -[[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" @@ -1121,9 +1136,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -1138,22 +1153,11 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "ignore", "walkdir", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -1197,21 +1201,22 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1219,9 +1224,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1232,9 +1237,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1246,16 +1251,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1266,15 +1272,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1304,9 +1310,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1314,9 +1320,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -1330,9 +1336,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -1348,33 +1354,33 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indoc" -version = "2.0.7" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" dependencies = [ "rustversion", ] [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] [[package]] name = "instability" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1400,23 +1406,24 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "kasuari" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" dependencies = [ - "hashbrown 0.16.1", + "hashbrown", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.21", ] [[package]] @@ -1433,15 +1440,15 @@ checksum = "10257499f089cd156ad82d0a9cd57d9501fa2c989068992a97eb3c27836f206b" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -1461,20 +1468,20 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -1491,15 +1498,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -1512,27 +1519,27 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.18.1" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown 0.17.1", + "hashbrown", ] [[package]] name = "mac_address" -version = "1.1.8" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" dependencies = [ - "nix 0.29.0", - "winapi", + "nix 0.31.3", + "windows-sys 0.61.2", ] [[package]] @@ -1610,9 +1617,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -1622,9 +1629,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -1648,7 +1655,19 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1673,9 +1692,9 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1699,9 +1718,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" dependencies = [ "libc", ] @@ -1773,27 +1792,27 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.3.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" [[package]] name = "palette" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", - "fast-srgb8", "libm", "palette_derive", + "palette_math", ] [[package]] name = "palette_derive" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" dependencies = [ "by_address", "proc-macro2", @@ -1801,6 +1820,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1842,19 +1870,21 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23" dependencies = [ "memchr", + "psm", + "stacker", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44" dependencies = [ "pest", "pest_generator", @@ -1862,25 +1892,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -1943,9 +1972,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" @@ -1953,7 +1982,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash", ] @@ -1964,30 +1993,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "predicates" @@ -2051,11 +2080,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "qrcode" @@ -2089,9 +2127,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_core 0.6.4", ] @@ -2130,17 +2168,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "compact_str", "critical-section", - "hashbrown 0.17.1", + "hashbrown", "itertools", "kasuari", "lru", "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -2195,8 +2233,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.17.1", + "bitflags 2.13.2", + "hashbrown", "indoc", "instability", "itertools", @@ -2215,18 +2253,17 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.21", ] [[package]] @@ -2243,9 +2280,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2271,12 +2308,12 @@ dependencies = [ [[package]] name = "rtoolbox" -version = "0.0.3" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2293,9 +2330,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc_version" @@ -2312,7 +2349,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2321,11 +2358,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2334,9 +2371,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2384,7 +2421,7 @@ dependencies = [ "cfg-if", "pbkdf2", "salsa20", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -2420,14 +2457,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2443,7 +2480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -2460,12 +2497,12 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -2478,6 +2515,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -2511,15 +2554,21 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smithay-client-toolkit" @@ -2555,6 +2604,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -2588,12 +2650,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "1.0.109" @@ -2618,9 +2674,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -2629,13 +2685,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -2645,9 +2701,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -2657,9 +2713,9 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "parking_lot", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook", "windows-sys 0.61.2", ] @@ -2699,7 +2755,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2735,34 +2791,34 @@ dependencies = [ [[package]] name = "test-case" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +checksum = "124953e7f67cb0b2fcfb87e899e4ae5a64fb68e2e160767933cc67b646fb0042" dependencies = [ "test-case-macros", ] [[package]] name = "test-case-core" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +checksum = "bd097615b407247e102046ba34426bb594923ab3554cf0f3cdfed050f1a5a3e8" dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "test-case-macros" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +checksum = "f4ce35ece947bccac166e1ded639133827a953f69a48ba134d17f91108333082" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "test-case-core", ] @@ -2777,11 +2833,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.21", ] [[package]] @@ -2797,29 +2853,29 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.9", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -2832,15 +2888,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2878,15 +2934,26 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "sharded-slab", "thread_local", "tracing-core", ] +[[package]] +name = "tui-input" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4fa0cb7933d19f91eb3109781bf7297d5e4cba57e857d97a29b9465d3383f56" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "typenum" version = "1.20.1" @@ -2901,15 +2968,15 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -2970,9 +3037,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "atomic", "getrandom 0.4.3", @@ -3028,18 +3095,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -3050,9 +3117,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3060,22 +3127,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -3283,15 +3350,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -3376,15 +3434,15 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11-clipboard" @@ -3419,21 +3477,21 @@ dependencies = [ [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3442,42 +3500,42 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -3495,9 +3553,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3506,9 +3564,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3517,13 +3575,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 73f9cccb..c4ecfcb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,25 +28,25 @@ strip = "symbols" [dependencies] serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" -dirs = "6.0.0" +serde_json = "1.0.154" +dirs = "7.0.0" rpassword = "7.5.4" -data-encoding = "2.11.0" +data-encoding = "2.11.1" copypasta-ext = "0.4.4" -zeroize = { version = "1.9.0", features = ["zeroize_derive"] } -clap = { version = "4.6.4", features = ["derive"] } +zeroize = { version = "1.9.1", features = ["zeroize_derive"] } +clap = { version = "4.6.7", features = ["derive"] } hmac = "0.13.0" sha1 = "0.11.0" -sha2 = "0.11.0" +sha2 = "0.11.1" chacha20poly1305 = "0.11.0" getrandom = "0.4.3" rust-argon2 = "3.0.0" scrypt = "0.12.0" -aes-gcm = "0.11.0" +aes-gcm = "0.11.1" hex = "0.4.3" qrcode = "0.14.1" urlencoding = "2.1.3" -base64 = "0.23.0" +base64 = "0.23.1" md-5 = "0.11.0" ratatui = { version = "0.30.2", features = ["all-widgets"] } crossterm = "0.29.0" @@ -54,12 +54,13 @@ url = "2.5.8" color-eyre = "0.6.5" enum_dispatch = "0.3.13" derive_builder = "0.20.2" -globset = "0.4.19" +globset = "0.4.20" prost = "0.14.4" +tui-input = { version = "0.15.5", default-features = false, features = ["crossterm"] } tempfile = "3.27.0" [dev-dependencies] assert_cmd = "2.2.2" assert_fs = "1.1.4" predicates = "3.1.4" -test-case = "3.3.1" +test-case = "3.4.0" diff --git a/README.md b/README.md index 43e16189..30a0fe20 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,50 @@ minimalist, secure, desktop accessible software to manage my two-factor authenti ## Interface -

- -Type `i` to get some instruction. Otherwise just enter `cotp --help`. -In the first run you will be prompted to insert a password to initialize the database. +

cotp demo: navigate and search accounts, edit and add an account, hide codes, and confirm saving changes

+ +Run `cotp` to open your vault. The dashboard adapts to your terminal, with searchable +accounts, a countdown for the selected code, grouped codes, and an account detail panel +on wide screens. On your first run, choose a password to initialize the encrypted database. + +Use the keyboard for every action, or click accounts, the search bar, and the visible +shortcuts. Keyboard and mouse-wheel navigation stop at the first and last account. +The `?` menu also offers clickable actions. No special icon font is required. The dashboard needs an interactive terminal; +use `cotp list` or `cotp list --json` when piping output. + +| Key | Action | +| --- | --- | +| `↑` / `↓`, `k` / `j` | Select an account | +| `Home` / `End`, `PgUp` / `PgDn` | First / last account, or move ten accounts | +| `/` or `Ctrl+F` | Filter by issuer or account label | +| `Enter` | Copy the selected code; on the QR screen, copy its OTP URI | +| `a` / `e` | Add / edit an account | +| `Space` | Show or close the selected account's QR code | +| `h` | Hide / reveal codes | +| `+` / `-` | Increase / decrease an HOTP counter | +| `d` or `Delete` | Delete an account, with confirmation | +| `?` or `i` | Open the shortcuts and actions menu | +| `Esc` | Close the current screen, finish searching, clear a filter, or quit | +| `q`, `Ctrl+C`, `Ctrl+D` | Quit; account changes prompt you to save or discard | + +### Add and edit accounts + +Press `a` or click **Add** to enter an issuer, account label, and secret. You can also +paste an `otpauth://` URI into the secret field to populate the form. Choose the OTP +type and algorithm, then adjust digits, period, counter, or PIN as required. Validation +focuses the field that needs attention. + +

cotp account editor with masked secret entry

+ +Use `Tab` / `Shift+Tab` or `↑` / `↓` to move through fields and buttons. Text fields +support cursor movement, `Home`, `End`, and `Ctrl+U` to clear. Click a type or algorithm +field, or use `←` / `→` or `Space`, to change it. `Ctrl+S` or **Save account** applies the +form; `Esc` or **Cancel** discards it. Secret keys and PINs are masked; when editing, +leave either blank to keep its existing value. + +Account additions, edits, deletions, and counter changes remain pending until you +quit and confirm **save**. Choosing **discard** leaves the on-disk vault unchanged. +CLI subcommands remain available for scripting; run `cotp --help` for examples. ## TL;DR @@ -24,6 +64,8 @@ In the first run you will be prompted to insert a password to initialize the dat # Display all the OTP codes in the interactive dashboard cotp # select any code with arrow keys, press enter to copy into the clipboard, even in an SSH remote shell +# In the dashboard, press a to add or e to edit an account + # Add a new TOTP code from a BASE32 secret key cotp add --label myaccount@gmail.com --issuer Google @@ -166,3 +208,26 @@ an issue. I created this project for my own needs, but I would be happy if this little program is useful to someone else, and I gratefully accept any pull requests. + +## UI development + +The repository pins Rust and Cargo in `rust-toolchain.toml`; rustup installs the selected +toolchain automatically. The interface uses Ratatui, Crossterm, and tui-input. Rendering +and interaction tests cover responsive layouts, keyboard and mouse actions, forms, +search, code timing, and masked fields. + +To regenerate the documentation previews from the real widgets and demonstration data: + +```bash +COTP_UI_PREVIEW=img/cotp-dashboard.svg COTP_FORM_PREVIEW=img/cotp-editor.svg cargo test interface::tests +``` + +To rebuild the animated demo with deterministic example accounts, install ImageMagick +(`magick`) and run: + +```bash +python3 ci/render_ui_demo.py +``` + +The recording uses the actual dashboard widgets and keyboard/mouse handlers, with +synthetic accounts and masked secrets. Tutorial captions appear below the application. diff --git a/ci/render_ui_demo.py b/ci/render_ui_demo.py new file mode 100644 index 00000000..dd3379c9 --- /dev/null +++ b/ci/render_ui_demo.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Render the README GIF using real cotp widgets and deterministic demo accounts. + +Requires the repository Rust toolchain and ImageMagick (magick) on PATH. +Run from any directory: python3 ci/render_ui_demo.py +""" + +import concurrent.futures +import html +import json +from pathlib import Path +import os +import shutil +import subprocess +import tempfile + + +def main(): + root = Path(__file__).resolve().parents[1] + if shutil.which("magick") is None: + raise SystemExit("ImageMagick is required: install it so 'magick' is on PATH.") + (root / "target").mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="ui-demo-", dir=root / "target") as temp: + directory = Path(temp) + subprocess.run( + ["cargo", "test", "--locked", "interface::tests::record_ui_demo", "--", "--ignored"], + cwd=root, + env={**os.environ, "COTP_UI_DEMO_DIR": temp}, + check=True, + ) + frames = json.loads((directory / "frames.json").read_text()) + + def render(frame): + source = directory / frame["file"] + svg = source.read_text() + # Place tutorial captions beneath the application, outside its viewport. + svg = svg.replace('height="612"', 'height="654"', 1) + svg = svg.replace('viewBox="0 0 1080 612"', 'viewBox="0 0 1080 654"', 1) + caption = html.escape(frame["caption"]) + svg = svg.replace( + "", + '' + '{caption}', + ) + source.write_text(svg) + target = source.with_suffix(".png") + subprocess.run(["magick", str(source), str(target)], check=True) + return target + + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + images = list(pool.map(render, frames)) + command = ["magick"] + for frame, image in zip(frames, images): + command.extend(["-delay", str(frame["delay"]), str(image)]) + output = root / "img" / "cotp_demo.gif" + command.extend(["-loop", "0", "-layers", "Optimize", str(output)]) + subprocess.run(command, check=True) + duration = sum(frame["delay"] for frame in frames) / 100 + print(f"Rendered {len(frames)} frames, {duration:.1f}s, {output.stat().st_size / 1024:.0f} KiB: {output}") + + +if __name__ == "__main__": + main() diff --git a/img/cotp-dashboard.svg b/img/cotp-dashboard.svg new file mode 100644 index 00000000..c9181361 --- /dev/null +++ b/img/cotp-dashboard.svg @@ -0,0 +1 @@ +cotp dashboard with demonstration accountscotp6accountsaAddeEditdDelete╭Search·/orCtrl+F──────────────────────────────────────────────────────────────────────────────────────────────╮│Findanissueroraccount…│╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╭Accounts───────────────────────────────────────────────────────────────────────╮╭Selectedaccount──────────────╮│ACCOUNTCODE││GitHub││││alex@studio.dev││▎GitHub751109││││alex@studio.dev││▀▀██▀▀▀█▀██▀██▀█││││█▀▀█████▀▀█││Google758428││▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀││alex@example.com││││││18s━━━━━━━━━━━━━━━──────────││Linear765747││││Designteam││TypeTOTP││││AlgorithmSHA1││AWS773066││Digits6││Production·engineering││Period30seconds││││││Figma780385││Entercopycode││alex@studio.dev││SpaceshowQRcode││││││Proton787704││││Personal│││╰──────────────────────────────────────────────────────────────────────────1/6╯╰────────────────────────────────╯Ready·codesrefreshautomatically↑↓moveEntercopy/searchSpaceQRhhide?helpqquit diff --git a/img/cotp-editor.svg b/img/cotp-editor.svg new file mode 100644 index 00000000..8c5708d6 --- /dev/null +++ b/img/cotp-editor.svg @@ -0,0 +1 @@ +cotp dashboard with demonstration accountscotp3accountsaAddeEditd╭Editaccount──────────────────────────────────────────────────────────────╮╭Search·/orCtrl│Accountdetails│───────────────────╮│Findanissueror│Changesarewrittentoyourvaultwhenyouquit.││╰───────────────────│╭Issuer────────────────────────────────────────────────────────────────╮│───────────────────╯╭Accounts─────────││GitHub││ount──────────────╮│ACCOUNT│╰────────────────────────────────────────────────────────────────────────╯││││╭Accountlabel─────────────────────────────────────────────────────────╮│dev││▎GitHub││alex@studio.dev││││alex@studio.dev│╰────────────────────────────────────────────────────────────────────────╯│█▀██▀██▀█│││╭Secret·masked───────────────────────────────────────────────────────╮│████▀▀█││Google││Unchanged││▀▀▀▀▀▀▀▀▀▀▀││alex@example.com│╰────────────────────────────────────────────────────────────────────────╯││││╭Type──────────────────────────────────────────────────────────────────╮│━━━━━━━━──────────││Linear││‹TOTP›││││Designteam│╰────────────────────────────────────────────────────────────────────────╯│TOTP│││╭Algorithm─────────────────────────────────────────────────────────────╮│SHA1││││‹SHA1›││6│││╰────────────────────────────────────────────────────────────────────────╯│30seconds│││╭Digits────────────────────────────────────────────────────────────────╮│││││6││code│││╰────────────────────────────────────────────────────────────────────────╯│QRcode│││╭Period(seconds)──────────────────────────────────────────────────────╮│││││30│││││╰────────────────────────────────────────────────────────────────────────╯││││Email,username,oraccountname││││Tabmovesbetweenfieldsandbuttons.Enteractivatesabutton.││││││││[Saveaccount][Cancel]││╰───────────────────│Ctrl+Ssave·Esccancel·Tab/Shift+Tabmove│───────────────────╯Ready·codesrefres╰────────────────────────────────────────────────────────────────────────────╯↑↓moveEntercopy/searchSpaceQRhhide?helpqquit diff --git a/img/cotp_demo.gif b/img/cotp_demo.gif index f4bdb975..dd0941b4 100644 Binary files a/img/cotp_demo.gif and b/img/cotp_demo.gif differ diff --git a/requirements.txt b/requirements.txt index 38168007..505f3999 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,16 @@ -astroid -attrs -iniconfig -isort -lazy-object-proxy -mccabe -packaging -pluggy -py -pylint -pyparsing -pytest -six -toml -wrapt -requests \ No newline at end of file +astroid==4.3.4 +attrs==26.1.0 +iniconfig==2.3.1 +isort==9.0.2 +lazy-object-proxy==1.13.0 +mccabe==0.7.0 +packaging==26.3 +pluggy==1.7.0 +py==1.11.0 +pylint==4.1.3 +pyparsing==3.3.3 +pytest==9.1.1 +six==1.17.0 +toml==0.10.2 +wrapt==2.5.1 +requests==2.34.2 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 8565ca6b..9ec55b3b 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.97.1" +channel = "1.99.1" components = ["rustfmt", "clippy"] diff --git a/src/arguments/mod.rs b/src/arguments/mod.rs index d4de5148..3646cddb 100644 --- a/src/arguments/mod.rs +++ b/src/arguments/mod.rs @@ -27,7 +27,17 @@ pub trait SubcommandExecutor { /// Main structure defining the Clap argument for the cotp commandline utility #[derive(Parser)] -#[command(author, version = env!("COTP_VERSION"), about, long_about = None)] +#[command(author, version = env!("COTP_VERSION"), about, long_about = None, + styles = cli_styles(), + after_help = "QUICK START + cotp Open the interactive vault + cotp add --issuer GitHub --label you Add an account + cotp import --help Migrate from another authenticator + cotp list --json Output codes for scripts + +DASHBOARD + a add · e edit · / search · Enter copy · Space QR code · ? help" +)] pub struct CotpArgs { #[command(subcommand)] command: Option, @@ -74,3 +84,13 @@ fn verify_cli() { use clap::CommandFactory; CotpArgs::command().debug_assert(); } + +fn cli_styles() -> clap::builder::Styles { + use clap::builder::styling::{AnsiColor, Effects}; + clap::builder::Styles::styled() + .header(AnsiColor::Cyan.on_default() | Effects::BOLD) + .usage(AnsiColor::Cyan.on_default() | Effects::BOLD) + .literal(AnsiColor::Green.on_default() | Effects::BOLD) + .placeholder(AnsiColor::Yellow.on_default()) + .error(AnsiColor::Red.on_default() | Effects::BOLD) +} diff --git a/src/interface/app.rs b/src/interface/app.rs index 757cea1b..ce4a6d54 100644 --- a/src/interface/app.rs +++ b/src/interface/app.rs @@ -1,315 +1,92 @@ -use std::error; - -use crate::interface::enums::Focus; -use crate::interface::enums::Page; -use crate::interface::enums::Page::{Main, Qrcode}; +use std::{ + error, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use super::{ + enums::{Action, Focus, Page, PopupAction}, + stateful_table::StatefulTable, +}; use crate::otp::otp_element::OTPDatabase; -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::layout::{Alignment, Constraint, Direction, Layout}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap}; - -use crate::interface::stateful_table::{StatefulTable, fill_table}; -use crate::utils::percentage; - -use super::enums::PopupAction; -use super::popup::centered_rect; - -const LARGE_APPLICATION_WIDTH: u16 = 75; -/// Application result type. pub type AppResult = Result>; -const DEFAULT_QRCODE_LABEL: &str = "Press enter to copy the OTP URI code"; - -/// Application. pub struct App<'a> { - /// Is the application running? pub running: bool, - title: String, pub(crate) table: StatefulTable, pub(crate) database: &'a mut OTPDatabase, - progress: u16, - /// Text to print replacing the percentage - pub(crate) label_text: String, - pub(crate) print_percentage: bool, pub(crate) current_page: Page, pub(crate) search_query: String, pub(crate) focus: Focus, pub(crate) popup: Popup, - - /// Info text in the `QRCode` page - pub(crate) qr_code_page_label: &'static str, + pub(crate) popup_scroll: u16, + pub(crate) notification: Option<(String, bool, Instant)>, + pub(crate) hide_codes: bool, + pub(crate) now: u64, + pub(crate) form: Option, + pub(crate) hitboxes: Vec<(ratatui::layout::Rect, Action)>, } pub struct Popup { pub(crate) text: String, pub(crate) action: PopupAction, - pub(crate) percent_x: u16, - pub(crate) percent_y: u16, } impl<'a> App<'a> { - /// Constructs a new instance of [`App`]. pub fn new(database: &'a mut OTPDatabase) -> Self { - let mut title = String::from(env!("CARGO_PKG_NAME")); - title.push_str(" v"); - // Settings cotp version from env var defined in build.rs - title.push_str(env!("COTP_VERSION")); Self { running: true, - title, table: StatefulTable::new(database.elements_ref()), database, - progress: percentage(), - label_text: String::new(), - print_percentage: true, current_page: Page::default(), search_query: String::new(), focus: Focus::MainPage, popup: Popup { text: String::new(), - action: PopupAction::EditOtp, - percent_x: 60, - percent_y: 20, + action: PopupAction::GeneralInfo, }, - qr_code_page_label: DEFAULT_QRCODE_LABEL, + popup_scroll: 0, + notification: None, + hide_codes: false, + now: unix_seconds(), + form: None, + hitboxes: Vec::new(), } } pub(crate) fn reset(&mut self) { - self.current_page = Page::default(); - self.print_percentage = true; - self.qr_code_page_label = DEFAULT_QRCODE_LABEL; + self.current_page = Page::Main; + self.focus = Focus::MainPage; } - /// Handles the tick event of the terminal. - pub fn tick(&mut self, force_update: bool) { - // Update progress bar - let new_progress = percentage(); - // Check for new cycle - if force_update || new_progress < self.progress { - // Update codes - self.table.items.clear(); - fill_table(&mut self.table, self.database.elements_ref()); - } - self.progress = new_progress; + pub(crate) fn notify(&mut self, message: impl Into, error: bool) { + self.notification = Some((message.into(), error, Instant::now())); } - /// Renders the user interface widgets. - pub fn render(&mut self, frame: &mut Frame<'_>) { - match &self.current_page { - Main => self.render_main_page(frame), - Qrcode => self.render_qrcode_page(frame), - } - } - - fn render_qrcode_page(&self, frame: &mut Frame<'_>) { - let paragraph = self - .table - .state - .selected() - .and_then(|index| self.database.elements_ref().get(index)) - .map_or_else( - || { - Paragraph::new("No element is selected") - .block(Block::default().title("Nope").borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - }, - |element| { - let title = if element.label.is_empty() { - element.issuer.clone() - } else { - format!("{} - {}", element.issuer, element.label) - }; - Paragraph::new(format!( - "{}\n{}", - element.get_qrcode(), - self.qr_code_page_label - )) - .block(Block::default().title(title).borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - }, - ); - Self::render_paragraph(frame, paragraph); + pub(crate) fn filter(&mut self) { + self.table + .refresh(self.database.elements_ref(), &self.search_query); } - fn render_paragraph(frame: &mut Frame<'_>, paragraph: Paragraph) { - let rects = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)].as_ref()) - .split(frame.area()); - - frame.render_widget(paragraph, rects[0]); - } - - fn render_main_page(&mut self, frame: &mut Frame<'_>) { - let height = frame.area().height; - let rects = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Length(3), // Search bar - Constraint::Length(height - 8), // Table + Info Box - Constraint::Length(1), // Progress bar - ] - .as_ref(), - ) - .margin(2) - .split(frame.area()); - - let search_bar_title = "Press CTRL + F to search a code..."; - let search_bar = Paragraph::new(&*self.search_query) - .block( - Block::default() - .title(search_bar_title) - .borders(Borders::ALL) - .border_style(Style::default().fg(if self.focus == Focus::SearchBar { - Color::LightRed - } else { - Color::White - })), - ) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - let progress_label = if self.print_percentage { - format!("{}%", self.progress) - } else { - self.label_text.clone() - }; - let progress_bar = Gauge::default() - .block(Block::default()) - .gauge_style( - Style::default() - .bg(Color::White) - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .percent(self.progress) - .label(progress_label); - - frame.render_widget(search_bar, rects[0]); - self.render_table_box(frame, rects[1]); - frame.render_widget(progress_bar, rects[2]); - if self.focus == Focus::Popup { - self.render_alert(frame); + pub fn tick(&mut self, force_update: bool) { + let now = unix_seconds(); + if force_update || now != self.now { + self.filter(); } - } - - fn render_alert(&mut self, frame: &mut Frame<'_>) { - let block = Block::default().title("Alert").borders(Borders::ALL); - let paragraph = Paragraph::new(&*self.popup.text) - .block(block) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - let area = centered_rect(self.popup.percent_x, self.popup.percent_y, frame.area()); - frame.render_widget(Clear, area); - //this clears out the background - frame.render_widget(paragraph, area); - } - - fn render_table_box(&mut self, frame: &mut Frame<'_>, area: Rect) { - let constraints = if Self::is_large_application(frame) { - vec![Constraint::Percentage(80), Constraint::Percentage(20)] - } else { - vec![Constraint::Percentage(100)] - }; - let chunks = Layout::default() - .constraints(constraints) - .direction(Direction::Horizontal) - .split(area); - - let header_cells = ["Id", "Issuer", "Label", "OTP"] - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(Color::Black))); - let header = Row::new(header_cells) - .style( - Style::default() - .bg(Color::White) - .add_modifier(Modifier::BOLD), - ) - .height(1) - .bottom_margin(1); - let rows = self.table.items.iter().map(|item| { - Row::new(item.cells()) - .height(item.height()) - .bottom_margin(1) - }); - - const TABLE_WIDTHS: &[Constraint] = &[ - Constraint::Percentage(5), - Constraint::Percentage(35), - Constraint::Percentage(35), - Constraint::Percentage(25), - ]; - - let t = Table::new(rows, TABLE_WIDTHS) - .header(header) - .block( - Block::default() - .borders(Borders::TOP | Borders::BOTTOM) - .title(self.title.as_str()), - ) - .row_highlight_style( - Style::default() - .bg(Color::White) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("-> "); - - let selected_element = self - .table - .state - .selected() - .and_then(|i| self.database.get_element(i)); - - let mut text = if let Some(element) = selected_element { - format!( - " - Type: {} - Algorithm: {} - Period: {} {} - Counter: {} - Pin: {} - ", - element.type_, - element.algorithm, - element.period, - if element.period == 1u64 { - "second" - } else { - "seconds" - }, - element - .counter - .map_or_else(|| String::from("N/A"), |e| e.to_string()), - element.pin.clone().unwrap_or_else(|| String::from("N/A")) - ) - } else { - String::new() - }; - - text.push_str("\n\n Press '?' to get help\n"); - let paragraph = Paragraph::new(text) - .block(Block::default().title("Code info").borders(Borders::ALL)) - .style(Style::default().fg(Color::White).bg(Color::Reset)) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); - frame.render_stateful_widget(t, chunks[0], &mut self.table.state); - if Self::is_large_application(frame) { - frame.render_widget(paragraph, chunks[1]); + self.now = now; + if self + .notification + .as_ref() + .is_some_and(|(_, _, start)| start.elapsed() >= Duration::from_secs(4)) + { + self.notification = None; } } +} - fn is_large_application(frame: &mut Frame<'_>) -> bool { - frame.area().width >= LARGE_APPLICATION_WIDTH - } +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() } diff --git a/src/interface/enums.rs b/src/interface/enums.rs index 373bb849..4337beb5 100644 --- a/src/interface/enums.rs +++ b/src/interface/enums.rs @@ -1,21 +1,33 @@ -#[derive(Eq, PartialEq, Debug)] -pub enum Focus { - MainPage, - SearchBar, - Popup, -} - -#[derive(Eq, PartialEq, Debug)] -pub enum PopupAction { - EditOtp, - DeleteOtp, - GeneralInfo, - SaveBeforeQuit, -} - -#[derive(Eq, PartialEq, Debug, Default)] -pub enum Page { - #[default] - Main, - Qrcode, -} +#[derive(Eq, PartialEq, Debug)] +pub enum Focus { + MainPage, + SearchBar, + Popup, + Form, +} + +#[derive(Eq, PartialEq, Debug)] +pub enum PopupAction { + DeleteOtp, + GeneralInfo, + SaveBeforeQuit, +} + +#[derive(Eq, PartialEq, Debug, Default)] +pub enum Page { + #[default] + Main, + Qrcode, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Action { + Key(crossterm::event::KeyCode), + Command(crossterm::event::KeyCode), + Search, + ClearSearch, + Select(usize), + Field(usize), + Save, + Cancel, +} diff --git a/src/interface/event.rs b/src/interface/event.rs index 2726fe17..9b7f30eb 100644 --- a/src/interface/event.rs +++ b/src/interface/event.rs @@ -1,88 +1,49 @@ -use std::sync::mpsc; -use std::thread; -use std::time::{Duration, Instant}; - -use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind}; - use crate::interface::app::AppResult; +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent}; +use std::time::{Duration, Instant}; -/// Terminal events. #[derive(Clone, Debug)] pub enum Event { - /// Terminal tick. Tick, - /// Key press. Key(KeyEvent), - /// Mouse click/scroll. - Mouse(()), - /// Terminal resize. - Resize((), ()), - /// Focus gained - FocusGained(), - /// Focus lost - FocusLost(), - /// Paste text - Paste(()), + Mouse(MouseEvent), + Resize, + Paste(String), } -/// Terminal event handler. +/// Poll on the UI thread so terminal errors propagate and no reader outlives the TUI. #[derive(Debug)] pub struct EventHandler { - // Event sender channel. - //sender: mpsc::Sender, - /// Event receiver channel. - receiver: mpsc::Receiver, - // Event handler thread. - //handler: thread::JoinHandle<()>, + tick_rate: Duration, + last_tick: Instant, } impl EventHandler { - /// Constructs a new instance of [`EventHandler`]. pub fn new(tick_rate: u64) -> Self { - let tick_rate = Duration::from_millis(tick_rate); - let (sender, receiver) = mpsc::channel(); - thread::spawn(move || { - let mut last_tick = Instant::now(); - loop { - let timeout = tick_rate - .checked_sub(last_tick.elapsed()) - .unwrap_or(tick_rate); + Self { + tick_rate: Duration::from_millis(tick_rate), + last_tick: Instant::now(), + } + } - if event::poll(timeout).expect("no events available") { - match event::read().expect("unable to read event") { - CrosstermEvent::Key(e) => { - // Workaround to fix double input on Windows - // Please check https://github.com/crossterm-rs/crossterm/issues/752 - if e.kind == KeyEventKind::Press { - sender.send(Event::Key(e)) - } else { - Ok(()) - } - } - CrosstermEvent::Mouse(_e) => sender.send(Event::Mouse(())), - CrosstermEvent::Resize(_w, _h) => sender.send(Event::Resize((), ())), - CrosstermEvent::FocusGained => sender.send(Event::FocusGained()), - CrosstermEvent::FocusLost => sender.send(Event::FocusLost()), - CrosstermEvent::Paste(_e) => sender.send(Event::Paste(())), + pub fn next(&mut self) -> AppResult { + loop { + let timeout = self.tick_rate.saturating_sub(self.last_tick.elapsed()); + if timeout.is_zero() { + self.last_tick = Instant::now(); + return Ok(Event::Tick); + } + if event::poll(timeout)? { + match event::read()? { + CrosstermEvent::Key(key) if key.kind != KeyEventKind::Release => { + return Ok(Event::Key(key)); } - .expect("failed to send terminal event"); - } - - if last_tick.elapsed() >= tick_rate { - sender.send(Event::Tick).expect("failed to send tick event"); - last_tick = Instant::now(); + CrosstermEvent::Mouse(mouse) => return Ok(Event::Mouse(mouse)), + CrosstermEvent::Paste(text) => return Ok(Event::Paste(text)), + CrosstermEvent::Resize(_, _) => return Ok(Event::Resize), + _ => {} } } - }); - - Self { receiver } - } - - /// Receive the next event from the handler thread. - /// - /// This function will always block the current thread if - /// there is no data available and it's possible for more data to be sent. - pub fn next(&self) -> AppResult { - Ok(self.receiver.recv()?) + } } } diff --git a/src/interface/form.rs b/src/interface/form.rs new file mode 100644 index 00000000..0ff24224 --- /dev/null +++ b/src/interface/form.rs @@ -0,0 +1,448 @@ +//! Account editor. Metadata uses tui-input; secret buffers are masked and zeroized. +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use tui_input::{Input, InputRequest, backend::crossterm::EventHandler}; +use zeroize::{Zeroize, Zeroizing}; + +use super::{ + app::App, + enums::{Focus, Page}, + row::code_for, +}; +use crate::otp::{ + from_otp_uri::FromOtpUri, otp_algorithm::OTPAlgorithm, otp_element::OTPElement, + otp_type::OTPType, +}; + +pub(crate) const SAVE: usize = 9; +pub(crate) const CANCEL: usize = 10; +const TYPES: [&str; 5] = ["TOTP", "HOTP", "STEAM", "YANDEX", "MOTP"]; +const ALGORITHMS: [&str; 4] = ["SHA1", "SHA256", "SHA512", "MD5"]; +pub(crate) const LABELS: [&str; 9] = [ + "Issuer", + "Account label", + "Secret", + "Type", + "Algorithm", + "Digits", + "Period (seconds)", + "Counter", + "PIN", +]; + +#[derive(Default)] +struct SecretInput { + value: Zeroizing, + cursor: usize, +} + +impl SecretInput { + // Secrets and OTP URIs are ASCII. Rebuild on mutation so removed text is zeroized too. + fn replace(&mut self, start: usize, end: usize, text: &str) { + let value = format!("{}{}{}", &self.value[..start], text, &self.value[end..]); + self.value = Zeroizing::new(value); + self.cursor = start + text.len(); + } + + fn handle(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.value.zeroize(); + self.cursor = 0; + } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => self.cursor = 0, + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.cursor = self.value.len() + } + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.value.len(), + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.value.len()), + KeyCode::Backspace if self.cursor > 0 => self.replace(self.cursor - 1, self.cursor, ""), + KeyCode::Delete if self.cursor < self.value.len() => { + self.replace(self.cursor, self.cursor + 1, "") + } + KeyCode::Char(c) + if c.is_ascii() + && !c.is_control() + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.replace(self.cursor, self.cursor, &c.to_string()); + } + _ => {} + } + } +} + +pub(crate) struct AccountForm { + pub(crate) editing: Option, + original: Option, + pub(crate) inputs: [Input; 9], + secret: SecretInput, + pin: SecretInput, + pub(crate) active: usize, + pub(crate) error: Option, +} + +impl AccountForm { + pub(crate) fn new(editing: Option, element: Option<&OTPElement>) -> Self { + let mut form = Self { + editing, + original: element.cloned(), + inputs: std::array::from_fn(|_| Input::default()), + secret: SecretInput::default(), + pin: SecretInput::default(), + active: 0, + error: None, + }; + let defaults = ["", "", "", "TOTP", "SHA1", "6", "30", "0", ""]; + for (input, value) in form.inputs.iter_mut().zip(defaults) { + *input = Input::from(value); + } + if let Some(element) = element { + form.populate_metadata(element); + } + form + } + + fn populate_metadata(&mut self, element: &OTPElement) { + for (index, value) in [ + (0, element.issuer.clone()), + (1, element.label.clone()), + (3, element.type_.to_string()), + (4, element.algorithm.to_string()), + (5, element.digits.to_string()), + (6, element.period.to_string()), + (7, element.counter.unwrap_or(0).to_string()), + ] { + self.inputs[index] = Input::new(value); + } + } + + pub(crate) fn fields(&self) -> Vec { + let kind = self.inputs[3].value(); + (0..9) + .filter(|index| match index { + 4 => kind != "MOTP", + 6 => kind != "HOTP" && kind != "STEAM", + 7 => kind == "HOTP", + 8 => kind == "YANDEX" || kind == "MOTP", + _ => true, + }) + .collect() + } + + pub(crate) fn navigate(&mut self, backwards: bool) { + let mut order = self.fields(); + order.extend([SAVE, CANCEL]); + let position = order + .iter() + .position(|index| *index == self.active) + .unwrap_or(0); + self.active = order[(position + if backwards { order.len() - 1 } else { 1 }) % order.len()]; + } + + pub(crate) fn is_choice(index: usize) -> bool { + index == 3 || index == 4 + } + + pub(crate) fn cycle(&mut self, backwards: bool) { + let options: &[&str] = if self.active == 3 { + &TYPES + } else { + &ALGORITHMS + }; + let current = self.inputs[self.active].value(); + let position = options + .iter() + .position(|value| *value == current) + .unwrap_or(0); + let next = (position + if backwards { options.len() - 1 } else { 1 }) % options.len(); + self.inputs[self.active] = Input::from(options[next]); + if self.active == 3 { + let digits = match options[next] { + "STEAM" => "5", + "YANDEX" => "8", + _ => "6", + }; + self.inputs[5] = Input::from(digits); + if options[next] == "YANDEX" { + self.inputs[4] = Input::from("SHA256"); + } + if options[next] == "MOTP" { + self.inputs[6] = Input::from("10"); + } + } + } + + pub(crate) fn display_input(&self, index: usize) -> Input { + match index { + 2 => Input::new("•".repeat(self.secret.value.len())).with_cursor(self.secret.cursor), + 8 => Input::new("•".repeat(self.pin.value.len())).with_cursor(self.pin.cursor), + _ => self.inputs[index].clone(), + } + } + + pub(crate) fn hint(&self, index: usize) -> &'static str { + match index { + 0 => "Service name, for example GitHub", + 1 => "Email, username, or account name", + 2 if self.editing.is_some() => "Leave blank to keep the current secret", + 2 => "Secret key; you can also paste an otpauth:// URI", + 3 | 4 => "Click, ← / → or Space to change", + 5 => "Number of characters in the generated code (1–10)", + 6 => "Must match the service's refresh period", + 7 => "Must match the service's HOTP counter", + 8 if self.editing.is_some() => "Leave blank to keep the current PIN", + 8 => "Required for Yandex and MOTP", + _ => "", + } + } + + pub(crate) fn paste(&mut self, text: &str) { + if self.active >= SAVE || Self::is_choice(self.active) { + return; + } + if self.active == 2 && text.trim().starts_with("otpauth://") { + if !["totp", "hotp", "steam", "yandex", "motp"] + .iter() + .any(|kind| text.trim().starts_with(&format!("otpauth://{kind}/"))) + { + self.error = Some("Unsupported OTP URI account type.".into()); + return; + } + match OTPElement::from_otp_uri(text.trim()) { + Ok(element) => { + self.populate_metadata(&element); + self.secret.value = Zeroizing::new(element.secret.clone()); + self.secret.cursor = self.secret.value.len(); + self.error = None; + } + Err(_) => { + self.error = + Some("Invalid OTP URI. Check the account and secret parameters.".into()) + } + } + return; + } + if self.active == 2 || self.active == 8 { + let clean = Zeroizing::new( + text.chars() + .filter(|c| c.is_ascii() && !c.is_control()) + .collect::(), + ); + let input = if self.active == 2 { + &mut self.secret + } else { + &mut self.pin + }; + input.replace(input.cursor, input.cursor, &clean); + } else { + for c in text.chars().filter(|c| !c.is_control()) { + self.inputs[self.active].handle(InputRequest::InsertChar(c)); + } + } + } + + pub(crate) fn input(&mut self, key: KeyEvent) { + if self.active >= SAVE { + return; + } + if Self::is_choice(self.active) { + if matches!( + key.code, + KeyCode::Left | KeyCode::Right | KeyCode::Char(' ') + ) { + self.cycle(key.code == KeyCode::Left); + } + } else if self.active == 2 { + self.secret.handle(key); + } else if self.active == 8 { + self.pin.handle(key); + } else { + self.inputs[self.active].handle_event(&Event::Key(key)); + } + } + + pub(crate) fn build(&self) -> Result { + let fail = |field, message: &str| (field, message.to_string()); + let label = self.inputs[1].value().trim(); + if label.is_empty() { + return Err(fail(1, "Enter an account label.")); + } + let digits = self.inputs[5] + .value() + .parse::() + .ok() + .filter(|n| (1..=10).contains(n)) + .ok_or_else(|| fail(5, "Digits must be a number from 1 to 10."))?; + let kind = OTPType::from(self.inputs[3].value()); + let period = match kind { + OTPType::Steam | OTPType::Hotp => 30, + _ => self.inputs[6] + .value() + .parse::() + .ok() + .filter(|n| *n > 0) + .ok_or_else(|| fail(6, "Period must be a whole number greater than zero."))?, + }; + let counter = if kind == OTPType::Hotp { + Some( + self.inputs[7] + .value() + .parse::() + .map_err(|_| fail(7, "Counter must be a non-negative whole number."))?, + ) + } else { + None + }; + let secret = if self.secret.value.is_empty() { + self.original + .as_ref() + .map(|e| e.secret.as_str()) + .unwrap_or("") + } else { + &self.secret.value + }; + let secret = Zeroizing::new( + secret + .chars() + .filter(|c| !c.is_whitespace()) + .collect::(), + ); + if secret.is_empty() { + return Err(fail(2, "Enter the account's secret key.")); + } + let pin = if matches!(kind, OTPType::Yandex | OTPType::Motp) { + let pin = if self.pin.value.is_empty() { + self.original + .as_ref() + .and_then(|e| e.pin.as_deref()) + .unwrap_or("") + } else { + &self.pin.value + }; + if pin.is_empty() { + return Err(fail(8, "A PIN is required for this account type.")); + } + Some(pin.to_string()) + } else { + None + }; + let element = OTPElement { + secret: if kind == OTPType::Motp { + secret.to_lowercase() + } else { + secret.to_uppercase() + }, + issuer: self.inputs[0].value().trim().into(), + label: label.into(), + digits, + type_: kind, + algorithm: OTPAlgorithm::from(self.inputs[4].value()), + period, + counter, + pin, + }; + let decoded = if kind == OTPType::Motp { + hex::decode(&element.secret) + .map_err(|_| fail(2, "MOTP requires a hexadecimal secret key."))? + } else { + data_encoding::BASE32_NOPAD + .decode(element.secret.as_bytes()) + .map_err(|_| fail(2, "Enter a valid Base32 secret key (A–Z and 2–7)."))? + }; + let _decoded = Zeroizing::new(decoded); + code_for(&element).map_err(|error| fail(2, &format!("Cannot generate a code: {error}")))?; + Ok(element) + } +} + +impl App<'_> { + pub(crate) fn open_form(&mut self, edit: bool) { + let index = if edit { + self.table.selected_index() + } else { + None + }; + if edit && index.is_none() { + self.notify("Select an account to edit", true); + return; + } + self.form = Some(AccountForm::new( + index, + index.and_then(|i| self.database.get_element(i)), + )); + self.current_page = Page::Main; + self.focus = Focus::Form; + } + + pub(crate) fn cancel_form(&mut self) { + self.form = None; + self.focus = Focus::MainPage; + } + + pub(crate) fn save_form(&mut self) { + let Some(form) = self.form.as_mut() else { + return; + }; + match form.build() { + Ok(element) => { + let editing = form.editing; + let index = editing.unwrap_or(self.database.elements_ref().len()); + if let Some(existing) = editing.and_then(|i| self.database.mut_element(i)) { + *existing = element; + self.database.mark_modified(); + } else { + self.database.add_element(element); + } + self.cancel_form(); + self.search_query.clear(); + self.filter(); + self.table.state.select( + self.table + .items + .iter() + .position(|row| row.database_index == index), + ); + self.notify( + if editing.is_some() { + "Account updated · changes pending save" + } else { + "Account added · changes pending save" + }, + false, + ); + } + Err((field, error)) => { + form.active = field; + form.error = Some(error); + } + } + } +} + +pub(crate) fn handle_form_key(key: KeyEvent, app: &mut App) { + if key.code == KeyCode::Esc + || key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) + { + app.cancel_form(); + return; + } + if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) { + app.save_form(); + return; + } + let Some(form) = app.form.as_mut() else { + return; + }; + match key.code { + KeyCode::Tab | KeyCode::Down => form.navigate(false), + KeyCode::BackTab | KeyCode::Up => form.navigate(true), + KeyCode::Enter if form.active == SAVE => app.save_form(), + KeyCode::Enter if form.active == CANCEL => app.cancel_form(), + KeyCode::Enter => form.navigate(false), + _ => form.input(key), + } +} diff --git a/src/interface/form_view.rs b/src/interface/form_view.rs new file mode 100644 index 00000000..9bb6e8b2 --- /dev/null +++ b/src/interface/form_view.rs @@ -0,0 +1,157 @@ +use super::{ + app::App, + enums::Action, + form::{AccountForm, CANCEL, LABELS, SAVE}, + popup::centered_rect, + theme::*, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Margin, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph, Wrap}, +}; + +impl App<'_> { + pub(crate) fn render_form(&mut self, frame: &mut Frame<'_>) { + self.hitboxes.clear(); + let Some(form) = self.form.as_ref() else { + return; + }; + let screen = frame.area(); + frame.buffer_mut().set_style( + screen, + Style::default().fg(MUTED).add_modifier(Modifier::DIM), + ); + let height = (form.fields().len() as u16 * 3 + 9).min(34); + let area = centered_rect(78, height, screen); + frame.render_widget(Clear, area); + let block = panel(if form.editing.is_some() { + "Edit account" + } else { + "Add account" + }) + .border_style(Style::default().fg(ACCENT)); + let inner = block.inner(area).inner(Margin::new(1, 0)); + frame.render_widget(block, area); + let [intro, fields_area, hint, message, buttons, shortcuts] = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + frame.render_widget( + Paragraph::new("Account details\nChanges are written to your vault when you quit.") + .style(Style::default().fg(MUTED)), + intro, + ); + let fields = form.fields(); + let visible = (fields_area.height / 3).max(1) as usize; + let active_position = fields + .iter() + .position(|index| *index == form.active) + .unwrap_or(fields.len().saturating_sub(1)); + let offset = active_position.saturating_sub(visible - 1); + for (row, index) in fields.iter().skip(offset).take(visible).enumerate() { + let index = *index; + let rect = Rect::new( + fields_area.x, + fields_area.y + row as u16 * 3, + fields_area.width, + 3.min(fields_area.height), + ); + if rect.bottom() > fields_area.bottom() { + break; + } + let active = form.active == index; + let input = form.display_input(index); + let block = panel(format!( + "{}{}", + LABELS[index], + if index == 2 || index == 8 { + " · masked" + } else { + "" + } + )) + .border_style(Style::default().fg(if active { ACCENT } else { BORDER })); + let input_area = block.inner(rect); + let width = input_area.width.saturating_sub(2) as usize; + let scroll = input.visual_scroll(width); + let value = if AccountForm::is_choice(index) { + format!("‹ {} ›", input.value()) + } else if input.value().is_empty() + && form.editing.is_some() + && (index == 2 || index == 8) + { + "Unchanged".into() + } else { + input.value().to_owned() + }; + frame.render_widget( + Paragraph::new(value) + .style(Style::default().fg(if active { TEXT } else { MUTED })) + .scroll((0, scroll.min(u16::MAX as usize) as u16)) + .block(block), + rect, + ); + self.hitboxes.push((rect, Action::Field(index))); + if active && !AccountForm::is_choice(index) && input_area.width > 0 { + frame.set_cursor_position(( + input_area.x + input.visual_cursor().saturating_sub(scroll).min(width) as u16, + input_area.y, + )); + } + } + frame.render_widget( + Paragraph::new(form.hint(form.active)).style(Style::default().fg(MUTED)), + hint, + ); + let message_text = form + .error + .as_deref() + .unwrap_or("Tab moves between fields and buttons. Enter activates a button."); + frame.render_widget( + Paragraph::new(message_text) + .style(Style::default().fg(if form.error.is_some() { DANGER } else { MUTED })) + .wrap(Wrap { trim: true }), + message, + ); + let [save, cancel, _] = Layout::horizontal([ + Constraint::Length(18), + Constraint::Length(12), + Constraint::Fill(1), + ]) + .spacing(1) + .areas(buttons); + for (rect, label, index, action) in [ + (save, "Save account", SAVE, Action::Save), + (cancel, "Cancel", CANCEL, Action::Cancel), + ] { + let active = form.active == index; + frame.render_widget( + Paragraph::new( + Line::from(Span::styled( + format!("[ {label} ]"), + Style::default() + .fg(if active { BACKGROUND } else { ACCENT }) + .bg(if active { ACCENT } else { SELECTED }) + .bold(), + )) + .centered(), + ), + rect, + ); + self.hitboxes.push((rect, action)); + } + frame.render_widget( + Paragraph::new("Ctrl+S save · Esc cancel · Tab / Shift+Tab move") + .style(Style::default().fg(MUTED)), + shortcuts, + ); + } +} diff --git a/src/interface/handlers/main_window.rs b/src/interface/handlers/main_window.rs index ec9ca955..e55cc413 100644 --- a/src/interface/handlers/main_window.rs +++ b/src/interface/handlers/main_window.rs @@ -1,145 +1,114 @@ -use super::{ - super::enums::Page::{Main, Qrcode}, - copy_selected_code_to_clipboard, -}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - +use super::{copy_selected, handle_exit, show_popup}; use crate::{ - clipboard::copy_string_to_clipboard, interface::{ app::{App, Popup}, enums::{Focus, Page, PopupAction}, }, otp::otp_type::OTPType, }; - -use super::{handle_exit, show_popup}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; pub(super) fn main_handler(key_event: KeyEvent, app: &mut App) { match key_event.code { - // exit application on ESC or Q - KeyCode::Esc | KeyCode::Char('q' | 'Q') if app.focus != Focus::SearchBar => { - handle_exit(app); - } - - // exit application on Ctrl-D - KeyCode::Char('d' | 'D' | 'c') => { - if key_event.modifiers == KeyModifiers::CONTROL { + KeyCode::Char('a') => app.open_form(false), + KeyCode::Char('e') => app.open_form(true), + KeyCode::Esc => { + if app.current_page == Page::Qrcode { + app.reset(); + } else if !app.search_query.is_empty() { + app.search_query.clear(); + app.filter(); + } else { handle_exit(app); - } else if app.table.state.selected().is_some() { - // Ask the user if he wants to delete the OTP Code + } + } + KeyCode::Char('q' | 'Q') => handle_exit(app), + KeyCode::Char('d' | 'D') | KeyCode::Delete if app.current_page == Page::Main => { + if let Some(index) = app.table.selected_index() { + let element = &app.database.elements_ref()[index]; show_popup( Popup { - text: String::from("Do you want to delete the selected OTP Code? [Y/N]"), - percent_x: 60, - percent_y: 20, + text: format!( + "Remove {} / {} from your vault?\n\nThis removes the saved secret for this account. Changes are saved when you quit.", + element.issuer, element.label + ), action: PopupAction::DeleteOtp, }, app, ); } } - - // Move into the table KeyCode::Up | KeyCode::Char('k') => { - app.print_percentage = true; - app.current_page = Main; + app.reset(); app.table.previous(); } - KeyCode::Down | KeyCode::Char('j') => { - app.print_percentage = true; - app.current_page = Main; + app.reset(); app.table.next(); } - - KeyCode::Char('+') => { - app.current_page = Main; - handle_counter_switch(app, true); + KeyCode::Home => { + app.reset(); + app.table.move_by(isize::MIN); } - - KeyCode::Char('-') => { - app.current_page = Main; - handle_counter_switch(app, false); + KeyCode::End => { + app.reset(); + app.table.move_by(isize::MAX); } - - KeyCode::Char(' ') => handle_switch_page(app, Qrcode), - - KeyCode::Char('?') => { - let info_text = String::from( - " - Press: - d -> Delete selected code - + -> Increment the HOTP counter - - -> Decrement the HOTP counter - Space -> Show QRCode of the selected element - Enter -> Copy the OTP Code to the clipboard - CTRL-F | '/' -> Search codes - CTRL-W | CTRL-U -> Clear the search query - q, CTRL-D, Esc -> Exit the application - ", - ); - show_popup( - Popup { - text: info_text, - percent_x: 40, - percent_y: 50, - action: PopupAction::GeneralInfo, - }, - app, - ); + KeyCode::PageUp => { + app.reset(); + app.table.move_by(-10); } - - KeyCode::Char('f' | 'F') if key_event.modifiers == KeyModifiers::CONTROL => { - app.focus = Focus::SearchBar; + KeyCode::PageDown => { + app.reset(); + app.table.move_by(10); } - - KeyCode::Char('/') => app.focus = Focus::SearchBar, - - KeyCode::Enter => match app.current_page { - Main => { - app.label_text = copy_selected_code_to_clipboard(app); - app.print_percentage = false; - } - Qrcode => { - let selected_element = app - .table - .state - .selected() - .and_then(|index| app.database.elements_ref().get(index)); - - if let Some(element) = selected_element { - let otp_uri = element.get_otpauth_uri(); - let _ = copy_string_to_clipboard(&otp_uri); - app.qr_code_page_label = "OTP URI Copied to clipboard"; - } + KeyCode::Char('+') => handle_counter_switch(app, true), + KeyCode::Char('-') => handle_counter_switch(app, false), + KeyCode::Char('h') => { + app.hide_codes = !app.hide_codes; + app.reset(); + } + KeyCode::Char(' ') => { + if app.current_page == Page::Qrcode { + app.reset(); + } else if app.table.selected_index().is_some() { + app.current_page = Page::Qrcode; } - }, + } + KeyCode::Char('?' | 'i') => show_popup( + Popup { + text: String::new(), + action: PopupAction::GeneralInfo, + }, + app, + ), + KeyCode::Char('f' | 'F') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + app.current_page = Page::Main; + app.focus = Focus::SearchBar; + } + KeyCode::Char('/') => { + app.current_page = Page::Main; + app.focus = Focus::SearchBar; + } + KeyCode::Enter => copy_selected(app, app.current_page == Page::Qrcode), _ => {} } } fn handle_counter_switch(app: &mut App, increment: bool) { - if let Some(selected) = app.table.state.selected() + if let Some(selected) = app.table.selected_index() && let Some(element) = app.database.mut_element(selected) && element.type_ == OTPType::Hotp { - // safe to unwrap because the element type is HOTP - let counter = element.counter.unwrap(); - element.counter = if increment { - Some(counter.saturating_add(1)) + let counter = element.counter.unwrap_or_default(); + let next = if increment { + counter.saturating_add(1) } else { - Some(counter.saturating_sub(1)) + counter.saturating_sub(1) }; + element.counter = Some(next); app.database.mark_modified(); app.tick(true); - } -} - -fn handle_switch_page(app: &mut App, page: Page) { - if app.current_page == page { - app.reset(); - } else { - app.current_page = page; + app.notify(format!("HOTP counter updated to {next}"), false); } } diff --git a/src/interface/handlers/mod.rs b/src/interface/handlers/mod.rs index 41fdd32a..988e9cd8 100644 --- a/src/interface/handlers/mod.rs +++ b/src/interface/handlers/mod.rs @@ -1,63 +1,160 @@ -use crossterm::event::KeyEvent; - -use crate::clipboard::{CopyType, copy_string_to_clipboard}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use self::{main_window::main_handler, popup::popup_handler, search_bar::search_bar_handler}; - use super::{ app::{App, Popup}, - enums::{Focus, PopupAction}, + enums::{Action, Focus, Page, PopupAction}, + row::code_for, }; +use crate::clipboard::{CopyType, copy_string_to_clipboard}; mod main_window; mod popup; mod search_bar; -/// Handles the key events and updates the state of [`App`]. pub fn handle_key_events(key_event: KeyEvent, app: &mut App) { + if app.focus == Focus::Form { + super::form::handle_form_key(key_event, app); + return; + } + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c' | 'd')) + { + handle_exit(app); + return; + } match app.focus { Focus::MainPage => main_handler(key_event, app), Focus::SearchBar => search_bar_handler(key_event, app), Focus::Popup => popup_handler(key_event, app), + Focus::Form => super::form::handle_form_key(key_event, app), + } +} + +pub fn handle_mouse_events(event: MouseEvent, app: &mut App) { + if event.kind == MouseEventKind::Down(MouseButton::Left) { + let action = app + .hitboxes + .iter() + .rev() + .find(|(area, _)| area.contains((event.column, event.row).into())) + .map(|(_, action)| *action); + match action { + Some(Action::Key(code)) => { + handle_key_events(KeyEvent::new(code, KeyModifiers::NONE), app) + } + Some(Action::Command(code)) => { + app.focus = Focus::MainPage; + main_handler(KeyEvent::new(code, KeyModifiers::NONE), app); + } + Some(Action::ClearSearch) => { + app.search_query.clear(); + app.filter(); + } + Some(Action::Search) => { + app.current_page = Page::Main; + app.focus = Focus::SearchBar; + } + Some(Action::Select(index)) => { + app.table.state.select(Some(index)); + app.focus = Focus::MainPage; + } + Some(Action::Field(index)) => { + if let Some(form) = app.form.as_mut() { + form.active = index; + if super::form::AccountForm::is_choice(index) { + form.cycle(false); + } + } + } + Some(Action::Save) => app.save_form(), + Some(Action::Cancel) => app.cancel_form(), + None => {} + } + return; + } + let backwards = match event.kind { + MouseEventKind::ScrollUp => true, + MouseEventKind::ScrollDown => false, + _ => return, + }; + match app.focus { + Focus::Form => { + if let Some(form) = app.form.as_mut() { + form.navigate(backwards); + } + } + Focus::Popup => { + popup_handler( + KeyEvent::new( + if backwards { + KeyCode::Up + } else { + KeyCode::Down + }, + KeyModifiers::NONE, + ), + app, + ); + } + _ if app.current_page == Page::Main => { + if backwards { + app.table.previous(); + } else { + app.table.next(); + } + } + _ => {} + } +} + +pub fn handle_paste(text: &str, app: &mut App) { + if let Some(form) = app.form.as_mut() { + form.paste(text); + return; + } + if app.focus == Focus::SearchBar { + app.search_query + .extend(text.chars().filter(|c| !c.is_control())); + app.filter(); } } pub(super) fn handle_exit(app: &mut App) { if app.database.is_modified() { - show_popup( - Popup { - text: String::from("Save changes? [Y/N]"), - percent_x: 60, - percent_y: 20, - action: PopupAction::SaveBeforeQuit, - }, - app, - ); + show_popup(Popup { + text: "Your account changes have not been saved.\n\nSave them to your encrypted vault before quitting?".into(), + action: PopupAction::SaveBeforeQuit, + }, app); } else { app.running = false; } } -pub(crate) fn copy_selected_code_to_clipboard(app: &mut App) -> String { - match app.table.state.selected() { - Some(selected) => match app.table.items.get(selected) { - Some(element) => match element.values.get(3) { - Some(otp_code) => match copy_string_to_clipboard(otp_code) { - Ok(result) => match result { - CopyType::Native => "Copied!".to_string(), - CopyType::OSC52 => "Remote copied!".to_string(), - }, - _ => "Cannot copy".to_string(), - }, - None => "Cannot get OTP Code column".to_string(), - }, - None => format!("Cannot fetch element from index: {selected}"), - }, - None => "No code selected".to_string(), +pub(crate) fn copy_selected(app: &mut App, uri: bool) { + let result = app + .table + .selected_index() + .and_then(|index| app.database.get_element(index)) + .ok_or_else(|| "No account selected".to_string()) + .and_then(|element| { + if uri { + Ok(element.get_otpauth_uri()) + } else { + code_for(element) + } + }) + .and_then(|text| copy_string_to_clipboard(&text).map_err(|error| error.to_string())); + let noun = if uri { "OTP URI" } else { "Code" }; + match result { + Ok(CopyType::Native) => app.notify(format!("✓ {noun} copied to clipboard"), false), + Ok(CopyType::OSC52) => app.notify(format!("✓ {noun} sent to terminal clipboard"), false), + Err(error) => app.notify(format!("Could not copy: {error}"), true), } } fn show_popup(popup: Popup, app: &mut App) { app.focus = Focus::Popup; app.popup = popup; + app.popup_scroll = 0; } diff --git a/src/interface/handlers/popup.rs b/src/interface/handlers/popup.rs index da032310..94a0f7d2 100644 --- a/src/interface/handlers/popup.rs +++ b/src/interface/handlers/popup.rs @@ -1,66 +1,51 @@ -use crossterm::event::{KeyCode, KeyEvent}; - use crate::interface::{ app::App, enums::{Focus, PopupAction}, }; +use crossterm::event::{KeyCode, KeyEvent}; pub(super) fn popup_handler(key_event: KeyEvent, app: &mut App) { match app.popup.action { - PopupAction::EditOtp => todo!(), PopupAction::DeleteOtp => match key_event.code { KeyCode::Char('y' | 'Y') => { - if let Err(e) = delete_selected_code(app) { - app.popup.text = e; - return; + if let Some(index) = app.table.selected_index() { + app.database.delete_element(index); + // Database indices shift after deletion. Select the next visible account. + let position = app.table.state.selected().unwrap_or(0); + app.table.items.clear(); + app.filter(); + if !app.table.items.is_empty() { + app.table + .state + .select(Some(position.min(app.table.items.len() - 1))); + } + app.notify("Account removed · changes pending save", false); } app.focus = Focus::MainPage; - // Force table render - app.tick(true); - } - KeyCode::Char('n' | 'N') | KeyCode::Esc => { - app.focus = Focus::MainPage; } + KeyCode::Char('n' | 'N') | KeyCode::Esc => app.focus = Focus::MainPage, _ => {} }, PopupAction::GeneralInfo => match key_event.code { - KeyCode::Char('I' | 'i') | KeyCode::Esc | KeyCode::Enter => { - app.focus = Focus::MainPage; + KeyCode::Down | KeyCode::Char('j') => { + app.popup_scroll = app.popup_scroll.saturating_add(1).min(14) + } + KeyCode::Up | KeyCode::Char('k') => { + app.popup_scroll = app.popup_scroll.saturating_sub(1) + } + KeyCode::Char('I' | 'i' | '?') | KeyCode::Esc | KeyCode::Enter => { + app.focus = Focus::MainPage } _ => {} }, PopupAction::SaveBeforeQuit => match key_event.code { - KeyCode::Char('y' | 'Y') => { - app.running = false; - } + KeyCode::Char('y' | 'Y') => app.running = false, KeyCode::Char('n' | 'N') => { app.database.needs_modification = false; app.running = false; } - KeyCode::Esc => { - app.focus = Focus::MainPage; - } + KeyCode::Esc => app.focus = Focus::MainPage, _ => {} }, } } - -fn delete_selected_code(app: &mut App) -> Result { - match app.table.state.selected() { - Some(selected) => { - if app.database.elements_ref().len() > selected { - app.database.delete_element(selected); - app.table.items.remove(selected); - if selected >= app.database.elements_ref().len() { - app.table.previous(); - } else if app.database.elements_ref().is_empty() { - app.table.state.select(None); - } - Ok("Done".to_string()) - } else { - Err("Index out of bounds".to_string()) - } - } - None => Err("No code selected".to_string()), - } -} diff --git a/src/interface/handlers/search_bar.rs b/src/interface/handlers/search_bar.rs index eaa00d4d..3b0cf496 100644 --- a/src/interface/handlers/search_bar.rs +++ b/src/interface/handlers/search_bar.rs @@ -1,102 +1,34 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - +use super::copy_selected; use crate::interface::{app::App, enums::Focus}; - -use super::{copy_selected_code_to_clipboard, handle_exit, main_window::main_handler}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; pub(super) fn search_bar_handler(key_event: KeyEvent, app: &mut App) { match key_event.code { - KeyCode::Char(c) => { - if key_event.modifiers == KeyModifiers::CONTROL { - match c.to_ascii_lowercase() { - 'f' => { - app.search_query.clear(); - app.focus = Focus::MainPage; - } - 'c' => handle_exit(app), - 'w' | 'u' => app.search_query.clear(), - _ => {} + KeyCode::Char(c) if key_event.modifiers.contains(KeyModifiers::CONTROL) => { + match c.to_ascii_lowercase() { + 'f' => app.focus = Focus::MainPage, + 'w' | 'u' => { + app.search_query.clear(); + app.filter(); } - } else { - app.search_query.push(c); - search_and_select(app); + _ => {} } } - KeyCode::Enter => { - app.label_text = copy_selected_code_to_clipboard(app); - app.print_percentage = false; - app.focus = Focus::MainPage; + KeyCode::Char(c) if !key_event.modifiers.contains(KeyModifiers::ALT) => { + app.search_query.push(c); + app.filter(); } - KeyCode::Esc => { + KeyCode::Enter => { + copy_selected(app, false); app.focus = Focus::MainPage; } + KeyCode::Esc | KeyCode::Tab => app.focus = Focus::MainPage, KeyCode::Backspace => { app.search_query.pop(); + app.filter(); } - KeyCode::Up | KeyCode::Down => { - app.focus = Focus::MainPage; - main_handler(key_event, app); - } + KeyCode::Up => app.table.previous(), + KeyCode::Down => app.table.next(), _ => {} } } - -fn search_and_select(app: &mut App) { - // Check for issuer - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(1) - .unwrap() - .to_lowercase() - .starts_with(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } - } - // Check for label - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(2) - .unwrap() - .to_lowercase() - .starts_with(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } - } - // Check if issuer contains the query - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(1) - .unwrap() - .to_lowercase() - .contains(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } - } - // Check if label contains the query - for iter in app.table.items.iter().enumerate() { - let (index, row) = iter; - if row - .values - .get(2) - .unwrap() - .to_lowercase() - .contains(&app.search_query.to_lowercase()) - { - app.table.state.select(Some(index)); - return; - } - } - // TODO Handle if no search results -} diff --git a/src/interface/mod.rs b/src/interface/mod.rs index e7be174a..00ddc4f4 100644 --- a/src/interface/mod.rs +++ b/src/interface/mod.rs @@ -1,8 +1,15 @@ -pub mod app; -mod enums; -pub mod event; -pub mod handlers; -mod popup; -mod row; -pub mod stateful_table; -pub mod ui; +pub mod app; +mod enums; +pub mod event; +pub mod handlers; +mod popup; +mod row; +pub mod stateful_table; +#[cfg(test)] +mod tests; +mod theme; +pub mod ui; +mod view; + +mod form; +mod form_view; diff --git a/src/interface/popup.rs b/src/interface/popup.rs index f542e345..02697d09 100644 --- a/src/interface/popup.rs +++ b/src/interface/popup.rs @@ -1,29 +1,10 @@ -use ratatui::layout::{Constraint, Direction}; -use ratatui::layout::{Layout, Rect}; - -/// helper function to create a centered rect using up certain percentage of the available rect `r` -pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ] - .as_ref(), - ) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ] - .as_ref(), - ) - .split(popup_layout[1])[1] -} +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +pub(crate) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let [area] = Layout::vertical([Constraint::Length(height.min(area.height))]) + .flex(Flex::Center) + .areas(area); + Layout::horizontal([Constraint::Length(width.min(area.width))]) + .flex(Flex::Center) + .areas::<1>(area)[0] +} diff --git a/src/interface/row.rs b/src/interface/row.rs index b6d164c7..00009c50 100644 --- a/src/interface/row.rs +++ b/src/interface/row.rs @@ -1,37 +1,56 @@ -use ratatui::style::Color::{Black, Yellow}; -use ratatui::style::Style; -use ratatui::widgets::Cell; +use crate::otp::{otp_element::OTPElement, otp_type::OTPType}; pub(crate) struct Row { - pub(crate) values: Vec, - has_error: bool, + /// Stable database position, independent of the filtered table position. + pub(crate) database_index: usize, + pub(crate) issuer: String, + pub(crate) label: String, + pub(crate) code: String, + pub(crate) has_error: bool, } impl Row { - pub(crate) fn new(values: Vec, has_error: bool) -> Self { - Row { values, has_error } + pub(crate) fn new(database_index: usize, element: &OTPElement) -> Self { + let result = code_for(element); + Self { + database_index, + issuer: element.issuer.clone(), + label: element.label.clone(), + has_error: result.is_err(), + code: result.unwrap_or_else(|error| error), + } } - pub fn height(&self) -> u16 { - (self - .values - .iter() - .map(|content| content.chars().filter(|c| *c == '\n').count()) - .max() - .unwrap_or(0) - + 1) as u16 +} + +pub(crate) fn code_for(element: &OTPElement) -> Result { + if period_for(element) == Some(0) { + return Err("Invalid period: must be greater than zero".into()); } + element.get_otp_code().map_err(|error| error.to_string()) +} - pub fn cells(&self) -> Vec> { - self.values - .iter() - .map(|c| { - let style = if self.has_error { - Style::default().bg(Yellow).fg(Black) - } else { - Style::default() - }; - Cell::from(c.as_str()).style(style) - }) - .collect() +pub(crate) fn period_for(element: &OTPElement) -> Option { + match element.type_ { + OTPType::Hotp => None, + OTPType::Steam => Some(30), + _ => Some(element.period), + } +} + +pub(crate) fn remaining(element: &OTPElement, now: u64) -> Option<(u64, u64)> { + period_for(element) + .filter(|period| *period > 0) + .map(|period| (period - now % period, period)) +} + +pub(crate) fn display_code(code: &str, hidden: bool) -> String { + if hidden { + return "••• •••".into(); + } + if code.len() >= 6 && code.bytes().all(|byte| byte.is_ascii_digit()) { + let (left, right) = code.split_at(code.len() / 2); + format!("{left} {right}") + } else { + code.to_owned() } } diff --git a/src/interface/stateful_table.rs b/src/interface/stateful_table.rs index 3d399d95..a7ec0938 100644 --- a/src/interface/stateful_table.rs +++ b/src/interface/stateful_table.rs @@ -1,84 +1,71 @@ use crate::interface::row::Row; +use crate::otp::otp_element::OTPElement; use ratatui::widgets::TableState; -use crate::otp::{otp_element::OTPElement, otp_type::OTPType}; - pub struct StatefulTable { pub(crate) state: TableState, pub(crate) items: Vec, } impl StatefulTable { - pub fn new(elements: &[OTPElement]) -> StatefulTable { - let mut table = StatefulTable { + pub fn new(elements: &[OTPElement]) -> Self { + let mut table = Self { state: TableState::default(), items: vec![], }; - fill_table(&mut table, elements); + table.refresh(elements, ""); table } - pub fn next(&mut self) { - let selected = if self.items.is_empty() { - None - } else { - Some(match self.state.selected() { - Some(i) => { - if i >= self.items.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, + + pub(crate) fn selected_index(&self) -> Option { + self.state + .selected() + .and_then(|index| self.items.get(index)) + .map(|row| row.database_index) + } + + pub(crate) fn refresh(&mut self, elements: &[OTPElement], query: &str) { + let selected = self.selected_index(); + let previous_position = self.state.selected().unwrap_or(0); + let query = query.to_lowercase(); + self.items = elements + .iter() + .enumerate() + .filter(|(_, element)| { + format!("{} {}", element.issuer, element.label) + .to_lowercase() + .contains(&query) }) - }; - self.state.select(selected); + .map(|(index, element)| Row::new(index, element)) + .collect(); + let position = selected + .and_then(|index| { + self.items + .iter() + .position(|row| row.database_index == index) + }) + .or_else(|| { + (!self.items.is_empty()).then(|| previous_position.min(self.items.len() - 1)) + }); + self.state.select(position); } - pub fn previous(&mut self) { - let i = match self.state.selected() { - Some(i) => { - if i == 0 { - self.items.len().checked_sub(1) - } else { - Some(i - 1) - } - } - None => { - if self.items.is_empty() { - None - } else { - Some(0) - } - } - }; - self.state.select(i); + pub fn next(&mut self) { + self.move_by(1); } -} -pub fn fill_table(table: &mut StatefulTable, elements: &[OTPElement]) { - for (i, element) in elements.iter().enumerate() { - let label = match element.type_ { - OTPType::Hotp => match element.counter { - Some(result) => element.label.clone() + (format!(" ({result} counter)").as_str()), - None => element.label.clone(), - }, - _ => element.label.clone(), - }; - let result = element.get_otp_code(); + pub fn previous(&mut self) { + self.move_by(-1); + } - let error = result.is_err(); - table.items.push(Row::new( - vec![ - (i + 1).to_string(), - element.issuer.clone(), - label, - match result { - Ok(code) => code, - Err(e) => e.to_string(), - }, - ], - error, - )); + pub(crate) fn move_by(&mut self, delta: isize) { + if !self.items.is_empty() { + let index = self + .state + .selected() + .map_or(0, |index| index.saturating_add_signed(delta)) + .min(self.items.len() - 1); + self.state.select(Some(index)); + } } } diff --git a/src/interface/tests.rs b/src/interface/tests.rs new file mode 100644 index 00000000..8eca81b1 --- /dev/null +++ b/src/interface/tests.rs @@ -0,0 +1,744 @@ +use super::{ + app::App, + enums::{Focus, Page}, + handlers::{handle_key_events, handle_mouse_events, handle_paste}, + row::{code_for, remaining}, +}; +use crate::otp::{ + otp_algorithm::OTPAlgorithm, + otp_element::{OTPDatabase, OTPElement}, + otp_type::OTPType, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use ratatui::{Terminal, backend::TestBackend, buffer::Buffer}; + +fn account(issuer: &str, label: &str) -> OTPElement { + OTPElement { + secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".into(), + issuer: issuer.into(), + label: label.into(), + digits: 6, + type_: OTPType::Totp, + algorithm: OTPAlgorithm::Sha1, + period: 30, + counter: None, + pin: None, + } +} + +fn database() -> OTPDatabase { + OTPDatabase { + elements: vec![ + account("GitHub", "alex@studio.dev"), + account("Google", "alex@example.com"), + account("Linear", "Design team"), + ], + ..Default::default() + } +} + +fn key(app: &mut App, code: KeyCode) { + handle_key_events(KeyEvent::new(code, KeyModifiers::NONE), app); +} + +fn render(app: &mut App, width: u16, height: u16) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| app.render(frame)).unwrap(); + terminal.backend().buffer().clone() +} + +fn text(buffer: &Buffer) -> String { + buffer.content.iter().map(|cell| cell.symbol()).collect() +} + +#[test] +fn filtering_tracks_database_identity_and_handles_no_results() { + let mut db = database(); + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char('/')); + handle_paste("GOOGLE", &mut app); + assert_eq!(app.table.items.len(), 1); + assert_eq!(app.table.selected_index(), Some(1)); + app.tick(true); + assert_eq!(app.table.selected_index(), Some(1)); + key(&mut app, KeyCode::Char('x')); + assert!(app.table.items.is_empty()); + assert_eq!(app.table.selected_index(), None); + assert!(text(&render(&mut app, 100, 24)).contains("No matching accounts")); + key(&mut app, KeyCode::Backspace); + assert_eq!(app.table.selected_index(), Some(1)); + key(&mut app, KeyCode::Esc); + key(&mut app, KeyCode::Esc); + assert_eq!(app.table.items.len(), 3); + assert_eq!(app.table.selected_index(), Some(1)); +} + +#[test] +fn deletion_and_counter_updates_target_the_filtered_account() { + let mut db = database(); + db.elements[1].type_ = OTPType::Hotp; + db.elements[1].counter = Some(4); + let mut app = App::new(&mut db); + app.search_query = "Google".into(); + app.filter(); + key(&mut app, KeyCode::Char('+')); + assert_eq!(app.database.elements[1].counter, Some(5)); + assert_eq!(app.database.elements[0].counter, None); + key(&mut app, KeyCode::Char('d')); + assert_eq!(app.focus, Focus::Popup); + key(&mut app, KeyCode::Char('y')); + assert_eq!(app.database.elements.len(), 2); + assert_eq!(app.database.elements[1].issuer, "Linear"); + assert!(app.table.items.is_empty()); + assert_eq!(app.table.state.selected(), None); + assert!(app.database.is_modified()); +} + +#[test] +fn deleting_last_account_leaves_a_safe_empty_state() { + let mut db = OTPDatabase { + elements: vec![account("Example", "alice")], + ..Default::default() + }; + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char('d')); + key(&mut app, KeyCode::Char('y')); + key(&mut app, KeyCode::Up); + key(&mut app, KeyCode::Down); + assert_eq!(app.table.selected_index(), None); + assert!(text(&render(&mut app, 120, 30)).contains("Your vault is ready")); +} + +#[test] +fn countdown_uses_the_account_period_and_steam_fixed_period() { + let mut element = account("Example", "alice"); + element.period = 45; + assert_eq!(remaining(&element, 44), Some((1, 45))); + assert_eq!(remaining(&element, 45), Some((45, 45))); + element.type_ = OTPType::Steam; + assert_eq!(remaining(&element, 45), Some((15, 30))); + element.type_ = OTPType::Hotp; + assert_eq!(remaining(&element, 45), None); + element.type_ = OTPType::Totp; + element.period = 0; + assert_eq!(remaining(&element, 45), None); + assert!(code_for(&element).is_err()); +} + +#[test] +fn refresh_updates_codes_without_resetting_filter_or_selection() { + let mut db = database(); + let mut app = App::new(&mut db); + app.search_query = "alex".into(); + app.filter(); + app.table.next(); + app.table.items[1].code = "stale".into(); + app.now = 0; + app.tick(false); + assert_eq!(app.table.selected_index(), Some(1)); + assert_eq!(app.table.items.len(), 2); + assert_ne!(app.table.items[1].code, "stale"); +} + +#[test] +fn qr_escape_returns_to_accounts_and_copy_errors_are_reported() { + let mut db = database(); + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char(' ')); + assert_eq!(app.current_page, Page::Qrcode); + key(&mut app, KeyCode::Esc); + assert!(app.running); + assert_eq!(app.current_page, Page::Main); + app.database.elements[0].secret = "INVALID!".into(); + key(&mut app, KeyCode::Enter); + assert!(app.notification.as_ref().unwrap().1); + assert!( + app.notification + .as_ref() + .unwrap() + .0 + .starts_with("Could not copy:") + ); +} + +#[test] +fn plain_c_does_not_delete_and_save_confirmation_can_be_cancelled() { + let mut db = database(); + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char('c')); + assert_eq!(app.focus, Focus::MainPage); + app.database.mark_modified(); + key(&mut app, KeyCode::Char('q')); + assert_eq!(app.focus, Focus::Popup); + key(&mut app, KeyCode::Esc); + assert!(app.running); + key(&mut app, KeyCode::Char('q')); + key(&mut app, KeyCode::Char('n')); + assert!(!app.running); + assert!(!app.database.is_modified()); +} + +#[test] +fn search_supports_unicode_paste_and_mouse_navigation() { + let mut db = database(); + db.elements[0].label = "東京のアカウント".into(); + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char('/')); + handle_paste("東京\n", &mut app); + assert_eq!(app.search_query, "東京"); + assert_eq!(app.table.items.len(), 1); + handle_key_events( + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + &mut app, + ); + assert_eq!(app.table.items.len(), 3); + handle_mouse_events( + MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 10, + row: 10, + modifiers: KeyModifiers::NONE, + }, + &mut app, + ); + assert_eq!(app.table.selected_index(), Some(1)); + assert_eq!(app.focus, Focus::SearchBar); +} + +#[test] +fn hidden_codes_and_pins_never_appear_on_dashboard() { + let mut db = database(); + db.elements[0].pin = Some("sensitive-pin".into()); + let mut app = App::new(&mut db); + app.table.items[0].code = "123456".into(); + key(&mut app, KeyCode::Char('h')); + let output = text(&render(&mut app, 120, 30)); + assert!(!output.contains("123 456")); + assert!(!output.contains("sensitive-pin")); + assert!(output.contains("Codes hidden")); +} + +#[test] +fn layouts_render_across_sizes_and_overlay_states() { + let mut db = database(); + let mut app = App::new(&mut db); + for (width, height) in [ + (1, 1), + (20, 5), + (36, 12), + (60, 18), + (80, 24), + (120, 30), + (180, 50), + ] { + render(&mut app, width, height); + key(&mut app, KeyCode::Char('?')); + render(&mut app, width, height); + key(&mut app, KeyCode::Esc); + key(&mut app, KeyCode::Char(' ')); + render(&mut app, width, height); + key(&mut app, KeyCode::Esc); + key(&mut app, KeyCode::Char('/')); + app.search_query = "東京👩‍💻".repeat(100); + render(&mut app, width, height); + app.search_query.clear(); + key(&mut app, KeyCode::Esc); + } +} + +#[test] +fn account_scroll_keeps_last_selection_visible() { + let mut db = database(); + for i in 0..40 { + db.elements + .push(account(&format!("Account {i:02}"), "alice")); + } + let mut app = App::new(&mut db); + key(&mut app, KeyCode::End); + assert!(text(&render(&mut app, 80, 24)).contains("Account 39")); + key(&mut app, KeyCode::Home); + assert!(text(&render(&mut app, 80, 24)).contains("GitHub")); +} + +#[test] +fn dashboard_preview() { + let mut db = database(); + db.elements.extend([ + account("AWS", "Production · engineering"), + account("Figma", "alex@studio.dev"), + account("Proton", "Personal"), + ]); + let mut app = App::new(&mut db); + app.now = 12; + for (i, item) in app.table.items.iter_mut().enumerate() { + item.code = format!("{:06}", 751109 + i * 7319); + } + let buffer = render(&mut app, 120, 30); + let output = text(&buffer); + assert!(output.contains("751 109")); + assert!(output.contains("Selected account")); + assert!(output.contains("18s")); + if let Ok(path) = std::env::var("COTP_UI_PREVIEW") { + write_preview_svg(&buffer, &path); + } +} + +// Optional documentation preview generated from the actual rendered buffer and fake accounts. +fn write_preview_svg(buffer: &Buffer, path: &str) { + use ratatui::style::Color; + fn color(value: Color) -> String { + match value { + Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), + Color::Black => "#000000".into(), + _ => "#e0e9f2".into(), + } + } + let mut svg = format!( + "cotp dashboard with demonstration accounts", + buffer.area.width * 9, + buffer.area.height * 18, + buffer.area.width * 9, + buffer.area.height * 18 + ); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let cell = &buffer[(x, y)]; + let symbol = cell + .symbol() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + svg.push_str(&format!( + "", + x * 9, + y * 18, + color(cell.bg) + )); + if !symbol.trim().is_empty() { + svg.push_str(&format!("{symbol}", x * 9, y * 18 + 14, color(cell.fg))); + } + } + } + svg.push_str("\n"); + std::fs::write(path, svg).unwrap(); +} + +fn control(app: &mut App, c: char) { + handle_key_events(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL), app); +} + +fn click(app: &mut App, action: super::enums::Action) { + let area = app + .hitboxes + .iter() + .find(|(_, candidate)| *candidate == action) + .expect("action must be visible") + .0; + handle_mouse_events( + MouseEvent { + kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }, + app, + ); +} + +#[test] +fn add_account_entirely_with_keyboard_and_save_button() { + let mut db = OTPDatabase::default(); + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char('a')); + assert_eq!(app.focus, Focus::Form); + handle_paste("Example", &mut app); + key(&mut app, KeyCode::Tab); + handle_paste("alice@example.com", &mut app); + key(&mut app, KeyCode::Tab); + handle_paste("gezd gnbv gy3t qojq gezd gnbv gy3t qojq", &mut app); + // Tab through type, algorithm, digits and period to the explicit Save button. + for _ in 0..5 { + key(&mut app, KeyCode::Tab); + } + assert_eq!(app.form.as_ref().unwrap().active, super::form::SAVE); + key(&mut app, KeyCode::Enter); + assert_eq!(app.focus, Focus::MainPage); + assert!(app.form.is_none()); + assert_eq!(app.database.elements.len(), 1); + assert_eq!(app.database.elements[0].issuer, "Example"); + assert_eq!(app.database.elements[0].label, "alice@example.com"); + assert_eq!( + app.database.elements[0].secret, + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + ); + assert!(app.database.is_modified()); +} + +#[test] +fn editing_filtered_account_keeps_secret_and_pin_and_supports_cursor_editing() { + let mut db = database(); + db.elements[1].type_ = OTPType::Motp; + db.elements[1].secret = "e3152afee62599c8".into(); + db.elements[1].pin = Some("1234".into()); + let mut app = App::new(&mut db); + app.search_query = "Google".into(); + app.filter(); + key(&mut app, KeyCode::Char('e')); + key(&mut app, KeyCode::Home); + handle_paste("My ", &mut app); + control(&mut app, 's'); + assert_eq!(app.database.elements[1].issuer, "My Google"); + assert_eq!(app.database.elements[0].issuer, "GitHub"); + assert_eq!(app.database.elements[1].secret, "e3152afee62599c8"); + assert_eq!(app.database.elements[1].pin.as_deref(), Some("1234")); + assert_eq!(app.table.selected_index(), Some(1)); +} + +#[test] +fn invalid_form_focuses_problem_and_never_mutates_database() { + let mut db = database(); + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char('e')); + app.form.as_mut().unwrap().inputs[6] = tui_input::Input::from("0"); + control(&mut app, 's'); + let form = app.form.as_ref().unwrap(); + assert_eq!(form.active, 6); + assert!(form.error.as_ref().unwrap().contains("Period")); + assert!(!app.database.is_modified()); + app.form.as_mut().unwrap().inputs[6] = tui_input::Input::from("45"); + app.form.as_mut().unwrap().inputs[5] = tui_input::Input::from("11"); + control(&mut app, 's'); + assert_eq!(app.form.as_ref().unwrap().active, 5); + assert!(!app.database.is_modified()); + key(&mut app, KeyCode::Esc); + assert_eq!(app.database.elements[0].period, 30); + assert!(app.form.is_none()); +} + +#[test] +fn hotp_form_validates_counter_and_applies_it() { + let mut db = database(); + let mut app = App::new(&mut db); + app.open_form(true); + let form = app.form.as_mut().unwrap(); + form.active = 3; + form.cycle(false); + assert!(form.fields().contains(&7)); + assert!(!form.fields().contains(&6)); + form.inputs[7] = tui_input::Input::from("-1"); + control(&mut app, 's'); + assert_eq!(app.form.as_ref().unwrap().active, 7); + app.form.as_mut().unwrap().inputs[7] = tui_input::Input::from("12"); + control(&mut app, 's'); + assert_eq!(app.database.elements[0].type_, OTPType::Hotp); + assert_eq!(app.database.elements[0].counter, Some(12)); +} + +#[test] +fn pasted_uri_populates_form_and_secret_remains_masked() { + let mut db = OTPDatabase::default(); + let mut app = App::new(&mut db); + app.open_form(false); + app.form.as_mut().unwrap().active = 2; + handle_paste( + "otpauth://totp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&period=45&digits=8", + &mut app, + ); + let output = text(&render(&mut app, 100, 30)); + assert!(!output.contains("GEZDGNBV")); + assert!(output.contains("masked")); + control(&mut app, 's'); + assert_eq!(app.database.elements[0].period, 45); + assert_eq!(app.database.elements[0].digits, 8); + assert_eq!(app.database.elements[0].issuer, "Example"); +} + +#[test] +fn form_rejects_invalid_secret_and_missing_pin_without_saving() { + let mut db = database(); + let mut app = App::new(&mut db); + app.open_form(true); + app.form.as_mut().unwrap().active = 2; + handle_paste("invalid!", &mut app); + control(&mut app, 's'); + assert_eq!(app.form.as_ref().unwrap().active, 2); + control(&mut app, 'u'); + app.form.as_mut().unwrap().inputs[3] = tui_input::Input::from("YANDEX"); + control(&mut app, 's'); + assert_eq!(app.form.as_ref().unwrap().active, 8); + assert!(!app.database.is_modified()); + control(&mut app, 'c'); + assert!(app.running); + assert!(app.form.is_none()); +} + +#[test] +fn mouse_actions_select_search_edit_and_cancel_without_clicking_through() { + use super::enums::Action; + let mut db = database(); + let mut app = App::new(&mut db); + render(&mut app, 120, 30); + click(&mut app, Action::Select(1)); + assert_eq!(app.table.selected_index(), Some(1)); + click(&mut app, Action::Search); + handle_paste("Google", &mut app); + render(&mut app, 120, 30); + click(&mut app, Action::Command(KeyCode::Char('e'))); + assert_eq!(app.focus, Focus::Form); + render(&mut app, 120, 30); + assert!( + !app.hitboxes + .iter() + .any(|(_, action)| matches!(action, Action::Select(_))) + ); + click(&mut app, Action::Field(1)); + control(&mut app, 'u'); + handle_paste("modified", &mut app); + click(&mut app, Action::Cancel); + assert_eq!(app.database.elements[1].label, "alex@example.com"); + assert!(!app.database.is_modified()); +} + +#[test] +fn mouse_can_add_fill_and_save_an_account() { + use super::enums::Action; + let mut db = OTPDatabase::default(); + let mut app = App::new(&mut db); + render(&mut app, 120, 34); + click(&mut app, Action::Command(KeyCode::Char('a'))); + render(&mut app, 120, 34); + click(&mut app, Action::Field(1)); + handle_paste("alice", &mut app); + click(&mut app, Action::Field(2)); + handle_paste("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", &mut app); + click(&mut app, Action::Save); + assert_eq!(app.database.elements.len(), 1); + assert_eq!(app.database.elements[0].label, "alice"); +} + +#[test] +fn forms_keep_focused_fields_visible_at_every_supported_size() { + let mut db = database(); + let mut app = App::new(&mut db); + app.open_form(true); + for (width, height) in [(36, 12), (60, 18), (80, 24), (120, 34)] { + for index in app.form.as_ref().unwrap().fields() { + app.form.as_mut().unwrap().active = index; + let output = text(&render(&mut app, width, height)); + assert!( + output.contains(super::form::LABELS[index]), + "focused field {index} missing at {width}x{height}" + ); + } + let output = text(&render(&mut app, width, height)); + assert!(output.contains("Save account")); + assert!(output.contains("Cancel")); + } +} + +#[test] +fn editor_preview() { + let mut db = database(); + let mut app = App::new(&mut db); + app.now = 12; + for (i, item) in app.table.items.iter_mut().enumerate() { + item.code = format!("{:06}", 751109 + i * 7319); + } + app.open_form(true); + app.form.as_mut().unwrap().active = 1; + let buffer = render(&mut app, 120, 34); + if let Ok(path) = std::env::var("COTP_FORM_PREVIEW") { + write_preview_svg(&buffer, &path); + } +} + +#[test] +fn clickable_help_exposes_actions_on_narrow_terminals() { + use super::enums::Action; + let mut db = database(); + let mut app = App::new(&mut db); + key(&mut app, KeyCode::Char('?')); + app.popup_scroll = 10; + render(&mut app, 36, 18); + click(&mut app, Action::Command(KeyCode::Char(' '))); + assert_eq!(app.current_page, Page::Qrcode); + assert_eq!(app.focus, Focus::MainPage); + render(&mut app, 36, 18); + click(&mut app, Action::Key(KeyCode::Esc)); + assert_eq!(app.current_page, Page::Main); + assert!(app.running); +} + +#[test] +fn minimum_terminal_size_still_displays_a_selected_code() { + let mut db = database(); + let mut app = App::new(&mut db); + app.table.items[0].code = "123456".into(); + let output = text(&render(&mut app, 36, 12)); + assert!(output.contains("GitHub")); + assert!(output.contains("123 456")); +} + +#[test] +fn keyboard_and_mouse_navigation_stop_at_both_boundaries() { + let mut db = database(); + let mut app = App::new(&mut db); + for backwards in [true, false] { + key( + &mut app, + if backwards { + KeyCode::Home + } else { + KeyCode::End + }, + ); + let expected = if backwards { + 0 + } else { + app.table.items.len() - 1 + }; + for _ in 0..3 { + key( + &mut app, + if backwards { + KeyCode::Up + } else { + KeyCode::Down + }, + ); + handle_mouse_events( + MouseEvent { + kind: if backwards { + MouseEventKind::ScrollUp + } else { + MouseEventKind::ScrollDown + }, + column: 10, + row: 10, + modifiers: KeyModifiers::NONE, + }, + &mut app, + ); + assert_eq!(app.table.state.selected(), Some(expected)); + } + } + app.search_query = "Google".into(); + app.filter(); + app.table.next(); + app.table.previous(); + assert_eq!(app.table.selected_index(), Some(1)); +} + +#[test] +fn countdown_is_shown_once_and_header_does_not_clip_status() { + let mut db = database(); + let mut app = App::new(&mut db); + app.now = 12; + for width in [36, 80, 120] { + let output = text(&render(&mut app, width, 24)); + assert_eq!(output.matches("18s").count(), 1); + assert!(!output.contains("VALID FOR")); + assert!(!output.contains("vault open")); + assert!(output.contains("3 accounts")); + } + app.database.mark_modified(); + let buffer = render(&mut app, 36, 24); + let header: String = (0..36).map(|x| buffer[(x, 1)].symbol()).collect(); + assert!(header.contains("3 accounts ● unsaved")); +} + +/// Scripted recording of the real widgets and handlers with demonstration data only. +/// Run through ci/render_ui_demo.py; excluded from ordinary verification runs. +#[test] +#[ignore = "generates frames for the README animation"] +fn record_ui_demo() { + let directory = std::env::var("COTP_UI_DEMO_DIR").expect("set the frame output directory"); + let mut db = database(); + db.elements.extend([ + account("AWS", "Production · engineering"), + account("Figma", "alex@studio.dev"), + ]); + let mut app = App::new(&mut db); + let mut frames = Vec::new(); + let mut elapsed = 0; + let mut record = |app: &mut App, caption: &str, delay: u64| { + app.now = 12 + elapsed / 100; + for item in &mut app.table.items { + item.code = format!( + "{:06}", + 751109 + item.database_index * 7319 + (app.now / 30) as usize * 1023 + ); + } + let filename = format!("frame-{:03}.svg", frames.len()); + let buffer = render(app, 120, 34); + write_preview_svg(&buffer, &format!("{directory}/{filename}")); + frames.push(serde_json::json!({"file": filename, "caption": caption, "delay": delay})); + elapsed += delay; + }; + + record(&mut app, "Your accounts, at a glance", 200); + key(&mut app, KeyCode::Down); + record( + &mut app, + "Arrow keys or mouse wheel to select an account", + 100, + ); + key(&mut app, KeyCode::Char('/')); + record(&mut app, "Press / to find an account", 50); + for c in "google".chars() { + key(&mut app, KeyCode::Char(c)); + record(&mut app, "Filter accounts as you type", 12); + } + record(&mut app, "Filter accounts as you type", 100); + key(&mut app, KeyCode::Esc); + key(&mut app, KeyCode::Char('e')); + record(&mut app, "Press e to edit the selected account", 100); + key(&mut app, KeyCode::Tab); + control(&mut app, 'u'); + handle_paste("work@example.com", &mut app); + record( + &mut app, + "Tab between fields; existing secrets stay masked", + 150, + ); + control(&mut app, 's'); + record(&mut app, "Ctrl+S applies your changes", 150); + app.notification = None; + + // Use the same hitboxes as a real mouse click to open the Add form. + render(&mut app, 120, 34); + click(&mut app, super::enums::Action::Command(KeyCode::Char('a'))); + record(&mut app, "Click Add, or press a, to create an account", 80); + handle_paste("Proton", &mut app); + key(&mut app, KeyCode::Tab); + record(&mut app, "Enter the service and account name", 60); + handle_paste("personal@example.com", &mut app); + key(&mut app, KeyCode::Tab); + record(&mut app, "Enter the service and account name", 60); + handle_paste("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", &mut app); + record(&mut app, "Paste a secret key or an otpauth:// URI", 100); + control(&mut app, 's'); + record(&mut app, "Your new account is ready", 180); + app.notification = None; + key(&mut app, KeyCode::Char('h')); + record(&mut app, "Press h to hide codes", 100); + key(&mut app, KeyCode::Char('h')); + record(&mut app, "Press h again to reveal them", 70); + key(&mut app, KeyCode::Char('?')); + record(&mut app, "Press ? for shortcuts and clickable actions", 140); + key(&mut app, KeyCode::Esc); + key(&mut app, KeyCode::Char('q')); + record( + &mut app, + "Confirm on exit to save changes to the encrypted vault", + 200, + ); + key(&mut app, KeyCode::Esc); + key(&mut app, KeyCode::Home); + record(&mut app, "cotp · built for your terminal", 180); + std::fs::write( + format!("{directory}/frames.json"), + serde_json::to_string_pretty(&frames).unwrap(), + ) + .unwrap(); +} diff --git a/src/interface/theme.rs b/src/interface/theme.rs new file mode 100644 index 00000000..2e0e72a6 --- /dev/null +++ b/src/interface/theme.rs @@ -0,0 +1,27 @@ +//! Shared palette and component styling for the terminal dashboard. +use ratatui::{ + style::{Color, Style}, + text::Line, + widgets::{Block, BorderType}, +}; + +pub const BACKGROUND: Color = Color::Rgb(13, 19, 29); +pub const SURFACE: Color = Color::Rgb(20, 29, 42); +pub const SELECTED: Color = Color::Rgb(28, 57, 67); +pub const BORDER: Color = Color::Rgb(48, 65, 83); +pub const TEXT: Color = Color::Rgb(224, 233, 242); +pub const MUTED: Color = Color::Rgb(144, 162, 181); +pub const ACCENT: Color = Color::Rgb(94, 234, 212); +pub const WARNING: Color = Color::Rgb(251, 191, 91); +pub const DANGER: Color = Color::Rgb(251, 113, 133); + +pub fn panel(title: impl Into) -> Block<'static> { + Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().fg(TEXT).bg(SURFACE)) + .title(Line::styled( + format!(" {} ", title.into()), + Style::default().fg(MUTED), + )) +} diff --git a/src/interface/ui.rs b/src/interface/ui.rs index e822bad3..943d3ff5 100644 --- a/src/interface/ui.rs +++ b/src/interface/ui.rs @@ -1,6 +1,8 @@ use std::io; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, +}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use ratatui::Terminal; use ratatui::prelude::Backend; @@ -18,12 +20,17 @@ pub struct Tui { terminal: Terminal, /// Terminal event handler. pub events: EventHandler, + active: bool, } impl Tui { /// Constructs a new instance of [`Tui`]. pub fn new(terminal: Terminal, events: EventHandler) -> Self { - Self { terminal, events } + Self { + terminal, + events, + active: false, + } } /// Initializes the terminal interface. @@ -34,7 +41,18 @@ impl Tui { ::Error: 'static, { terminal::enable_raw_mode()?; - crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; + self.active = true; + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + previous_hook(info); + })); + crossterm::execute!( + io::stderr(), + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; self.terminal.hide_cursor()?; self.terminal.clear()?; Ok(()) @@ -60,8 +78,33 @@ impl Tui { ::Error: 'static, { terminal::disable_raw_mode()?; - crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?; + crossterm::execute!( + io::stderr(), + LeaveAlternateScreen, + DisableMouseCapture, + DisableBracketedPaste + )?; self.terminal.show_cursor()?; + self.active = false; Ok(()) } } + +fn restore_terminal() { + let _ = terminal::disable_raw_mode(); + let _ = crossterm::execute!( + io::stderr(), + LeaveAlternateScreen, + DisableMouseCapture, + DisableBracketedPaste, + crossterm::cursor::Show + ); +} + +impl Drop for Tui { + fn drop(&mut self) { + if self.active { + restore_terminal(); + } + } +} diff --git a/src/interface/view.rs b/src/interface/view.rs new file mode 100644 index 00000000..0ebee393 --- /dev/null +++ b/src/interface/view.rs @@ -0,0 +1,636 @@ +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{ + Cell, Clear, HighlightSpacing, LineGauge, Paragraph, Row, Scrollbar, ScrollbarOrientation, + ScrollbarState, Table, Wrap, + }, +}; + +use super::{ + app::App, + enums::{Action, Focus, Page, PopupAction}, + popup::centered_rect, + row::{display_code, remaining}, + theme::*, +}; + +impl App<'_> { + pub fn render(&mut self, frame: &mut Frame<'_>) { + self.hitboxes.clear(); + frame.render_widget( + ratatui::widgets::Block::default().style(Style::default().bg(BACKGROUND).fg(TEXT)), + frame.area(), + ); + if frame.area().width < 36 || frame.area().height < 12 { + frame.render_widget( + Paragraph::new("cotp\n\nEnlarge the terminal to 36 × 12.\nq quit") + .style(Style::default().fg(ACCENT)) + .wrap(Wrap { trim: true }), + frame.area(), + ); + return; + } + let area = frame.area().inner(Margin::new(1, 1)); + let [header, search, content, status, shortcuts] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + self.render_header(frame, header); + self.render_search(frame, search); + if self.current_page == Page::Qrcode { + self.render_qrcode(frame, content); + } else if content.width >= 100 { + let [accounts, detail] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(34)]) + .spacing(1) + .areas(content); + self.render_accounts(frame, accounts); + self.render_detail(frame, detail); + } else { + self.render_accounts(frame, content); + } + self.render_status(frame, status); + let hints = if self.current_page == Page::Qrcode && area.width < 65 { + vec![("↵", "URI"), ("Esc", "back"), ("q", "quit")] + } else if self.current_page == Page::Qrcode { + vec![ + ("Enter", "copy URI"), + ("Esc / Space", "back"), + ("q", "quit"), + ] + } else if self.focus == Focus::SearchBar { + vec![ + ("↑↓", "select"), + ("Enter", "copy"), + ("Esc", "done"), + ("Ctrl+U", "clear"), + ] + } else if area.width < 65 { + vec![ + ("↑", ""), + ("↓", ""), + ("↵", "copy"), + ("/", "find"), + ("?", ""), + ] + } else { + vec![ + ("↑↓", "move"), + ("Enter", "copy"), + ("/", "search"), + ("Space", "QR"), + ("h", "hide"), + ("?", "help"), + ("q", "quit"), + ] + }; + self.render_actions(frame, shortcuts, &hints, false); + if self.focus == Focus::Popup { + self.render_popup(frame); + } else if self.focus == Focus::Form { + self.render_form(frame); + } + } + + pub(crate) fn render_actions( + &mut self, + frame: &mut Frame<'_>, + area: Rect, + hints: &[(&str, &str)], + commands: bool, + ) { + let mut x = area.x; + let mut y = area.y; + for (key, label) in hints { + let line = shortcut_line(&[(*key, *label)]); + let width = line.width() as u16; + if x + width > area.right() { + x = area.x; + y += 1; + } + if y >= area.bottom() { + break; + } + let rect = Rect::new(x, y, width.min(area.width), 1); + frame.render_widget(Paragraph::new(line), rect); + let action = match *key { + "Ctrl+U" => Some(Action::ClearSearch), + "↑" => Some(Action::Key(KeyCode::Up)), + "↓" | "↑↓" => Some(Action::Key(KeyCode::Down)), + "Enter" | "↵" => Some(Action::Key(KeyCode::Enter)), + "Esc" | "Esc / Enter" | "n / Esc" | "Esc / Space" => { + Some(Action::Key(KeyCode::Esc)) + } + "Space" => Some(Action::Key(KeyCode::Char(' '))), + key if key.len() == 1 => key.chars().next().map(|c| { + if commands { + Action::Command(KeyCode::Char(c)) + } else { + Action::Key(KeyCode::Char(c)) + } + }), + _ => None, + }; + if let Some(action) = action { + self.hitboxes.push((rect, action)); + } + x += width; + } + } + + fn render_header(&mut self, frame: &mut Frame<'_>, area: Rect) { + let count = self.database.elements_ref().len(); + let mut summary_text = vec![Span::styled( + format!( + "{count} {}", + if count == 1 { "account" } else { "accounts" } + ), + Style::default().fg(MUTED), + )]; + if self.database.is_modified() { + summary_text.push(Span::styled(" ● unsaved", Style::default().fg(WARNING))); + } + let summary_text = Line::from(summary_text); + let summary_width = summary_text + .width() + .min(area.width.saturating_sub(9) as usize) as u16; + let [brand, summary] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(summary_width)]) + .areas(area); + frame.render_widget( + Paragraph::new(Span::styled( + " cotp ", + Style::default().fg(BACKGROUND).bg(ACCENT).bold(), + )), + brand, + ); + frame.render_widget( + Paragraph::new(summary_text).alignment(Alignment::Right), + summary, + ); + if area.height > 1 { + let toolbar = Rect::new(area.x, area.y + 1, area.width, 1); + self.render_actions( + frame, + toolbar, + &[("a", "Add"), ("e", "Edit"), ("d", "Delete")], + true, + ); + } + } + + fn render_search(&mut self, frame: &mut Frame<'_>, area: Rect) { + self.hitboxes.push((area, Action::Search)); + let active = self.focus == Focus::SearchBar; + let block = panel(if active { + "Search · Esc to finish" + } else { + "Search · / or Ctrl+F" + }) + .border_style(Style::default().fg(if active { ACCENT } else { BORDER })); + let inner = block.inner(area); + // Display the end of long queries; width() accounts for wide Unicode characters. + let mut query = self.search_query.as_str(); + let available = inner.width.saturating_sub(2) as usize; + while Line::from(query).width() > available { + query = &query[query.chars().next().map_or(0, char::len_utf8)..]; + } + let text = if self.search_query.is_empty() && !active { + Span::styled(" Find an issuer or account…", Style::default().fg(MUTED)) + } else { + Span::styled(format!(" {query}"), Style::default().fg(TEXT)) + }; + frame.render_widget(Paragraph::new(text).block(block), area); + if active && inner.width > 1 { + frame.set_cursor_position((inner.x + 1 + Line::from(query).width() as u16, inner.y)); + } + } + + fn render_accounts(&mut self, frame: &mut Frame<'_>, area: Rect) { + let count = self.table.items.len(); + let title = if self.search_query.is_empty() { + "Accounts".to_string() + } else { + format!("Accounts · {count} matches") + }; + let position = self.table.state.selected().map_or(0, |i| i + 1); + let block = panel(title) + .title_bottom(Line::from(format!(" {position} / {count} ")).right_aligned()); + if count == 0 { + let text = if self.database.elements_ref().is_empty() { + vec![ + Line::from(""), + Line::from("Your vault is ready").style(Style::default().fg(ACCENT).bold()), + Line::from(""), + Line::from("Press a or click Add to create your first account."), + Line::from("Paste a secret key or an otpauth:// URI into the form."), + Line::from(""), + Line::from("Already use an authenticator? Try cotp import --help."), + ] + } else { + vec![ + Line::from(""), + Line::from("No matching accounts").style(Style::default().fg(ACCENT).bold()), + Line::from(""), + Line::from("Try another issuer or account name."), + Line::from("Esc, then Esc again to clear the filter."), + ] + }; + frame.render_widget( + Paragraph::new(text) + .block(block) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + area, + ); + return; + } + let compact = area.height < 8; + let row_height = if compact { 1 } else { 2 }; + let row_margin = if compact { 0 } else { 1 }; + let header_height = if compact { 0 } else { 2 }; + let rows = self.table.items.iter().enumerate().map(|(index, item)| { + let element = &self.database.elements_ref()[item.database_index]; + let issuer = if item.issuer.is_empty() { + "Personal account" + } else { + &item.issuer + }; + let account = Text::from(vec![ + Line::from(Span::styled(issuer, Style::default().fg(TEXT).bold())), + Line::from(Span::styled( + item.label.as_str(), + Style::default().fg(MUTED), + )), + ]); + let code = if item.has_error { + "Unavailable".into() + } else { + display_code(&item.code, self.hide_codes) + }; + let timing = remaining(element, self.now); + let code_color = if item.has_error { + DANGER + } else if timing.is_some_and(|(left, _)| left <= 5) { + WARNING + } else { + ACCENT + }; + let cells = vec![ + Cell::from(account), + Cell::from(code).style(Style::default().fg(code_color).bold()), + ]; + Row::new(cells) + .height(row_height) + .bottom_margin(row_margin) + .style(Style::default().bg(if index % 2 == 0 { SURFACE } else { BACKGROUND })) + }); + let widths = [Constraint::Fill(1), Constraint::Length(13)]; + let headers = ["ACCOUNT", "CODE"]; + let mut table = Table::new(rows, widths) + .block(block) + .column_spacing(2) + .row_highlight_style(Style::default().bg(SELECTED)) + .highlight_symbol(Span::styled("▎ ", Style::default().fg(ACCENT))) + .highlight_spacing(HighlightSpacing::Always); + if !compact { + table = table.header( + Row::new(headers) + .style(Style::default().fg(MUTED)) + .bottom_margin(1), + ); + } + frame.render_stateful_widget(table, area, &mut self.table.state); + let viewport = area + .height + .saturating_sub(2 + header_height) + .saturating_add(row_margin) as usize + / (row_height + row_margin) as usize; + for (visible, index) in (self.table.state.offset()..count) + .take(viewport) + .enumerate() + { + self.hitboxes.push(( + Rect::new( + area.x + 1, + area.y + 1 + header_height + visible as u16 * (row_height + row_margin), + area.width.saturating_sub(2), + row_height, + ), + Action::Select(index), + )); + } + if count > viewport { + let mut state = ScrollbarState::new(count.saturating_sub(viewport)) + .position(self.table.state.offset()); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some("│")) + .thumb_style(Style::default().fg(ACCENT)) + .track_style(Style::default().fg(BORDER)), + area.inner(Margin::new(0, 1)), + &mut state, + ); + } + } + + fn render_detail(&self, frame: &mut Frame<'_>, area: Rect) { + let block = panel("Selected account"); + let inner = block.inner(area).inner(Margin::new(1, 0)); + frame.render_widget(block, area); + let Some(item) = self + .table + .state + .selected() + .and_then(|index| self.table.items.get(index)) + else { + frame.render_widget( + Paragraph::new("Select an account to see its details.") + .wrap(Wrap { trim: true }) + .style(Style::default().fg(MUTED)), + inner, + ); + return; + }; + let element = &self.database.elements_ref()[item.database_index]; + let [identity, code_area, gauge_area, metadata] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(4), + Constraint::Length(2), + Constraint::Fill(1), + ]) + .areas(inner); + frame.render_widget( + Paragraph::new(vec![ + Line::from(if item.issuer.is_empty() { + "Personal account" + } else { + &item.issuer + }) + .style(Style::default().fg(TEXT).bold()), + Line::from(item.label.as_str()).style(Style::default().fg(MUTED)), + ]), + identity, + ); + if item.has_error { + frame.render_widget( + Paragraph::new(item.code.as_str()) + .style(Style::default().fg(DANGER)) + .wrap(Wrap { trim: true }), + code_area, + ); + } else { + frame.render_widget( + Paragraph::new(large_code(&item.code, self.hide_codes, code_area.width)) + .style(Style::default().fg(ACCENT).bold()) + .alignment(Alignment::Center), + code_area, + ); + } + if let Some((left, period)) = remaining(element, self.now) { + frame.render_widget( + LineGauge::default() + .ratio(left as f64 / period as f64) + .filled_symbol("━") + .unfilled_symbol("─") + .filled_style(Style::default().fg(if left <= 5 { WARNING } else { ACCENT })) + .unfilled_style(Style::default().fg(BORDER)) + .label(Span::styled( + format!("{left}s "), + Style::default().fg(MUTED), + )), + gauge_area, + ); + } else { + frame.render_widget( + Paragraph::new("Counter based · + / − to change").style(Style::default().fg(MUTED)), + gauge_area, + ); + } + let mut lines = vec![ + detail_line("Type", element.type_.to_string()), + detail_line("Algorithm", element.algorithm.to_string()), + detail_line("Digits", element.digits.to_string()), + ]; + if let Some((_, period)) = remaining(element, self.now) { + lines.push(detail_line("Period", format!("{period} seconds"))); + } + if let Some(counter) = element.counter { + lines.push(detail_line("Counter", counter.to_string())); + } + lines.extend([ + Line::from(""), + shortcut_line(&[("Enter", "copy code")]), + shortcut_line(&[("Space", "show QR code")]), + ]); + frame.render_widget(Paragraph::new(lines), metadata); + } + + fn render_status(&self, frame: &mut Frame<'_>, area: Rect) { + let (text, color) = if let Some((text, error, _)) = &self.notification { + (text.clone(), if *error { DANGER } else { ACCENT }) + } else if self.hide_codes { + ("Codes hidden · press h to reveal".into(), WARNING) + } else if self.current_page == Page::Qrcode { + ("This QR code contains the account secret.".into(), WARNING) + } else if self.database.is_modified() { + ( + "Unsaved changes · you will be asked to save when you quit".into(), + WARNING, + ) + } else if area.width < 100 + && let Some((left, _)) = self + .table + .selected_index() + .and_then(|index| self.database.get_element(index)) + .and_then(|element| remaining(element, self.now)) + { + ( + format!("Selected code · {left}s remaining"), + if left <= 5 { WARNING } else { MUTED }, + ) + } else { + ("Ready · codes refresh automatically".into(), MUTED) + }; + frame.render_widget(Paragraph::new(text).style(Style::default().fg(color)), area); + } + + fn render_qrcode(&self, frame: &mut Frame<'_>, area: Rect) { + let block = panel("Transfer account · QR code"); + let inner = block.inner(area); + frame.render_widget(block, area); + let Some(element) = self + .table + .selected_index() + .and_then(|i| self.database.get_element(i)) + else { + return; + }; + let qr = element.get_qrcode(); + let width = qr + .lines() + .map(|line| Line::from(line).width()) + .max() + .unwrap_or(0) as u16; + let height = qr.lines().count() as u16; + if inner.width < width || inner.height < height { + frame.render_widget(Paragraph::new(format!("Enlarge the terminal to display the QR code ({width} × {height} cells).\n\nEnter copies the OTP URI.\nEsc returns to accounts.")) + .alignment(Alignment::Center).wrap(Wrap { trim: true }).style(Style::default().fg(MUTED)), inner); + } else { + frame.render_widget( + Paragraph::new(qr).style(Style::default().fg(Color::White).bg(Color::Black)), + centered_rect(width, height, inner), + ); + } + } + + fn render_help_body(&mut self, frame: &mut Frame<'_>, area: Rect) { + let actions = [ + ("↑ / k", "Previous account", KeyCode::Up), + ("↓ / j", "Next account", KeyCode::Down), + ("Home", "First account", KeyCode::Home), + ("End", "Last account", KeyCode::End), + ("PgUp / PgDn", "Move ten accounts", KeyCode::PageDown), + ("a", "Add account", KeyCode::Char('a')), + ("e", "Edit account", KeyCode::Char('e')), + ("Enter", "Copy code / URI", KeyCode::Enter), + ("/ or Ctrl+F", "Filter accounts", KeyCode::Char('/')), + ("h", "Hide / reveal codes", KeyCode::Char('h')), + ("Space", "Show / close QR", KeyCode::Char(' ')), + ("+", "Increase HOTP counter", KeyCode::Char('+')), + ("−", "Decrease HOTP counter", KeyCode::Char('-')), + ("d / Delete", "Delete account", KeyCode::Char('d')), + ("q / Ctrl+D", "Quit", KeyCode::Char('q')), + ]; + for (row, (key, label, code)) in actions + .iter() + .skip(self.popup_scroll as usize) + .take(area.height as usize) + .enumerate() + { + let rect = Rect::new(area.x, area.y + row as u16, area.width, 1); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!("{key:<12}"), Style::default().fg(ACCENT)), + Span::styled(*label, Style::default().fg(TEXT)), + ])), + rect, + ); + self.hitboxes.push((rect, Action::Command(*code))); + } + } + + fn render_popup(&mut self, frame: &mut Frame<'_>) { + self.hitboxes.clear(); + let (title, color, height, hints) = match self.popup.action { + PopupAction::GeneralInfo => ( + "Shortcuts · click an action", + ACCENT, + 25, + vec![("↑↓", "scroll"), ("Esc / Enter", "close")], + ), + PopupAction::DeleteOtp => ( + "Delete account?", + DANGER, + 11, + vec![("y", "delete"), ("n / Esc", "cancel")], + ), + PopupAction::SaveBeforeQuit => ( + "Save your changes?", + WARNING, + 10, + vec![("y", "save & quit"), ("n", "discard"), ("Esc", "cancel")], + ), + }; + // Dim the dashboard while retaining its spatial context. + let screen = frame.area(); + frame.buffer_mut().set_style( + screen, + Style::default().fg(MUTED).add_modifier(Modifier::DIM), + ); + let area = centered_rect(62, height, frame.area()); + frame.render_widget(Clear, area); + let block = panel(title).border_style(Style::default().fg(color)); + let inner = block.inner(area).inner(Margin::new(2, 1)); + frame.render_widget(block, area); + let [body, footer] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(inner); + if self.popup.action == PopupAction::GeneralInfo { + self.render_help_body(frame, body); + } else { + frame.render_widget( + Paragraph::new(self.popup.text.as_str()).wrap(Wrap { trim: false }), + body, + ); + } + self.render_actions(frame, footer, &hints, false); + } +} + +fn detail_line(label: &str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:<12}"), Style::default().fg(MUTED)), + Span::styled(value, Style::default().fg(TEXT)), + ]) +} + +fn shortcut_line(items: &[(&str, &str)]) -> Line<'static> { + let mut spans = Vec::new(); + for (key, action) in items { + spans.push(Span::styled( + format!(" {key} "), + Style::default().fg(ACCENT).bold(), + )); + spans.push(Span::styled( + format!("{action} "), + Style::default().fg(MUTED), + )); + } + Line::from(spans) +} + +fn large_code(code: &str, hidden: bool, width: u16) -> Text<'static> { + if hidden + || !code.bytes().all(|byte| byte.is_ascii_digit()) + || code.len() * 4 + 1 > width as usize + { + return Text::from(format!("\n{}", display_code(code, hidden))); + } + const DIGITS: [[&str; 3]; 10] = [ + ["█▀█", "█ █", "▀▀▀"], + ["▀█ ", " █ ", "▀▀▀"], + ["▀▀█", "█▀▀", "▀▀▀"], + ["▀▀█", " ▀█", "▀▀▀"], + ["█ █", "▀▀█", " ▀"], + ["█▀▀", "▀▀█", "▀▀▀"], + ["█▀▀", "█▀█", "▀▀▀"], + ["▀▀█", " █", " ▀"], + ["█▀█", "█▀█", "▀▀▀"], + ["█▀█", "▀▀█", "▀▀▀"], + ]; + Text::from( + (0..3) + .map(|row| { + let mut line = String::new(); + for (i, byte) in code.bytes().enumerate() { + if i == code.len() / 2 { + line.push(' '); + } + line.push_str(DIGITS[(byte - b'0') as usize][row]); + line.push(' '); + } + Line::from(line) + }) + .collect::>(), + ) +} diff --git a/src/main.rs b/src/main.rs index f8fdef8b..716e22cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,14 +4,17 @@ use clap::Parser; use color_eyre::eyre::eyre; use interface::app::AppResult; use interface::event::{Event, EventHandler}; -use interface::handlers::handle_key_events; +use interface::handlers::{handle_key_events, handle_mouse_events, handle_paste}; use interface::ui::Tui; use otp::otp_element::{CURRENT_DATABASE_VERSION, OTPDatabase}; use path::init_path; use ratatui::Terminal; use ratatui::prelude::CrosstermBackend; use reading::{ReadResult, get_elements_from_input, get_elements_from_stdin}; -use std::{io, vec}; +use std::{ + io::{self, IsTerminal}, + vec, +}; use zeroize::Zeroize; mod arguments; @@ -92,38 +95,34 @@ fn main() -> AppResult<()> { } fn dashboard(mut database: OTPDatabase) -> AppResult { - if database.elements_ref().is_empty() { - println!("No codes, type \"cotp -h\" to get help"); - } else { - // Create an application. - let mut app = interface::app::App::new(&mut database); - - // Initialize the terminal user interface. - let backend = CrosstermBackend::new(io::stderr()); - let terminal = Terminal::new(backend)?; - let events = EventHandler::new(250); - let mut tui = Tui::new(terminal, events); - tui.init()?; - - // Start the main loop. - while app.running { - // Render the user interface. - tui.draw(&mut app)?; - // Handle events. - match tui.events.next()? { - Event::Tick => app.tick(false), - Event::Key(key_event) => handle_key_events(key_event, &mut app), - Event::Mouse(()) - | Event::Resize((), ()) - | Event::FocusGained() - | Event::FocusLost() - | Event::Paste(()) => {} + if !io::stderr().is_terminal() || !io::stdin().is_terminal() { + if database.elements_ref().is_empty() { + println!("No codes, type \"cotp -h\" to get help"); + return Ok(database); + } + return Err( + "The dashboard needs an interactive terminal. Use cotp list or cotp list --json." + .into(), + ); + } + let mut app = interface::app::App::new(&mut database); + let backend = CrosstermBackend::new(io::stderr()); + let terminal = Terminal::new(backend)?; + let mut tui = Tui::new(terminal, EventHandler::new(250)); + tui.init()?; + while app.running { + tui.draw(&mut app)?; + match tui.events.next()? { + Event::Tick => app.tick(false), + Event::Key(key) => handle_key_events(key, &mut app), + Event::Mouse(mouse) => handle_mouse_events(mouse, &mut app), + Event::Paste(mut text) => { + handle_paste(&text, &mut app); + text.zeroize(); } + Event::Resize => {} } - - // Exit the user interface. - tui.exit()?; } - + tui.exit()?; Ok(database) } diff --git a/src/otp/algorithms/yandex_otp_maker.rs b/src/otp/algorithms/yandex_otp_maker.rs index b5bad7f7..a60ecad3 100644 --- a/src/otp/algorithms/yandex_otp_maker.rs +++ b/src/otp/algorithms/yandex_otp_maker.rs @@ -84,7 +84,11 @@ where period_hash[offset] &= 0x7f; // calculate code - let code_bytes: [u8; 8] = match period_hash[offset..offset + 8].try_into() { + let code_bytes: [u8; 8] = match period_hash + .get(offset..offset + 8) + .ok_or(OtpError::InvalidDigest)? + .try_into() + { Ok(x) => x, Err(_) => return Err(OtpError::InvalidDigest), }; @@ -114,6 +118,20 @@ mod tests { use super::calculate_yandex_code; + #[test] + fn short_digest_returns_an_error_instead_of_panicking() { + // SHA1 may not have eight bytes left at the dynamic offset. + for seconds in (0..3000).step_by(30) { + let _ = calculate_yandex_code::( + "6SB2IKNM6OBZPAVBVTOHDKS4FAAAAAAADFUTQMBTRY", + "5239", + 30, + 8, + seconds, + ); + } + } + #[test] fn test_yandex() { let seconds: u64 = 1641559648; diff --git a/src/utils.rs b/src/utils.rs index fb328dc6..3cca40ce 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,3 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - use crate::path::DATABASE_PATH; pub fn init_app() -> Result { @@ -14,17 +12,6 @@ pub fn init_app() -> Result { Ok(!db_path.exists()) } -pub fn millis_before_next_step() -> u64 { - let now = SystemTime::now(); - let since_the_epoch = now.duration_since(UNIX_EPOCH).unwrap(); - let in_ms = since_the_epoch.as_secs() * 1000 + since_the_epoch.subsec_nanos() as u64 / 1000000; - in_ms % 30000 -} - -pub fn percentage() -> u16 { - (millis_before_next_step() * 100 / 30000) as u16 -} - pub fn password(message: &str, minimum_length: usize) -> String { loop { let password = rpassword::prompt_password(message).unwrap();