From 7508c18c53b53b9359379a633f247addfbf03b9e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:23:09 -0300 Subject: [PATCH] feat(app): update Botloft from inside the app (M5) The app checks the latest published GitHub release (latest.json) when it opens and every six hours. A newer version shows "Update available" in the title bar; one click downloads it with progress, runs the installer passively and reopens Botloft, and the reopened app updates the daemon because it is now outdated. Failures keep the current version, with the error under "Details". Dev builds never offer to replace themselves. Updates are signed with the Tauri updater key (public key in tauri.conf.json, private key in the TAURI_SIGNING_PRIVATE_KEY secret), and requireSignedVersion keeps a tampered feed from pushing an older signed release back. Pushing a tag vX.Y.Z runs release.yml: it checks the tag against the workspace version, builds the signed installer and opens a draft release with the installer, its signature and latest.json. The updater sees it once the owner publishes it. The version now lives only in Cargo.toml. --- .github/workflows/release.yml | 44 ++ CLAUDE.md | 2 + Cargo.lock | 575 +++++++++++++++++++++- README.md | 14 + app/package.json | 1 + app/pnpm-lock.yaml | 10 + app/scripts/release.mjs | 79 +++ app/src-tauri/Cargo.toml | 1 + app/src-tauri/capabilities/default.json | 3 +- app/src-tauri/src/lib.rs | 2 + app/src-tauri/tauri.bundle.conf.json | 1 + app/src-tauri/tauri.conf.json | 11 +- app/src/features/updates/UpdateButton.tsx | 129 +++++ app/src/features/updates/updates.test.tsx | 65 +++ app/src/lib/fakeHost.ts | 29 +- app/src/lib/host.ts | 16 + app/src/lib/tauriHost.ts | 41 +- app/src/shell/Workspace.tsx | 2 + docs/spec.md | 8 + 19 files changed, 1012 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 app/scripts/release.mjs create mode 100644 app/src/features/updates/UpdateButton.tsx create mode 100644 app/src/features/updates/updates.test.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0747615b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +# Pushing a tag vX.Y.Z (matching the workspace version) builds the signed +# installer and opens a draft release; publishing it makes the in-app +# updater offer it (spec 15.5). +on: + push: + tags: ["v*"] + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + installer: + name: Installer (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - run: node app/scripts/release.mjs check + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: pnpm/action-setup@v4 + with: + package_json_file: app/package.json + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: app/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + working-directory: app + - name: Build the signed installer + run: pnpm bundle + working-directory: app + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + - name: Open a draft release + run: node app/scripts/release.mjs publish + env: + GH_TOKEN: ${{ github.token }} diff --git a/CLAUDE.md b/CLAUDE.md index 39ae825a..a71ad662 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,8 @@ pnpm check # tsc --noEmit + biome check + vitest run pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release\bundle\nsis ``` +Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com instalador, `.sig` e `latest.json`; o updater só o vê depois de publicado. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou. + Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá. ## Convenções diff --git a/Cargo.lock b/Cargo.lock index aef4ca2e..51a3492b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -266,11 +275,12 @@ dependencies = [ name = "botloft-app" version = "0.1.0" dependencies = [ - "dirs", + "dirs 7.0.0", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-updater", "toml 1.1.6+spec-1.1.0", ] @@ -308,7 +318,7 @@ dependencies = [ "botloft-store", "bytes", "clap", - "dirs", + "dirs 7.0.0", "embed-resource", "futures-util", "getrandom 0.4.3", @@ -468,6 +478,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfb" version = "0.14.0" @@ -590,6 +611,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -613,7 +644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -626,7 +657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -811,6 +842,17 @@ dependencies = [ "serde_core", ] +[[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.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -859,6 +901,15 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "7.0.0" @@ -1061,6 +1112,16 @@ dependencies = [ "rustc_version", ] +[[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.14" @@ -1306,6 +1367,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1614,6 +1686,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.21" @@ -1633,9 +1720,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1805,13 +1894,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb 0.7.3", +] + [[package]] name = "infer" version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ - "cfb", + "cfb 0.14.0", ] [[package]] @@ -1925,6 +2023,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2145,6 +2273,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[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" @@ -2403,6 +2537,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2418,6 +2553,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2487,12 +2634,32 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "pango" version = "0.18.3" @@ -2922,15 +3089,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2942,6 +3114,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -2995,6 +3181,79 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3016,6 +3275,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3073,6 +3341,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -3359,6 +3650,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -3483,6 +3790,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3552,6 +3865,27 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3573,7 +3907,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3583,7 +3917,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3616,6 +3950,17 @@ dependencies = [ "syn 2.0.119", ] +[[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 = "target-lexicon" version = "0.12.16" @@ -3631,7 +3976,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3639,7 +3984,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3681,7 +4026,7 @@ checksum = "5b5ae674f48836f5dd2eeaf9e221c095bd3e78a8d0439c77ca93b48740a1b644" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3735,6 +4080,55 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ff3ebb9fda56ceb93d46f6cbb126bae82951bf43498543a5163dde5def49a1" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae4783480c5d97373b98ae9f2e70158ca992cdf29e04b236a42b1f82b1b3804" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer 0.19.0", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.12.0" @@ -3745,7 +4139,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3768,7 +4162,7 @@ checksum = "fa829eb69860cf1fb0bedb21d2d5f1effce3527d054a68ca1cd7355c654400a3" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3800,7 +4194,7 @@ dependencies = [ "dunce", "glob", "http", - "infer", + "infer 0.22.0", "json-patch", "log", "memchr", @@ -3988,6 +4382,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -4249,7 +4653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 7.0.0", "libappindicator", "muda", "objc2", @@ -4358,6 +4762,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[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" @@ -4630,6 +5040,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.39.1" @@ -4794,6 +5213,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4821,6 +5251,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4830,6 +5269,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4863,13 +5311,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -4900,6 +5365,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4912,6 +5383,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4924,12 +5401,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4942,6 +5431,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4954,6 +5449,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4966,6 +5467,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4978,6 +5485,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -5028,7 +5541,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -5036,7 +5549,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5083,6 +5596,16 @@ dependencies = [ "pkg-config", ] +[[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" @@ -5147,6 +5670,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -5180,6 +5709,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/README.md b/README.md index fae20cfa..2b7d997d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,20 @@ to do. > tasks between bots. M5 (distribution) is in progress. The design lives in > [`docs/spec.md`](docs/spec.md). +## Install + +1. Install [Claude Code](https://code.claude.com/docs/en/setup) and sign in once. +2. Download `Botloft__x64-setup.exe` from + [Releases](https://github.com/httpsphl/botloft/releases) and run it. It installs for your + Windows user only, with no administrator prompt. The installer is not code-signed yet, so + Windows SmartScreen may ask you to confirm. +3. Open Botloft. It sets itself up; there is nothing to configure. + +Botloft updates itself: when a new version is out, an "Update available" button shows in the +title bar. Uninstalling (Settings > Apps) removes the app and stops Botloft from running in the +background; your bots and their files stay in `%LOCALAPPDATA%\Botloft` and +`%USERPROFILE%\Botloft`. + ## How it works - **The daemon is the source of truth.** Closing the app never stops a bot. The daemon runs as a diff --git a/app/package.json b/app/package.json index 0fdb9f03..f08fb286 100644 --- a/app/package.json +++ b/app/package.json @@ -21,6 +21,7 @@ "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", "@tauri-apps/api": "^2.12.0", + "@tauri-apps/plugin-updater": "^2.13.0", "lucide-react": "^1.48.0", "react": "^19.3.0", "react-dom": "^19.3.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 080c32e9..9226cf85 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: '@tauri-apps/api': specifier: ^2.12.0 version: 2.12.0 + '@tauri-apps/plugin-updater': + specifier: ^2.13.0 + version: 2.13.0 lucide-react: specifier: ^1.48.0 version: 1.48.0(react@19.3.0) @@ -662,6 +665,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-updater@2.13.0': + resolution: {integrity: sha512-9iwTSOlxMPZGI6M84Nd00rv6g+xERo8g4GDvG9cazOao+HjdIHZYCcvkvSP6JGn7Z50/R8t2jbR+PxQxf2qKMQ==} + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -1964,6 +1970,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.12.0 '@tauri-apps/cli-win32-x64-msvc': 2.12.0 + '@tauri-apps/plugin-updater@2.13.0': + dependencies: + '@tauri-apps/api': 2.12.0 + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 diff --git a/app/scripts/release.mjs b/app/scripts/release.mjs new file mode 100644 index 00000000..5b4f488c --- /dev/null +++ b/app/scripts/release.mjs @@ -0,0 +1,79 @@ +// Release steps for .github/workflows/release.yml (spec 15.5). +// +// node app/scripts/release.mjs check the tag matches the workspace version +// node app/scripts/release.mjs publish draft GitHub release with the installer, +// its signature and latest.json +// +// latest.json is the feed the in-app updater reads from the latest published +// release. A draft is invisible to it until the owner publishes the release. + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const tag = process.env.GITHUB_REF_NAME ?? ""; +const repo = process.env.GITHUB_REPOSITORY ?? ""; + +function workspaceVersion() { + const cargo = readFileSync(join(root, "Cargo.toml"), "utf8"); + const version = cargo.match(/\[workspace\.package\][^[]*?\nversion = "([^"]+)"/)?.[1]; + if (!version) { + throw new Error("Cargo.toml has no [workspace.package] version"); + } + return version; +} + +function check() { + const version = workspaceVersion(); + if (tag !== `v${version}`) { + throw new Error(`tag ${tag || "(none)"} does not match the workspace version ${version}`); + } + console.log(`releasing ${version}`); +} + +function publish() { + const version = workspaceVersion(); + const dir = join(root, "target", "release", "bundle", "nsis"); + const installer = `Botloft_${version}_x64-setup.exe`; + const signature = readFileSync(join(dir, `${installer}.sig`), "utf8").trim(); + const feed = { + version, + notes: "", + pub_date: new Date().toISOString(), + platforms: { + "windows-x86_64": { + signature, + url: `https://github.com/${repo}/releases/download/${tag}/${installer}`, + }, + }, + }; + const latest = join(dir, "latest.json"); + writeFileSync(latest, `${JSON.stringify(feed, null, 2)}\n`); + execFileSync( + "gh", + [ + "release", + "create", + tag, + join(dir, installer), + join(dir, `${installer}.sig`), + latest, + "--draft", + "--title", + `Botloft ${version}`, + "--generate-notes", + ], + { stdio: "inherit" }, + ); +} + +const step = process.argv[2]; +if (step === "check") { + check(); +} else if (step === "publish") { + publish(); +} else { + throw new Error("usage: release.mjs check|publish"); +} diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index dfeee243..af714b01 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ dirs = "7.0.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" tauri = { version = "2", features = [] } +tauri-plugin-updater = "2" toml = "1.1.6" [lints] diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index c50a0065..88596018 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -11,6 +11,7 @@ "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-close", - "core:window:allow-set-overlay-icon" + "core:window:allow-set-overlay-icon", + "updater:default" ] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 64f0c0cf..8068926e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -89,6 +89,8 @@ async fn open_url(url: String) -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + // Checks the release feed and installs signed updates (spec 15.5). + .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ daemon_status, daemon_install, diff --git a/app/src-tauri/tauri.bundle.conf.json b/app/src-tauri/tauri.bundle.conf.json index 775c0d51..8e22e10e 100644 --- a/app/src-tauri/tauri.bundle.conf.json +++ b/app/src-tauri/tauri.bundle.conf.json @@ -2,6 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "bundle": { "externalBin": ["binaries/botloftd"], + "createUpdaterArtifacts": true, "windows": { "nsis": { "installMode": "currentUser", diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index ea2262f4..c476cbb7 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -2,7 +2,6 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Botloft", "mainBinaryName": "Botloft", - "version": "0.1.0", "identifier": "io.github.httpsphl.botloft", "build": { "beforeDevCommand": "pnpm dev", @@ -25,6 +24,16 @@ "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'" } }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY4Mzk1NTc0OUYwODcyNTAKUldSUWNnaWZkRlU1K0xsNTVPcWw1c3A3dUNEZzRxUjJscWJpdUJWcVNCa3RkRFNFZFhrU21xQm8K", + "endpoints": ["https://github.com/httpsphl/botloft/releases/latest/download/latest.json"], + "requireSignedVersion": true, + "windows": { + "installMode": "passive" + } + } + }, "bundle": { "active": true, "targets": [ diff --git a/app/src/features/updates/UpdateButton.tsx b/app/src/features/updates/UpdateButton.tsx new file mode 100644 index 00000000..18567c03 --- /dev/null +++ b/app/src/features/updates/UpdateButton.tsx @@ -0,0 +1,129 @@ +// Updating Botloft from inside the app (spec 15.5): a quiet title-bar +// button when a newer version is out, and one click to install it. + +import { ArrowDownToLine, RefreshCw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { errorText } from "../../lib/api"; +import type { AppUpdate } from "../../lib/host"; +import { useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Details } from "../../ui/Details"; +import { Dialog } from "../../ui/Dialog"; + +/** How often a running app looks for a new version. */ +const CHECK_EVERY_MS = 6 * 60 * 60 * 1000; + +/** The newest version the release feed offers, checked now and every few hours. */ +function useAppUpdate(): AppUpdate | null { + const host = useHost(); + const [update, setUpdate] = useState(null); + useEffect(() => { + let alive = true; + const look = () => { + host.checkForUpdate().then( + (found) => alive && setUpdate(found), + // Offline or no release yet: try again at the next check. + () => {}, + ); + }; + look(); + const timer = setInterval(look, CHECK_EVERY_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [host]); + return update; +} + +export function UpdateButton() { + const update = useAppUpdate(); + const [open, setOpen] = useState(false); + if (!update) { + return null; + } + return ( + <> + + {open && setOpen(false)} />} + + ); +} + +type Progress = + | { stage: "idle" } + | { stage: "downloading"; fraction: number | null } + | { stage: "failed"; error: string }; + +function UpdateDialog({ update, onClose }: { update: AppUpdate; onClose(): void }) { + const [progress, setProgress] = useState({ stage: "idle" }); + const busy = progress.stage === "downloading"; + + const install = async () => { + setProgress({ stage: "downloading", fraction: null }); + try { + // Botloft closes when the installer starts; returning means it failed. + await update.install((fraction) => setProgress({ stage: "downloading", fraction })); + } catch (error) { + setProgress({ stage: "failed", error: errorText(error) }); + } + }; + + return ( + {} : onClose} + footer={ + <> + + + + } + > +
+

