commit d3ad760798e8e7718adffdf0d3578196c18b5ec3 Author: Phil Date: Sat Oct 31 19:05:50 2026 -0600 tuipub: terminal Micropub client (IndieAuth PKCE login, compose and publish notes) Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..aa09c35 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4351 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + +[[package]] +name = "cssparser" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.14.0", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ego-tree" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd50d24b1b75aa817bb0339cb9f2e0e664bdfab1e160a69a0fda9145b77fab2" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "headers" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "html5ever" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" +dependencies = [ + "log", + "markup5ever", + "memchr", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654f72e90ee9871781fd37154c1c0049ef20f988f8a2931887dc285170e682ab" +dependencies = [ + "assert-json-diff", + "base64 0.23.1", + "bytes", + "form_urlencoded", + "futures-timer", + "headers", + "http", + "http-body-util", + "hyper", + "hyper-util", + "path-tree", + "regex", + "serde", + "serde_json", + "similar", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "secret-service", + "zeroize", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mac_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" +dependencies = [ + "nix 0.31.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "markup5ever" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[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", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "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" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "path-tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136" +dependencies = [ + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" +dependencies = [ + "memchr", + "pest_stack_guard", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "pest_meta" +version = "2.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" +dependencies = [ + "pest", +] + +[[package]] +name = "pest_stack_guard" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2" +dependencies = [ + "psm", + "stacker", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-textarea" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf7263fbb7f37caa4bcfd00fc604ff1409f70456a833fbdff99282e88fdfe92" +dependencies = [ + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844b5f49a5579f12ba4f89d9f7eec6d71889e4aa0bc7181821040ce66f08fff" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", +] + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8322aebea94a6cc288ff884e8a49bdbceef60206c400c6c46d22e62fc4fd91a6" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eceb519ec34d8290aaf6e46f435102f2bfa63f22b05a144fb16ab266d3b198" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f8bec09ae8fae0f4f265a4559a9334be3929d8504e9b536df0a41302bd1733" +dependencies = [ + "bstr", +] + +[[package]] +name = "siphasher" +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 = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.14.0", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tuipub" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.23.1", + "clap", + "directories", + "httpmock", + "keyring", + "open", + "rand 0.10.3", + "ratatui", + "ratatui-textarea", + "reqwest", + "scraper", + "serde", + "serde_json", + "sha2 0.11.1", + "thiserror 2.0.21", + "tiny_http", + "toml", + "url", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" +dependencies = [ + "phf 0.14.0", + "phf_codegen 0.14.0", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..28ebbb6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "tuipub" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.104" +base64 = "0.23.1" +clap = { version = "4.6.7", features = ["derive"] } +directories = "6.0.0" +keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } +open = "5.4.5" +rand = "0.10.3" +ratatui = "0.30.2" +ratatui-textarea = "0.9.4" +reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "json", "rustls", "form", "query"] } +scraper = "0.29.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +sha2 = "0.11.1" +thiserror = "2.0.21" +tiny_http = "0.12.0" +toml = "1.1.8" +url = "2.5.8" + +[dev-dependencies] +httpmock = "0.9.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..713303f --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# tuipub + +A terminal [Micropub](https://micropub.spec.indieweb.org/) client for posting notes to your IndieWeb site. +It logs in with [IndieAuth](https://indieauth.spec.indieweb.org/) and is built with Rust and [Ratatui](https://ratatui.rs). +It's developed against StarPunk, but sticks to the spec, so other Micropub servers should work too. + +## Install + +```sh +cargo install --path . +``` + +## Use + +```sh +tuipub login example.com # opens your browser to approve access +tuipub # write and publish a note +tuipub whoami # show the account and endpoints +tuipub logout # revoke the token and forget the account +``` + +Running `tuipub` with no account starts the login flow first. + +### Keys + +| Key | Action | +| --- | --- | +| Tab / Shift-Tab | Next / previous field | +| Ctrl-S | Publish (asks to confirm) | +| Ctrl-R | Reload crosspost targets from the server | +| Ctrl-O | Open the last published post | +| Esc / Ctrl-Q | Quit (asks if there's unpublished text) | +| ↑ ↓ / j k, Space, a | Crosspost list: move, toggle, all/none | + +Content is sent as written (Markdown for StarPunk). Tags are comma-separated, and a leading `#` is dropped. + +## How it works + +- **Discovery**: reads `rel="micropub"` and `rel="indieauth-metadata"` from your site's `Link` headers and HTML. If there's no metadata link, it falls back to `authorization_endpoint` / `token_endpoint`. +- **Login**: authorization code flow with PKCE (S256). tuipub runs a one-shot callback server on `127.0.0.1:` and uses `http://127.0.0.1:/` as its `client_id`. The client ID and redirect URI share an origin, so the server needs no client registration. It checks `state` and `iss`. Scopes requested: `create media`. +- **Token storage**: the system keyring (Secret Service / gnome-keyring), keyed by your profile URL. Account settings go in `~/.config/tuipub/config.toml`. +- **Posting**: always sends a JSON body. `mp-syndicate-to` lists exactly the checked targets, and an empty list means "crosspost nowhere". If the target list can't be loaded, `mp-syndicate-to` is left out and the server's default applies (the confirm dialog says so). + +### Environment + +- `TUIPUB_TOKEN`: use this token instead of the keyring (e.g. one made in StarPunk's `/admin/tokens`). +- `TUIPUB_CONFIG_DIR`: use a different config directory (handy for dev servers). + +## StarPunk notes + +- StarPunk takes a note's title from its first line and ignores the Micropub `name` property, so the Title field has no effect there. Start the note with `# Heading` instead. +- Editing, deleting and listing posts aren't supported yet, because StarPunk's Micropub endpoint doesn't implement `update`, `delete` or `q=source` listing. + +## Develop + +```sh +cargo test +cargo clippy --all-targets -- -D warnings +``` + +To try it against a local StarPunk, start it with `DEV_MODE=true`, `SITE_URL`/`ADMIN_ME`/`DEV_ADMIN_ME=http://127.0.0.1:8099/`, then run `TUIPUB_CONFIG_DIR=/tmp/tp tuipub login 127.0.0.1:8099`. Bare `localhost` / `127.*` addresses default to `http://`. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3c32e1b --- /dev/null +++ b/src/app.rs @@ -0,0 +1,523 @@ +//! App state and key handling. Pure logic: no terminal or network here. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::style::{Modifier, Style}; +use ratatui_textarea::{Input, Key, TextArea}; + +use crate::micropub::{self, Error, Note, ServerConfig, SyndicationTarget}; +use crate::worker::{Request, Response}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Title, + Content, + Tags, + Syndication, +} + +impl Focus { + const ORDER: [Focus; 4] = [ + Focus::Title, + Focus::Content, + Focus::Tags, + Focus::Syndication, + ]; + + fn step(self, by: isize) -> Focus { + let i = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0) as isize; + Self::ORDER[(i + by).rem_euclid(Self::ORDER.len() as isize) as usize] + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Mode { + Editing, + ConfirmPublish, + Publishing, + Published(String), + ConfirmQuit, +} + +/// Where the syndication target list came from. +#[derive(Debug, Clone, PartialEq)] +pub enum Targets { + Loading, + Loaded, + /// Couldn't load q=config. Publishing leaves syndication to the server default. + Failed(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Status { + Info(String), + Error(String), +} + +pub struct App { + pub me: String, + pub endpoint: String, + pub title: TextArea<'static>, + pub content: TextArea<'static>, + pub tags: TextArea<'static>, + pub targets: Vec<(SyndicationTarget, bool)>, + pub targets_state: Targets, + pub target_cursor: usize, + pub focus: Focus, + pub mode: Mode, + pub status: Option, + pub last_permalink: Option, + pub should_quit: bool, + /// Set when a request failed for auth reasons, so main can suggest logging in again. + pub needs_login: bool, +} + +fn text_area(placeholder: &str) -> TextArea<'static> { + let mut t = TextArea::default(); + t.set_placeholder_text(placeholder); + t.set_cursor_line_style(Style::default()); + t +} + +impl App { + pub fn new(me: String, endpoint: String) -> Self { + let mut app = App { + me, + endpoint, + title: text_area("Optional title"), + content: text_area("What's on your mind? (Markdown)"), + tags: text_area("Comma-separated, e.g. rust, indieweb"), + targets: Vec::new(), + targets_state: Targets::Loading, + target_cursor: 0, + focus: Focus::Content, + mode: Mode::Editing, + status: None, + last_permalink: None, + should_quit: false, + needs_login: false, + }; + app.update_cursors(); + app + } + + fn update_cursors(&mut self) { + let focus = self.focus; + for (field, area) in [ + (Focus::Title, &mut self.title), + (Focus::Content, &mut self.content), + (Focus::Tags, &mut self.tags), + ] { + let style = if field == focus { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + area.set_cursor_style(style); + } + } + + pub fn is_dirty(&self) -> bool { + [&self.title, &self.content, &self.tags] + .iter() + .any(|t| t.lines().iter().any(|l| !l.trim().is_empty())) + } + + fn clear_form(&mut self) { + self.title = text_area("Optional title"); + self.content = text_area("What's on your mind? (Markdown)"); + self.tags = text_area("Comma-separated, e.g. rust, indieweb"); + self.focus = Focus::Content; + self.update_cursors(); + } + + pub fn note(&self) -> Note { + let title = self.title.lines().join(" ").trim().to_string(); + Note { + title: (!title.is_empty()).then_some(title), + content: self.content.lines().join("\n").trim_end().to_string(), + tags: micropub::parse_tags(&self.tags.lines().join(",")), + syndicate_to: match self.targets_state { + Targets::Loaded => Some( + self.targets + .iter() + .filter(|(_, on)| *on) + .map(|(t, _)| t.uid.clone()) + .collect(), + ), + _ => None, + }, + } + } + + /// Human summary of where the post will be crossposted. + pub fn syndication_summary(&self) -> String { + match &self.targets_state { + Targets::Loaded => { + let names: Vec<_> = self + .targets + .iter() + .filter(|(_, on)| *on) + .map(|(t, _)| t.name.as_str()) + .collect(); + if names.is_empty() { + "nowhere".into() + } else { + names.join(", ") + } + } + _ => "server default".into(), + } + } + + pub fn handle_response(&mut self, resp: Response) { + match resp { + Response::Config(Ok(cfg)) => self.set_config(cfg), + Response::Config(Err(e)) => { + self.targets_state = Targets::Failed(e.to_string()); + self.report(e); + } + Response::Published(Ok(url)) => { + self.clear_form(); + self.status = Some(Status::Info(format!("Published {url}"))); + self.last_permalink = Some(url.clone()); + self.mode = Mode::Published(url); + } + Response::Published(Err(e)) => { + self.mode = Mode::Editing; + self.report(e); + } + } + } + + fn report(&mut self, e: Error) { + if matches!(e, Error::Unauthorized(_)) { + self.needs_login = true; + } + self.status = Some(Status::Error(e.to_string())); + } + + fn set_config(&mut self, cfg: ServerConfig) { + // Keep earlier choices on refresh; new targets start checked (the server's default). + let previous = std::mem::take(&mut self.targets); + self.targets = cfg + .syndicate_to + .into_iter() + .map(|t| { + let on = previous + .iter() + .find(|(p, _)| p.uid == t.uid) + .is_none_or(|(_, on)| *on); + (t, on) + }) + .collect(); + self.target_cursor = self.target_cursor.min(self.targets.len().saturating_sub(1)); + self.targets_state = Targets::Loaded; + } + + /// Handle a key press. Returns a request for the worker, if any. + pub fn handle_key(&mut self, key: KeyEvent) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match self.mode.clone() { + Mode::Publishing => None, + Mode::ConfirmPublish => { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.mode = Mode::Publishing; + self.status = Some(Status::Info("Publishing…".into())); + return Some(Request::Publish(self.note())); + } + _ => self.mode = Mode::Editing, + } + None + } + Mode::ConfirmQuit => { + match key.code { + KeyCode::Char('y') => self.should_quit = true, + _ => self.mode = Mode::Editing, + } + None + } + Mode::Published(url) => { + if key.code == KeyCode::Char('o') { + self.open(&url); + } + self.mode = Mode::Editing; + None + } + Mode::Editing => self.handle_editing_key(key, ctrl), + } + } + + fn open(&mut self, url: &str) { + if let Err(e) = open::that_detached(url) { + self.status = Some(Status::Error(format!("Could not open browser: {e}"))); + } + } + + fn handle_editing_key(&mut self, key: KeyEvent, ctrl: bool) -> Option { + match (ctrl, key.code) { + (true, KeyCode::Char('q' | 'c')) | (false, KeyCode::Esc) => { + if self.is_dirty() { + self.mode = Mode::ConfirmQuit; + } else { + self.should_quit = true; + } + return None; + } + (true, KeyCode::Char('s')) => { + if self.note().content.trim().is_empty() { + self.status = Some(Status::Error("Write something first.".into())); + } else { + self.status = None; + self.mode = Mode::ConfirmPublish; + } + return None; + } + (true, KeyCode::Char('r')) => { + self.targets_state = Targets::Loading; + self.status = Some(Status::Info("Refreshing server config…".into())); + return Some(Request::LoadConfig); + } + (true, KeyCode::Char('o')) => { + if let Some(url) = self.last_permalink.clone() { + self.open(&url); + } + return None; + } + (_, KeyCode::Tab) => { + self.focus = self.focus.step(1); + self.update_cursors(); + return None; + } + (_, KeyCode::BackTab) => { + self.focus = self.focus.step(-1); + self.update_cursors(); + return None; + } + _ => {} + } + + match self.focus { + Focus::Content => { + self.content.input(Input::from(key)); + } + Focus::Title | Focus::Tags => { + let input = Input::from(key); + // Single-line fields: Enter moves on instead of adding a line. + if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) { + self.focus = self.focus.step(1); + self.update_cursors(); + } else { + let area = if self.focus == Focus::Title { + &mut self.title + } else { + &mut self.tags + }; + area.input(input); + } + } + Focus::Syndication => self.handle_target_key(key), + } + None + } + + fn handle_target_key(&mut self, key: KeyEvent) { + let n = self.targets.len(); + if n == 0 { + return; + } + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.target_cursor = self.target_cursor.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + self.target_cursor = (self.target_cursor + 1).min(n - 1) + } + KeyCode::Char(' ') | KeyCode::Enter => { + if let Some((_, on)) = self.targets.get_mut(self.target_cursor) { + *on = !*on; + } + } + KeyCode::Char('a') => { + let all_on = self.targets.iter().all(|(_, on)| *on); + for (_, on) in &mut self.targets { + *on = !all_on; + } + } + _ => {} + } + } + + /// Bracketed paste: insert text into the focused field. + pub fn handle_paste(&mut self, text: &str) { + if self.mode != Mode::Editing { + return; + } + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + match self.focus { + Focus::Content => { + self.content.insert_str(text); + } + Focus::Title => { + self.title.insert_str(text.replace('\n', " ")); + } + Focus::Tags => { + self.tags.insert_str(text.replace('\n', ",")); + } + Focus::Syndication => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + fn typ(app: &mut App, s: &str) { + for c in s.chars() { + app.handle_key(key(KeyCode::Char(c))); + } + } + fn target(uid: &str) -> SyndicationTarget { + SyndicationTarget { + uid: uid.into(), + name: uid.to_uppercase(), + } + } + fn loaded(targets: &[&str]) -> App { + let mut app = App::new( + "https://example.com/".into(), + "https://example.com/micropub".into(), + ); + app.handle_response(Response::Config(Ok(ServerConfig { + syndicate_to: targets.iter().map(|t| target(t)).collect(), + }))); + app + } + + #[test] + fn compose_and_publish_flow() { + let mut app = loaded(&["gts", "bsky"]); + typ(&mut app, "Hello"); + app.handle_key(key(KeyCode::Enter)); + typ(&mut app, "world"); + app.handle_key(key(KeyCode::Tab)); // -> tags + typ(&mut app, "rust, #tui"); + app.handle_key(key(KeyCode::Tab)); // -> syndication + app.handle_key(key(KeyCode::Down)); + app.handle_key(key(KeyCode::Char(' '))); // uncheck bsky + + assert!(app.handle_key(ctrl('s')).is_none()); + assert_eq!(app.mode, Mode::ConfirmPublish); + let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('y'))) else { + panic!("expected publish request"); + }; + assert_eq!(app.mode, Mode::Publishing); + assert_eq!(note.content, "Hello\nworld"); + assert_eq!(note.title, None); + assert_eq!(note.tags, vec!["rust", "tui"]); + assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()])); + + app.handle_response(Response::Published( + Ok("https://example.com/notes/x".into()), + )); + assert_eq!( + app.mode, + Mode::Published("https://example.com/notes/x".into()) + ); + assert!(!app.is_dirty(), "form clears after publishing"); + app.handle_key(key(KeyCode::Esc)); + assert_eq!(app.mode, Mode::Editing); + } + + #[test] + fn title_enter_moves_to_content() { + let mut app = loaded(&[]); + app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.focus, Focus::Title); + typ(&mut app, "My title"); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.focus, Focus::Content); + assert_eq!(app.title.lines(), ["My title"]); + } + + #[test] + fn empty_note_is_not_published() { + let mut app = loaded(&[]); + app.handle_key(ctrl('s')); + assert_eq!(app.mode, Mode::Editing); + assert!(matches!(app.status, Some(Status::Error(_)))); + } + + #[test] + fn unchecking_all_sends_empty_list_but_unknown_config_sends_none() { + let mut app = loaded(&["gts"]); + app.focus = Focus::Syndication; + app.handle_key(key(KeyCode::Char('a'))); + assert_eq!(app.note().syndicate_to, Some(vec![])); + assert_eq!(app.syndication_summary(), "nowhere"); + + let mut app = App::new("m".into(), "e".into()); + app.handle_response(Response::Config(Err(Error::NoLocation( + reqwest::StatusCode::OK, + )))); + assert_eq!(app.note().syndicate_to, None); + assert_eq!(app.syndication_summary(), "server default"); + } + + #[test] + fn refresh_keeps_choices() { + let mut app = loaded(&["gts", "bsky"]); + app.targets[0].1 = false; + app.handle_response(Response::Config(Ok(ServerConfig { + syndicate_to: vec![target("gts"), target("bsky"), target("new")], + }))); + let on: Vec = app.targets.iter().map(|(_, on)| *on).collect(); + assert_eq!(on, [false, true, true]); + } + + #[test] + fn quit_asks_when_dirty() { + let mut app = loaded(&[]); + typ(&mut app, "draft"); + app.handle_key(key(KeyCode::Esc)); + assert_eq!(app.mode, Mode::ConfirmQuit); + app.handle_key(key(KeyCode::Char('n'))); + assert!(!app.should_quit); + app.handle_key(ctrl('q')); + app.handle_key(key(KeyCode::Char('y'))); + assert!(app.should_quit); + + let mut clean = loaded(&[]); + clean.handle_key(key(KeyCode::Esc)); + assert!(clean.should_quit); + } + + #[test] + fn publish_error_returns_to_editing_and_flags_login() { + let mut app = loaded(&[]); + typ(&mut app, "x"); + app.handle_key(ctrl('s')); + app.handle_key(key(KeyCode::Char('y'))); + app.handle_response(Response::Published(Err(Error::Unauthorized( + "expired".into(), + )))); + assert_eq!(app.mode, Mode::Editing); + assert!(app.needs_login); + assert!(app.is_dirty(), "draft is kept after a failed publish"); + } + + #[test] + fn paste_into_single_line_fields() { + let mut app = loaded(&[]); + app.focus = Focus::Tags; + app.handle_paste("a\nb"); + assert_eq!(app.note().tags, vec!["a", "b"]); + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..31e6729 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,428 @@ +//! IndieAuth login: authorization code flow with PKCE and a loopback redirect. +//! +//! The client_id is `http://127.0.0.1:/` and the redirect_uri is +//! `http://127.0.0.1:/callback`. They share an origin, so servers accept the +//! redirect without fetching client metadata (the IndieAuth spec allows loopback clients). + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use reqwest::blocking::Client; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::{config::Config, discovery, token}; + +pub const SCOPE: &str = "create media"; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(300); + +pub struct Pkce { + pub verifier: String, + pub challenge: String, +} + +impl Pkce { + pub fn new() -> Self { + Self::from_verifier(random_token(32)) + } + + pub fn from_verifier(verifier: String) -> Self { + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + Pkce { + verifier, + challenge, + } + } +} + +fn random_token(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::fill(&mut buf[..]); + URL_SAFE_NO_PAD.encode(buf) +} + +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub me: String, + #[serde(default)] + pub scope: String, +} + +#[derive(Debug, Deserialize)] +struct OAuthError { + error: String, + error_description: Option, +} + +pub fn http_client() -> Client { + Client::builder() + .user_agent(concat!("tuipub/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(30)) + .build() + .expect("building HTTP client") +} + +/// Build the authorization URL the browser is sent to. +pub fn authorization_url( + endpoint: &Url, + client_id: &str, + redirect_uri: &str, + state: &str, + pkce: &Pkce, + me: &Url, +) -> Url { + let mut url = endpoint.clone(); + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("state", state) + .append_pair("code_challenge", &pkce.challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("scope", SCOPE) + .append_pair("me", me.as_str()); + url +} + +/// What came back on the redirect: the code, or an error from the server. +#[derive(Debug, PartialEq)] +pub enum Callback { + Code(String), + Denied(String), +} + +/// Check a `/callback?...` query against the expected state and issuer. +pub fn parse_callback(query: &str, state: &str, issuer: Option<&str>) -> Result { + let params: std::collections::HashMap = + url::form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect(); + if params.get("state").map(String::as_str) != Some(state) { + bail!("the login callback had the wrong state; try logging in again"); + } + if let (Some(expected), Some(got)) = (issuer, params.get("iss")) + && expected != got + { + bail!("the login callback came from issuer {got}, expected {expected}"); + } + if let Some(err) = params.get("error") { + let desc = params.get("error_description").cloned().unwrap_or_default(); + return Ok(Callback::Denied(format!("{err} {desc}").trim().to_string())); + } + params + .get("code") + .cloned() + .map(Callback::Code) + .ok_or_else(|| anyhow!("the login callback had no code")) +} + +const CLIENT_PAGE: &str = r#"tuipub +
tuipub — a terminal Micropub client
"#; + +fn page(message: &str) -> String { + format!( + "tuipub\ +

tuipub

{message}

" + ) +} + +fn respond(req: tiny_http::Request, status: u16, body: String) { + let header = tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8") + .expect("static header"); + let _ = req.respond( + tiny_http::Response::from_string(body) + .with_status_code(status) + .with_header(header), + ); +} + +/// Wait for the browser to hit /callback. Answers anything else with the client page. +fn wait_for_callback( + server: &tiny_http::Server, + state: &str, + issuer: Option<&str>, +) -> Result { + let deadline = Instant::now() + LOGIN_TIMEOUT; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + bail!("timed out waiting for the browser login"); + } + let Some(req) = server.recv_timeout(left)? else { + continue; + }; + let url = req.url().to_string(); + let (path, query) = url.split_once('?').unwrap_or((&url, "")); + if path != "/callback" { + respond(req, 200, CLIENT_PAGE.to_string()); + continue; + } + match parse_callback(query, state, issuer) { + Ok(Callback::Code(code)) => { + respond( + req, + 200, + page("Logged in. You can close this tab and go back to the terminal."), + ); + return Ok(code); + } + Ok(Callback::Denied(why)) => { + respond( + req, + 400, + page("Login was not approved. You can close this tab."), + ); + bail!("login was not approved: {why}"); + } + Err(e) => { + // A stray or forged request: tell the browser, keep waiting for the real one. + respond(req, 400, page(&format!("{e}"))); + } + } + } +} + +pub fn redeem_code( + http: &Client, + token_endpoint: &Url, + code: &str, + client_id: &str, + redirect_uri: &str, + verifier: &str, +) -> Result { + let resp = http + .post(token_endpoint.clone()) + .header("Accept", "application/json") + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("client_id", client_id), + ("redirect_uri", redirect_uri), + ("code_verifier", verifier), + ]) + .send() + .context("contacting the token endpoint")?; + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + if !status.is_success() { + if let Ok(e) = serde_json::from_str::(&body) { + bail!( + "token endpoint refused the code: {} {}", + e.error, + e.error_description.unwrap_or_default() + ); + } + bail!("token endpoint returned {status}"); + } + serde_json::from_str(&body).context("token endpoint sent an unexpected response") +} + +/// Run the full browser login for `me_input` and save the result. +pub fn login(me_input: &str) -> Result { + let http = http_client(); + let me = discovery::normalize_me(me_input)?; + eprintln!("Looking up endpoints for {me} …"); + let ep = discovery::discover(&http, &me)?; + + let server = tiny_http::Server::http("127.0.0.1:0") + .map_err(|e| anyhow!("could not start the login callback server: {e}"))?; + let port = server + .server_addr() + .to_ip() + .context("callback server has no IP address")? + .port(); + let client_id = format!("http://127.0.0.1:{port}/"); + let redirect_uri = format!("{client_id}callback"); + let state = random_token(16); + let pkce = Pkce::new(); + + let auth_url = authorization_url( + &ep.authorization_endpoint, + &client_id, + &redirect_uri, + &state, + &pkce, + &me, + ); + eprintln!("Opening your browser to log in. If it doesn't open, visit:\n\n {auth_url}\n"); + if let Err(e) = open::that_detached(auth_url.as_str()) { + eprintln!("(could not open a browser: {e})"); + } + + let code = wait_for_callback(&server, &state, ep.issuer.as_deref())?; + let tok = redeem_code( + &http, + &ep.token_endpoint, + &code, + &client_id, + &redirect_uri, + &pkce.verifier, + )?; + + // The server's `me` is authoritative. If it differs from what was typed, + // make sure it points at the same authorization server before trusting it. + let canonical = Url::parse(&tok.me).context("token endpoint returned an invalid me")?; + let ep = if canonical != me { + let other = discovery::discover(&http, &canonical)?; + if other.authorization_endpoint != ep.authorization_endpoint { + bail!("{canonical} uses a different authorization server; refusing the login"); + } + other + } else { + ep + }; + + let cfg = Config { + me: canonical.to_string(), + micropub: ep.micropub.to_string(), + authorization_endpoint: ep.authorization_endpoint.to_string(), + token_endpoint: ep.token_endpoint.to_string(), + revocation_endpoint: ep.revocation_endpoint.map(|u| u.to_string()), + scope: tok.scope.clone(), + }; + token::set(&cfg.me, &tok.access_token)?; + cfg.save()?; + eprintln!("Logged in as {} (scope: {})", cfg.me, cfg.scope); + if !cfg.scope.split_whitespace().any(|s| s == "create") { + eprintln!("Warning: the server did not grant the `create` scope, so posting will fail."); + } + Ok(cfg) +} + +/// Revoke the token on the server (best effort) and forget it locally. +pub fn logout() -> Result<()> { + let Some(cfg) = Config::load()? else { + eprintln!("Not logged in."); + return Ok(()); + }; + if let Some(tok) = token::get(&cfg.me)? { + let http = http_client(); + let result = match &cfg.revocation_endpoint { + Some(url) => http.post(url).form(&[("token", tok.as_str())]).send(), + None => http + .post(&cfg.token_endpoint) + .form(&[("action", "revoke"), ("token", tok.as_str())]) + .send(), + }; + match result.and_then(|r| r.error_for_status()) { + Ok(_) => eprintln!("Revoked the token on the server."), + Err(e) => eprintln!( + "Could not revoke the token on the server ({e}); removing it locally anyway." + ), + } + } + token::delete(&cfg.me)?; + Config::remove()?; + eprintln!("Logged out of {}.", cfg.me); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_matches_rfc7636_vector() { + let p = Pkce::from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".into()); + assert_eq!(p.challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + #[test] + fn random_verifier_is_long_enough() { + let p = Pkce::new(); + assert!(p.verifier.len() >= 43, "RFC 7636 needs 43+ chars"); + assert_ne!(p.verifier, Pkce::new().verifier); + } + + #[test] + fn authorization_url_has_required_params() { + let ep = Url::parse("https://example.com/auth/authorize").unwrap(); + let me = Url::parse("https://example.com/").unwrap(); + let pkce = Pkce::from_verifier("v".repeat(43)); + let url = authorization_url( + &ep, + "http://127.0.0.1:1234/", + "http://127.0.0.1:1234/callback", + "st", + &pkce, + &me, + ); + let q: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!(q["response_type"], "code"); + assert_eq!(q["client_id"], "http://127.0.0.1:1234/"); + assert_eq!(q["redirect_uri"], "http://127.0.0.1:1234/callback"); + assert_eq!(q["code_challenge_method"], "S256"); + assert_eq!(q["code_challenge"], pkce.challenge); + assert_eq!(q["scope"], SCOPE); + assert_eq!(q["state"], "st"); + } + + #[test] + fn callback_checks_state_and_issuer() { + let iss = Some("https://example.com/"); + assert_eq!( + parse_callback( + "code=abc&state=s1&iss=https%3A%2F%2Fexample.com%2F", + "s1", + iss + ) + .unwrap(), + Callback::Code("abc".into()) + ); + assert!(parse_callback("code=abc&state=wrong", "s1", iss).is_err()); + assert!( + parse_callback( + "code=abc&state=s1&iss=https%3A%2F%2Fevil.example%2F", + "s1", + iss + ) + .is_err() + ); + assert!(matches!( + parse_callback("error=access_denied&state=s1", "s1", None).unwrap(), + Callback::Denied(_) + )); + assert!(parse_callback("state=s1", "s1", None).is_err()); + } + + #[test] + fn redeem_code_posts_form_and_reports_errors() { + use httpmock::prelude::*; + let server = MockServer::start(); + let ok = server.mock(|when, then| { + when.method(POST) + .path("/token") + .form_urlencoded_tuple("grant_type", "authorization_code") + .form_urlencoded_tuple("code", "good") + .form_urlencoded_tuple("code_verifier", "ver"); + then.status(200).json_body(serde_json::json!({ + "access_token": "tok", "token_type": "Bearer", + "scope": "create media", "me": "https://example.com/" + })); + }); + server.mock(|when, then| { + when.method(POST) + .path("/token") + .form_urlencoded_tuple("code", "bad"); + then.status(400).json_body(serde_json::json!({ + "error": "invalid_grant", "error_description": "expired" + })); + }); + let ep = Url::parse(&server.url("/token")).unwrap(); + let http = Client::new(); + let tok = redeem_code(&http, &ep, "good", "c", "r", "ver").unwrap(); + assert_eq!(tok.access_token, "tok"); + assert_eq!(tok.scope, "create media"); + ok.assert(); + let err = redeem_code(&http, &ep, "bad", "c", "r", "ver") + .unwrap_err() + .to_string(); + assert!( + err.contains("invalid_grant") && err.contains("expired"), + "{err}" + ); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..69e29b2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,62 @@ +//! Account settings stored in ~/.config/tuipub/config.toml. + +use std::{fs, path::PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct Config { + /// The user's canonical profile URL, as returned by the token endpoint. + pub me: String, + pub micropub: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revocation_endpoint: Option, + #[serde(default)] + pub scope: String, +} + +/// Directory for config.toml. `TUIPUB_CONFIG_DIR` overrides it (handy for tests and dev servers). +pub fn dir() -> Result { + if let Some(d) = std::env::var_os("TUIPUB_CONFIG_DIR") { + return Ok(PathBuf::from(d)); + } + directories::ProjectDirs::from("", "", "tuipub") + .map(|p| p.config_dir().to_path_buf()) + .context("could not work out a config directory") +} + +fn path() -> Result { + Ok(dir()?.join("config.toml")) +} + +impl Config { + pub fn load() -> Result> { + let path = path()?; + match fs::read_to_string(&path) { + Ok(text) => Ok(Some( + toml::from_str(&text).with_context(|| format!("reading {}", path.display()))?, + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + pub fn save(&self) -> Result<()> { + let path = path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, toml::to_string_pretty(self)?) + .with_context(|| format!("writing {}", path.display())) + } + + pub fn remove() -> Result<()> { + match fs::remove_file(path()?) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()), + _ => Ok(()), + } + } +} diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..88a3f7f --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,293 @@ +//! Endpoint discovery from the user's profile URL. +//! +//! Looks at HTTP `Link` headers first, then `` / `` in the HTML, +//! as the IndieAuth and Micropub specs describe. Prefers the `indieauth-metadata` +//! document and falls back to the older `authorization_endpoint` / `token_endpoint` rels. + +use anyhow::{Context, Result, bail}; +use reqwest::blocking::Client; +use scraper::{Html, Selector}; +use serde::Deserialize; +use url::Url; + +#[derive(Debug, Clone, PartialEq)] +pub struct Endpoints { + pub micropub: Url, + pub authorization_endpoint: Url, + pub token_endpoint: Url, + pub revocation_endpoint: Option, + pub issuer: Option, +} + +#[derive(Debug, Deserialize)] +struct Metadata { + issuer: Option, + authorization_endpoint: String, + token_endpoint: String, + revocation_endpoint: Option, +} + +/// Turn what the user typed ("example.com") into a profile URL ("https://example.com/"). +pub fn normalize_me(input: &str) -> Result { + let input = input.trim(); + let with_scheme = if input.contains("://") { + input.to_string() + } else if ["localhost", "127.", "[::1]"] + .iter() + .any(|p| input.starts_with(p)) + { + // Local dev servers rarely have TLS. + format!("http://{input}") + } else { + format!("https://{input}") + }; + let url = Url::parse(&with_scheme).with_context(|| format!("not a valid URL: {input}"))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + bail!("profile URL must be http(s) with a host: {input}"); + } + Ok(url) +} + +/// All `(rel, url)` pairs from a `Link` header value, resolved against `base`. +pub fn parse_link_header(value: &str, base: &Url) -> Vec<(String, Url)> { + let mut out = Vec::new(); + let mut rest = value; + while let Some(start) = rest.find('<') { + let Some(end) = rest[start..].find('>') else { + break; + }; + let target = &rest[start + 1..start + end]; + rest = &rest[start + end + 1..]; + // A link's params run until the next '<' (the trailing ',' is ignored below). + let params = &rest[..rest.find('<').unwrap_or(rest.len())]; + for param in params.split(';') { + let Some((k, v)) = param.split_once('=') else { + continue; + }; + if !k.trim().eq_ignore_ascii_case("rel") { + continue; + } + let Ok(url) = base.join(target) else { continue }; + let v = v.trim().trim_end_matches(',').trim().trim_matches('"'); + for rel in v.split_whitespace() { + out.push((rel.to_ascii_lowercase(), url.clone())); + } + } + } + out +} + +/// All `(rel, url)` pairs from `` and `` elements, in document order. +pub fn parse_html_links(html: &str, base: &Url) -> Vec<(String, Url)> { + let doc = Html::parse_document(html); + let base = Selector::parse("base[href]") + .ok() + .and_then(|s| { + doc.select(&s) + .next()? + .value() + .attr("href") + .map(str::to_string) + }) + .and_then(|href| base.join(&href).ok()) + .unwrap_or_else(|| base.clone()); + let sel = Selector::parse("link[rel][href], a[rel][href]").expect("valid selector"); + let mut out = Vec::new(); + for el in doc.select(&sel) { + let (Some(rel), Some(href)) = (el.value().attr("rel"), el.value().attr("href")) else { + continue; + }; + let Ok(url) = base.join(href) else { continue }; + for r in rel.split_whitespace() { + out.push((r.to_ascii_lowercase(), url.clone())); + } + } + out +} + +fn first(links: &[(String, Url)], rel: &str) -> Option { + links.iter().find(|(r, _)| r == rel).map(|(_, u)| u.clone()) +} + +pub fn discover(http: &Client, me: &Url) -> Result { + let resp = http + .get(me.clone()) + .header("Accept", "text/html") + .send() + .with_context(|| format!("fetching {me}"))? + .error_for_status() + .with_context(|| format!("fetching {me}"))?; + let final_url = resp.url().clone(); + + let mut links = Vec::new(); + for value in resp.headers().get_all("link") { + if let Ok(v) = value.to_str() { + links.extend(parse_link_header(v, &final_url)); + } + } + let body = resp.text().unwrap_or_default(); + links.extend(parse_html_links(&body, &final_url)); + + let micropub = first(&links, "micropub").with_context(|| { + format!("{me} does not advertise a Micropub endpoint (rel=\"micropub\")") + })?; + + if let Some(meta_url) = first(&links, "indieauth-metadata") { + let meta: Metadata = http + .get(meta_url.clone()) + .header("Accept", "application/json") + .send() + .and_then(|r| r.error_for_status()) + .with_context(|| format!("fetching IndieAuth metadata {meta_url}"))? + .json() + .with_context(|| format!("parsing IndieAuth metadata {meta_url}"))?; + return Ok(Endpoints { + micropub, + authorization_endpoint: meta_url.join(&meta.authorization_endpoint)?, + token_endpoint: meta_url.join(&meta.token_endpoint)?, + revocation_endpoint: meta + .revocation_endpoint + .and_then(|u| meta_url.join(&u).ok()), + issuer: meta.issuer, + }); + } + + Ok(Endpoints { + micropub, + authorization_endpoint: first(&links, "authorization_endpoint").with_context(|| { + format!("{me} does not advertise an IndieAuth authorization endpoint") + })?, + token_endpoint: first(&links, "token_endpoint") + .with_context(|| format!("{me} does not advertise an IndieAuth token endpoint"))?, + revocation_endpoint: None, + issuer: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> Url { + Url::parse("https://example.com/about/").unwrap() + } + + #[test] + fn normalizes_bare_domain() { + assert_eq!( + normalize_me("127.0.0.1:8099").unwrap().as_str(), + "http://127.0.0.1:8099/" + ); + assert_eq!( + normalize_me("example.com").unwrap().as_str(), + "https://example.com/" + ); + assert_eq!( + normalize_me(" http://example.com/me ").unwrap().as_str(), + "http://example.com/me" + ); + assert!(normalize_me("ftp://example.com").is_err()); + } + + #[test] + fn link_header_multiple_links_and_rels() { + let v = r#"; rel="micropub", ; rel="indieauth-metadata me""#; + let links = parse_link_header(v, &base()); + assert_eq!( + first(&links, "micropub").unwrap().as_str(), + "https://example.com/micropub" + ); + assert_eq!( + first(&links, "indieauth-metadata").unwrap().as_str(), + "https://example.com/.well-known/oauth-authorization-server" + ); + assert!(first(&links, "me").is_some()); + } + + #[test] + fn link_header_unquoted_rel_and_relative() { + let links = parse_link_header("<../auth>; rel=authorization_endpoint", &base()); + assert_eq!( + first(&links, "authorization_endpoint").unwrap().as_str(), + "https://example.com/auth" + ); + } + + #[test] + fn html_links_resolve_and_keep_order() { + let html = r#" + + + + x"#; + let links = parse_html_links(html, &base()); + assert_eq!( + first(&links, "micropub").unwrap().as_str(), + "https://example.com/micropub" + ); + assert_eq!( + first(&links, "token_endpoint").unwrap().as_str(), + "https://auth.example/t" + ); + } + + #[test] + fn header_links_win_over_html() { + use httpmock::prelude::*; + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200) + .header("Link", "; rel=\"micropub\"") + .header("Content-Type", "text/html") + .body( + r#" + "#, + ); + }); + server.mock(|when, then| { + when.method(GET).path("/meta"); + then.status(200).json_body(serde_json::json!({ + "issuer": server.url("/"), + "authorization_endpoint": "/auth/authorize", + "token_endpoint": "/auth/token", + "revocation_endpoint": "/auth/revoke" + })); + }); + let me = Url::parse(&server.url("/")).unwrap(); + let ep = discover(&Client::new(), &me).unwrap(); + assert_eq!(ep.micropub.path(), "/header-micropub"); + assert_eq!(ep.authorization_endpoint.path(), "/auth/authorize"); + assert_eq!(ep.token_endpoint.path(), "/auth/token"); + assert_eq!(ep.revocation_endpoint.unwrap().path(), "/auth/revoke"); + assert_eq!(ep.issuer.as_deref(), Some(server.url("/").as_str())); + } + + #[test] + fn falls_back_to_legacy_rels() { + use httpmock::prelude::*; + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body( + r#" + "#, + ); + }); + let ep = discover(&Client::new(), &Url::parse(&server.url("/")).unwrap()).unwrap(); + assert_eq!(ep.token_endpoint.path(), "/t"); + assert!(ep.revocation_endpoint.is_none()); + } + + #[test] + fn missing_micropub_is_an_error() { + use httpmock::prelude::*; + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body(""); + }); + let err = discover(&Client::new(), &Url::parse(&server.url("/")).unwrap()).unwrap_err(); + assert!(err.to_string().contains("Micropub")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f4ff5eb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,146 @@ +mod app; +mod auth; +mod config; +mod discovery; +mod micropub; +mod token; +mod ui; +mod worker; + +use std::io::{self, BufRead, Write}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use ratatui::crossterm::{ + event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind}, + execute, +}; +use url::Url; + +use crate::{app::App, config::Config, worker::Request}; + +/// A terminal Micropub client for posting notes to your IndieWeb site. +#[derive(Parser)] +#[command(version, about)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Log in to your site with IndieAuth (opens a browser) + Login { + /// Your site's URL, e.g. example.com + me: Option, + }, + /// Revoke the token and forget the account + Logout, + /// Show the logged-in account and endpoints + Whoami, +} + +fn prompt(question: &str) -> Result { + eprint!("{question}"); + io::stderr().flush()?; + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let line = line.trim().to_string(); + anyhow::ensure!(!line.is_empty(), "no URL entered"); + Ok(line) +} + +fn login(me: Option) -> Result { + let me = match me { + Some(m) => m, + None => match Config::load()? { + Some(cfg) => cfg.me, + None => prompt("Your site URL: ")?, + }, + }; + auth::login(&me) +} + +/// The saved account and a token for it, logging in first if needed. +fn session() -> Result<(Config, String)> { + let cfg = match Config::load()? { + Some(cfg) => cfg, + None => { + eprintln!("No account yet. Let's log in."); + login(None)? + } + }; + if let Some(tok) = token::get(&cfg.me)? { + return Ok((cfg, tok)); + } + eprintln!("No saved token for {}. Logging in again.", cfg.me); + let cfg = auth::login(&cfg.me)?; + let tok = token::get(&cfg.me)?.context("login finished but no token was saved")?; + Ok((cfg, tok)) +} + +fn run_tui() -> Result<()> { + let (cfg, tok) = session()?; + let endpoint = Url::parse(&cfg.micropub).context("invalid Micropub endpoint in config")?; + let client = micropub::Client::new(auth::http_client(), endpoint, tok); + let (tx, rx) = worker::spawn(client); + tx.send(Request::LoadConfig).ok(); + + let mut app = App::new(cfg.me.clone(), cfg.micropub.clone()); + let mut terminal = ratatui::init(); + execute!(io::stdout(), EnableBracketedPaste).ok(); + + let result = (|| -> Result<()> { + while !app.should_quit { + terminal.draw(|f| ui::draw(f, &mut app))?; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(req) = app.handle_key(key) { + tx.send(req).context("background worker stopped")?; + } + } + Event::Paste(text) => app.handle_paste(&text), + _ => {} + } + } + while let Ok(resp) = rx.try_recv() { + app.handle_response(resp); + } + } + Ok(()) + })(); + + execute!(io::stdout(), DisableBracketedPaste).ok(); + ratatui::restore(); + if app.needs_login { + eprintln!("The server rejected the token. Run `tuipub login` to log in again."); + } + result +} + +fn main() -> Result<()> { + match Cli::parse().command { + None => run_tui(), + Some(Command::Login { me }) => login(me).map(|_| ()), + Some(Command::Logout) => auth::logout(), + Some(Command::Whoami) => { + match Config::load()? { + Some(cfg) => { + let has_token = token::get(&cfg.me)?.is_some(); + println!("me: {}", cfg.me); + println!("micropub: {}", cfg.micropub); + println!("token: {}", if has_token { "saved" } else { "missing" }); + println!("scope: {}", cfg.scope); + println!( + "config: {}", + config::dir()?.join("config.toml").display() + ); + } + None => println!("Not logged in. Run `tuipub login `."), + } + Ok(()) + } + } +} diff --git a/src/micropub.rs b/src/micropub.rs new file mode 100644 index 0000000..4d12a81 --- /dev/null +++ b/src/micropub.rs @@ -0,0 +1,332 @@ +//! Micropub client: `q=config` and create. + +use reqwest::{StatusCode, blocking::Client as Http}; +use serde::Deserialize; +use serde_json::{Value, json}; +use url::Url; + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct SyndicationTarget { + pub uid: String, + pub name: String, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ServerConfig { + #[serde(rename = "syndicate-to", default)] + pub syndicate_to: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Note { + pub title: Option, + pub content: String, + pub tags: Vec, + /// `None` leaves syndication to the server's default. `Some(vec![])` means none. + pub syndicate_to: Option>, +} + +impl Note { + /// Micropub JSON create body. JSON (not form encoding) is the only way to send + /// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere". + pub fn to_json(&self) -> Value { + let mut props = serde_json::Map::new(); + props.insert("content".into(), json!([self.content])); + if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) { + props.insert("name".into(), json!([t])); + } + if !self.tags.is_empty() { + props.insert("category".into(), json!(self.tags)); + } + let mut body = json!({ "type": ["h-entry"], "properties": props }); + if let Some(targets) = &self.syndicate_to { + body["mp-syndicate-to"] = json!(targets); + } + body + } +} + +/// Split "a, #b,, a" into ["a", "b"]. +pub fn parse_tags(input: &str) -> Vec { + let mut out: Vec = Vec::new(); + for tag in input.split(',') { + let tag = tag.trim().trim_start_matches('#').trim(); + if !tag.is_empty() && !out.iter().any(|t| t.eq_ignore_ascii_case(tag)) { + out.push(tag.to_string()); + } + } + out +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not authorized: {0}. Run `tuipub login` again.")] + Unauthorized(String), + #[error("{error}: {description}")] + Server { + status: StatusCode, + error: String, + description: String, + }, + #[error("server returned {0} without a Location header")] + NoLocation(StatusCode), + #[error("network error: {0}")] + Http(#[from] reqwest::Error), +} + +#[derive(Deserialize)] +struct ErrorBody { + error: String, + #[serde(default)] + error_description: String, +} + +pub struct Client { + http: Http, + endpoint: Url, + token: String, +} + +impl Client { + pub fn new(http: Http, endpoint: Url, token: String) -> Self { + Client { + http, + endpoint, + token, + } + } + + fn check(resp: reqwest::blocking::Response) -> Result { + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + let body = resp.text().unwrap_or_default(); + let (error, description) = match serde_json::from_str::(&body) { + Ok(b) => (b.error, b.error_description), + Err(_) => ( + status.canonical_reason().unwrap_or("error").to_string(), + body.chars().take(200).collect(), + ), + }; + if status == StatusCode::UNAUTHORIZED + || status == StatusCode::FORBIDDEN + || error == "insufficient_scope" + { + let why = if description.is_empty() { + error + } else { + description + }; + return Err(Error::Unauthorized(why)); + } + Err(Error::Server { + status, + error, + description, + }) + } + + fn query(&self, q: &str) -> Result { + let mut url = self.endpoint.clone(); + url.query_pairs_mut().append_pair("q", q); + let resp = self + .http + .get(url) + .bearer_auth(&self.token) + .header("Accept", "application/json") + .send()?; + Self::check(resp) + } + + pub fn config(&self) -> Result { + let mut cfg: ServerConfig = self.query("config")?.json()?; + // syndicate-to is optional in q=config; some servers only answer q=syndicate-to. + if cfg.syndicate_to.is_empty() + && let Ok(resp) = self.query("syndicate-to") + && let Ok(s) = resp.json::() + { + cfg.syndicate_to = s.syndicate_to; + } + Ok(cfg) + } + + /// Publish a note. Returns the post's permalink. + pub fn create(&self, note: &Note) -> Result { + let resp = self + .http + .post(self.endpoint.clone()) + .bearer_auth(&self.token) + .header("Accept", "application/json") + .json(¬e.to_json()) + .send()?; + let resp = Self::check(resp)?; + let status = resp.status(); + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or(Error::NoLocation(status))?; + Ok(self + .endpoint + .join(location) + .map(|u| u.to_string()) + .unwrap_or_else(|_| location.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + + fn client(server: &MockServer) -> Client { + Client::new( + Http::new(), + Url::parse(&server.url("/micropub")).unwrap(), + "tok".into(), + ) + } + + #[test] + fn tags_are_trimmed_and_deduped() { + assert_eq!( + parse_tags(" rust, #tui,,Rust , indieweb "), + vec!["rust", "tui", "indieweb"] + ); + assert!(parse_tags(" , ").is_empty()); + } + + #[test] + fn note_json_shape() { + let note = Note { + title: Some("Hi".into()), + content: "Hello **world**".into(), + tags: vec!["a".into()], + syndicate_to: Some(vec![]), + }; + assert_eq!( + note.to_json(), + json!({ + "type": ["h-entry"], + "properties": {"content": ["Hello **world**"], "name": ["Hi"], "category": ["a"]}, + "mp-syndicate-to": [] + }) + ); + let bare = Note { + content: "x".into(), + title: Some(String::new()), + ..Default::default() + }; + assert_eq!( + bare.to_json(), + json!({"type": ["h-entry"], "properties": {"content": ["x"]}}) + ); + } + + #[test] + fn config_reads_targets() { + let server = MockServer::start(); + let m = server.mock(|when, then| { + when.method(GET) + .path("/micropub") + .query_param("q", "config") + .header("authorization", "Bearer tok"); + then.status(200).json_body(json!({ + "media-endpoint": "https://example.com/micropub/media", + "syndicate-to": [{"uid": "gotosocial", "name": "GoToSocial"}], + "post-types": [] + })); + }); + let cfg = client(&server).config().unwrap(); + m.assert(); + assert_eq!( + cfg.syndicate_to, + vec![SyndicationTarget { + uid: "gotosocial".into(), + name: "GoToSocial".into() + }] + ); + } + + #[test] + fn config_falls_back_to_syndicate_to_query() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/micropub") + .query_param("q", "config"); + then.status(200).json_body(json!({})); + }); + server.mock(|when, then| { + when.method(GET) + .path("/micropub") + .query_param("q", "syndicate-to"); + then.status(200) + .json_body(json!({"syndicate-to": [{"uid": "x", "name": "X"}]})); + }); + assert_eq!(client(&server).config().unwrap().syndicate_to.len(), 1); + } + + #[test] + fn create_posts_json_and_resolves_location() { + let server = MockServer::start(); + let note = Note { + content: "hello".into(), + syndicate_to: Some(vec!["gts".into()]), + ..Default::default() + }; + let m = server.mock(|when, then| { + when.method(POST) + .path("/micropub") + .header("authorization", "Bearer tok") + .json_body(note.to_json()); + then.status(201).header("Location", "/notes/hello"); + }); + let url = client(&server).create(¬e).unwrap(); + m.assert(); + assert_eq!(url, server.url("/notes/hello")); + } + + #[test] + fn create_maps_errors() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/micropub"); + then.status(403).json_body(json!({"error": "insufficient_scope", "error_description": "Token lacks create scope"})); + }); + let err = client(&server) + .create(&Note { + content: "x".into(), + ..Default::default() + }) + .unwrap_err(); + assert!( + matches!(err, Error::Unauthorized(ref d) if d.contains("create scope")), + "{err}" + ); + + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/micropub"); + then.status(400).json_body( + json!({"error": "invalid_request", "error_description": "Content is required"}), + ); + }); + let err = client(&server).create(&Note::default()).unwrap_err(); + assert_eq!(err.to_string(), "invalid_request: Content is required"); + + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/micropub"); + then.status(201); + }); + let err = client(&server) + .create(&Note { + content: "x".into(), + ..Default::default() + }) + .unwrap_err(); + assert!(matches!(err, Error::NoLocation(_))); + } +} diff --git a/src/token.rs b/src/token.rs new file mode 100644 index 0000000..404c891 --- /dev/null +++ b/src/token.rs @@ -0,0 +1,36 @@ +//! Access token storage in the system keyring (Secret Service on Linux). +//! `TUIPUB_TOKEN` overrides the keyring, e.g. for a token pasted from the server's admin page. + +use anyhow::{Context, Result}; + +const SERVICE: &str = "tuipub"; + +fn entry(me: &str) -> Result { + keyring::Entry::new(SERVICE, me).context("opening the system keyring") +} + +pub fn get(me: &str) -> Result> { + if let Ok(t) = std::env::var("TUIPUB_TOKEN") + && !t.is_empty() + { + return Ok(Some(t)); + } + match entry(me)?.get_password() { + Ok(t) => Ok(Some(t)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e).context("reading the token from the system keyring"), + } +} + +pub fn set(me: &str, token: &str) -> Result<()> { + entry(me)? + .set_password(token) + .context("saving the token to the system keyring") +} + +pub fn delete(me: &str) -> Result<()> { + match entry(me)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e).context("removing the token from the system keyring"), + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..132215b --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,219 @@ +//! Rendering. + +use ratatui::{ + Frame, + layout::{Constraint, Flex, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap}, +}; + +use crate::app::{App, Focus, Mode, Status, Targets}; + +const ACCENT: Color = Color::Cyan; + +fn field_block(title: &str, focused: bool) -> Block<'static> { + let block = Block::bordered().title(format!(" {title} ")); + if focused { + block + .border_type(BorderType::Thick) + .border_style(Style::default().fg(ACCENT)) + .title_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)) + } else { + block.border_style(Style::default().fg(Color::DarkGray)) + } +} + +pub fn draw(f: &mut Frame, app: &mut App) { + let targets_height = match app.targets_state { + Targets::Loaded if !app.targets.is_empty() => (app.targets.len() as u16 + 2).min(8), + _ => 3, + }; + let [header, title, content, tags, targets, status, help] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + Constraint::Length(targets_height), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(f.area()); + + f.render_widget( + Line::from(vec![ + Span::styled( + " tuipub ", + Style::default().fg(Color::Black).bg(ACCENT).bold(), + ), + Span::raw(" "), + Span::styled(app.me.clone(), Style::default().bold()), + Span::styled( + format!(" via {}", app.endpoint), + Style::default().fg(Color::DarkGray), + ), + ]), + header, + ); + + app.title + .set_block(field_block("Title", app.focus == Focus::Title)); + f.render_widget(&app.title, title); + let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum(); + app.content.set_block(field_block( + &format!("Note · {chars} chars"), + app.focus == Focus::Content, + )); + f.render_widget(&app.content, content); + app.tags + .set_block(field_block("Tags", app.focus == Focus::Tags)); + f.render_widget(&app.tags, tags); + + draw_targets(f, app, targets); + + let status_line = match &app.status { + Some(Status::Info(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Green)), + Some(Status::Error(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Red)), + None => Line::raw(""), + }; + f.render_widget(status_line, status); + + let help_text = match app.focus { + Focus::Syndication => { + " ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Esc quit" + } + _ => " Tab next field · Ctrl-S publish · Ctrl-R refresh · Ctrl-O open last post · Esc quit", + }; + f.render_widget( + Line::styled(help_text, Style::default().fg(Color::DarkGray)), + help, + ); + + match &app.mode { + Mode::Editing => {} + Mode::ConfirmPublish => popup( + f, + "Publish?", + vec![ + Line::from(vec![ + Span::raw("Post to "), + Span::styled(app.me.clone(), Style::default().bold()), + ]), + Line::from(vec![ + Span::raw("Crosspost: "), + Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)), + ]), + Line::raw(""), + Line::styled( + "y / Enter publish · any other key cancel", + Style::default().fg(Color::DarkGray), + ), + ], + ), + Mode::Publishing => popup(f, "Publishing", vec![Line::raw("Sending to your site…")]), + Mode::Published(url) => popup( + f, + "Published", + vec![ + Line::styled(url.clone(), Style::default().fg(ACCENT).underlined()), + Line::raw(""), + Line::styled( + "o open in browser · any other key new note", + Style::default().fg(Color::DarkGray), + ), + ], + ), + Mode::ConfirmQuit => popup( + f, + "Quit?", + vec![ + Line::raw("This note hasn't been published and will be lost."), + Line::raw(""), + Line::styled( + "y quit · any other key keep editing", + Style::default().fg(Color::DarkGray), + ), + ], + ), + } +} + +fn draw_targets(f: &mut Frame, app: &App, area: Rect) { + let focused = app.focus == Focus::Syndication; + let block = field_block("Crosspost to", focused); + match &app.targets_state { + Targets::Loading => f.render_widget( + Paragraph::new("Loading targets…") + .fg(Color::DarkGray) + .block(block), + area, + ), + Targets::Failed(_) => f.render_widget( + Paragraph::new("Couldn't load targets; the server's default applies (Ctrl-R to retry)") + .fg(Color::Yellow) + .block(block), + area, + ), + Targets::Loaded if app.targets.is_empty() => f.render_widget( + Paragraph::new("This server has no crosspost targets") + .fg(Color::DarkGray) + .block(block), + area, + ), + Targets::Loaded => { + let items: Vec = app + .targets + .iter() + .map(|(t, on)| { + let mark = if *on { "[x] " } else { "[ ] " }; + ListItem::new(Line::from(vec![ + Span::styled( + mark, + Style::default().fg(if *on { Color::Green } else { Color::DarkGray }), + ), + Span::raw(t.name.clone()), + ])) + }) + .collect(); + let mut list = List::new(items).block(block); + if focused { + list = list.highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + } + let mut state = ListState::default().with_selected(Some(app.target_cursor)); + f.render_stateful_widget(list, area, &mut state); + } + } +} + +fn popup(f: &mut Frame, title: &str, lines: Vec>) { + let width = lines + .iter() + .map(|l| l.width() as u16) + .max() + .unwrap_or(20) + .max(30) + + 4; + let height = lines.len() as u16 + 2; + let area = centered(f.area(), width, height); + f.render_widget(Clear, area); + f.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: false }).block( + Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(ACCENT)) + .title(format!(" {title} ")) + .title_style(Style::default().bold()), + ), + area, + ); +} + +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let [row] = Layout::vertical([Constraint::Length(height.min(area.height))]) + .flex(Flex::Center) + .areas(area); + let [cell] = Layout::horizontal([Constraint::Length(width.min(area.width))]) + .flex(Flex::Center) + .areas(row); + cell +} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..7e30954 --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,33 @@ +//! Runs Micropub requests on a background thread so the UI never blocks. + +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; + +use crate::micropub::{Client, Error, Note, ServerConfig}; + +pub enum Request { + LoadConfig, + Publish(Note), +} + +pub enum Response { + Config(Result), + Published(Result), +} + +pub fn spawn(client: Client) -> (Sender, Receiver) { + let (req_tx, req_rx) = mpsc::channel::(); + let (resp_tx, resp_rx) = mpsc::channel::(); + thread::spawn(move || { + for req in req_rx { + let resp = match req { + Request::LoadConfig => Response::Config(client.config()), + Request::Publish(note) => Response::Published(client.create(¬e)), + }; + if resp_tx.send(resp).is_err() { + break; + } + } + }); + (req_tx, resp_rx) +}