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`.