+ Botloft {update.version} is ready. Botloft closes, installs the update and opens again. + Your bots pause for a moment and pick up where they left off. +

+ {update.notes && ( +
+ What's new +

{update.notes}

+
+ )} + {progress.stage === "downloading" && ( +

+ {progress.fraction === null + ? "Downloading…" + : progress.fraction >= 1 + ? "Installing…" + : `Downloading… ${Math.round(progress.fraction * 100)}%`} +

+ )} + {progress.stage === "failed" && ( + + Botloft keeps working on the current version. +
{progress.error}
+
+ )} +
+
+ ); +} diff --git a/app/src/features/updates/updates.test.tsx b/app/src/features/updates/updates.test.tsx new file mode 100644 index 00000000..56d0b292 --- /dev/null +++ b/app/src/features/updates/updates.test.tsx @@ -0,0 +1,65 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { App } from "../../App"; +import type { Client } from "../../lib/client"; +import { FakeBotloft } from "../../lib/fake"; +import { FakeHost, FakeUpdate } from "../../lib/fakeHost"; + +afterEach(cleanup); + +function renderApp(host: FakeHost) { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + render( fake as Client} />); +} + +describe("app updates", () => { + test("nothing shows while this is the latest version", async () => { + const host = new FakeHost(); + renderApp(host); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + expect(host.updateChecks).toBe(1); + expect(screen.queryByRole("button", { name: "Update available" })).toBeNull(); + }); + + test("a new version installs in one click, in plain words", async () => { + const host = new FakeHost(); + const update = new FakeUpdate("0.2.0"); + update.notes = "Bots start faster."; + host.update = update; + renderApp(host); + fireEvent.click(await screen.findByRole("button", { name: "Update available" })); + const dialog = screen.getByRole("dialog", { name: "Update Botloft" }); + expect(dialog.textContent).toContain("Botloft 0.2.0 is ready"); + expect(dialog.textContent).toContain("Bots start faster."); + fireEvent.click(within(dialog).getByRole("button", { name: "Update now" })); + expect(await within(dialog).findByText("Downloading… 50%")).toBeDefined(); + expect(update.installs).toBe(1); + expect( + (within(dialog).getByRole("button", { name: "Later" }) as HTMLButtonElement).disabled, + ).toBe(true); + }); + + test("a failed update says so and can be tried again", async () => { + const host = new FakeHost(); + const update = new FakeUpdate("0.2.0"); + update.failure = new Error("signature verification failed"); + host.update = update; + renderApp(host); + fireEvent.click(await screen.findByRole("button", { name: "Update available" })); + const dialog = screen.getByRole("dialog", { name: "Update Botloft" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Update now" })); + expect(await within(dialog).findByText("The update didn't install")).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "Try again" })); + expect(update.installs).toBe(2); + }); + + test("an unreachable release feed stays quiet", async () => { + const host = new FakeHost(); + host.update = new Error("offline"); + renderApp(host); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + expect(screen.queryByRole("button", { name: "Update available" })).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index e30e068e..8d6b727b 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -1,6 +1,6 @@ // A `Host` for tests: a daemon that is running unless told otherwise. -import type { DaemonStatus, Host } from "./host"; +import type { AppUpdate, DaemonStatus, Host } from "./host"; import { PROTOCOL_VERSION } from "./protocol.gen"; /** A current daemon on the default port. */ @@ -24,6 +24,9 @@ export class FakeHost implements Host { token: string | Error = "a".repeat(64); readonly opened: string[] = []; readonly installs: ("install" | "restart")[] = []; + /** What `checkForUpdate` finds, or its error. */ + update: AppUpdate | null | Error = null; + updateChecks = 0; maximized = false; closed = false; attention = false; @@ -40,6 +43,13 @@ export class FakeHost implements Host { return this.settle("restart"); } + checkForUpdate(): Promise { + this.updateChecks += 1; + return this.update instanceof Error + ? Promise.reject(this.update) + : Promise.resolve(this.update); + } + private settle(action: "install" | "restart"): Promise { this.installs.push(action); if (this.afterInstall instanceof Error) { @@ -81,3 +91,20 @@ export class FakeHost implements Host { }, }; } + +/** An update whose install reports some progress, then fails or hangs. */ +export class FakeUpdate implements AppUpdate { + notes: string | null = null; + installs = 0; + /** Rejects the install with this, or never settles, as a real one exits. */ + failure: Error | null = null; + + constructor(readonly version: string) {} + + install(progress: (fraction: number | null) => void): Promise { + this.installs += 1; + progress(null); + progress(0.5); + return this.failure ? Promise.reject(this.failure) : new Promise(() => {}); + } +} diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index 8ec0a0da..dffa91de 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -17,6 +17,20 @@ export type DaemonStatus = /** Something that is not botloftd holds the port. */ | { state: "foreign"; port: number }; +/** A newer Botloft, ready to download and install (spec 15.5). */ +export interface AppUpdate { + version: string; + /** Release notes, if the release has any. */ + notes: string | null; + /** + * Downloads and installs the update. Botloft closes, the installer runs + * and opens it again, so this only returns if something fails. + * `progress` gets the downloaded fraction, or null while the size is + * unknown. + */ + install(progress: (fraction: number | null) => void): Promise; +} + export interface AppWindow { minimize(): Promise; toggleMaximize(): Promise; @@ -38,6 +52,8 @@ export interface Host { installDaemon(): Promise; /** Stops the daemon and starts it again from its scheduled task. */ restartDaemon(): Promise; + /** A newer version of the app, or null when this one is the latest. */ + checkForUpdate(): Promise; readOwnerToken(): Promise; /** Opens a folder in Explorer. */ openPath(path: string): Promise; diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index c3c21b67..90194304 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -3,7 +3,8 @@ import { invoke } from "@tauri-apps/api/core"; import { Image } from "@tauri-apps/api/image"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import type { DaemonStatus, Host } from "./host"; +import { check } from "@tauri-apps/plugin-updater"; +import type { AppUpdate, DaemonStatus, Host } from "./host"; const DOT = 16; @@ -24,6 +25,43 @@ function dot(): Uint8Array { return rgba; } +/** + * Asks the release feed for a newer version (spec 15.5). A dev build never + * offers to replace itself with the released app. + */ +async function checkForUpdate(): Promise { + if (import.meta.env.DEV) { + return null; + } + const update = await check(); + if (!update) { + return null; + } + return { + version: update.version, + notes: update.body?.trim() || null, + install: async (progress) => { + let total = 0; + let done = 0; + await update.downloadAndInstall((event) => { + switch (event.event) { + case "Started": + total = event.data.contentLength ?? 0; + progress(total > 0 ? 0 : null); + break; + case "Progress": + done += event.data.chunkLength; + progress(total > 0 ? Math.min(1, done / total) : null); + break; + case "Finished": + progress(1); + break; + } + }); + }, + }; +} + export function tauriHost(): Host { const window = getCurrentWindow(); let overlay: Promise | undefined; @@ -31,6 +69,7 @@ export function tauriHost(): Host { daemonStatus: () => invoke("daemon_status"), installDaemon: () => invoke("daemon_install"), restartDaemon: () => invoke("daemon_restart"), + checkForUpdate, readOwnerToken: () => invoke("read_owner_token"), openPath: (path) => invoke("open_path", { path }), openUrl: (url) => invoke("open_url", { url }), diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index ac2c1668..5425ebf2 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -10,6 +10,7 @@ import { Sidebar } from "../features/crews/Sidebar"; import { FailedDeliveries } from "../features/messages/FailedDeliveries"; import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp"; import { Welcome } from "../features/onboarding/Welcome"; +import { UpdateButton } from "../features/updates/UpdateButton"; import { useApp } from "../store/context"; import { Callout } from "../ui/Callout"; import { useAttentionMark } from "./attention"; @@ -58,6 +59,7 @@ export function Workspace() { + diff --git a/docs/spec.md b/docs/spec.md index 26f56774..6b54ef20 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -599,6 +599,14 @@ Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em ` - Hook `NSIS_HOOK_PREUNINSTALL` (`src-tauri/windows/hooks.nsh`): numa desinstalação de verdade, `botloftd service uninstall` para o daemon e apaga a tarefa e o binário; bots e dados ficam. Uma atualização também roda o desinstalador antigo, com `/UPDATE`: aí o hook não faz nada e o daemon continua rodando até o app novo abrir e atualizá-lo (15.2). - O manifesto do app (`src-tauri/windows/app.manifest`) é o padrão do Tauri (controles comuns v6) mais `longPathAware`. +### 15.5 Atualizações + +- O app usa o `tauri-plugin-updater`. O feed é `latest.json` do último release publicado no GitHub (`releases/latest/download/latest.json`). O app o consulta ao abrir e a cada 6 h; sem rede ou sem release, fica quieto. Build de dev não consulta, para não se trocar pelo app publicado. +- Com versão nova, aparece "Update available" na barra de título. Um clique abre o diálogo: a versão, "What's new" (se o release tiver notas) e o aviso de que o Botloft fecha, instala e abre de novo, e de que os bots pausam por um instante e continuam de onde pararam. "Update now" baixa com progresso e roda o instalador; se falhar, o erro fica em "Details" e dá para tentar de novo. +- O instalador roda como `/P /UPDATE /R`: passivo, sem perguntas, e reabre o app. O hook de desinstalação não faz nada com `/UPDATE` (15.4), o daemon segue rodando, e o app reaberto o atualiza porque ele ficou `outdated` (15.2). +- Os artefatos são assinados com a chave do updater do Tauri (`createUpdaterArtifacts`), e a chave pública fica no `tauri.conf.json`. `requireSignedVersion` exige que a assinatura traga a versão, para um feed adulterado não empurrar uma versão antiga de volta. O instalador não tem assinatura Authenticode, então o SmartScreen avisa na primeira execução. +- Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`, que confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório) e abre um release **rascunho** com o instalador, o `.sig` e o `latest.json` (`app/scripts/release.mjs`). O updater só enxerga o release depois que o dono o publica. + ## 16. Qualidade - Rust: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`.