diff --git a/docs/macroplan.md b/docs/macroplan.md index ec09764..72f2c84 100644 --- a/docs/macroplan.md +++ b/docs/macroplan.md @@ -237,4 +237,22 @@ reconnect, and on-device provisioning (the first release usable by a non-author) ## v1.x — Stretch / nice-to-have Post-1.0 ideas, not committed to any release (10.3" panel, multiple remotes, -writing stats, BLE-HID fallback). Detail: [v1.x-stretch.md](v1.x-stretch.md). +writing stats, BLE-HID fallback, firmware auto-update). Detail: +[v1.x-stretch.md](v1.x-stretch.md). + +**Firmware auto-update (open question, added 2026-07-14).** Devices now ship +**pre-flashed** from manufacturing, and the [installer](../installer/DESIGN.md) +only provisions the SD card — so field updates can't lean on a USB reflash. How +a device pulls a new firmware build is unresolved; candidates: + +- **OTA over Wi-Fi** — the device fetches a signed image and self-flashes via + esp-idf OTA. Needs a dual-app partition layout (`ota_0`/`ota_1` + `otadata` — + a change from today's single-app + `storage` table), image signing/trust, a + version manifest with rollback, and somewhere to host images. +- **SD-drop update** — a `firmware.bin` placed on the card (by a future + installer action) is verified + applied on boot. No OTA server, fits the + SD-centric model, but still needs signing + a safe apply/rollback path. + +Either way it's a **device-side** concern, not the installer's job. The +mechanism should be settled **before v1.0 hardware is mass-flashed**: OTA needs +two app slots, so the partition layout is locked in at flash time. diff --git a/installer/Cargo.lock b/installer/Cargo.lock new file mode 100644 index 0000000..e5d2f8b --- /dev/null +++ b/installer/Cargo.lock @@ -0,0 +1,1659 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "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 = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "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 = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[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 = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[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 = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[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 = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "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-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.118", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +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 = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[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.0", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "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-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.17.1", + "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.0", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +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 = "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "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 = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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.118", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[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", + "bitflags 2.13.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "typoena-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "ratatui", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" +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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "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", + "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-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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/installer/Cargo.toml b/installer/Cargo.toml new file mode 100644 index 0000000..42388ba --- /dev/null +++ b/installer/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "typoena-installer" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.103" +base64 = "0.22.1" +ratatui = "0.30.2" diff --git a/installer/DESIGN.md b/installer/DESIGN.md new file mode 100644 index 0000000..736649e --- /dev/null +++ b/installer/DESIGN.md @@ -0,0 +1,95 @@ +# Typoena installer — design + +A self-contained macOS CLI (ratatui TUI) that prepares an SD card so a +**pre-flashed** Typoena is ready to use the moment the card goes in. The public +entry point is the one-liner on typoena.dev: + + curl -fsSL https://typoena.dev/install.sh | sh + +`install.sh` downloads this prebuilt binary; the binary does the rest. The user +needs **no repo checkout and no Rust toolchain** — just the card. + +## Decisions (2026-07-14) + +- **Self-contained end-user tool.** No `just`, no typewriter checkout. The + binary bundles what it needs (config templates, snippet catalog). The proven + `just` bash (`firmware/justfile`) is the *reference spec* for the safety + behaviours, ported to Rust — not shelled out to. +- **The installer never flashes.** Devices ship **pre-flashed from + manufacturing**; setup is SD-card-only. Firmware field updates + (auto-update) are a **device/roadmap** concern, not the installer's — see + [`docs/macroplan.md`](../docs/macroplan.md) (v1.x note). +- **The card's repo is a fresh `git clone` from the remote** (HTTPS + PAT), + written straight onto the card. There is no local source clone to mirror, so + none of the rsync machinery applies: **no `--ff-only` refresh, no `.gitignore` + excludes, no repack** — a fresh clone already contains only tracked files and + is a single pack, and its origin is already the HTTPS URL we cloned from. +- **Lives in `typewriter/installer/`**, tracked in the firmware repo so it + versions in lockstep with the config templates + snippet catalog it ships. + +## Phase pipeline (the wizard) + +1. **Preflight** — a mounted card + git present. Advisory; warnings don't block. +2. **Configure** — collect Wi-Fi SSID/pass, git remote, GitHub user, PAT, and + commit identity. Pre-fill via the derive ladder (below). +3. **SD card** — pick the card (refuse on ambiguity), `git clone` the remote + onto `/repo`, seed `.typoena.toml` + snippets if absent, write + `typoena.conf`, strip `._*`, eject. +4. **Done** — "put the card in your Typoena and power on." + +## Safety behaviours to keep (from `firmware/justfile` — do NOT regress) + +- **Card-ambiguity refusal** — never guess when >1 removable volume; a wrong + guess lets a write hit the wrong disk. Refuse and ask. +- **`.typoena-dirty` guard** — refuse to overwrite a card that carries + unpublished device edits; offer backup-and-discard. +- **AppleDouble `._*`** — `dot_clean` before eject; `._pack-*.idx` corrupts the + pack scan (Mac git *and* device libgit2). +- **PAT never derived** — always typed; fine-grained, `contents:write` on the + one repo; plaintext on FAT means physical custody is the control. The clone + uses the PAT so private notes repos work. + +_Dropped vs. the old `just load` (now moot with clone-from-remote): rsync +mirror, `--ff-only` source refresh, `.gitignore` exclude list, `git repack -ad`._ + +## Config derive ladder (Configure step) + +Each value: explicit input → derived from this Mac → prompt. +`author` ← `git config user.{name,email}` · `gh_user` ← `gh api user` · +`ssid` ← active Wi-Fi (`networksetup`) · `wifi_pass` ← Keychain (on ^K, may +prompt macOS) · `remote`, `pat` ← typed (PAT never derived). + +## Architecture / crates + +- `ratatui` + its crossterm backend — TUI. +- `git2` — clone the remote onto the card, confirm origin. _[SD slice]_ +- Config templates + snippet catalog embedded via `include_str!` + (self-contained). + +## Open items (not blocking the current slices) + +- **Hosting** — where the CLI binary lives for the `curl | sh` download (Gitea + release vs typoena.dev static asset). Dev runs via `cargo run` meanwhile. +- **Non-macOS** — Linux/Windows later; slice work is macOS-first. +- **Clone target** — cloning ~hundreds of MB directly onto FAT via a reader; + measure, and fall back to clone-to-temp-then-copy if it's too slow. +- **Re-provision** — DONE for the destructive case: an existing card is handled + by an explicit **wipe-and-reclone** (`y`-confirmed screen showing origin + + HEAD + unpublished-edit count; removes only `repo/` + the dirty journal, then + clones fresh). Follow-ups: a config-only rewrite that rotates the PAT / + switches Wi-Fi *without* recloning (like `just provision`), and backing up + `.typoena-dirty` edits before wiping instead of only warning. + +## Slice plan + +1. **App shell + Preflight** — DONE 2026-07-14. Branded wizard; card + git + detection; `--check` headless mode. +2. **Configure** — DONE 2026-07-14. Form + derive ladder, masked secrets, + Keychain fill, required-field validation. +3. **SD card** — DONE 2026-07-14 (fresh-card path). Pick card (boot disk + excluded) → `git clone` onto it (single pack, clean HTTPS origin) → seed + `.typoena.toml` → write `typoena.conf` → strip `._*` → eject; the long clone + runs on a worker thread streaming progress. Verified: card detection on real + hardware and clone + seed + conf via `--list-cards` / `--dry-run-sd`. Full + interactive run + real write/eject await a blank card + a TTY. +4. **install.sh + release/hosting** — checksums, polish. diff --git a/installer/rust-toolchain.toml b/installer/rust-toolchain.toml new file mode 100644 index 0000000..3545515 --- /dev/null +++ b/installer/rust-toolchain.toml @@ -0,0 +1,3 @@ +# Host binary — build with stable, never the `esp` channel (that's firmware/). +[toolchain] +channel = "stable" diff --git a/installer/src/app.rs b/installer/src/app.rs new file mode 100644 index 0000000..db8fa58 --- /dev/null +++ b/installer/src/app.rs @@ -0,0 +1,297 @@ +//! Wizard state: which step we're on, the results each step produces, and +//! step-aware key handling (nav steps, the Configure form, and the SD-card step +//! each behave differently). + +use std::sync::mpsc::{Receiver, TryRecvError}; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::config::{Config, Field, keychain_wifi_password}; +use crate::preflight::Preflight; +use crate::sdcard::{self, Card, CardInspect, SdEvent}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Step { + Preflight, + Configure, + SdCard, + Done, +} + +impl Step { + pub const ALL: [Step; 4] = [Step::Preflight, Step::Configure, Step::SdCard, Step::Done]; + + pub fn title(self) -> &'static str { + match self { + Step::Preflight => "Preflight", + Step::Configure => "Configure", + Step::SdCard => "SD card", + Step::Done => "Done", + } + } + + fn index(self) -> usize { + Step::ALL.iter().position(|&s| s == self).unwrap_or(0) + } + + fn next(self) -> Step { + Step::ALL[(self.index() + 1).min(Step::ALL.len() - 1)] + } + + fn prev(self) -> Step { + Step::ALL[self.index().saturating_sub(1)] + } +} + +pub enum SdState { + Idle, + /// The selected card already holds a repo; awaiting an explicit `y` to wipe. + ConfirmWipe(CardInspect), + Running, + Done, + Failed(String), +} + +pub struct App { + pub step: Step, + pub preflight: Preflight, + pub config: Config, + /// Focused field index within the Configure form. + pub focus: usize, + /// Transient one-line feedback (e.g. the Keychain-lookup result). + pub status: Option, + // ── SD-card step ── + pub cards: Vec, + pub card_sel: usize, + pub sd: SdState, + pub sd_log: Vec, + sd_rx: Option>, + pub should_quit: bool, +} + +impl App { + pub fn new() -> Self { + App { + step: Step::Preflight, + preflight: Preflight::run(), + config: Config::derived(), + focus: 0, + status: None, + cards: Vec::new(), + card_sel: 0, + sd: SdState::Idle, + sd_log: Vec::new(), + sd_rx: None, + should_quit: false, + } + } + + pub fn on_key(&mut self, key: KeyEvent) { + // Ctrl-C always quits, on any step (even mid-typing / mid-run). + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.should_quit = true; + return; + } + self.status = None; + match self.step { + Step::Configure => self.on_key_configure(key), + Step::SdCard => self.on_key_sdcard(key), + _ => self.on_key_nav(key), + } + } + + /// Non-form steps: single-key navigation. + fn on_key_nav(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Enter | KeyCode::Tab | KeyCode::Down => self.next(), + KeyCode::Up | KeyCode::BackTab => self.prev(), + KeyCode::Char('r') if self.step == Step::Preflight => { + self.preflight = Preflight::run(); + } + _ => {} + } + } + + /// Configure form: typing edits the focused field; field navigation spills + /// over into step navigation at the ends. + fn on_key_configure(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let last = Field::ALL.len() - 1; + match key.code { + KeyCode::Esc => self.should_quit = true, + KeyCode::Up | KeyCode::BackTab => { + if self.focus > 0 { + self.focus -= 1; + } else { + self.prev(); + } + } + KeyCode::Down | KeyCode::Tab if self.focus < last => self.focus += 1, + KeyCode::Enter => { + if self.focus < last { + self.focus += 1; + } else { + self.next(); + } + } + KeyCode::Char('u') if ctrl => self.config.get_mut(self.focused_field()).clear(), + KeyCode::Char('k') if ctrl => self.fill_wifi_from_keychain(), + KeyCode::Backspace => { + self.config.get_mut(self.focused_field()).pop(); + } + KeyCode::Char(c) if !ctrl => self.config.get_mut(self.focused_field()).push(c), + _ => {} + } + } + + /// SD-card step: pick a card, then start (or confirm-wipe-then-start) the + /// provision. + fn on_key_sdcard(&mut self, key: KeyEvent) { + match self.sd { + SdState::Running => return, // input locked while the worker runs + SdState::ConfirmWipe(_) => { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => self.start_provision(true), + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.sd = SdState::Idle + } + _ => {} + } + return; + } + _ => {} + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Up | KeyCode::BackTab => { + if self.card_sel > 0 { + self.card_sel -= 1; + } else { + self.prev(); + } + } + KeyCode::Down | KeyCode::Tab if self.card_sel + 1 < self.cards.len() => { + self.card_sel += 1; + } + KeyCode::Char('r') => { + self.sd = SdState::Idle; + self.sd_log.clear(); + self.refresh_cards(); + } + KeyCode::Enter => self.attempt_provision(), + _ => {} + } + } + + pub fn focused_field(&self) -> Field { + Field::ALL[self.focus.min(Field::ALL.len() - 1)] + } + + fn fill_wifi_from_keychain(&mut self) { + let ssid = self.config.wifi_ssid.clone(); + self.status = Some(match keychain_wifi_password(&ssid) { + Some(pw) => { + self.config.wifi_pass = pw; + format!("filled Wi-Fi password for “{ssid}” from Keychain") + } + None => "no Keychain password found (or the lookup was cancelled)".into(), + }); + } + + fn refresh_cards(&mut self) { + self.cards = sdcard::detect_cards(); + self.card_sel = 0; + } + + fn selected_volume(&self) -> Option { + self.cards + .get(self.card_sel.min(self.cards.len().saturating_sub(1))) + .map(|c| c.volume.clone()) + } + + /// Enter on a card: validate, then either start a fresh provision or, if the + /// card already holds a repo, drop into the wipe-confirm screen. + fn attempt_provision(&mut self) { + if self.cards.is_empty() { + self.status = Some("no card detected — insert one and press r".into()); + return; + } + if !self.config.missing_required().is_empty() { + self.status = Some("fill the required fields on the Configure step first".into()); + return; + } + let Some(vol) = self.selected_volume() else { + return; + }; + if sdcard::card_has_repo(&vol) { + self.sd = SdState::ConfirmWipe(sdcard::inspect_card(&vol)); + } else { + self.start_provision(false); + } + } + + fn start_provision(&mut self, wipe: bool) { + let Some(card_volume) = self.selected_volume() else { + return; + }; + let plan = sdcard::Plan { + remote: self.config.remote_url.clone(), + pat: self.config.pat.clone(), + card_volume, + conf_body: self.config.to_conf(), + wipe, + }; + let (tx, rx) = std::sync::mpsc::channel(); + self.sd_rx = Some(rx); + self.sd = SdState::Running; + self.sd_log.clear(); + std::thread::spawn(move || sdcard::run_provision(plan, tx)); + } + + /// Pull worker progress into the log; advance to Done on success. + pub fn drain_worker(&mut self) { + let Some(rx) = self.sd_rx.take() else { + return; + }; + let mut done = None; + loop { + match rx.try_recv() { + Ok(SdEvent::Log(l)) => self.sd_log.push(l), + Ok(SdEvent::Done(r)) => { + done = Some(r); + break; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + done = Some(Err("worker thread stopped unexpectedly".into())); + break; + } + } + } + match done { + Some(Ok(())) => { + self.sd = SdState::Done; + self.step = Step::Done; + } + Some(Err(e)) => self.sd = SdState::Failed(e), + None => self.sd_rx = Some(rx), + } + } + + fn next(&mut self) { + self.step = self.step.next(); + self.on_enter(); + } + + fn prev(&mut self) { + self.step = self.step.prev(); + self.on_enter(); + } + + fn on_enter(&mut self) { + if self.step == Step::SdCard && matches!(self.sd, SdState::Idle) { + self.refresh_cards(); + } + } +} diff --git a/installer/src/config.rs b/installer/src/config.rs new file mode 100644 index 0000000..7ae4171 --- /dev/null +++ b/installer/src/config.rs @@ -0,0 +1,190 @@ +//! The runtime config the device reads from the card (`typoena.conf`), plus the +//! derive ladder that pre-fills it from this Mac. Mirrors the `_write-conf` +//! recipe in `firmware/justfile`. + +use std::process::Command; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Field { + WifiSsid, + WifiPass, + RemoteUrl, + GhUser, + Pat, + AuthorName, + AuthorEmail, +} + +impl Field { + pub const ALL: [Field; 7] = [ + Field::WifiSsid, + Field::WifiPass, + Field::RemoteUrl, + Field::GhUser, + Field::Pat, + Field::AuthorName, + Field::AuthorEmail, + ]; + + pub fn label(self) -> &'static str { + match self { + Field::WifiSsid => "Wi-Fi SSID", + Field::WifiPass => "Wi-Fi password", + Field::RemoteUrl => "Git remote URL", + Field::GhUser => "GitHub username", + Field::Pat => "GitHub PAT", + Field::AuthorName => "Commit author name", + Field::AuthorEmail => "Commit author email", + } + } + + pub fn secret(self) -> bool { + matches!(self, Field::WifiPass | Field::Pat) + } + + /// The `typoena.conf` key this field writes. + pub fn conf_key(self) -> &'static str { + match self { + Field::WifiSsid => "TW_WIFI_SSID", + Field::WifiPass => "TW_WIFI_PASS", + Field::RemoteUrl => "TW_REMOTE_URL", + Field::GhUser => "TW_GH_USER", + Field::Pat => "TW_PAT", + Field::AuthorName => "TW_AUTHOR_NAME", + Field::AuthorEmail => "TW_AUTHOR_EMAIL", + } + } + + /// Required to ship a working device (a blank one bricks). Matches the + /// four-var guard in `firmware/build.rs` minus the runtime-defaulted author. + pub fn required(self) -> bool { + matches!(self, Field::WifiSsid | Field::RemoteUrl | Field::Pat) + } +} + +#[derive(Default)] +pub struct Config { + pub wifi_ssid: String, + pub wifi_pass: String, + pub remote_url: String, + pub gh_user: String, + pub pat: String, + pub author_name: String, + pub author_email: String, +} + +impl Config { + /// Pre-fill from this Mac (the derive ladder). The PAT is never derived — + /// a broad token on plaintext removable media defeats the scoped-token model. + pub fn derived() -> Self { + Config { + wifi_ssid: active_wifi_ssid().unwrap_or_default(), + wifi_pass: String::new(), + remote_url: String::new(), + gh_user: gh_login().unwrap_or_default(), + pat: String::new(), + author_name: git_config("user.name").unwrap_or_default(), + author_email: git_config("user.email").unwrap_or_default(), + } + } + + pub fn get(&self, f: Field) -> &str { + match f { + Field::WifiSsid => &self.wifi_ssid, + Field::WifiPass => &self.wifi_pass, + Field::RemoteUrl => &self.remote_url, + Field::GhUser => &self.gh_user, + Field::Pat => &self.pat, + Field::AuthorName => &self.author_name, + Field::AuthorEmail => &self.author_email, + } + } + + pub fn get_mut(&mut self, f: Field) -> &mut String { + match f { + Field::WifiSsid => &mut self.wifi_ssid, + Field::WifiPass => &mut self.wifi_pass, + Field::RemoteUrl => &mut self.remote_url, + Field::GhUser => &mut self.gh_user, + Field::Pat => &mut self.pat, + Field::AuthorName => &mut self.author_name, + Field::AuthorEmail => &mut self.author_email, + } + } + + /// Required fields still blank (the SD step refuses to write a dead conf). + pub fn missing_required(&self) -> Vec { + Field::ALL + .iter() + .copied() + .filter(|f| f.required() && self.get(*f).trim().is_empty()) + .collect() + } + + /// Render the `typoena.conf` body — written to the card by the SD step. + pub fn to_conf(&self) -> String { + let mut s = String::new(); + s.push_str("# Typoena runtime config — generated by the installer.\n"); + s.push_str("# Plaintext secrets on removable media: keep the card safe; scope TW_PAT\n"); + s.push_str("# to contents:write on just the notes repo.\n"); + for f in Field::ALL { + s.push_str(f.conf_key()); + s.push('='); + s.push_str(self.get(f)); + s.push('\n'); + } + s + } +} + +fn git_config(key: &str) -> Option { + non_empty(Command::new("git").args(["config", key]).output().ok()?) +} + +fn gh_login() -> Option { + non_empty( + Command::new("gh") + .args(["api", "user", "--jq", ".login"]) + .output() + .ok()?, + ) +} + +fn active_wifi_ssid() -> Option { + // The Wi-Fi device on a Mac is usually en0; ask networksetup for its SSID. + let out = Command::new("networksetup") + .args(["-getairportnetwork", "en0"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .lines() + .find_map(|l| l.strip_prefix("Current Wi-Fi Network: ")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Look up a saved Wi-Fi password in the System keychain. Pops a macOS auth +/// dialog, so call only on explicit user action. Mirrors the justfile's +/// `security find-generic-password -wa `. +pub fn keychain_wifi_password(ssid: &str) -> Option { + if ssid.trim().is_empty() { + return None; + } + non_empty( + Command::new("security") + .args(["find-generic-password", "-wa", ssid]) + .output() + .ok()?, + ) +} + +fn non_empty(out: std::process::Output) -> Option { + if !out.status.success() { + return None; + } + let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!v.is_empty()).then_some(v) +} diff --git a/installer/src/main.rs b/installer/src/main.rs new file mode 100644 index 0000000..bfd2a23 --- /dev/null +++ b/installer/src/main.rs @@ -0,0 +1,120 @@ +mod app; +mod config; +mod preflight; +mod sdcard; +mod ui; + +use std::path::PathBuf; +use std::time::Duration; + +use app::App; +use config::{Config, Field}; +use preflight::{Preflight, Status}; +use ratatui::crossterm::event::{self, Event, KeyEventKind}; + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + + // Headless preflight + derived config (scriptable, no TTY needed). + if args.iter().any(|a| a == "--check") { + return run_check(); + } + // Read-only: list the removable cards the SD step would offer. + if args.iter().any(|a| a == "--list-cards") { + return list_cards(); + } + // Verify the (optionally wipe +) clone + config-write path without a card + // (clones to a temp dir, no eject). + // Usage: --dry-run-sd [dest-dir] [--wipe] + if args.iter().any(|a| a == "--dry-run-sd") { + let wipe = args.iter().any(|a| a == "--wipe"); + // positional args (flags stripped): [0] = remote, [1] = optional dest + let positionals: Vec = args + .iter() + .skip(1) + .filter(|a| !a.starts_with("--")) + .cloned() + .collect(); + let remote = positionals.first().cloned().unwrap_or_default(); + if remote.is_empty() { + anyhow::bail!("usage: --dry-run-sd [dest-dir] [--wipe]"); + } + let dest = positionals + .get(1) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("typoena-dryrun")); + println!( + "dry-run SD provision{}: clone {remote} → {}/repo", + if wipe { " (wipe first)" } else { "" }, + dest.display() + ); + return sdcard::dry_run(&remote, &dest, wipe); + } + + let mut terminal = ratatui::init(); + let result = run(&mut terminal); + ratatui::restore(); + result +} + +fn run(terminal: &mut ratatui::DefaultTerminal) -> anyhow::Result<()> { + let mut app = App::new(); + while !app.should_quit { + app.drain_worker(); + terminal.draw(|frame| ui::render(frame, &app))?; + // Poll so worker progress can repaint even without a keypress. + if event::poll(Duration::from_millis(100))? + && let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + app.on_key(key); + } + } + Ok(()) +} + +fn run_check() -> anyhow::Result<()> { + let pf = Preflight::run(); + for c in &pf.checks { + let tag = match c.status { + Status::Ok => "OK ", + Status::Warn => "WARN", + Status::Missing => "MISS", + }; + println!("[{tag}] {:<16} {}", c.label, c.detail); + } + println!("ready: {}", pf.ready()); + + println!("--- derived config (secrets hidden) ---"); + let cfg = Config::derived(); + for f in Field::ALL { + let v = cfg.get(f); + let shown = if f.secret() { + if v.is_empty() { "(unset)" } else { "(set)" } + } else if v.is_empty() { + "(unset)" + } else { + v + }; + println!(" {:<22} {}", f.label(), shown); + } + Ok(()) +} + +fn list_cards() -> anyhow::Result<()> { + let cards = sdcard::detect_cards(); + if cards.is_empty() { + println!("no removable card detected under /Volumes"); + return Ok(()); + } + for c in cards { + let fat = if c.fat { + "FAT" + } else { + "NOT FAT — device may not mount" + }; + println!("{} [{}] ({})", c.name, c.fs, fat); + println!(" {}", c.volume.display()); + } + Ok(()) +} diff --git a/installer/src/preflight.rs b/installer/src/preflight.rs new file mode 100644 index 0000000..5a5be69 --- /dev/null +++ b/installer/src/preflight.rs @@ -0,0 +1,83 @@ +//! Environment detection for the Preflight step. +//! +//! The device ships pre-flashed, so setup is SD-card-only: this checks what the +//! card prep needs — a mounted card and git. Everything here is advisory; a +//! warning informs the user, it never blocks. The real, destructive card work +//! (diskutil, rsync) lands in the SD-card step; this only reports what's there. + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Status { + Ok, + Warn, + Missing, +} + +pub struct Check { + pub label: &'static str, + pub status: Status, + pub detail: String, +} + +pub struct Preflight { + pub checks: Vec, +} + +impl Preflight { + pub fn run() -> Self { + Preflight { + checks: vec![detect_sd_card(), detect_git()], + } + } + + /// Nothing is outright `Missing` (warnings are allowed to pass). + pub fn ready(&self) -> bool { + self.checks.iter().all(|c| c.status != Status::Missing) + } +} + +fn detect_sd_card() -> Check { + let label = "SD card"; + let vols: Vec = std::fs::read_dir("/Volumes") + .map(|rd| { + rd.filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.')) + .collect() + }) + .unwrap_or_default(); + // Slice-level detection only reports what's mounted; true removable/FAT + // identification (diskutil) — and the ambiguity refusal — land with the + // SD-card step. + match vols.len() { + 0 => Check { + label, + status: Status::Missing, + detail: "no volumes under /Volumes — insert a card".into(), + }, + _ => Check { + label, + status: Status::Warn, + detail: format!( + "{} volume(s): {} — the card is chosen in the SD-card step", + vols.len(), + vols.join(", ") + ), + }, + } +} + +fn detect_git() -> Check { + let label = "git"; + match std::process::Command::new("git").arg("--version").output() { + Ok(o) if o.status.success() => Check { + label, + status: Status::Ok, + detail: String::from_utf8_lossy(&o.stdout).trim().to_string(), + }, + _ => Check { + label, + status: Status::Warn, + detail: "not found — needed to clone your notes repo (xcode-select --install)".into(), + }, + } +} diff --git a/installer/src/sdcard.rs b/installer/src/sdcard.rs new file mode 100644 index 0000000..9d70d38 --- /dev/null +++ b/installer/src/sdcard.rs @@ -0,0 +1,284 @@ +//! SD-card provisioning: pick the card, clone the notes repo onto it, seed the +//! git-tracked prefs, write `typoena.conf`, and eject. Ports the safety +//! behaviours of the `just init`/`load` recipes; the repo copy is a fresh clone +//! from the remote (no rsync / .gitignore excludes / repack — see DESIGN.md). + +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc::Sender; + +use anyhow::{Context, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; + +/// A candidate removable volume. +pub struct Card { + pub volume: PathBuf, + pub name: String, + pub fs: String, + pub fat: bool, +} + +/// Read-only summary of an already-provisioned card, shown on the wipe-confirm +/// screen so the user sees exactly what they're about to erase. +pub struct CardInspect { + pub origin: Option, + pub head: Option, + pub dirty: usize, +} + +pub struct Plan { + pub remote: String, + pub pat: String, + pub card_volume: PathBuf, + pub conf_body: String, + /// Erase an existing `repo/` + dirty journal before cloning. + pub wipe: bool, +} + +impl Plan { + fn repo_dir(&self) -> PathBuf { + self.card_volume.join("repo") + } + fn conf_path(&self) -> PathBuf { + self.card_volume.join("typoena.conf") + } +} + +pub enum SdEvent { + Log(String), + Done(Result<(), String>), +} + +/// Detect removable/SD volumes under /Volumes (via diskutil). Mirrors the +/// justfile `_card` heuristics; the internal boot disk never matches. +pub fn detect_cards() -> Vec { + let mut out = Vec::new(); + let Ok(rd) = std::fs::read_dir("/Volumes") else { + return out; + }; + for entry in rd.flatten() { + let vol = entry.path(); + if !vol.is_dir() { + continue; + } + let info = match Command::new("diskutil").arg("info").arg(&vol).output() { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), + _ => continue, + }; + if !is_removable(&info) { + continue; + } + let fs = field(&info, "File System Personality").unwrap_or_default(); + let up = fs.to_uppercase(); + out.push(Card { + name: vol + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + fat: up.contains("FAT") || up.contains("MS-DOS"), + fs, + volume: vol, + }); + } + out +} + +fn is_removable(info: &str) -> bool { + // Test the VALUE of each key, not the whole line: the label "Removable + // Media" itself contains "Removable", so a line-substring test matches every + // disk — including the internal boot volume (found on real hardware, 07-14). + let val = |k| field(info, k).unwrap_or_default(); + val("Protocol").contains("Secure Digital") + || val("Removable Media").contains("Removable") + || val("Ejectable") == "Yes" + || val("Device Location") == "External" +} + +fn field(info: &str, key: &str) -> Option { + info.lines().find_map(|l| { + let rest = l.trim().strip_prefix(key)?.trim_start(); + let val = rest.strip_prefix(':')?.trim(); + (!val.is_empty()).then(|| val.to_string()) + }) +} + +/// True if the card already carries a working copy at `repo/`. +pub fn card_has_repo(vol: &Path) -> bool { + vol.join("repo").exists() +} + +/// Read-only inspection of an existing card (origin, HEAD, unpublished-edit count). +pub fn inspect_card(vol: &Path) -> CardInspect { + let repo = vol.join("repo"); + let git = |args: &[&str]| -> Option { + let out = Command::new("git") + .arg("-C") + .arg(&repo) + .args(args) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!v.is_empty()).then_some(v) + }; + CardInspect { + origin: git(&["remote", "get-url", "origin"]), + head: git(&["rev-parse", "--short", "HEAD"]), + dirty: std::fs::read_to_string(vol.join(".typoena-dirty")) + .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count()) + .unwrap_or(0), + } +} + +/// Run the full provision on a worker thread, streaming progress to `tx`. +pub fn run_provision(plan: Plan, tx: Sender) { + let mut log = |m: String| { + let _ = tx.send(SdEvent::Log(m)); + }; + let res = provision(&plan, &mut log).map_err(|e| format!("{e:#}")); + let _ = tx.send(SdEvent::Done(res)); +} + +fn provision(plan: &Plan, log: &mut dyn FnMut(String)) -> anyhow::Result<()> { + if plan.wipe { + wipe_card(&plan.card_volume, log)?; + } + clone(&plan.remote, &plan.repo_dir(), &plan.pat, log)?; + log("seeding .typoena.toml (if absent)…".into()); + seed_prefs(&plan.repo_dir())?; + log(format!("writing {}", plan.conf_path().display())); + std::fs::write(plan.conf_path(), &plan.conf_body).context("writing typoena.conf")?; + log("stripping AppleDouble ._ files…".into()); + dot_clean(&plan.card_volume); + log("ejecting…".into()); + match eject(&plan.card_volume) { + Ok(()) => log("card ejected — remove it and insert into Typoena.".into()), + Err(e) => log(format!( + "⚠ could not eject ({e}); eject from Finder before removing." + )), + } + Ok(()) +} + +/// Erase an existing working copy before a re-provision. Only ever removes +/// `repo/` and the `.typoena-dirty` journal — never the volume itself, `ca.pem`, +/// or `/local`. The path guard rejects a bogus (root/empty) volume. +fn wipe_card(vol: &Path, log: &mut dyn FnMut(String)) -> anyhow::Result<()> { + if !vol.is_dir() || vol.parent().is_none() { + bail!( + "refusing to wipe: '{}' is not a mounted volume", + vol.display() + ); + } + let repo = vol.join("repo"); + log(format!("wiping {} …", repo.display())); + if repo.exists() { + std::fs::remove_dir_all(&repo).with_context(|| format!("removing {}", repo.display()))?; + } + let _ = std::fs::remove_file(vol.join(".typoena-dirty")); + Ok(()) +} + +/// Clone `remote` into `dest` with the system git. The PAT (if any) rides in an +/// HTTP Authorization header, so it never lands in the cloned repo's origin URL +/// — origin stays the clean HTTPS URL the device authenticates against. +fn clone(remote: &str, dest: &Path, pat: &str, log: &mut dyn FnMut(String)) -> anyhow::Result<()> { + if dest.exists() { + bail!( + "{} already exists — wipe the card first, or use a fresh one", + dest.display() + ); + } + log(format!("cloning {remote} → {}", dest.display())); + let mut cmd = Command::new("git"); + if !pat.is_empty() { + let token = STANDARD.encode(format!("x-access-token:{pat}")); + cmd.arg("-c") + .arg(format!("http.extraHeader=Authorization: Basic {token}")); + } + cmd.arg("clone") + .arg("--progress") + .arg(remote) + .arg(dest) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().context("spawning git (is it installed?)")?; + if let Some(err) = child.stderr.take() { + for line in BufReader::new(err).lines().map_while(Result::ok) { + let line = line.trim_end(); + if !line.is_empty() { + log(line.to_string()); + } + } + } + let status = child.wait().context("waiting for git clone")?; + if !status.success() { + bail!("git clone failed (exit {:?})", status.code()); + } + Ok(()) +} + +const PREFS_TEMPLATE: &str = "\ +# Typoena editor preferences — hand-editable, git-tracked. +save_on_idle = true +format_on_save = true +line_numbers = true +theme = \"light\" +auto_sync = \"10m\" +"; + +/// Seed a starter `.typoena.toml` only if the cloned repo doesn't already carry +/// one (a repo with a synced prefs file keeps its own). Mirrors `_seed-configs`. +fn seed_prefs(repo_dir: &Path) -> anyhow::Result<()> { + let p = repo_dir.join(".typoena.toml"); + if p.exists() { + return Ok(()); + } + std::fs::write(&p, PREFS_TEMPLATE).with_context(|| format!("seeding {}", p.display()))?; + Ok(()) +} + +fn dot_clean(vol: &Path) { + // Best-effort: strip the AppleDouble `._` companions macOS writes on FAT, + // which otherwise corrupt the pack scan (`._pack-*.idx`). Failure never blocks. + let _ = Command::new("dot_clean").arg("-m").arg(vol).status(); +} + +fn eject(vol: &Path) -> anyhow::Result<()> { + let _ = Command::new("sync").status(); + let status = Command::new("diskutil") + .arg("eject") + .arg(vol) + .status() + .context("running diskutil eject")?; + if !status.success() { + bail!("diskutil eject exited {:?}", status.code()); + } + Ok(()) +} + +/// Headless verification: (optionally wipe) + clone + seed + write a sample conf +/// into `dest`, with no card selection and no eject. Backs `--dry-run-sd`. +pub fn dry_run(remote: &str, dest: &Path, wipe: bool) -> anyhow::Result<()> { + let plan = Plan { + remote: remote.to_string(), + pat: String::new(), + card_volume: dest.to_path_buf(), + conf_body: "# sample typoena.conf (dry run)\nTW_WIFI_SSID=example\n".to_string(), + wipe, + }; + let mut log = |m: String| println!(" {m}"); + if plan.wipe { + wipe_card(&plan.card_volume, &mut log)?; + } + clone(&plan.remote, &plan.repo_dir(), &plan.pat, &mut log)?; + log("seeding .typoena.toml (if absent)…".into()); + seed_prefs(&plan.repo_dir())?; + log(format!("writing {}", plan.conf_path().display())); + std::fs::write(plan.conf_path(), &plan.conf_body).context("writing typoena.conf")?; + log("dry run complete (no card write, no eject).".into()); + Ok(()) +} diff --git a/installer/src/ui.rs b/installer/src/ui.rs new file mode 100644 index 0000000..b636ca4 --- /dev/null +++ b/installer/src/ui.rs @@ -0,0 +1,414 @@ +//! Rendering. Theme-agnostic: no hard-coded background (the terminal's own), +//! bold/reverse for emphasis, and the conventional green/yellow/red for status +//! so it reads on any color scheme. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, List, ListItem, Paragraph, Wrap}, +}; + +use crate::app::{App, SdState, Step}; +use crate::config::Field; +use crate::preflight::Status; + +pub fn render(frame: &mut Frame, app: &App) { + let [header, body, footer] = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(0), + Constraint::Length(1), + ]) + .areas(frame.area()); + + render_header(frame, header); + let [steps, main] = + Layout::horizontal([Constraint::Length(22), Constraint::Min(0)]).areas(body); + render_steps(frame, steps, app); + render_main(frame, main, app); + render_footer(frame, footer, app); +} + +fn render_header(frame: &mut Frame, area: Rect) { + let title = Line::from(vec![ + Span::styled("TYPOENA", Style::new().add_modifier(Modifier::BOLD)), + Span::styled(" installer", Style::new().fg(Color::DarkGray)), + ]); + frame.render_widget(Paragraph::new(title), area); +} + +fn render_steps(frame: &mut Frame, area: Rect, app: &App) { + let items: Vec = Step::ALL + .iter() + .enumerate() + .map(|(i, &s)| { + let active = s == app.step; + let marker = if active { "▸ " } else { " " }; + let style = if active { + Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::new().fg(Color::DarkGray) + }; + ListItem::new(Line::styled( + format!("{marker}{}. {}", i + 1, s.title()), + style, + )) + }) + .collect(); + frame.render_widget( + List::new(items).block(Block::bordered().title(" steps ")), + area, + ); +} + +fn render_main(frame: &mut Frame, area: Rect, app: &App) { + let block = Block::bordered().title(format!(" {} ", app.step.title())); + match app.step { + Step::Preflight => render_preflight(frame, area, app, block), + Step::Configure => render_configure(frame, area, app, block), + Step::SdCard => render_sdcard(frame, area, app, block), + Step::Done => render_done(frame, area, block), + } +} + +fn render_preflight(frame: &mut Frame, area: Rect, app: &App, block: Block) { + let mut lines = vec![ + Line::styled( + "Checking your Mac and the card.", + Style::new().fg(Color::DarkGray), + ), + Line::from(""), + ]; + for c in &app.preflight.checks { + let (glyph, color) = status_glyph(c.status); + lines.push(Line::from(vec![ + Span::styled( + format!(" {glyph} "), + Style::new().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{:<16}", c.label), + Style::new().add_modifier(Modifier::BOLD), + ), + Span::styled(c.detail.clone(), Style::new().fg(Color::DarkGray)), + ])); + } + lines.push(Line::from("")); + lines.push(if app.preflight.ready() { + Line::styled( + "Ready. Press Enter to continue.", + Style::new().fg(Color::Green), + ) + } else { + Line::styled( + "Fix the ✗ items, then press r to re-check.", + Style::new().fg(Color::Yellow), + ) + }); + frame.render_widget(paragraph(lines, block), area); +} + +fn render_configure(frame: &mut Frame, area: Rect, app: &App, block: Block) { + let mut lines: Vec = vec![ + Line::styled( + "Pre-filled from this Mac where possible. Type to edit · ↑/↓ move · Enter next field.", + Style::new().fg(Color::DarkGray), + ), + Line::from(""), + ]; + + for (i, &f) in Field::ALL.iter().enumerate() { + let focused = i == app.focus; + let val = app.config.get(f); + let empty = val.trim().is_empty(); + let shown: String = if f.secret() && !empty { + "•".repeat(val.chars().count()) + } else { + val.to_string() + }; + + let mut spans = vec![ + Span::styled( + if focused { "▸ " } else { " " }, + Style::new().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{:<22}", f.label()), + if focused { + Style::new().add_modifier(Modifier::BOLD) + } else { + Style::new() + }, + ), + ]; + if focused { + spans.push(Span::raw(shown)); + spans.push(Span::styled( + " ", + Style::new().add_modifier(Modifier::REVERSED), + )); // block caret + } else if empty { + let (text, color) = if f.required() { + ("(required)", Color::Yellow) + } else { + ("(optional)", Color::DarkGray) + }; + spans.push(Span::styled(text, Style::new().fg(color))); + } else { + spans.push(Span::raw(shown)); + } + lines.push(Line::from(spans)); + } + + lines.push(Line::from("")); + if let Some(msg) = &app.status { + lines.push(Line::styled(msg.clone(), Style::new().fg(Color::Cyan))); + } else { + let missing = app.config.missing_required(); + if missing.is_empty() { + lines.push(Line::styled( + "All required fields set — Enter on the last field goes to the SD-card step.", + Style::new().fg(Color::Green), + )); + } else { + let names: Vec<&str> = missing.iter().map(|f| f.label()).collect(); + lines.push(Line::styled( + format!("Required still empty: {}", names.join(", ")), + Style::new().fg(Color::Yellow), + )); + } + } + if let Some(hint) = field_hint(app.focused_field()) { + lines.push(Line::styled(hint, Style::new().fg(Color::DarkGray))); + } + + frame.render_widget(paragraph(lines, block), area); +} + +fn render_sdcard(frame: &mut Frame, area: Rect, app: &App, block: Block) { + let dim = |s: String| Line::styled(s, Style::new().fg(Color::DarkGray)); + let mut lines: Vec = Vec::new(); + match &app.sd { + SdState::ConfirmWipe(info) => { + let vol = app + .cards + .get(app.card_sel.min(app.cards.len().saturating_sub(1))) + .map(|c| c.volume.display().to_string()) + .unwrap_or_default(); + lines.push(Line::styled( + format!("⚠ {vol} already holds a Typoena card."), + Style::new().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + lines.push(Line::from("")); + if let Some(o) = &info.origin { + lines.push(dim(format!(" origin: {o}"))); + } + if let Some(h) = &info.head { + lines.push(dim(format!(" HEAD: {h}"))); + } + if info.dirty > 0 { + lines.push(Line::styled( + format!( + " {} unpublished edit(s) will be LOST (not yet published from the device).", + info.dirty + ), + Style::new().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + } + lines.push(Line::from("")); + lines.push(Line::from(format!( + "This ERASES repo/ (and the dirty journal) and re-clones {} onto the card.", + app.config.remote_url + ))); + lines.push(Line::from("")); + lines.push(Line::styled( + "Press y to wipe and continue · n to cancel.", + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + } + SdState::Idle => { + if app.cards.is_empty() { + lines.push(Line::styled( + "No removable card detected.", + Style::new().fg(Color::Yellow), + )); + lines.push(Line::from("")); + lines.push(dim("Insert a FAT32 SD card, then press r to rescan.".into())); + } else { + lines.push(dim("Choose the card to write (↑/↓), then Enter:".into())); + lines.push(Line::from("")); + for (i, c) in app.cards.iter().enumerate() { + let sel = i == app.card_sel; + let marker = if sel { "▸ " } else { " " }; + let warn = if c.fat { + String::new() + } else { + " (not FAT32 — the device may not mount it)".to_string() + }; + let style = if sel { + Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::new() + }; + lines.push(Line::styled( + format!("{marker}{} [{}]{}", c.name, c.fs, warn), + style, + )); + } + lines.push(Line::from("")); + if app.config.missing_required().is_empty() { + lines.push(Line::styled( + "Enter writes the card: clone → seed config → typoena.conf → eject.", + Style::new().fg(Color::Green), + )); + } else { + lines.push(Line::styled( + "Configure the required fields first (↑ to go back a step).", + Style::new().fg(Color::Yellow), + )); + } + } + if let Some(msg) = &app.status { + lines.push(Line::from("")); + lines.push(Line::styled(msg.clone(), Style::new().fg(Color::Cyan))); + } + } + rest => { + lines.push(match rest { + SdState::Running => Line::styled( + "Provisioning the card… (Ctrl-C aborts)", + Style::new().fg(Color::Yellow), + ), + SdState::Failed(e) => { + Line::styled(format!("Failed: {e}"), Style::new().fg(Color::Red)) + } + _ => Line::styled( + "Card ready ✓ — remove it and insert into Typoena.", + Style::new().fg(Color::Green), + ), + }); + lines.push(Line::from("")); + let start = app.sd_log.len().saturating_sub(12); + for l in &app.sd_log[start..] { + lines.push(dim(l.clone())); + } + } + } + frame.render_widget(paragraph(lines, block), area); +} + +fn render_done(frame: &mut Frame, area: Rect, block: Block) { + let lines = vec![ + Line::styled( + "All set.", + Style::new().fg(Color::Green).add_modifier(Modifier::BOLD), + ), + Line::from(""), + Line::from("Remove the card and insert it into your Typoena, then power on."), + Line::styled( + "Open lid → write → push. Nothing else runs on it.", + Style::new().fg(Color::DarkGray), + ), + ]; + frame.render_widget(paragraph(lines, block), area); +} + +fn field_hint(f: Field) -> Option<&'static str> { + match f { + Field::Pat => Some( + "Fine-grained PAT, contents:write on the notes repo. Never derived; masked. ^U clears.", + ), + Field::WifiPass => { + Some("^K fills this from your Keychain for the current SSID (may prompt macOS).") + } + Field::RemoteUrl => { + Some("HTTPS URL of your notes repo, e.g. https://github.com/you/notes.git") + } + _ => None, + } +} + +fn status_glyph(s: Status) -> (&'static str, Color) { + match s { + Status::Ok => ("✓", Color::Green), + Status::Warn => ("!", Color::Yellow), + Status::Missing => ("✗", Color::Red), + } +} + +fn paragraph<'a>(lines: Vec>, block: Block<'a>) -> Paragraph<'a> { + Paragraph::new(Text::from(lines)) + .block(block) + .wrap(Wrap { trim: false }) +} + +fn render_footer(frame: &mut Frame, area: Rect, app: &App) { + let key = |k: &str| { + Span::styled( + format!(" {k} "), + Style::new().add_modifier(Modifier::REVERSED), + ) + }; + let lbl = |l: &'static str| Span::styled(l, Style::new().fg(Color::DarkGray)); + let sep = || Span::raw(" "); + + let spans = if matches!(app.sd, SdState::ConfirmWipe(_)) && app.step == Step::SdCard { + vec![ + key("y"), + lbl(" wipe & continue"), + sep(), + key("n"), + lbl(" cancel"), + sep(), + key("^C"), + lbl(" quit"), + ] + } else { + match app.step { + Step::Configure => vec![ + key("↑/↓"), + lbl(" field"), + sep(), + key("Enter"), + lbl(" next"), + sep(), + key("^K"), + lbl(" wifi pw"), + sep(), + key("^U"), + lbl(" clear"), + sep(), + key("Esc"), + lbl(" quit"), + ], + Step::SdCard => vec![ + key("↑/↓"), + lbl(" card"), + sep(), + key("r"), + lbl(" rescan"), + sep(), + key("Enter"), + lbl(" write"), + sep(), + key("q"), + lbl(" quit"), + ], + _ => vec![ + key("↑/↓ Tab"), + lbl(" step"), + sep(), + key("Enter"), + lbl(" next"), + sep(), + key("r"), + lbl(" re-check"), + sep(), + key("q"), + lbl(" quit"), + ], + } + }; + frame.render_widget(Paragraph::new(Line::from(spans)), area); +}