From a40d79eaedc5b07fd8b671c077afa8259e75940d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:14:48 +0000 Subject: [PATCH 1/2] feat(updater): give CEC Support a self-updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CEC Support had no update mechanism at all. It kept the engine *beneath* it current — on bring-up it asks a reused `allmystuff-serve` to move to the version CEC pins, and AllMyStuff in turn asks `myownmesh` to update to its pin, each layer falling back to its own bundled sidecar — but nothing ever updated CEC Support itself. The version in Settings was a label read from the bundle, not a check: no release feed, no timer, no network call. This adds the missing top of that chain, so the whole stack moves forward instead of only its lower two thirds. `cec-support-updater` is modelled on `allmystuff-updater` but self-contained, in the light root workspace so `cargo test` here still builds with no webview or media stack. Self-contained rather than a git dep on AllMyStuff's updater, because a tag dep can only carry already-released code — the same call `allmystuff-updater` made when it was ported from `myownmesh-updater`. One artifact (the `cec-support` binary), fail-closed verification (a published SHA-256 is mandatory; minisign required once a key is baked in), stage-then-apply on next launch, and the shared apply-policy semantics so a patch bump means the same thing across all three apps. It carries over what was just fixed in AllMyStuff rather than repeating it: Program Files is not treated as a package manager (that is where our own MSI lands), writability is probed at runtime, a package-managed install still checks and reports `ManualUpdateAvailable`, the interval is stamped only after a successful fetch, and every outcome is logged. Wiring: `apply_pending_if_any()` runs first in `main` (after the CEC home is resolved), the ticker is spawned in setup and emits `update://checked` so a found release reaches the customer without them opening Settings, five Tauri commands back a new Updates card, and a staged update offers "Restart and update" inline. release.yml only published installers, which a self-updater cannot apply — it swaps a binary. It now also packages the portable `cec-support-windows-x86_64.zip` plus the mandatory `.sha256` sidecar, named to match `platform_asset()`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FMPNLjvNYQCeR3xGfdDUCg --- .github/workflows/release.yml | 42 + Cargo.lock | 1644 ++++++++++++++++++++-- Cargo.toml | 24 +- crates/cec-support-updater/Cargo.toml | 34 + crates/cec-support-updater/src/lib.rs | 1272 +++++++++++++++++ crates/cec-support-updater/src/policy.rs | 115 ++ gui/src-tauri/Cargo.toml | 7 + gui/src-tauri/src/main.rs | 83 ++ gui/src/store.svelte.ts | 111 ++ gui/src/tauri.ts | 52 + gui/src/types.ts | 45 + gui/src/ui/SettingsPanel.svelte | 82 ++ scripts/bump-version.sh | 9 +- 13 files changed, 3419 insertions(+), 101 deletions(-) create mode 100644 crates/cec-support-updater/Cargo.toml create mode 100644 crates/cec-support-updater/src/lib.rs create mode 100644 crates/cec-support-updater/src/policy.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b91efe..a8dab51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,12 @@ name: Release # GitHub release at that tag — this is what support.cec.direct's "Download for # Windows" button points at (the NSIS `*-setup.exe` asset). # +# Two kinds of asset ship here, for two different jobs: +# *-setup.exe / .msi first install (what a customer downloads) +# cec-support-windows-x86_64.zip self-update (what the running app fetches) +# The updater applies an update by swapping the binary, so it needs the plain +# archive — it can't install from an installer. +# # CEC Support is a Windows-only product (one download, no OS picker), so the # matrix is Windows-only. The app is a Tauri bundle that ships two sidecars — # `myownmesh` (.myownmesh-rev) and `allmystuff-serve` (.allmystuff-rev) — which @@ -119,3 +125,39 @@ jobs: releaseDraft: false prerelease: false args: --target x86_64-pc-windows-msvc + + # Package the portable `cec-support.exe` so the SELF-UPDATER has something + # to fetch. The installers above (`*-setup.exe` / `.msi`) are for first + # install only — `cec-support-updater` applies an update by swapping the + # binary, which it can't do from an installer, so without this asset every + # background check would find a newer release and then fail to stage it + # ("release has no asset cec-support-windows-x86_64.zip"). + # + # The name must stay in lockstep with `platform_asset()` in + # crates/cec-support-updater/src/lib.rs: `cec-support-.zip`. + # The `.sha256` sidecar is mandatory — the updater refuses to stage an + # artifact it can't verify, so a missing sidecar fails the update closed. + - name: Package portable binary (for self-update) + shell: bash + run: | + set -e + BIN=gui/src-tauri/target/x86_64-pc-windows-msvc/release/cec-support.exe + if [[ ! -f "$BIN" ]]; then + echo "::error::cec-support.exe not found at $BIN"; exit 1 + fi + mkdir -p dist-bin + NAME="cec-support-windows-x86_64" + cp "$BIN" dist-bin/cec-support.exe + (cd dist-bin && 7z a "${NAME}.zip" cec-support.exe) + (cd dist-bin && (sha256sum "${NAME}.zip" || shasum -a 256 "${NAME}.zip") > "${NAME}.zip.sha256") + # Drop the raw binary so the upload glob only matches the archive + + # its checksum. + rm -f dist-bin/cec-support.exe + + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.ref_name }} + files: | + dist-bin/cec-support-* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 85b7951..0e5c5df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,76 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anyhow" version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cec-support-service" version = "0.1.28" @@ -20,190 +84,1364 @@ dependencies = [ ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "cec-support-updater" +version = "0.1.28" +dependencies = [ + "dirs", + "flate2", + "hex", + "minisign-verify", + "reqwest", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tracing", + "zip", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "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 = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[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.3", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +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.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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +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 = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[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 = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[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 = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[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", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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 = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[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-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "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 = "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 = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_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 = "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", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "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 = "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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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 = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[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 = "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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] -name = "dirs" -version = "5.0.1" +name = "tinyvec" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ - "dirs-sys", + "tinyvec_macros", ] [[package]] -name = "dirs-sys" -version = "0.4.1" +name = "tinyvec_macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", - "option-ext", - "redox_users", - "windows-sys", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "tokio-macros" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ - "cfg-if", - "libc", - "wasi", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] [[package]] -name = "libc" -version = "0.2.186" +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] [[package]] -name = "libredox" -version = "0.1.18" +name = "tower-http" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "libc", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", ] [[package]] -name = "memchr" -version = "2.8.3" +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "option-ext" -version = "0.2.0" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "unicode-ident", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "quote" -version = "1.0.46" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "redox_users" -version = "0.4.6" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "getrandom", - "libredox", - "thiserror", + "once_cell", ] [[package]] -name = "serde" -version = "1.0.228" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = [ - "serde_core", - "serde_derive", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "serde_derive", + "try-lock", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ - "proc-macro2", - "quote", - "syn", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "serde_json" -version = "1.0.150" +name = "wasm-bindgen-futures" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "syn" -version = "2.0.118" +name = "wasm-bindgen-macro" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +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 = "thiserror" -version = "1.0.69" +name = "web-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ - "thiserror-impl", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "proc-macro2", - "quote", - "syn", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "webpki-roots" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" @@ -211,7 +1449,25 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -220,13 +1476,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -235,44 +1507,220 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[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.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[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.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[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.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[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.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[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.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[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.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.19", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index d8eacbd..bfcabb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,13 +10,20 @@ # This light workspace holds only what builds with no webview / media stack: # # cec-support-service the client's own OS service installer (Win SCM etc.) +# cec-support-updater the client's own self-updater (release feed + apply) +# +# The updater is self-contained on purpose: CEC Support is the top of the +# sidecar chain (it keeps `allmystuff-serve` current, which keeps `myownmesh` +# current), so it can't rely on a sibling repo's unreleased code to update +# itself. Same call `allmystuff-updater` made when it was ported from +# `myownmesh-updater`. # # The Tauri + Svelte client GUI (`gui/`) — which embeds the AllMyStuff node in # "CEC client mode" — lives in its own excluded workspace, the same split # AllMyStuff and MyOwnMesh use, so `cargo build` here stays fast and webview-free. [workspace] resolver = "2" -members = ["crates/cec-support-service"] +members = ["crates/cec-support-service", "crates/cec-support-updater"] exclude = ["gui"] [workspace.package] @@ -35,6 +42,21 @@ serde_json = "1" dirs = "5" libc = "0.2" tempfile = "3" +# Self-update (`cec-support-updater`): release feed over HTTPS, fail-closed +# SHA-256 + minisign verification, archive extraction. Kept to the same +# versions AllMyStuff's updater uses so the two stay in step. +thiserror = "2" +tokio = { version = "1", features = ["rt", "time", "macros"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } +tracing = "0.1" +sha2 = "0.10" +hex = "0.4" +flate2 = "1" +tar = "0.4" +zip = { version = "2", default-features = false, features = ["deflate"] } [profile.release] panic = "abort" diff --git a/crates/cec-support-updater/Cargo.toml b/crates/cec-support-updater/Cargo.toml new file mode 100644 index 0000000..f9f4e1b --- /dev/null +++ b/crates/cec-support-updater/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "cec-support-updater" +description = "Self-update for CEC Support: configurable release feed, fail-closed SHA-256 + minisign verification, stage-then-apply — modelled on allmystuff-updater, self-contained." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[lints.rust] +unsafe_code = "forbid" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +reqwest = { workspace = true } +tracing = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +# Detached Ed25519 (minisign) verification of release artifacts. Pure-Rust, +# verification-only — no signing in the client, which only checks. Matches +# allmystuff-updater / myownmesh-updater so the whole ecosystem shares one +# release-signing scheme. +minisign-verify = "0.2" +flate2 = { workspace = true } +tar = { workspace = true } +zip = { workspace = true } +dirs = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/cec-support-updater/src/lib.rs b/crates/cec-support-updater/src/lib.rs new file mode 100644 index 0000000..695a6dd --- /dev/null +++ b/crates/cec-support-updater/src/lib.rs @@ -0,0 +1,1272 @@ +//! Self-update for CEC Support. +//! +//! Modelled on `allmystuff-updater` so the two behave identically, but +//! deliberately **self-contained**: it links none of the node engine and lives +//! in this repo's light root workspace, so `cargo test` here still builds +//! without a webview or media stack. That's the same call `allmystuff-updater` +//! made when it was ported from `myownmesh-updater` — each app in the +//! ecosystem carries its own updater rather than depending on a sibling's +//! unreleased code. +//! +//! # Where this sits in the ecosystem +//! +//! CEC Support already keeps the *engine beneath it* current: on bring-up it +//! asks a reused, separately-installed `allmystuff-serve` to update itself to +//! the pin CEC was built against (`ALLMYSTUFF_PIN`), and AllMyStuff in turn +//! asks `myownmesh` to update itself to *its* pin — each layer bringing its own +//! bundled sidecar when the update can't be had. What was missing is the top of +//! that chain: nothing ever updated **CEC Support itself**. This crate is that +//! half, so the whole stack moves forward instead of only its lower two thirds. +//! +//! # Shape +//! +//! One artifact — the `cec-support` binary. A check fetches the release feed, +//! compares tags, and *stages* a verified download under the CEC home; the swap +//! happens on the next launch ([`apply_pending_if_any`]), because a running +//! executable can't reliably replace itself in place. Verification is +//! fail-closed: a published SHA-256 sidecar is mandatory, and when a release +//! signing key is baked in at build time a valid detached minisign signature is +//! required too. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +mod policy; +pub use policy::{compare_semver, policy_allows, ApplyPolicy}; + +// --------------------------------------------------------------------------- +// Release feed (build-time + runtime overridable, for white-labelling). +// --------------------------------------------------------------------------- + +pub fn default_release_api_stable() -> &'static str { + option_env!("CEC_RELEASE_URL_STABLE") + .unwrap_or("https://api.github.com/repos/mrjeeves/CECSupport/releases/latest") +} + +pub fn default_release_api_beta() -> &'static str { + option_env!("CEC_RELEASE_URL_BETA") + .unwrap_or("https://api.github.com/repos/mrjeeves/CECSupport/releases") +} + +const USER_AGENT: &str = concat!("cec-support-self-update/", env!("CARGO_PKG_VERSION")); + +/// The minisign public key releases are signed with, baked in at build time. +/// `None` until release signing is configured (set `CEC_RELEASE_PUBKEY` to the +/// base64 public key in the release build env). When configured, the updater +/// refuses any artifact lacking a valid signature; otherwise it still requires +/// the mandatory SHA-256. +fn release_pubkey() -> Option<&'static str> { + normalize_pubkey(option_env!("CEC_RELEASE_PUBKEY")) +} + +/// Recover the key line from whatever was pasted into the build variable. +/// +/// CI exports the variable unconditionally, so an unset repo secret arrives as +/// `Some("")` rather than `None` — which must degrade to "unconfigured", never +/// to "require a signature verified against an empty key" (that would fail +/// every update closed against a `.minisig` nobody publishes). A whole +/// `minisign.pub` file pasted in keeps its comment on line 1 and the key on +/// line 2, so selection is positional. +fn normalize_pubkey(key: Option<&str>) -> Option<&str> { + let mut lines = key?.lines().map(str::trim).filter(|l| !l.is_empty()); + let first = lines.next()?; + Some(lines.next().unwrap_or(first)) +} + +// --------------------------------------------------------------------------- +// Errors. +// --------------------------------------------------------------------------- + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Msg(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Http(#[from] reqwest::Error), + #[error("checksum mismatch for {asset}: expected {expected}, got {actual}")] + ChecksumMismatch { + asset: String, + expected: String, + actual: String, + }, +} + +impl Error { + fn msg(s: impl Into) -> Self { + Error::Msg(s.into()) + } +} + +// --------------------------------------------------------------------------- +// Paths. +// --------------------------------------------------------------------------- + +/// CEC Support's app-file home. Must agree with the app's own +/// `default_cec_home()` / `CEC_HOME_ENV` — duplicated rather than imported so +/// this crate stays free of the node engine. The mesh stack deliberately lives +/// elsewhere (the shared `~/.myownmesh` home); this is only CEC's own files. +const HOME_ENV: &str = "CEC_SUPPORT_HOME"; + +fn home() -> Result { + if let Some(h) = std::env::var_os(HOME_ENV) { + return Ok(PathBuf::from(h)); + } + dirs::data_dir() + .map(|d| d.join("CEC Support")) + .or_else(|| dirs::home_dir().map(|h| h.join(".cec-support"))) + .ok_or_else(|| Error::msg("no home directory")) +} + +fn updates_dir() -> Result { + let d = home()?.join("updates"); + std::fs::create_dir_all(&d)?; + Ok(d) +} + +fn config_path() -> Result { + Ok(home()?.join("config.json")) +} + +// --------------------------------------------------------------------------- +// Auto-update config (persisted under config.json's "auto_update" key). +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutoUpdateConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_channel")] + pub channel: String, + #[serde(default = "default_auto_apply")] + pub auto_apply: String, + #[serde(default = "default_interval")] + pub check_interval_hours: u32, + #[serde(default)] + pub stable_url: Option, + #[serde(default)] + pub beta_url: Option, +} + +fn default_true() -> bool { + true +} +fn default_channel() -> String { + "stable".into() +} +fn default_auto_apply() -> String { + // "Up to minor": patch + minor bumps apply on their own, major waits. + // CEC Support sits in front of a customer who is, by definition, not a + // technician — the fewer update decisions handed to them, the better. + "minor".into() +} +fn default_interval() -> u32 { + 24 +} + +impl Default for AutoUpdateConfig { + fn default() -> Self { + AutoUpdateConfig { + enabled: true, + channel: default_channel(), + auto_apply: default_auto_apply(), + check_interval_hours: default_interval(), + stable_url: None, + beta_url: None, + } + } +} + +fn load_auto_update() -> AutoUpdateConfig { + let Ok(path) = config_path() else { + return AutoUpdateConfig::default(); + }; + let Ok(text) = std::fs::read_to_string(path) else { + return AutoUpdateConfig::default(); + }; + let Ok(doc) = serde_json::from_str::(&text) else { + return AutoUpdateConfig::default(); + }; + serde_json::from_value(doc.get("auto_update").cloned().unwrap_or_default()).unwrap_or_default() +} + +fn save_auto_update(au: &AutoUpdateConfig) -> Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut doc: serde_json::Value = std::fs::read_to_string(&path) + .ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_else(|| serde_json::json!({})); + doc["auto_update"] = serde_json::to_value(au)?; + std::fs::write(&path, serde_json::to_string_pretty(&doc)?)?; + Ok(()) +} + +fn resolve_release_url(au: &AutoUpdateConfig) -> String { + let override_url = if au.channel == "beta" { + au.beta_url.as_deref() + } else { + au.stable_url.as_deref() + }; + match override_url { + Some(u) if !u.is_empty() => u.to_string(), + _ if au.channel == "beta" => default_release_api_beta().to_string(), + _ => default_release_api_stable().to_string(), + } +} + +fn env_disabled() -> bool { + matches!( + std::env::var("CEC_SUPPORT_AUTOUPDATE").ok().as_deref(), + Some("0") + ) +} + +// --------------------------------------------------------------------------- +// Public types. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallKind { + Raw, + PackageManager, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UpdateStatus { + pub current_version: String, + pub install_kind: InstallKind, + pub enabled: bool, + pub channel: String, + pub auto_apply: String, + pub check_interval_hours: u32, + pub last_check_at: Option, + pub staged_version: Option, + pub release_url: String, + pub release_url_overridden: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum CheckOutcome { + Disabled, + NotDue, + UpToDate { + current: String, + latest: String, + }, + PolicyBlocked { + current: String, + latest: String, + policy: String, + }, + Staged { + version: String, + }, + /// A newer release exists, but this install can't swap its own binary — + /// a package-managed copy, or a per-machine install this process can't + /// write to. Reported rather than swallowed so the app can still say so. + ManualUpdateAvailable { + current: String, + latest: String, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum UpdateNowOutcome { + PackageManager, + UpToDate { current: String, latest: String }, + Updated { to: String }, +} + +#[derive(Debug, Default, Deserialize)] +pub struct UpdatePrefs { + pub enabled: Option, + pub channel: Option, + pub auto_apply: Option, + pub check_interval_hours: Option, + pub stable_url: Option, + pub beta_url: Option, +} + +fn current_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +// --------------------------------------------------------------------------- +// The artifact: CEC Support ships exactly one binary. +// --------------------------------------------------------------------------- + +/// Release-asset stem — `cec-support-.`, matching the +/// "Package portable binary" step in `.github/workflows/release.yml`. The +/// installers (`.msi` / `-setup.exe`) are a different thing entirely: they're +/// for first install, and can't be applied by swapping a file. +const ASSET_STEM: &str = "cec-support"; + +fn bin_name() -> &'static str { + if cfg!(windows) { + "cec-support.exe" + } else { + "cec-support" + } +} + +fn platform_triple() -> &'static str { + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { + "linux-x86_64" + } + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + { + "linux-aarch64" + } + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { + "macos-x86_64" + } + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + "macos-aarch64" + } + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + { + "windows-x86_64" + } + #[cfg(not(any( + all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ), + all( + target_os = "macos", + any(target_arch = "x86_64", target_arch = "aarch64") + ), + all(target_os = "windows", target_arch = "x86_64"), + )))] + { + "unknown" + } +} + +fn archive_ext() -> &'static str { + if cfg!(windows) { + "zip" + } else { + "tar.gz" + } +} + +fn platform_asset() -> String { + format!("{ASSET_STEM}-{}.{}", platform_triple(), archive_ext()) +} + +// --------------------------------------------------------------------------- +// Install-kind detection. +// --------------------------------------------------------------------------- + +pub fn detect_install_kind() -> InstallKind { + let Ok(exe) = std::env::current_exe() else { + return InstallKind::Raw; + }; + if detect_install_kind_from_path(&exe.to_string_lossy()) == InstallKind::PackageManager { + return InstallKind::PackageManager; + } + // Not a foreign package manager — the remaining question is whether the + // swap can actually happen. CEC Support's MSI is a per-machine install into + // `C:\Program Files\CEC Support\`, which an unelevated process cannot write + // to: staging a download there produces an apply that fails forever. Treat + // it as managed so the *check* still runs and reports, and installing goes + // back through the installer that owns the files. + match exe.parent() { + Some(dir) if !dir_is_writable(dir) => InstallKind::PackageManager, + _ => InstallKind::Raw, + } +} + +/// Whether `dir` accepts writes from this process. Probing by *doing* it is the +/// only reliable test on Windows, where ACLs (and virtualisation) make a +/// permissions read meaningless — and it's what the swap itself will attempt. +fn dir_is_writable(dir: &Path) -> bool { + let probe = dir.join(format!(".cec-support-write-probe-{}", std::process::id())); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +/// Path-only classification: is this binary owned by a *foreign* package +/// manager that must do the updating? Deliberately not a test for "somewhere +/// under Program Files" — that's where our own MSI lands, and treating it as +/// package-managed is what silently disabled self-update in AllMyStuff. +fn detect_install_kind_from_path(path_str: &str) -> InstallKind { + if path_str.contains("/Cellar/") + || path_str.starts_with("/opt/homebrew/") + || path_str.starts_with("/home/linuxbrew/") + || path_str.starts_with("/usr/local/Cellar/") + { + return InstallKind::PackageManager; + } + #[cfg(target_os = "linux")] + if path_str.starts_with("/usr/bin/") || path_str.starts_with("/usr/sbin/") { + return InstallKind::PackageManager; + } + { + let lower = path_str.to_lowercase(); + if lower.contains("\\chocolatey\\lib\\") || lower.contains("\\scoop\\apps\\") { + return InstallKind::PackageManager; + } + } + InstallKind::Raw +} + +// --------------------------------------------------------------------------- +// Apply (runs at process start, or on demand). +// --------------------------------------------------------------------------- + +/// Apply any staged update before real work starts. Idempotent, and never +/// fatal: a failure leaves the staged marker in place so the next launch +/// retries rather than silently dropping the update. Call this first in `main`. +pub fn apply_pending_if_any() { + cleanup_old_replaced_binary(); + if let Err(e) = apply_pending() { + tracing::warn!("self-update apply skipped: {e}"); + } +} + +/// Apply a staged update now, surfacing the applied version (the swap is on +/// disk; it takes effect on next start), or `None` if nothing was pending. +pub fn apply_now() -> Result> { + cleanup_old_replaced_binary(); + apply_pending() +} + +fn apply_pending() -> Result> { + let pending = updates_dir()?.join("pending.json"); + if !pending.exists() { + return Ok(None); + } + let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pending)?)?; + let target_version = doc["version"].as_str().unwrap_or("?").to_string(); + let Some(archive) = doc["path"].as_str().map(PathBuf::from) else { + // A marker that names nothing usable is junk — clear it. + let _ = std::fs::remove_file(&pending); + return Ok(None); + }; + + // Downgrade guard: only swap when the staged build is actually newer than + // what's running, so a stale marker can't roll the app back. + if compare_semver(&target_version, current_version()) != std::cmp::Ordering::Greater { + let _ = std::fs::remove_file(&pending); + return Ok(None); + } + + let Some(target) = installed_path() else { + let _ = std::fs::remove_file(&pending); + return Ok(None); + }; + let staged_dir = archive + .parent() + .ok_or_else(|| Error::msg("staged archive has no parent"))?; + let binary = extract_binary(&archive, staged_dir, bin_name())?; + atomic_replace(&binary, &target)?; + + let _ = std::fs::remove_file(&pending); + tracing::info!("self-update applied {target_version}"); + Ok(Some(target_version)) +} + +/// Where CEC Support is installed. `None` when the running binary sits inside +/// an OS bundle we must not mutate. +/// +/// The bundle guard is load-bearing: inside a macOS `.app` an in-place Mach-O +/// swap breaks the bundle's signature and identity, so the relaunch comes back +/// refused or running the stale cached image. A bundled app is updated by its +/// own installer, never by swapping a binary underneath it. +fn installed_path() -> Option { + let exe = std::env::current_exe().ok()?; + (!path_in_os_bundle(&exe)).then_some(exe) +} + +fn path_in_os_bundle(path: &Path) -> bool { + path.components() + .any(|c| c.as_os_str().to_str().is_some_and(|s| s.ends_with(".app"))) +} + +/// Atomically replace `target` with `staged`. A same-dir temp + rename keeps +/// the swap atomic on the target's filesystem. Unix can rename over a running +/// executable (the live process keeps its old inode); Windows can't, so the +/// running binary is side-renamed to `.old` (which Windows *does* allow +/// while it's mapped) and rolled back if the swap-in then fails. +fn atomic_replace(staged: &Path, target: &Path) -> Result<()> { + let dir = target + .parent() + .ok_or_else(|| Error::msg("target has no parent dir"))?; + let tmp = dir.join(format!(".cec-support-update-{}.tmp", std::process::id())); + std::fs::copy(staged, &tmp).map_err(|e| { + Error::msg(format!( + "cannot copy staged binary into {}: {e}", + dir.display() + )) + })?; + set_exec_perms(&tmp); + + #[cfg(not(windows))] + { + std::fs::rename(&tmp, target).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + })?; + Ok(()) + } + #[cfg(windows)] + { + match std::fs::rename(&tmp, target) { + Ok(()) => Ok(()), + Err(_) => rename_via_side_swap_windows(&tmp, target).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }), + } + } +} + +#[cfg(windows)] +fn rename_via_side_swap_windows(src: &Path, dst: &Path) -> Result<()> { + let old = old_binary_path(dst); + let _ = std::fs::remove_file(&old); + std::fs::rename(dst, &old).map_err(|e| { + Error::msg(format!( + "could not rename running binary aside to {}: {e}", + old.display() + )) + })?; + if let Err(e) = std::fs::rename(src, dst) { + // Roll back so we never leave the install without a binary. + let _ = std::fs::rename(&old, dst); + return Err(Error::msg(format!( + "swap-in failed after side-rename ({e}); restored original binary" + ))); + } + Ok(()) +} + +#[cfg(windows)] +fn old_binary_path(target: &Path) -> PathBuf { + let mut name = target + .file_name() + .map(|s| s.to_owned()) + .unwrap_or_else(|| std::ffi::OsString::from("cec-support")); + name.push(".old"); + target.with_file_name(name) +} + +/// Delete the `.old` litter a previous Windows side-swap left behind. +/// Cheap, idempotent, runs at startup. +fn cleanup_old_replaced_binary() { + #[cfg(windows)] + if let Some(p) = installed_path() { + let old = old_binary_path(&p); + if old.exists() { + let _ = std::fs::remove_file(&old); + } + } +} + +#[cfg(unix)] +fn set_exec_perms(to: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o755)); +} +#[cfg(not(unix))] +fn set_exec_perms(_to: &Path) {} + +// --------------------------------------------------------------------------- +// Check + stage. +// --------------------------------------------------------------------------- + +/// Run one check. With `force`, ignore the interval cooldown. Stages a +/// permitted update; never applies (that happens on next launch). +pub async fn check_now(force: bool) -> Result { + let au = load_auto_update(); + if !au.enabled || env_disabled() { + return Ok(CheckOutcome::Disabled); + } + if !force && !is_due(au.check_interval_hours) { + return Ok(CheckOutcome::NotDue); + } + + // A package-managed / unwritable install can't stage or apply anything, but + // it can still look — that's the difference between "self-update is off + // here" and telling the customer a new version exists. + let managed = detect_install_kind() == InstallKind::PackageManager; + + let release = fetch_release(&au).await?; + let latest = release_tag(&release)?; + let current = current_version().to_string(); + // Stamped only after a successful fetch: one offline moment must not cost a + // full `check_interval_hours` before the next attempt. + stamp_check_now(); + + if compare_semver(¤t, &latest) != std::cmp::Ordering::Less { + return Ok(CheckOutcome::UpToDate { current, latest }); + } + if managed { + return Ok(CheckOutcome::ManualUpdateAvailable { current, latest }); + } + + let pol = ApplyPolicy::parse(&au.auto_apply).unwrap_or(ApplyPolicy::Patch); + if !policy_allows(pol, ¤t, &latest) { + return Ok(CheckOutcome::PolicyBlocked { + current, + latest, + policy: au.auto_apply.clone(), + }); + } + + stage_release(&release, &latest).await?; + Ok(CheckOutcome::Staged { version: latest }) +} + +/// Log what a check decided — *every* outcome. Swallowing the uninteresting +/// ones makes a ticker that is silently disabled and one that is running fine +/// and finding nothing look identical (no output at any log level), which is +/// the hardest part of "it never checks" to diagnose. +fn log_check_outcome(outcome: &CheckOutcome) { + match outcome { + CheckOutcome::Staged { version } => { + tracing::info!("self-update staged {version}; applies on next launch"); + } + CheckOutcome::ManualUpdateAvailable { current, latest } => { + tracing::info!( + "self-update: {latest} is available (running {current}), but this install is \ + package-managed or not writable by this process — reinstall to update" + ); + } + CheckOutcome::UpToDate { current, latest } => { + tracing::debug!("self-update: up to date (running {current}, latest {latest})"); + } + CheckOutcome::PolicyBlocked { + current, + latest, + policy, + } => { + tracing::info!( + "self-update: {latest} is available (running {current}) but the '{policy}' \ + apply policy holds it back" + ); + } + CheckOutcome::NotDue => tracing::debug!("self-update: not due yet, skipping this tick"), + CheckOutcome::Disabled => tracing::debug!("self-update: disabled, skipping this tick"), + } +} + +async fn run_check(force: bool, notify: &(dyn Fn(&CheckOutcome) + Send + Sync)) { + match check_now(force).await { + Ok(outcome) => { + log_check_outcome(&outcome); + notify(&outcome); + } + Err(e) => tracing::warn!("self-update check failed: {e}"), + } +} + +/// Background auto-update ticker — the half that makes self-update "set and +/// forget". A **launch check** fires shortly after start and ignores the +/// interval cooldown, so opening the app is itself a check; then the timer +/// takes over at `check_interval_hours` (re-read each loop, so a settings +/// change takes effect without a restart). Whatever it stages applies on the +/// next launch (see [`apply_pending_if_any`]). +/// +/// Spawning this is what separates an updater that runs from one that only ever +/// reacts to a "Check now" button. +pub async fn tick_forever() { + tick_forever_notify(|_| {}).await +} + +/// [`tick_forever`], plus a callback fired with the outcome of every check. +/// +/// The desktop app passes a closure that emits a Tauri event, which is what +/// makes "an update is ready" actually reach the customer — a background task +/// holds no handle to the UI and otherwise cannot tell it anything. +pub async fn tick_forever_notify(notify: F) +where + F: Fn(&CheckOutcome) + Send + Sync + 'static, +{ + // Let a freshly launched app settle (node bring-up, first-run AV scans of + // the sidecars) before the first network hit. + tokio::time::sleep(Duration::from_secs(30)).await; + run_check(true, ¬ify).await; + loop { + let hours = load_auto_update().check_interval_hours.max(1); + tokio::time::sleep(Duration::from_secs(hours as u64 * 3600)).await; + run_check(false, ¬ify).await; + } +} + +/// User-driven "update now". Ignores policy + interval (consent implied) but +/// still defers to a package manager. Stages and applies to disk immediately; +/// the running process picks it up on restart. +pub async fn update_now() -> Result { + if detect_install_kind() == InstallKind::PackageManager { + return Ok(UpdateNowOutcome::PackageManager); + } + let au = load_auto_update(); + let release = fetch_release(&au).await?; + let latest = release_tag(&release)?; + let current = current_version().to_string(); + if compare_semver(¤t, &latest) != std::cmp::Ordering::Less { + return Ok(UpdateNowOutcome::UpToDate { current, latest }); + } + stage_release(&release, &latest).await?; + stamp_check_now(); + match apply_now()? { + Some(to) => Ok(UpdateNowOutcome::Updated { to }), + None => Ok(UpdateNowOutcome::UpToDate { current, latest }), + } +} + +/// The latest release version on the configured channel (read-only — it stages +/// nothing). +pub async fn latest_version() -> Result> { + let au = load_auto_update(); + let release = fetch_release(&au).await?; + Ok(release_tag(&release).ok()) +} + +pub fn status() -> Result { + let au = load_auto_update(); + let overridden = if au.channel == "beta" { + au.beta_url.as_deref().is_some_and(|u| !u.is_empty()) + } else { + au.stable_url.as_deref().is_some_and(|u| !u.is_empty()) + }; + Ok(UpdateStatus { + current_version: current_version().to_string(), + install_kind: detect_install_kind(), + enabled: au.enabled && !env_disabled(), + channel: au.channel.clone(), + auto_apply: au.auto_apply.clone(), + check_interval_hours: au.check_interval_hours, + last_check_at: last_check_at(), + staged_version: staged_version(), + release_url: resolve_release_url(&au), + release_url_overridden: overridden, + }) +} + +pub fn set_prefs(prefs: UpdatePrefs) -> Result { + let mut au = load_auto_update(); + if let Some(v) = prefs.enabled { + au.enabled = v; + } + if let Some(v) = prefs.channel { + au.channel = v; + } + if let Some(v) = prefs.auto_apply { + au.auto_apply = v; + } + if let Some(v) = prefs.check_interval_hours { + au.check_interval_hours = v.max(1); + } + if let Some(v) = prefs.stable_url { + au.stable_url = (!v.is_empty()).then_some(v); + } + if let Some(v) = prefs.beta_url { + au.beta_url = (!v.is_empty()).then_some(v); + } + save_auto_update(&au)?; + status() +} + +// --------------------------------------------------------------------------- +// Stamps. +// --------------------------------------------------------------------------- + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn last_check_at() -> Option { + let p = updates_dir().ok()?.join("last_check.json"); + let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok()?; + doc["at"].as_i64() +} + +fn stamp_check_now() { + if let Ok(dir) = updates_dir() { + let _ = std::fs::write( + dir.join("last_check.json"), + serde_json::json!({ "at": now_secs() }).to_string(), + ); + } +} + +fn is_due(interval_hours: u32) -> bool { + match last_check_at() { + Some(at) => now_secs() - at >= (interval_hours as i64) * 3600, + None => true, + } +} + +fn staged_version() -> Option { + let p = updates_dir().ok()?.join("pending.json"); + let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok()?; + doc["version"].as_str().map(str::to_string) +} + +// --------------------------------------------------------------------------- +// Network: fetch / stage. +// --------------------------------------------------------------------------- + +fn release_tag(release: &serde_json::Value) -> Result { + // The "latest" endpoint returns an object; the "list" endpoint an array + // — take the first entry there. + let obj = if release.is_array() { + release + .get(0) + .ok_or_else(|| Error::msg("empty release list"))? + } else { + release + }; + obj["tag_name"] + .as_str() + .map(|s| s.trim_start_matches('v').to_string()) + .ok_or_else(|| Error::msg("release missing tag_name")) +} + +async fn fetch_release(au: &AutoUpdateConfig) -> Result { + let url = resolve_release_url(au); + let client = reqwest::Client::builder().user_agent(USER_AGENT).build()?; + let resp = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) +} + +async fn stage_release(release: &serde_json::Value, version: &str) -> Result<()> { + let obj = if release.is_array() { + release + .get(0) + .ok_or_else(|| Error::msg("empty release list"))? + } else { + release + }; + let assets = obj["assets"] + .as_array() + .ok_or_else(|| Error::msg("release has no assets"))?; + let dir = updates_dir()?.join(version); + std::fs::create_dir_all(&dir)?; + + let asset_name = platform_asset(); + let asset = assets + .iter() + .find(|a| a["name"].as_str() == Some(asset_name.as_str())) + .ok_or_else(|| Error::msg(format!("release has no asset {asset_name}")))?; + let url = asset["browser_download_url"] + .as_str() + .ok_or_else(|| Error::msg("asset missing download url"))?; + + let client = reqwest::Client::builder().user_agent(USER_AGENT).build()?; + let dest = dir.join(&asset_name); + download_verify_stage(&client, assets, url, &dest, &asset_name).await?; + + std::fs::write( + updates_dir()?.join("pending.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "version": version, + "path": dest.to_string_lossy(), + }))?, + )?; + Ok(()) +} + +async fn download_verify_stage( + client: &reqwest::Client, + assets: &[serde_json::Value], + url: &str, + dest: &Path, + asset_name: &str, +) -> Result<()> { + let bytes = client + .get(url) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + // Integrity: a published checksum is mandatory. Falling through to a + // warning when the sidecar is missing would let anyone able to omit it + // serve any payload, so this refuses to stage instead. + let expected = find_sha256(assets, asset_name, client) + .await + .ok_or_else(|| { + Error::msg(format!( + "no checksum sidecar for {asset_name}; refusing to stage unverified" + )) + })?; + let actual = hex::encode(Sha256::digest(&bytes)); + if !actual.eq_ignore_ascii_case(&expected) { + return Err(Error::ChecksumMismatch { + asset: asset_name.to_string(), + expected, + actual, + }); + } + + // Authenticity: when a release signing key is baked in, a valid detached + // minisign signature over the artifact is required before staging. + match release_pubkey() { + Some(pubkey) => { + let sig_name = format!("{asset_name}.minisig"); + let sig_asset = assets + .iter() + .find(|a| a["name"].as_str() == Some(sig_name.as_str())) + .ok_or_else(|| { + Error::msg(format!("no signature for {asset_name}; refusing to stage")) + })?; + let sig_url = sig_asset["browser_download_url"] + .as_str() + .ok_or_else(|| Error::msg("signature asset missing url"))?; + let sig_text = fetch_text(client, sig_url).await?; + verify_signature(pubkey, &bytes, &sig_text) + .map_err(|e| Error::msg(format!("signature check failed for {asset_name}: {e}")))?; + } + None => tracing::warn!( + "release signing not configured in this build; {asset_name} verified by SHA-256 only" + ), + } + + std::fs::write(dest, &bytes)?; + Ok(()) +} + +fn verify_signature(pubkey: &str, bytes: &[u8], sig_text: &str) -> std::result::Result<(), String> { + use minisign_verify::{PublicKey, Signature}; + let pk = PublicKey::from_base64(pubkey).map_err(|e| e.to_string())?; + let sig = Signature::decode(sig_text).map_err(|e| e.to_string())?; + pk.verify(bytes, &sig, false).map_err(|e| e.to_string()) +} + +async fn fetch_text(client: &reqwest::Client, url: &str) -> Result { + Ok(client + .get(url) + .send() + .await? + .error_for_status()? + .text() + .await?) +} + +/// The expected SHA-256 for `asset_name`, from its published `.sha256` sidecar. +/// The file is `sha256sum` format (" "), so take the first field. +async fn find_sha256( + assets: &[serde_json::Value], + asset_name: &str, + client: &reqwest::Client, +) -> Option { + let want = format!("{asset_name}.sha256"); + let asset = assets + .iter() + .find(|a| a["name"].as_str() == Some(want.as_str()))?; + let url = asset["browser_download_url"].as_str()?; + let text = fetch_text(client, url).await.ok()?; + text.split_whitespace().next().map(str::to_string) +} + +/// Pull `bin_name` out of a staged archive into `out_dir`, returning its path. +/// A bare (un-archived) binary is passed through unchanged. +fn extract_binary(archive: &Path, out_dir: &Path, bin_name: &str) -> Result { + let name = archive + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string(); + let out = out_dir.join(bin_name); + + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + let f = std::fs::File::open(archive)?; + let dec = flate2::read::GzDecoder::new(f); + let mut ar = tar::Archive::new(dec); + for entry in ar.entries()? { + let mut entry = entry?; + let is_match = entry + .path()? + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n == bin_name) + .unwrap_or(false); + if is_match { + let mut dst = std::fs::File::create(&out)?; + std::io::copy(&mut entry, &mut dst)?; + return Ok(out); + } + } + Err(Error::msg(format!("{bin_name} not found in {name}"))) + } else if name.ends_with(".zip") { + let f = std::fs::File::open(archive)?; + let mut zip = zip::ZipArchive::new(f).map_err(|e| Error::msg(e.to_string()))?; + for i in 0..zip.len() { + let mut file = zip.by_index(i).map_err(|e| Error::msg(e.to_string()))?; + let fname = Path::new(file.name()) + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string); + if fname.as_deref() == Some(bin_name) { + let mut dst = std::fs::File::create(&out)?; + std::io::copy(&mut file, &mut dst)?; + return Ok(out); + } + } + Err(Error::msg(format!("{bin_name} not found in {name}"))) + } else { + // Already a bare binary. + Ok(archive.to_path_buf()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `CEC_SUPPORT_HOME` is process-global; serialize the tests that mutate it + /// so cargo's parallel runner can't cross their temp dirs. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn package_managers_are_detected_but_program_files_is_not() { + assert_eq!( + detect_install_kind_from_path("/opt/homebrew/bin/cec-support"), + InstallKind::PackageManager + ); + assert_eq!( + detect_install_kind_from_path( + r"C:\ProgramData\chocolatey\lib\cec-support\cec-support.exe" + ), + InstallKind::PackageManager + ); + // Our own MSI lands in Program Files. Classifying that as + // package-managed is exactly what silently switched AllMyStuff's + // self-update — the check included — off for every MSI install, so the + // path alone must not decide it; `dir_is_writable` asks at runtime. + assert_eq!( + detect_install_kind_from_path(r"C:\Program Files\CEC Support\cec-support.exe"), + InstallKind::Raw + ); + assert_eq!( + detect_install_kind_from_path(r"C:\Users\me\AppData\Local\CEC Support\cec-support.exe"), + InstallKind::Raw + ); + } + + #[test] + fn write_probe_tells_a_writable_dir_from_a_missing_one() { + let tmp = tempfile::tempdir().expect("tempdir"); + assert!(dir_is_writable(tmp.path())); + // It must clean up after itself — a stray file next to the installed + // binary on every status() call would be its own bug report. + assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0); + assert!(!dir_is_writable(&tmp.path().join("nope"))); + } + + #[test] + fn asset_name_has_stem_triple_and_ext() { + let a = platform_asset(); + assert!(a.starts_with("cec-support-")); + assert!(a.ends_with(".tar.gz") || a.ends_with(".zip")); + assert_eq!( + bin_name(), + if cfg!(windows) { + "cec-support.exe" + } else { + "cec-support" + } + ); + } + + #[test] + fn os_bundle_paths_are_left_alone() { + assert!(path_in_os_bundle(Path::new( + "/Applications/CEC Support.app/Contents/MacOS/cec-support" + ))); + assert!(!path_in_os_bundle(Path::new( + r"C:\Program Files\CEC Support\cec-support.exe" + ))); + assert!(!path_in_os_bundle(Path::new("/usr/local/bin/cec-support"))); + } + + #[test] + fn release_tag_handles_object_and_array() { + let obj = serde_json::json!({ "tag_name": "v0.1.28" }); + assert_eq!(release_tag(&obj).unwrap(), "0.1.28"); + let arr = serde_json::json!([{ "tag_name": "v0.2.0" }]); + assert_eq!(release_tag(&arr).unwrap(), "0.2.0"); + } + + #[test] + fn empty_baked_pubkey_is_treated_as_unconfigured() { + // CI may export CEC_RELEASE_PUBKEY unconditionally, so an unset repo + // variable reaches the compiler as Some("") — that must degrade to + // SHA-256-only, never "require a signature verified against an empty + // key", which would fail every update closed against a `.minisig` + // nobody publishes. + assert_eq!(normalize_pubkey(Some("")), None); + assert_eq!(normalize_pubkey(None), None); + assert_eq!(normalize_pubkey(Some(" \r\n ")), None); + let real = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3"; + assert_eq!(normalize_pubkey(Some(real)), Some(real)); + // A whole minisign.pub pasted in (CRLF, comment on line 1). + let whole = format!("untrusted comment: minisign public key ABC\r\n{real}\r\n"); + assert_eq!(normalize_pubkey(Some(&whole)), Some(real)); + } + + #[test] + fn signature_verification_fails_closed_on_garbage() { + assert!(verify_signature("not-a-key", b"payload", "not-a-sig").is_err()); + } + + #[test] + fn auto_apply_defaults_to_up_to_minor() { + assert_eq!(AutoUpdateConfig::default().auto_apply, "minor"); + assert_eq!( + ApplyPolicy::parse(&AutoUpdateConfig::default().auto_apply), + Some(ApplyPolicy::Minor) + ); + assert!(AutoUpdateConfig::default().enabled); + } + + #[test] + fn config_round_trips_under_a_temp_home() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var(HOME_ENV, tmp.path()); + + let au = AutoUpdateConfig { + channel: "beta".into(), + auto_apply: "patch".into(), + ..AutoUpdateConfig::default() + }; + save_auto_update(&au).unwrap(); + let back = load_auto_update(); + assert_eq!(back.channel, "beta"); + assert_eq!(back.auto_apply, "patch"); + // Other config keys survive an auto_update write. + let cfg: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(config_path().unwrap()).unwrap()) + .unwrap(); + assert!(cfg.get("auto_update").is_some()); + + std::env::remove_var(HOME_ENV); + } + + #[test] + fn release_url_follows_the_channel_and_overrides() { + let mut au = AutoUpdateConfig::default(); + assert_eq!(resolve_release_url(&au), default_release_api_stable()); + au.channel = "beta".into(); + assert_eq!(resolve_release_url(&au), default_release_api_beta()); + au.beta_url = Some("https://example.invalid/feed".into()); + assert_eq!(resolve_release_url(&au), "https://example.invalid/feed"); + // An empty override falls back to the built-in feed rather than + // resolving to an empty URL every fetch then fails on. + au.beta_url = Some(String::new()); + assert_eq!(resolve_release_url(&au), default_release_api_beta()); + } + + #[test] + fn extract_binary_pulls_the_named_file_from_a_tar_gz() { + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("cec-support-linux-x86_64.tar.gz"); + { + let f = std::fs::File::create(&archive).unwrap(); + let enc = flate2::write::GzEncoder::new(f, flate2::Compression::default()); + let mut builder = tar::Builder::new(enc); + let payload = b"#!/bin/sh\necho hi\n"; + let mut header = tar::Header::new_gnu(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "cec-support", &payload[..]) + .unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + } + let out = tmp.path().join("out"); + std::fs::create_dir_all(&out).unwrap(); + let bin = extract_binary(&archive, &out, "cec-support").unwrap(); + assert!(bin.exists()); + let s = std::fs::read_to_string(&bin).unwrap(); + assert!(s.contains("echo hi")); + } + + #[test] + fn a_stale_pending_marker_never_downgrades() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var(HOME_ENV, tmp.path()); + + // A marker naming an OLDER version than the running build must be + // discarded, not applied — otherwise a leftover pending.json rolls the + // customer back on the next launch. + let dir = updates_dir().unwrap(); + std::fs::write( + dir.join("pending.json"), + serde_json::json!({ "version": "0.0.1", "path": "/nonexistent" }).to_string(), + ) + .unwrap(); + assert_eq!(apply_pending().unwrap(), None); + assert!(!dir.join("pending.json").exists()); + + std::env::remove_var(HOME_ENV); + } + + #[test] + fn staged_version_reads_the_marker() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var(HOME_ENV, tmp.path()); + + assert_eq!(staged_version(), None); + std::fs::write( + updates_dir().unwrap().join("pending.json"), + serde_json::json!({ "version": "9.9.9", "path": "/x" }).to_string(), + ) + .unwrap(); + assert_eq!(staged_version().as_deref(), Some("9.9.9")); + + std::env::remove_var(HOME_ENV); + } + + #[test] + fn interval_gate_opens_when_never_checked_and_closes_right_after() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var(HOME_ENV, tmp.path()); + + assert!(is_due(24), "a home that never checked is always due"); + stamp_check_now(); + assert!(!is_due(24), "a check just now is not due again"); + assert!(is_due(0), "a zero-hour interval is always due"); + + std::env::remove_var(HOME_ENV); + } +} diff --git a/crates/cec-support-updater/src/policy.rs b/crates/cec-support-updater/src/policy.rs new file mode 100644 index 0000000..58dadf6 --- /dev/null +++ b/crates/cec-support-updater/src/policy.rs @@ -0,0 +1,115 @@ +//! Apply-policy gate — whether a candidate version bump applies +//! automatically, waits for the user, or is off entirely. Ported verbatim from +//! `allmystuff-updater` (which took it from `myownmesh-updater`; it has no +//! engine coupling), so all three apps in the ecosystem share one set of +//! update semantics — a patch bump means the same thing everywhere. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApplyPolicy { + /// Apply patch-level bumps automatically (`0.1.5 → 0.1.6`); minor / + /// major stage but wait. + Patch, + /// Apply patch + minor automatically (`0.1.5 → 0.2.0`); major waits. + Minor, + /// Apply any version bump automatically. + All, + /// Disable auto-apply; staging still happens, the user triggers apply. + None, +} + +impl ApplyPolicy { + pub fn parse(s: &str) -> Option { + match s { + "patch" => Some(Self::Patch), + "minor" => Some(Self::Minor), + "all" => Some(Self::All), + "none" => Some(Self::None), + _ => None, + } + } +} + +/// Compare two semver-like versions (`MAJOR.MINOR.PATCH`). Pre-release +/// suffixes are stripped for the numeric compare, then used as a +/// lexicographic tiebreaker, with a bare version outranking a pre-release. +pub fn compare_semver(a: &str, b: &str) -> std::cmp::Ordering { + use std::cmp::Ordering; + let (a_core, a_pre) = split_prerelease(a); + let (b_core, b_pre) = split_prerelease(b); + match parse_core(a_core).cmp(&parse_core(b_core)) { + Ordering::Equal => match (a_pre.is_empty(), b_pre.is_empty()) { + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + _ => a_pre.cmp(b_pre), + }, + other => other, + } +} + +fn split_prerelease(v: &str) -> (&str, &str) { + match v.split_once('-') { + Some((core, pre)) => (core, pre), + None => (v, ""), + } +} + +fn parse_core(core: &str) -> [u32; 3] { + let mut parts = [0u32; 3]; + for (i, p) in core.split('.').take(3).enumerate() { + parts[i] = p.parse().unwrap_or(0); + } + parts +} + +/// True when `candidate` is a permitted upgrade from `current` under +/// `policy`. Same/older candidates return false. +pub fn policy_allows(policy: ApplyPolicy, current: &str, candidate: &str) -> bool { + use std::cmp::Ordering; + if compare_semver(candidate, current) != Ordering::Greater { + return false; + } + let [cur_maj, cur_min, _] = parse_core(split_prerelease(current).0); + let [cand_maj, cand_min, _] = parse_core(split_prerelease(candidate).0); + match policy { + ApplyPolicy::None => false, + ApplyPolicy::Patch => cur_maj == cand_maj && cur_min == cand_min, + ApplyPolicy::Minor => cur_maj == cand_maj, + ApplyPolicy::All => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cmp::Ordering; + + #[test] + fn compare_basic() { + assert_eq!(compare_semver("1.2.3", "1.2.3"), Ordering::Equal); + assert_eq!(compare_semver("1.2.3", "1.2.4"), Ordering::Less); + assert_eq!(compare_semver("1.10.0", "1.2.0"), Ordering::Greater); + assert_eq!(compare_semver("2.0.0", "1.99.99"), Ordering::Greater); + } + + #[test] + fn compare_prerelease() { + assert_eq!(compare_semver("1.2.3", "1.2.3-rc1"), Ordering::Greater); + assert_eq!(compare_semver("1.2.3-rc1", "1.2.3-rc2"), Ordering::Less); + } + + #[test] + fn policy_gates() { + assert!(policy_allows(ApplyPolicy::Patch, "0.1.5", "0.1.6")); + assert!(!policy_allows(ApplyPolicy::Patch, "0.1.5", "0.2.0")); + assert!(policy_allows(ApplyPolicy::Minor, "0.1.5", "0.2.0")); + assert!(!policy_allows(ApplyPolicy::Minor, "0.1.5", "1.0.0")); + assert!(policy_allows(ApplyPolicy::All, "0.1.5", "1.0.0")); + assert!(!policy_allows(ApplyPolicy::None, "0.1.5", "0.1.6")); + // Downgrade / same never allowed. + assert!(!policy_allows(ApplyPolicy::All, "0.1.5", "0.1.4")); + assert!(!policy_allows(ApplyPolicy::All, "0.1.5", "0.1.5")); + } +} diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 60c2425..7ffc248 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -60,6 +60,13 @@ allmystuff-cec-consent = { git = "https://github.com/mrjeeves/AllMyStuff", tag = # The client's OWN OS-service installer (never AllMyStuff's), so installing the # background service never clobbers an existing AllMyStuff install. cec-support-service = { path = "../../crates/cec-support-service" } +# The client's OWN self-updater. CEC keeps the engine *beneath* it current (it +# asks a reused `allmystuff-serve` to move to ALLMYSTUFF_PIN, which in turn +# keeps `myownmesh` current); this is the missing top of that chain — the thing +# that updates CEC Support itself. Self-contained by design: a git dep on +# AllMyStuff's updater would pin us to a released tag that can't carry +# not-yet-released fixes. +cec-support-updater = { path = "../../crates/cec-support-updater" } # Async runtime — Tauri commands run on it; setup drives node bring-up + the # event pump; the headless `run --service` agent blocks on it. diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index c1d99c2..a51f036 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -794,6 +794,54 @@ fn background_set(state: State<'_, AppState>, enabled: bool) -> bool { enabled } +// --------------------------------------------------------------------------- +// Self-update (CEC Support's own release feed, not the node's) +// --------------------------------------------------------------------------- + +/// Current updater state: running version, install kind, prefs, what's staged. +#[tauri::command] +async fn update_status() -> Result { + serde_json::to_value(cec_support_updater::status().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) +} + +/// Check the release feed right now, ignoring the interval cooldown, and stage +/// anything the apply policy permits. +#[tauri::command] +async fn update_check() -> Result { + let outcome = cec_support_updater::check_now(true) + .await + .map_err(|e| e.to_string())?; + serde_json::to_value(outcome).map_err(|e| e.to_string()) +} + +/// Apply a staged update to disk. The swap lands immediately, but this process +/// keeps the old build until it restarts. +#[tauri::command] +async fn update_apply() -> Result { + let applied = cec_support_updater::apply_now().map_err(|e| e.to_string())?; + Ok(json!({ "applied": applied })) +} + +/// Apply a staged update and relaunch into it. Applying *before* the restart is +/// what makes the relaunch land on the new version in one step: a bare restart +/// would re-exec the still-old binary and only swap it in on the following +/// boot. Never returns on success — the process restarts. +#[tauri::command] +async fn update_relaunch(app: tauri::AppHandle) -> Result<(), String> { + cec_support_updater::apply_now().map_err(|e| e.to_string())?; + app.restart() +} + +/// Change updater preferences (auto-update on/off, channel, policy, interval). +#[tauri::command] +async fn update_set_prefs(prefs: Value) -> Result { + let prefs: cec_support_updater::UpdatePrefs = + serde_json::from_value(prefs).map_err(|e| e.to_string())?; + serde_json::to_value(cec_support_updater::set_prefs(prefs).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) +} + // --------------------------------------------------------------------------- // GUI plumbing // --------------------------------------------------------------------------- @@ -997,6 +1045,11 @@ fn run_gui() -> ExitCode { autostart_set, background_get, background_set, + update_status, + update_check, + update_apply, + update_relaunch, + update_set_prefs, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { @@ -1094,6 +1147,29 @@ fn run_gui() -> ExitCode { } run_event_pump(handle, node).await; }); + + // Self-update ticker. A launch check fires ~30s in (past the + // interval cooldown, so opening the app is itself a check), then + // every `check_interval_hours`. Spawned unconditionally — + // `check_now` no-ops when auto-update is off, and reports rather + // than stages when this install can't write its own binary. + // + // Every outcome is forwarded to the webview as `update://checked`. + // A background task holds no handle to the UI, so without this a + // staged update would sit on disk with nothing to announce it — + // which is precisely how an updater ends up looking like it never + // runs at all. + let update_handle = app.handle().clone(); + tauri::async_runtime::spawn(cec_support_updater::tick_forever_notify( + move |outcome| match serde_json::to_value(outcome) { + Ok(payload) => { + if let Err(e) = update_handle.emit("update://checked", payload) { + tracing::warn!("couldn't emit the self-update outcome: {e}"); + } + } + Err(e) => tracing::warn!("couldn't serialise the self-update outcome: {e}"), + }, + )); Ok(()) }) .build(tauri::generate_context!()) @@ -1251,6 +1327,13 @@ fn main() -> ExitCode { // before the shared node socket is addressed. apply_cec_env(); + // Swap in anything the updater staged on a previous run, before any of it + // is loaded. A running executable can't reliably replace itself, so the + // apply always happens here — at the very start of the *next* launch — + // rather than at the moment the download finished. Never fatal: a failure + // logs and leaves the marker for the launch after this one. + cec_support_updater::apply_pending_if_any(); + // Elevated Windows service action: ` --service-do ` — run the // verb in-process and exit, no webview. (Unix calls the crate directly.) if let Some(verb) = service_do_verb() { diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index 041c577..f56721e 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -28,6 +28,12 @@ import { cecViewing, cecPending, cecRevoke, + onUpdateChecked, + updateApply, + updateCheck, + updateRelaunch, + updateSetPrefs, + updateStatus, cecSetLabel, claimNode, fleetKick, @@ -71,6 +77,9 @@ import type { SessionEvent, SessionSnapshot, SiteAdvert, + UpdateStatus, + CheckOutcome, + UpdatePrefs, } from "./types"; /** The stable machine identity inside a mesh device id: the bare pubkey with @@ -143,6 +152,12 @@ class CecStore { readonly demo = !isTauri(); version = $state(null); + /** Updater state — null until read, and in web mode where there's no + * backend. */ + updateInfo = $state(null); + /** Result of the most recent check (manual or from the background ticker). */ + updateOutcome = $state(null); + updateBusy = $state(false); status = $state(null); /** Technician requests awaiting a decision; `request` shows the first. */ pending = $state([]); @@ -433,6 +448,15 @@ class CecStore { // updates. Runs in the background so the rest of init never blocks on it. void this.bringUp(); + // The background self-update ticker's verdict. Registered here rather than + // in the settings panel so a release found while the customer is anywhere + // in the app still surfaces — the panel only mounts when they go looking, + // which is exactly what makes an updater seem never to run. + this.unlisteners.push( + await onUpdateChecked((o) => this.applyUpdateChecked(o)), + ); + void this.loadUpdateStatus(); + this.service = await serviceStatus(); this.autostart = await autostartGet(); this.autostartMode = await autostartModeGet(); @@ -1528,6 +1552,93 @@ class CecStore { this.snapshot = { ...(this.snapshot ?? { ready: true }), peers }; } + // ---- self-update ------------------------------------------------------- + + /** Read the updater's current state (version, install kind, prefs, staged). */ + async loadUpdateStatus(): Promise { + if (this.demo) return; + this.updateInfo = await updateStatus(); + } + + /** A background check reported in. Only outcomes that mean "something newer + * than what you're running exists" are worth a toast — the routine + * up-to-date / not-due / disabled ticks refresh state quietly. */ + private applyUpdateChecked(o: CheckOutcome): void { + this.updateOutcome = o; + void this.loadUpdateStatus(); + switch (o.outcome) { + case "staged": + this.notify(`Update ${o.version} is ready — restart CEC Support to use it`); + break; + case "manual_update_available": + this.notify(`Version ${o.latest} is available — reinstall to update`); + break; + case "policy_blocked": + this.notify(`Version ${o.latest} is available — see Settings to install it`); + break; + default: + break; + } + } + + /** Check the release feed now and stage anything permitted. */ + async checkUpdates(): Promise { + if (this.demo) return; + this.updateBusy = true; + this.updateOutcome = null; + try { + this.updateOutcome = await updateCheck(); + this.updateInfo = (await updateStatus()) ?? this.updateInfo; + } finally { + this.updateBusy = false; + } + } + + /** Apply a staged update and restart into it. On success the process + * restarts, so nothing after this runs. */ + async applyUpdateAndRestart(): Promise { + if (this.demo) return; + this.updateBusy = true; + try { + await updateRelaunch(); + } catch (e) { + this.notify(`Couldn't install the update: ${String(e)}`); + // Best-effort: leave the staged marker in place for the next launch. + await updateApply(); + this.updateInfo = (await updateStatus()) ?? this.updateInfo; + } finally { + this.updateBusy = false; + } + } + + /** Flip an updater preference (e.g. automatic updates on/off). */ + async setUpdatePrefs(prefs: UpdatePrefs): Promise { + if (this.demo) return; + const next = await updateSetPrefs(prefs); + if (next) this.updateInfo = next; + } + + /** Plain-language summary of a check result, for the settings panel. */ + checkOutcomeText(o: CheckOutcome | null): string | null { + if (!o) return null; + switch (o.outcome) { + case "staged": + return `Version ${o.version} is ready — restart CEC Support to use it`; + case "manual_update_available": + return `Version ${o.latest} is available, but this copy can't update itself — reinstall to update`; + case "up_to_date": + return "You're on the latest version"; + case "policy_blocked": + return `Version ${o.latest} is available and waiting for you`; + case "disabled": + return "Automatic updates are off"; + case "not_due": + return "Checked recently — try again shortly"; + default: + return null; + } + } + // ---- toasts ---------------------------------------------------------- notify(message: string): void { diff --git a/gui/src/tauri.ts b/gui/src/tauri.ts index 25dc6a0..4edf48b 100644 --- a/gui/src/tauri.ts +++ b/gui/src/tauri.ts @@ -41,6 +41,9 @@ import type { SessionSnapshot, ServiceStatus, ServiceResult, + UpdateStatus, + CheckOutcome, + UpdatePrefs, } from "./types"; /** True when running inside the Tauri webview (vs a plain browser tab). */ @@ -446,3 +449,52 @@ export async function copyToClipboard(text: string): Promise { } return false; } + +// ---- self-update ------------------------------------------------------- +// +// CEC Support's own release feed, not the node's. These degrade to null in web +// mode (no backend), so the settings panel can render a plain version line +// instead of throwing. + +/** Current updater state: running version, install kind, prefs, what's staged. */ +export function updateStatus(): Promise { + return tryInvoke("update_status"); +} + +/** Check the release feed now, ignoring the interval cooldown. */ +export function updateCheck(): Promise { + return tryInvoke("update_check"); +} + +/** Apply a staged update to disk. Takes effect when the app next starts. */ +export function updateApply(): Promise<{ applied: string | null } | null> { + return tryInvoke<{ applied: string | null }>("update_apply"); +} + +/** Apply a staged update and relaunch into it. The process restarts on + * success, so this never resolves then — it only returns (throwing) if the + * apply failed and we stayed on the old build. Uses a raw invoke so that + * failure surfaces instead of being swallowed to null. */ +export async function updateRelaunch(): Promise { + if (!isTauri()) return; + await rawInvoke("update_relaunch"); +} + +/** Change updater preferences. Returns the resulting status. */ +export function updateSetPrefs( + prefs: UpdatePrefs, +): Promise { + return tryInvoke("update_set_prefs", { prefs }); +} + +/** The background ticker reporting what a check decided — the launch check + * (~30s after start) and then every `check_interval_hours`. Without this the + * ticker is mute: it stages updates nobody is told about, which is what makes + * auto-update look like it never runs. No-op listener in web mode. */ +export async function onUpdateChecked( + cb: (o: CheckOutcome) => void, +): Promise<() => void> { + if (!isTauri()) return () => {}; + const { listen } = await import("@tauri-apps/api/event"); + return listen("update://checked", (e) => cb(e.payload)); +} diff --git a/gui/src/types.ts b/gui/src/types.ts index 3211695..c66c59d 100644 --- a/gui/src/types.ts +++ b/gui/src/types.ts @@ -341,3 +341,48 @@ export interface KvmWifiNetwork { security?: string; frequency?: number; } + +// ---- self-update (mirrors `cec-support-updater`) ----------------------- + +export type InstallKind = "raw" | "package_manager"; + +/** Updater state (from `update_status`). */ +export interface UpdateStatus { + current_version: string; + install_kind: InstallKind; + enabled: boolean; + /** "stable" | "beta". */ + channel: string; + /** Auto-apply policy: "patch" | "minor" | "all" | "none". */ + auto_apply: string; + check_interval_hours: number; + last_check_at: number | null; + staged_version: string | null; + release_url: string; + release_url_overridden: boolean; +} + +/** What a check decided (from `update_check`, and the `update://checked` + * event the background ticker emits). Tagged on `outcome`. */ +export interface CheckOutcome { + outcome: + | "disabled" + | "not_due" + | "up_to_date" + | "policy_blocked" + | "staged" + /** Newer release exists, but this install can't swap its own binary. */ + | "manual_update_available"; + current?: string; + latest?: string; + policy?: string; + version?: string; +} + +/** The bits of updater config the UI can change (sent to `update_set_prefs`). */ +export interface UpdatePrefs { + enabled?: boolean; + channel?: string; + auto_apply?: string; + check_interval_hours?: number; +} diff --git a/gui/src/ui/SettingsPanel.svelte b/gui/src/ui/SettingsPanel.svelte index bdb80cd..d691f9b 100644 --- a/gui/src/ui/SettingsPanel.svelte +++ b/gui/src/ui/SettingsPanel.svelte @@ -20,6 +20,20 @@ let serviceSupported = $derived(store.service?.supported ?? false); let serviceInstalled = $derived(store.service?.installed ?? false); + + // ---- updates ---- + const update = $derived(store.updateInfo); + // An install that can't swap its own binary: a package manager owns it, or + // it's a per-machine install this process can't write to. It still checks — + // it just can't install what it finds. + const managed = $derived(update?.install_kind === "package_manager"); + const checkResult = $derived(store.checkOutcomeText(store.updateOutcome)); + + const lastChecked = $derived( + update?.last_check_at + ? new Date(update.last_check_at * 1000).toLocaleString() + : "not yet", + );
@@ -130,6 +144,66 @@ +
+

Updates

+

+ CEC Support keeps itself up to date so your technician is always working with a version + that matches theirs. It checks quietly in the background — you don't have to do anything. +

+ + {#if store.demo} +

Updates are handled by the installed app.

+ {:else if !update} +

Checking your version…

+ {:else} +
+ Version {update.current_version} + +
+ + {#if checkResult && !store.updateBusy} +

{checkResult}

+ {/if} + + {#if update.staged_version} + +
Version {update.staged_version} is ready to install
+
+ +
+ {/if} + + {#if managed} +

+ This copy of CEC Support can't replace its own files — it was installed for all users, + or through a package manager. It still checks for new versions and will tell you when + one is out; installing it means running the installer again. +

+ {:else} + + {/if} + +

Last checked: {lastChecked}

+ {/if} +
+