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 1/5] 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`. From 9726761e0484817a4cf24313b7a9799f4d124c9b Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:49:10 -0300 Subject: [PATCH 2/5] feat(daemon): know whether Claude Code is signed in, and word nothing for the app The daemon runs `claude auth status` (documented: JSON with loggedIn, exit 0 when signed in, 1 when not; checked with 2.1.284) once Claude Code is found, every 30 s while signed out, at once when a bot hits a sign-in error, and when the app calls the new `system.refresh`. `system.status` reports `claudeSignedIn` and `claudePath`. When the answer turns from signed out to signed in, the bots a sign-in error stopped start again by themselves. An account problem with a valid sign-in (billing, a blocked organization) does not change the answer, so it cannot loop. So the app can speak the owner's language, notices carry a code (signed_out, usage_limit, turn_failed) next to the English text, and the conversation-list line carries its kind with no wording of its own: the app adds "You:" and "Waiting for approval". --- app/src/features/chat/NoticeRow.tsx | 16 ++- app/src/features/crews/Sidebar.tsx | 16 ++- app/src/lib/fake.ts | 8 ++ app/src/lib/fakeChat.ts | 17 ++- app/src/lib/protocol.gen.ts | 35 +++++- crates/botloft-core/src/chat.rs | 40 ++++--- crates/botloft-core/src/protocol/chat.rs | 36 ++++++- crates/botloft-core/src/protocol/export.rs | 7 ++ crates/botloft-core/src/protocol/methods.rs | 4 + crates/botloft-core/src/protocol/mod.rs | 1 + crates/botloft-store/src/chat.rs | 6 +- crates/botloftd/src/chat/events.rs | 27 +++-- crates/botloftd/src/chat/items.rs | 3 +- crates/botloftd/src/rpc/dispatch.rs | 5 + crates/botloftd/src/runtime/claude.rs | 78 ++++++++++---- crates/botloftd/src/runtime/fake.rs | 32 +++++- crates/botloftd/src/runtime/mod.rs | 7 ++ crates/botloftd/src/runtime/pipe.rs | 17 ++- crates/botloftd/src/service/mod.rs | 5 + crates/botloftd/src/supervisor/mod.rs | 10 ++ crates/botloftd/src/supervisor/reconcile.rs | 1 + crates/botloftd/src/supervisor/sign_in.rs | 111 ++++++++++++++++++++ crates/botloftd/src/supervisor/spawn.rs | 1 + crates/botloftd/src/supervisor/turns.rs | 4 +- crates/botloftd/tests/chat.rs | 4 +- crates/botloftd/tests/courier.rs | 5 +- crates/botloftd/tests/sign_in.rs | 84 +++++++++++++++ docs/spec.md | 14 ++- 28 files changed, 517 insertions(+), 77 deletions(-) create mode 100644 crates/botloftd/src/supervisor/sign_in.rs create mode 100644 crates/botloftd/tests/sign_in.rs diff --git a/app/src/features/chat/NoticeRow.tsx b/app/src/features/chat/NoticeRow.tsx index de6e2b94..7a1b97c0 100644 --- a/app/src/features/chat/NoticeRow.tsx +++ b/app/src/features/chat/NoticeRow.tsx @@ -19,8 +19,22 @@ export function NoticeRow({ notice }: { notice: NoticeItem }) { >

- {notice.text} + {noticeText(notice)}

); } + +/** The app's own words for notices it knows; the daemon's text otherwise. */ +function noticeText(notice: NoticeItem): string { + switch (notice.code) { + case "signed_out": + return "Claude Code is not signed in, or the account can't be used right now. Sign in to Claude, or check your Claude plan."; + case "usage_limit": + return "Your Claude plan reached its usage limit. Messages wait until it resets."; + case "turn_failed": + return `This turn failed: ${notice.text}`; + default: + return notice.text; + } +} diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index f72582da..7bc55ea5 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -2,7 +2,7 @@ import { Pause, Plus } from "lucide-react"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { when } from "../../lib/format"; -import type { Bot, Crew } from "../../lib/protocol.gen"; +import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; @@ -98,7 +98,7 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { - {activity?.text ?? (bot.role || "No messages yet")} + {activity ? activityText(activity) : bot.role || "No messages yet"} @@ -106,3 +106,15 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { ); } + +/** The conversation-list line, worded here from what the daemon sends. */ +function activityText(activity: Activity): string { + switch (activity.kind) { + case "owner": + return `You: ${activity.text}`; + case "approval": + return `Waiting for approval: ${activity.text}`; + default: + return activity.text; + } +} diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 1b401695..c8d9a0b1 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -40,9 +40,13 @@ export class FakeBotloft implements BotloftApi { uptimeMs: 1000, claudeVersion: "2.1.284", runtimeError: null, + claudePath: "C:\\Users\\owner\\.local\\bin\\claude.exe", + claudeSignedIn: true, deliveries: { pending: 0, dead: 0 }, usage: null, }; + /** How many times the app asked for a new Claude Code check. */ + refreshes = 0; /** Every call, in order. */ readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); @@ -188,6 +192,10 @@ export class FakeBotloft implements BotloftApi { protocol: PROTOCOL_VERSION, }), "system.status": () => this.system, + "system.refresh": () => { + this.refreshes += 1; + return this.system; + }, "crews.list": () => [...this.crews.values()].filter((crew) => crew.archivedAt === null), "crews.create": ({ name }) => { const checked = checkName(name); diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index beeb0de5..db390b1d 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -5,6 +5,7 @@ import type { FakeBotloft, Handlers } from "./fake"; import { conflict, notFound } from "./fakeRules"; import type { Activity, + ActivityKind, Approval, ApprovalItem, BotId, @@ -14,30 +15,36 @@ import type { } from "./protocol.gen"; /** The conversation-list line for an item, like the daemon's (spec 8.3). */ -export function activityLine(body: ChatBody): string | null { +export function activityLine(body: ChatBody): { kind: ActivityKind; text: string } | null { const label = (name: string) => name.split("__").at(-1) ?? name; + let kind: ActivityKind; let text: string; switch (body.kind) { case "inbound": - text = body.message.fromKind === "owner" ? `You: ${body.message.body}` : body.message.body; + kind = body.message.fromKind === "owner" ? "owner" : "message"; + text = body.message.body; break; case "reply": + kind = "reply"; text = body.text; break; case "tool": + kind = "tool"; text = body.summary ? `${label(body.name)} · ${body.summary}` : label(body.name); break; case "approval": - text = `Waiting for approval: ${label(body.toolName)}`; + kind = "approval"; + text = label(body.toolName); break; case "notice": + kind = "notice"; text = body.text; break; case "turn": return null; } const flat = text.split(/\s+/).filter(Boolean).join(" "); - return flat.length <= 120 ? flat : `${flat.slice(0, 119).trimEnd()}…`; + return { kind, text: flat.length <= 120 ? flat : `${flat.slice(0, 119).trimEnd()}…` }; } export class FakeChat { @@ -167,7 +174,7 @@ export class FakeChat { private announce(item: ChatItem, isNew: boolean): void { const line = isNew ? activityLine(item.body) : null; - const activity: Activity | null = line === null ? null : { text: line, at: item.updatedAt }; + const activity: Activity | null = line === null ? null : { ...line, at: item.updatedAt }; if (activity) { const bot = this.fake.bots.get(item.botId); if (bot) { diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 35bc9432..9fb5ac0c 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -77,12 +77,17 @@ archivedAt: number | null, }; */ export type BotState = "offline" | "launching" | "idle" | "busy" | "needs_approval" | "rate_limited" | "auth_error" | "backoff" | "archived"; +/** + * What the conversation-list line shows; the app words it. + */ +export type ActivityKind = "owner" | "message" | "reply" | "tool" | "approval" | "notice"; + /** * The last thing in a bot's chat, for the conversation list. */ -export type Activity = { +export type Activity = { kind: ActivityKind, /** - * One line, at most 120 characters. + * One line, at most 120 characters, without wording of its own. */ text: string, /** @@ -193,7 +198,15 @@ claudeVersion: string | null, /** * Why bots cannot start (Claude Code missing or too old); `null` when fine. */ -runtimeError: string | null, deliveries: DeliveryBacklog, +runtimeError: string | null, +/** + * The Claude Code executable the bots run; `null` until it is found. + */ +claudePath: string | null, +/** + * Whether Claude Code is signed in (`claude auth status`); `null` until checked. + */ +claudeSignedIn: boolean | null, deliveries: DeliveryBacklog, /** * The Claude account's usage as last reported; `null` before any turn. */ @@ -411,6 +424,11 @@ export type ApprovalStatus = "pending" | "allowed" | "denied" | "expired"; export type NoticeLevel = "info" | "warning" | "error"; +/** + * What a notice is about, so the app can say it in the owner's language. + */ +export type NoticeCode = "signed_out" | "usage_limit" | "turn_failed"; + export type InboundItem = { message: Message, }; export type ReplyItem = { text: string, }; @@ -445,7 +463,15 @@ costUsd: number | null, */ error: string | null, }; -export type NoticeItem = { level: NoticeLevel, text: string, }; +export type NoticeItem = { level: NoticeLevel, +/** + * Absent on notices stored before codes existed. + */ +code?: NoticeCode, +/** + * English text, or the detail for [`NoticeCode::TurnFailed`]. + */ +text: string, }; /** * What a chat item holds, by `kind`. @@ -512,6 +538,7 @@ note?: string, }; export interface RpcMethods { "session.hello": { params: HelloParams; result: HelloResult }; "system.status": { params: undefined; result: SystemStatus }; + "system.refresh": { params: undefined; result: SystemStatus }; "crews.list": { params: undefined; result: Array }; "crews.create": { params: CrewsCreateParams; result: Crew }; "crews.rename": { params: CrewsRenameParams; result: Crew }; diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 8ac5f8f9..3c8405e9 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -3,7 +3,7 @@ use serde_json::Value; -use crate::protocol::{ChatBody, SenderKind}; +use crate::protocol::{ActivityKind, ChatBody, SenderKind}; /// Longest tool input kept, in bytes. pub const TOOL_INPUT_MAX: usize = 4 * 1024; @@ -90,26 +90,32 @@ pub fn tool_label(name: &str) -> &str { name.rsplit("__").next().unwrap_or(name) } -/// The conversation-list line for a chat item; `None` for items that do not -/// change it (turn ends). -pub fn activity_line(body: &ChatBody) -> Option { - let text = match body { +/// The conversation-list line for a chat item, and what kind of line it +/// is; `None` for items that do not change it (turn ends). The text has no +/// wording of its own ("You:", "Waiting for approval"): the app adds it in +/// the owner's language. +pub fn activity_line(body: &ChatBody) -> Option<(ActivityKind, String)> { + let (kind, text) = match body { ChatBody::Inbound(item) => match item.message.from_kind { - SenderKind::Owner => format!("You: {}", item.message.body), - _ => item.message.body.clone(), + SenderKind::Owner => (ActivityKind::Owner, item.message.body.clone()), + _ => (ActivityKind::Message, item.message.body.clone()), }, - ChatBody::Reply(item) => item.text.clone(), - ChatBody::Tool(item) => match item.summary.as_str() { - "" => tool_label(&item.name).to_owned(), - summary => format!("{} · {summary}", tool_label(&item.name)), - }, - ChatBody::Approval(item) => { - format!("Waiting for approval: {}", tool_label(&item.tool_name)) - } - ChatBody::Notice(item) => item.text.clone(), + ChatBody::Reply(item) => (ActivityKind::Reply, item.text.clone()), + ChatBody::Tool(item) => ( + ActivityKind::Tool, + match item.summary.as_str() { + "" => tool_label(&item.name).to_owned(), + summary => format!("{} · {summary}", tool_label(&item.name)), + }, + ), + ChatBody::Approval(item) => ( + ActivityKind::Approval, + tool_label(&item.tool_name).to_owned(), + ), + ChatBody::Notice(item) => (ActivityKind::Notice, item.text.clone()), ChatBody::Turn(_) => return None, }; - Some(one_line(&text, ACTIVITY_MAX_CHARS)) + Some((kind, one_line(&text, ACTIVITY_MAX_CHARS))) } #[cfg(test)] diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index 02c76780..b7676717 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -33,6 +33,34 @@ text_enum!( } ); +text_enum!( + /// What a notice is about, so the app can say it in the owner's language. + NoticeCode, "notice code" { + /// Claude Code is not signed in or the account cannot be used. + SignedOut => "signed_out", + /// The account reached its usage limit. + UsageLimit => "usage_limit", + /// The turn failed; `text` holds Claude Code's detail or error code. + TurnFailed => "turn_failed", + } +); + +text_enum!( + /// What the conversation-list line shows; the app words it. + ActivityKind, "activity kind" { + /// The owner's message; `text` is its body. + Owner => "owner", + /// A message from another bot or the daemon; `text` is its body. + Message => "message", + Reply => "reply", + /// `text` is the tool and its summary. + Tool => "tool", + /// A permission request; `text` is the tool. + Approval => "approval", + Notice => "notice", + } +); + /// One entry of a bot's chat. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -122,6 +150,11 @@ pub struct TurnItem { #[cfg_attr(test, derive(ts_rs::TS))] pub struct NoticeItem { pub level: NoticeLevel, + /// Absent on notices stored before codes existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, ts(optional))] + pub code: Option, + /// English text, or the detail for [`NoticeCode::TurnFailed`]. pub text: String, } @@ -194,7 +227,8 @@ pub struct ApprovalsAnswerParams { #[serde(rename_all = "camelCase")] #[cfg_attr(test, derive(ts_rs::TS))] pub struct Activity { - /// One line, at most 120 characters. + pub kind: ActivityKind, + /// One line, at most 120 characters, without wording of its own. pub text: String, /// Unix time in milliseconds. pub at: i64, diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 76ca96a9..db1db5a5 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -87,6 +87,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -126,6 +127,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -158,6 +160,11 @@ fn export_bindings() { "undefined", &out.name::(), ); + out.method( + method::SYSTEM_REFRESH, + "undefined", + &out.name::(), + ); out.method(method::CREWS_LIST, "undefined", &crews); out.method( method::CREWS_CREATE, diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index 522b10b9..342c2df9 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -56,6 +56,10 @@ pub struct SystemStatus { pub claude_version: Option, /// Why bots cannot start (Claude Code missing or too old); `null` when fine. pub runtime_error: Option, + /// The Claude Code executable the bots run; `null` until it is found. + pub claude_path: Option, + /// Whether Claude Code is signed in (`claude auth status`); `null` until checked. + pub claude_signed_in: Option, pub deliveries: DeliveryBacklog, /// The Claude account's usage as last reported; `null` before any turn. pub usage: Option, diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index c4d4fa38..00883bb4 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -69,6 +69,7 @@ pub const PROTOCOL_VERSION: u32 = 2; pub mod method { pub const SESSION_HELLO: &str = "session.hello"; pub const SYSTEM_STATUS: &str = "system.status"; + pub const SYSTEM_REFRESH: &str = "system.refresh"; pub const CREWS_LIST: &str = "crews.list"; pub const CREWS_CREATE: &str = "crews.create"; pub const CREWS_RENAME: &str = "crews.rename"; diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index 48445d2f..c07099ad 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -139,7 +139,8 @@ impl Store { ))?; let item = stmt.query_row([bot.as_str()], from_row).optional()?; Ok(item.and_then(|item| { - activity_line(&item.body).map(|text| Activity { + activity_line(&item.body).map(|(kind, text)| Activity { + kind, text, at: item.updated_at, }) @@ -149,7 +150,7 @@ impl Store { #[cfg(test)] mod tests { - use botloft_core::protocol::{ReplyItem, ToolItem, ToolStatus, TurnItem}; + use botloft_core::protocol::{ActivityKind, ReplyItem, ToolItem, ToolStatus, TurnItem}; use super::*; use crate::tests::Fixture; @@ -253,6 +254,7 @@ mod tests { assert_eq!( fx.store.last_activity(bot).expect("activity"), Some(Activity { + kind: ActivityKind::Reply, text: "All done. Details below".into(), at: 7 }) diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index 0e571af3..ea5b644d 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -5,8 +5,8 @@ use botloft_core::chat::{TOOL_INPUT_MAX, TOOL_OUTPUT_MAX, clip, tool_summary}; use botloft_core::ids::BotId; use botloft_core::protocol::{ - AccountUsage, ChatBody, ChatDelta, NoticeItem, NoticeLevel, ReplyItem, ToolItem, ToolStatus, - TurnItem, UsageWindow, + AccountUsage, ChatBody, ChatDelta, NoticeCode, NoticeItem, NoticeLevel, ReplyItem, ToolItem, + ToolStatus, TurnItem, UsageWindow, }; use serde_json::Value; use tracing::{debug, warn}; @@ -130,22 +130,31 @@ fn failed_turn(daemon: &Daemon, bot: &BotId, generation: u64, error: &str, event .filter_map(|block| block["text"].as_str()) .collect::>() .join(" "); - let text = if SIGN_IN_ERRORS.contains(&error) { - "Claude Code is not signed in or the account cannot be used. Sign in with \ - `claude auth login` in a terminal, then restart the bot." - .to_owned() + // The code lets the app say it in the owner's language; the text stays + // for older apps and for the conversation list. + let (code, text) = if SIGN_IN_ERRORS.contains(&error) { + ( + NoticeCode::SignedOut, + "Claude Code is not signed in or the account cannot be used. Sign in, then restart \ + the bot." + .to_owned(), + ) } else if error == "rate_limit" { - "The account reached its usage limit. Messages wait until it resets.".to_owned() + ( + NoticeCode::UsageLimit, + "The account reached its usage limit. Messages wait until it resets.".to_owned(), + ) } else if detail.is_empty() { - format!("The turn failed: {error}.") + (NoticeCode::TurnFailed, error.to_owned()) } else { - detail + (NoticeCode::TurnFailed, detail) }; items::add( daemon, bot, ChatBody::Notice(NoticeItem { level: NoticeLevel::Error, + code: Some(code), text, }), ); diff --git a/crates/botloftd/src/chat/items.rs b/crates/botloftd/src/chat/items.rs index 2834fea7..f89c0d61 100644 --- a/crates/botloftd/src/chat/items.rs +++ b/crates/botloftd/src/chat/items.rs @@ -28,7 +28,8 @@ pub(crate) fn add(daemon: &Daemon, bot: &BotId, body: ChatBody) -> Option) -> Result { match name { method::SYSTEM_STATUS => reply(service::status(daemon)), + // Asks for a new check and answers at once; the app polls the result. + method::SYSTEM_REFRESH => { + daemon.supervisor.refresh_claude(); + reply(service::status(daemon)) + } method::CREWS_LIST => reply(crews::list(daemon)), method::CREWS_CREATE => reply(crews::create(daemon, parse(params)?)), method::CREWS_RENAME => reply(crews::rename(daemon, parse(params)?)), diff --git a/crates/botloftd/src/runtime/claude.rs b/crates/botloftd/src/runtime/claude.rs index df9ec3d5..68509653 100644 --- a/crates/botloftd/src/runtime/claude.rs +++ b/crates/botloftd/src/runtime/claude.rs @@ -3,7 +3,7 @@ use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::{Command, Output, Stdio}; use std::time::{Duration, Instant}; /// First version with the inbox as a named pipe on native Windows. @@ -87,13 +87,45 @@ fn not_a_launcher(path: PathBuf) -> Result { /// Runs `claude --version` and checks it against [`MIN_VERSION`]. pub fn probe(path: &Path, env: &[(OsString, OsString)]) -> Result { - let run_error = |source| ClaudeError::Run { + let output = run(path, &["--version"], env).map_err(|source| ClaudeError::Run { path: path.to_owned(), source, - }; + })?; + let text = String::from_utf8_lossy(&output.stdout); + let (version, parsed) = parse_version(&text).ok_or(ClaudeError::UnknownVersion)?; + if parsed < MIN_VERSION { + let (major, minor, patch) = MIN_VERSION; + return Err(ClaudeError::TooOld { + found: version, + needed: format!("{major}.{minor}.{patch}"), + }); + } + Ok(Claude { + path: path.to_owned(), + version, + }) +} + +/// Runs `claude auth status`, documented to print JSON with `loggedIn` and +/// to exit with 0 when signed in and 1 when not (spec 19). +pub fn signed_in(path: &Path, env: &[(OsString, OsString)]) -> io::Result { + let output = run(path, &["auth", "status", "--json"], env)?; + Ok(parse_signed_in(&output.stdout).unwrap_or(output.status.success())) +} + +fn parse_signed_in(stdout: &[u8]) -> Option { + serde_json::from_slice::(stdout) + .ok()? + .get("loggedIn")? + .as_bool() +} + +/// Runs Claude Code without a window and with `env` only, giving up after +/// [`PROBE_TIMEOUT`]. +fn run(path: &Path, args: &[&str], env: &[(OsString, OsString)]) -> io::Result { let mut command = Command::new(path); command - .arg("--version") + .args(args) .env_clear() .envs(env.iter().map(|(k, v)| (k, v))) .stdin(Stdio::null()) @@ -105,32 +137,19 @@ pub fn probe(path: &Path, env: &[(OsString, OsString)]) -> Result PROBE_TIMEOUT { let _ = child.kill(); - return Err(run_error(io::Error::new( + return Err(io::Error::new( io::ErrorKind::TimedOut, - "`claude --version` did not finish", - ))); + format!("`claude {}` did not finish", args.join(" ")), + )); } std::thread::sleep(Duration::from_millis(50)); } - let output = child.wait_with_output().map_err(run_error)?; - let text = String::from_utf8_lossy(&output.stdout); - let (version, parsed) = parse_version(&text).ok_or(ClaudeError::UnknownVersion)?; - if parsed < MIN_VERSION { - let (major, minor, patch) = MIN_VERSION; - return Err(ClaudeError::TooOld { - found: version, - needed: format!("{major}.{minor}.{patch}"), - }); - } - Ok(Claude { - path: path.to_owned(), - version, - }) + child.wait_with_output() } /// Reads `2.1.283` from output such as `2.1.283 (Claude Code)`. @@ -156,6 +175,19 @@ mod tests { assert!(parse_version("2.1.74 (Claude Code)").is_some_and(|(_, v)| v < MIN_VERSION)); } + #[test] + fn reads_the_sign_in_from_auth_status() { + assert_eq!( + parse_signed_in(br#"{"loggedIn": true, "authMethod": "claude.ai"}"#), + Some(true) + ); + assert_eq!( + parse_signed_in(br#"{"loggedIn": false, "authMethod": "none"}"#), + Some(false) + ); + assert_eq!(parse_signed_in(b"Logged in"), None); + } + #[test] fn refuses_npm_launchers() { assert!(matches!( diff --git a/crates/botloftd/src/runtime/fake.rs b/crates/botloftd/src/runtime/fake.rs index 23b3e891..0a2a158c 100644 --- a/crates/botloftd/src/runtime/fake.rs +++ b/crates/botloftd/src/runtime/fake.rs @@ -12,7 +12,7 @@ use tokio::sync::{Notify, mpsc}; use serde_json::Value; -use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec}; +use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SignInCheck, SpawnSpec}; #[derive(Clone, Default)] pub struct FakeRuntime { @@ -20,10 +20,22 @@ pub struct FakeRuntime { spawned: Arc, } -#[derive(Default)] struct State { processes: Vec, failures: usize, + signed_in: bool, + sign_in_checks: usize, +} + +impl Default for State { + fn default() -> Self { + Self { + processes: Vec::new(), + failures: 0, + signed_in: true, + sign_in_checks: 0, + } + } } #[derive(Clone)] @@ -40,6 +52,16 @@ impl FakeRuntime { Self::default() } + /// What `claude auth status` answers from now on (signed in by default). + pub fn set_signed_in(&self, signed_in: bool) { + lock(&self.state).signed_in = signed_in; + } + + /// How many times the daemon asked whether Claude Code is signed in. + pub fn sign_in_checks(&self) -> usize { + lock(&self.state).sign_in_checks + } + /// Every process spawned so far, oldest first. pub fn processes(&self) -> Vec { lock(&self.state).processes.clone() @@ -71,6 +93,12 @@ impl FakeRuntime { } impl Runtime for FakeRuntime { + fn signed_in(&self, _program: std::path::PathBuf) -> SignInCheck { + let mut state = lock(&self.state); + state.sign_in_checks += 1; + Box::pin(std::future::ready(Ok(state.signed_in))) + } + fn spawn(&self, spec: SpawnSpec) -> io::Result { let mut state = lock(&self.state); if state.failures > 0 { diff --git a/crates/botloftd/src/runtime/mod.rs b/crates/botloftd/src/runtime/mod.rs index 50c231e7..54ffe902 100644 --- a/crates/botloftd/src/runtime/mod.rs +++ b/crates/botloftd/src/runtime/mod.rs @@ -7,8 +7,10 @@ pub mod fake; mod pipe; use std::ffi::OsString; +use std::future::Future; use std::io; use std::path::PathBuf; +use std::pin::Pin; use bytes::Bytes; use tokio::sync::mpsc; @@ -47,8 +49,13 @@ pub struct Process { pub control: Box, } +/// The answer to "is Claude Code signed in?", computed off the async workers. +pub type SignInCheck = Pin> + Send>>; + pub trait Runtime: Send + Sync + 'static { fn spawn(&self, spec: SpawnSpec) -> io::Result; + /// Whether the Claude Code at `program` is signed in (`claude auth status`). + fn signed_in(&self, program: PathBuf) -> SignInCheck; } /// Capacity of the event channel between a process and the daemon. When it diff --git a/crates/botloftd/src/runtime/pipe.rs b/crates/botloftd/src/runtime/pipe.rs index 2ad2c5cd..66a344b8 100644 --- a/crates/botloftd/src/runtime/pipe.rs +++ b/crates/botloftd/src/runtime/pipe.rs @@ -12,8 +12,10 @@ use bytes::Bytes; use tokio::sync::mpsc; use tracing::debug; -use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec}; -use crate::platform::ProcessJob; +use super::{ + EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SignInCheck, SpawnSpec, claude, +}; +use crate::platform::{self, ProcessJob}; const READ_CHUNK: usize = 64 * 1024; /// Longest stderr line kept for the debug log. @@ -22,6 +24,17 @@ const STDERR_LINE_MAX: usize = 300; pub struct PipeRuntime; impl Runtime for PipeRuntime { + fn signed_in(&self, program: std::path::PathBuf) -> SignInCheck { + Box::pin(async move { + tokio::task::spawn_blocking(move || { + let env = platform::user_environment()?; + claude::signed_in(&program, &env) + }) + .await + .map_err(io::Error::other)? + }) + } + fn spawn(&self, spec: SpawnSpec) -> io::Result { let mut command = Command::new(&spec.program); command diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 8318a352..b3c42fe4 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -66,6 +66,11 @@ pub fn status(daemon: &Daemon) -> ApiResult { uptime_ms: daemon.uptime_ms(), claude_version: daemon.supervisor.claude_version(), runtime_error: daemon.supervisor.runtime_error(), + claude_path: daemon + .supervisor + .claude_path() + .map(|path| path.display().to_string()), + claude_signed_in: daemon.supervisor.claude_signed_in(), deliveries, usage: daemon.usage(), }) diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index fe0674b6..8724f696 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -7,6 +7,7 @@ mod reconcile; mod settings; +mod sign_in; mod slot; mod spawn; mod turns; @@ -63,6 +64,7 @@ struct Inner { /// database has it too (spec 7.3). sessions: HashMap, claude: ClaudeStatus, + sign_in: sign_in::SignIn, } #[derive(Default)] @@ -121,6 +123,14 @@ impl Supervisor { self.busy.subscribe() } + /// The Claude Code executable the bots run, once found. + pub fn claude_path(&self) -> Option { + match &self.lock().claude { + ClaudeStatus::Ready(claude) => Some(claude.path.clone()), + _ => None, + } + } + pub fn claude_version(&self) -> Option { match &self.lock().claude { ClaudeStatus::Ready(claude) => Some(claude.version.clone()), diff --git a/crates/botloftd/src/supervisor/reconcile.rs b/crates/botloftd/src/supervisor/reconcile.rs index 9f448015..947a459d 100644 --- a/crates/botloftd/src/supervisor/reconcile.rs +++ b/crates/botloftd/src/supervisor/reconcile.rs @@ -23,6 +23,7 @@ impl Supervisor { return; }; self.ensure_claude().await; + self.ensure_sign_in().await; // Read before taking the supervisor lock (lock order, see mod.rs). let read = { let store = daemon.store(); diff --git a/crates/botloftd/src/supervisor/sign_in.rs b/crates/botloftd/src/supervisor/sign_in.rs new file mode 100644 index 00000000..f1afec03 --- /dev/null +++ b/crates/botloftd/src/supervisor/sign_in.rs @@ -0,0 +1,111 @@ +//! Whether Claude Code is signed in (spec 7.3), from `claude auth status`: +//! checked once Claude Code is found, every 30 s while signed out or +//! unknown, and at once when a bot hits a sign-in error or the app asks. +//! When it turns from signed out to signed in, the bots a sign-in error +//! stopped start again by themselves: the owner just signed in. + +use botloft_core::protocol::BotState; +use tokio::time::Instant; +use tracing::{debug, info}; + +use super::{ClaudeStatus, Inner, REPROBE_AFTER, Supervisor}; + +#[derive(Debug, Default)] +pub(super) struct SignIn { + known: Option, + checked_at: Option, + recheck: bool, +} + +impl SignIn { + fn due(&self) -> bool { + if self.recheck { + return true; + } + match (self.known, self.checked_at) { + (_, None) => true, + (Some(true), Some(_)) => false, + (_, Some(at)) => at.elapsed() >= REPROBE_AFTER, + } + } +} + +impl Supervisor { + /// Whether Claude Code is signed in; `None` until checked. + pub fn claude_signed_in(&self) -> Option { + self.lock().sign_in.known + } + + /// Checks Claude Code again now: the sign-in, and the executable if it + /// was missing or too old (after the owner signed in or installed it). + pub fn refresh_claude(&self) { + let mut inner = self.lock(); + inner.sign_in.recheck = true; + if matches!(inner.claude, ClaudeStatus::Failed { .. }) { + inner.claude = ClaudeStatus::Unknown; + } + drop(inner); + self.wake(); + } + + /// A bot hit a sign-in error: find out whether Claude Code signed out, + /// or whether the account itself cannot be used (billing, a blocked + /// organization), which signing in again does not fix. + pub(super) fn recheck_sign_in(&self) { + self.lock().sign_in.recheck = true; + self.wake(); + } + + pub(super) async fn ensure_sign_in(&self) { + let program = { + let inner = self.lock(); + match &inner.claude { + ClaudeStatus::Ready(claude) if inner.sign_in.due() => claude.path.clone(), + _ => return, + } + }; + let checked = self.runtime.signed_in(program).await; + let mut inner = self.lock(); + inner.sign_in.recheck = false; + inner.sign_in.checked_at = Some(Instant::now()); + match checked { + Ok(signed_in) => { + let was = inner.sign_in.known.replace(signed_in); + if signed_in && was == Some(false) { + info!("Claude Code is signed in again; starting the bots it stopped"); + self.start_signed_out_bots(&mut inner); + } + } + // Not fatal: bots still run, and a sign-in error shows in their chat. + Err(err) => debug!("cannot check the Claude Code sign-in: {err}"), + } + } + + fn start_signed_out_bots(&self, inner: &mut Inner) { + for (bot, slot) in &mut inner.slots { + if slot.state == BotState::AuthError && slot.running.is_none() { + slot.backoff.reset(); + slot.restart_at = None; + self.set_state(bot, slot, BotState::Offline); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checks_until_signed_in_and_again_when_asked() { + let mut sign_in = SignIn::default(); + assert!(sign_in.due(), "never checked"); + sign_in.checked_at = Some(Instant::now()); + sign_in.known = Some(false); + assert!(!sign_in.due(), "checked a moment ago"); + sign_in.known = Some(true); + assert!(!sign_in.due(), "signed in: nothing to watch"); + sign_in.recheck = true; + assert!(sign_in.due()); + } +} diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index a6dc92e9..00757a4b 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -39,6 +39,7 @@ impl Supervisor { tokens, sessions, claude, + .. } = inner; let Some(slot) = slots.get_mut(&bot.id) else { return; diff --git a/crates/botloftd/src/supervisor/turns.rs b/crates/botloftd/src/supervisor/turns.rs index 81721eb9..88afbd99 100644 --- a/crates/botloftd/src/supervisor/turns.rs +++ b/crates/botloftd/src/supervisor/turns.rs @@ -79,8 +79,10 @@ impl Supervisor { } /// Claude Code is not signed in: restarting cannot fix that, so the - /// process stops until the owner restarts the bot (spec 7.3). + /// process stops until the owner restarts the bot or signs in again + /// (spec 7.3). pub(crate) fn signed_out(&self, bot: &BotId, generation: u64) { + self.recheck_sign_in(); self.with_current(bot, generation, |supervisor, slot| { supervisor.set_state(bot, slot, BotState::AuthError); if slot.stop.is_none() { diff --git a/crates/botloftd/tests/chat.rs b/crates/botloftd/tests/chat.rs index efe9c689..bb8b5523 100644 --- a/crates/botloftd/tests/chat.rs +++ b/crates/botloftd/tests/chat.rs @@ -152,7 +152,9 @@ async fn approvals_wait_for_the_owner() { (&json!("pending"), &json!("Bash")) ); assert_eq!(body["summary"], "rm -rf build"); - assert_eq!(pending["activity"]["text"], "Waiting for approval: Bash"); + // The app words the line ("Waiting for approval: Bash") in the owner's language. + assert_eq!(pending["activity"]["kind"], "approval"); + assert_eq!(pending["activity"]["text"], "Bash"); c.t.until_state(&lead, BotState::NeedsApproval).await; let answered = c diff --git a/crates/botloftd/tests/courier.rs b/crates/botloftd/tests/courier.rs index 812f326a..c70f1a12 100644 --- a/crates/botloftd/tests/courier.rs +++ b/crates/botloftd/tests/courier.rs @@ -70,7 +70,10 @@ async fn an_owner_message_reaches_the_bot_as_its_user_and_is_read() { item["item"]["body"], json!({ "kind": "inbound", "message": message }) ); - assert_eq!(item["activity"]["text"], "You: Olá! Tudo bem?"); + assert_eq!( + (&item["activity"]["kind"], &item["activity"]["text"]), + (&json!("owner"), &json!("Olá! Tudo bem?")) + ); t.until_state(&bot, BotState::Idle).await; t.clock.advance(Duration::from_secs(5)); diff --git a/crates/botloftd/tests/sign_in.rs b/crates/botloftd/tests/sign_in.rs new file mode 100644 index 00000000..9a74f665 --- /dev/null +++ b/crates/botloftd/tests/sign_in.rs @@ -0,0 +1,84 @@ +//! Claude Code's sign-in (spec 7.3): checked with `claude auth status`, +//! and bots stopped by a sign-in error come back once the owner signs in. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::BotState; +use botloftd::service; +use common::stream; +use common::supervised::{Setup, setup}; + +async fn sign_in_error(s: &Setup) { + let process = s.runtime.process(s.runtime.processes().len()).await; + s.until(BotState::Idle).await; + s.message("hi"); + process + .emit(stream::api_error( + "authentication_failed", + "Invalid API key", + )) + .await; + s.until(BotState::AuthError).await; +} + +#[tokio::test(start_paused = true)] +async fn the_status_says_whether_claude_code_is_signed_in() { + let s = setup().await; + s.until(BotState::Launching).await; + let status = service::status(&s.daemon).expect("status"); + assert_eq!(status.claude_signed_in, Some(true)); + assert!(status.claude_path.is_some()); + assert_eq!(s.runtime.sign_in_checks(), 1, "checked once, then trusted"); + tokio::time::sleep(Duration::from_secs(300)).await; + assert_eq!(s.runtime.sign_in_checks(), 1); +} + +#[tokio::test(start_paused = true)] +async fn signing_in_again_brings_the_stopped_bots_back() { + let s = setup().await; + s.runtime.process(1).await; + s.runtime.set_signed_in(false); + sign_in_error(&s).await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(s.daemon.supervisor.claude_signed_in(), Some(false)); + + // Still signed out: checked every 30 s, the bot stays stopped. + tokio::time::sleep(Duration::from_secs(40)).await; + assert_eq!(s.runtime.processes().len(), 1); + assert!(s.runtime.sign_in_checks() >= 3); + + s.runtime.set_signed_in(true); + tokio::time::sleep(Duration::from_secs(31)).await; + s.runtime.process(2).await; + assert_eq!(s.daemon.supervisor.claude_signed_in(), Some(true)); +} + +#[tokio::test(start_paused = true)] +async fn the_app_can_ask_for_a_check_right_after_signing_in() { + let s = setup().await; + s.runtime.process(1).await; + s.runtime.set_signed_in(false); + sign_in_error(&s).await; + tokio::time::sleep(Duration::from_millis(50)).await; + + s.runtime.set_signed_in(true); + s.daemon.supervisor.refresh_claude(); + tokio::time::timeout(Duration::from_secs(1), s.runtime.process(2)) + .await + .expect("restarted without waiting for the next check"); +} + +/// Billing or a blocked organization also stop the bot, but Claude Code is +/// still signed in: nothing changes by itself, so nothing restarts in a loop. +#[tokio::test(start_paused = true)] +async fn an_account_problem_does_not_restart_in_a_loop() { + let s = setup().await; + s.runtime.process(1).await; + sign_in_error(&s).await; + tokio::time::sleep(Duration::from_secs(600)).await; + assert_eq!(s.runtime.processes().len(), 1); + assert_eq!(s.state(), BotState::AuthError); + assert_eq!(s.daemon.supervisor.claude_signed_in(), Some(true)); +} diff --git a/docs/spec.md b/docs/spec.md index 6b54ef20..6ba10b80 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -189,7 +189,8 @@ Os estados saem do próprio fluxo de eventos (8.1); não há hooks. | a tool de aprovação é chamada | `needs_approval` | | aprovação respondida ou vencida | `busy` | | erro `rate_limit` num turno, ou `rate_limit_event` com status diferente de `allowed` | `rate_limited` até `resetsAt` (5 min se não vier), depois `idle` | -| erro `authentication_failed`, `oauth_org_not_allowed`, `billing_error` ou `account_on_hold` | `auth_error`; o processo é parado | +| erro `authentication_failed`, `oauth_org_not_allowed`, `billing_error` ou `account_on_hold` | `auth_error`; o processo é parado e o daemon confere o login na hora (`claude auth status`) | +| o login do Claude Code passa de desconectado para conectado | os bots em `auth_error` voltam a `offline` e sobem de novo | | saída do processo | `backoff` (ou `offline`/`archived` se foi pedido) | Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.state {botId, state, generation}`, mesmo que o nome do estado não mude, porque a generation mudou. @@ -200,7 +201,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - Backoff exponencial com jitter entre `restart_backoff_initial_ms` e `restart_backoff_max_ms`; zera após 10 min de sessão estável. - **Sessão:** o daemon guarda em `bots.session_id` o id da conversa. O primeiro start usa `--session-id `; os seguintes, `--resume `. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem com um id novo. `bots.restart {fresh: true}` também começa conversa nova. O histórico do chat fica no banco do daemon (seção 8) e não depende do transcript do Claude Code. - Mudanças em nome, papel ou instruções regravam as regras na hora, mas o bot só as lê no próximo start; o daemon não reinicia o bot sozinho. -- `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart`. O login é feito fora do bot (`claude auth login` num terminal), e o app diz isso. +- `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart` ou até o daemon ver o Claude Code conectado de novo. +- **Login do Claude Code:** o daemon roda `claude auth status` (JSON com `loggedIn`; sai com 0 conectado e 1 desconectado, seção 19) no ambiente do usuário, sem janela: quando acha o Claude Code, a cada 30 s enquanto desconectado ou desconhecido, na hora quando um bot cai em `auth_error` e quando o app pede (`system.refresh`). Conectado, não confere de novo sozinho. Quando o resultado passa de desconectado para conectado, os bots em `auth_error` sobem de novo sem o dono pedir. Um erro de conta com o login válido (`billing_error`, `account_on_hold`, organização bloqueada) não muda o resultado e por isso não vira loop. O resultado sai em `system.status.claudeSignedIn`. O login em si é feito fora do bot (`claude auth login` num terminal). - Cada processo de bot entra num **Job Object** com `KILL_ON_JOB_CLOSE`. Se o daemon morrer, a árvore de processos dos bots morre junto e não sobra `claude.exe` órfão. ### 7.4 Spawn @@ -274,7 +276,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. | `tool` | `toolUseId`, `name`, `summary`, `input`, `status` (`running`, `done`, `failed`), `output` | ferramenta usada pelo bot | | `approval` | `approvalId`, `toolName`, `summary`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1) | | `turn` | `durationMs`, `costUsd`, `error` | fim de um turno | -| `notice` | `level` (`info`, `warning`, `error`), `text` | avisos do daemon: limite de uso, login, sessão reiniciada | +| `notice` | `level` (`info`, `warning`, `error`), `code` (`signed_out`, `usage_limit`, `turn_failed`; ausente em avisos antigos), `text` | avisos do daemon: limite de uso, login, turno com erro. O app escreve os avisos com `code` no idioma do dono; `text` fica em inglês para quem não conhece o código e, em `turn_failed`, traz o detalhe do erro | - `summary` é uma frase curta feita pelo daemon a partir da entrada: o comando do `Bash`, o arquivo do `Read`/`Edit`/`Write`, o padrão do `Grep`/`Glob`, a URL do `WebFetch`, a busca do `WebSearch`, o destinatário do `send_message`. Ferramenta desconhecida mostra só o nome. - `input` guarda o JSON da entrada até 4 KB; `output`, até 8 KB de texto. O resto fica só no transcript do próprio Claude Code. @@ -424,7 +426,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | Método | Params | Result | |---|---|---| -| `system.status` | | versão, uptime, versão do claude, `runtimeError` (por que os bots não sobem), backlog de entrega, `usage` (uso da conta, 8.1) | +| `system.status` | | versão, uptime, versão e caminho do claude (`claudeVersion`, `claudePath`), `runtimeError` (por que os bots não sobem), `claudeSignedIn` (7.3; `null` antes de conferir), backlog de entrega, `usage` (uso da conta, 8.1) | +| `system.refresh` | | pede uma nova conferência do Claude Code (login e, se falhou, o executável) e responde na hora com o `system.status` atual; o app relê o status até ver o resultado | | `crews.list` | | `Crew[]` | | `crews.create` | `name` | `Crew` | | `crews.rename` | `crewId, name` | `Crew` | @@ -445,7 +448,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `deliveries.retry` | `deliveryId` | `Delivery` | | `tasks.list` | `crewId?, status?` | `Task[]` | -`Bot` traz também `lastActivity`: o último item do chat resumido em uma linha (`text`, `at`), para a lista de conversas. +`Bot` traz também `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:" e a aprovação só com o nome da ferramenta, e o app completa no idioma do dono. ### 11.3 Notificações do servidor @@ -655,6 +658,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | `--permission-prompts host` | 10.1 | Documentado só para o SDK. **Visto com 2.1.284**: sem o aperto de mão do SDK, nega tudo (`system/permission_denied`). Não usado | não se aplica | | `timeout` por servidor MCP | 10 | Confirmado (`env-vars`): HTTP tem 60 s por request e 5 min sem resposta por padrão; `timeout` >= 1000 no servidor sobe os dois. **Testado com 2.1.284**: a aprovação respondida depois de 95 s chegou ao bot | feito (M4.1) | | `--setting-sources project,local` | 7.4 | Confirmado (`cli-reference`). **Testado com 2.1.284**: sem os hooks, skills e agents do usuário; modo `default`; login da assinatura continua valendo | feito (M4.1) | +| `claude auth status` | 7.3 | Confirmado (`cli-reference`): JSON por padrão, sai com 0 conectado e 1 desconectado. **Testado com 2.1.284**: conectado traz `"loggedIn": true`; com `CLAUDE_CONFIG_DIR` vazio, `"loggedIn": false`, `"authMethod": "none"` e saída 1. As credenciais ficam em `%USERPROFILE%\.claude\.credentials.json` (`authentication`) | feito | | Regras `allow` do projeto em `-p` sem confiança | 7.4 | Confirmado (`permissions`): não são aplicadas numa pasta nunca confiada; `deny` vale sempre. Por isso `--allowedTools mcp__botloft` | M4.1 | | `--session-id`, `--resume` em `-p` | 7.3 | Confirmado (`cli-reference`, `sessions`): a sessão retoma histórico e modelo; flags como `--mcp-config` têm de ser passadas de novo. **Testado com 2.1.284**: depois de reiniciar o bot e depois de reiniciar o daemon, o bot lembrou arquivos, a imagem e a mensagem de outro bot | feito (M4.1) | | Tools MCP adiadas | 10 | **Visto com 2.1.284**: as tools do `botloft` chegam adiadas; antes da primeira `send_message` o bot chama `ToolSearch` com `select:mcp__botloft__send_message`. O chat mostra isso como "load send_message" | feito (M4.1) | From 45cee6f8a0abdab6681ccfd276be9a4f4772c4c8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:49:11 -0300 Subject: [PATCH 3/5] test(app): run the port tests one at a time Both pick a free port; run in parallel, one could take the port the other had just freed and read it as taken. --- app/src-tauri/src/daemon.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src-tauri/src/daemon.rs b/app/src-tauri/src/daemon.rs index 8a8de91d..45c00080 100644 --- a/app/src-tauri/src/daemon.rs +++ b/app/src-tauri/src/daemon.rs @@ -230,8 +230,14 @@ pub fn owner_token(endpoint: &Endpoint) -> Result { #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; + /// The port tests pick free ports; run one at a time so one does not + /// take the port the other just freed. + static PORTS: Mutex<()> = Mutex::new(()); + #[test] fn port_defaults_and_reads_the_config() { let dir = std::env::temp_dir().join(format!("botloft-app-{}", std::process::id())); @@ -260,6 +266,7 @@ mod tests { #[test] fn a_closed_port_reads_as_stopped() { + let _ports = PORTS.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); // Bind and drop to find a port nothing listens on. let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind"); let port = listener.local_addr().expect("addr").port(); @@ -269,6 +276,7 @@ mod tests { #[test] fn a_port_that_is_not_botloft_reads_as_foreign() { + let _ports = PORTS.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind"); let port = listener.local_addr().expect("addr").port(); let server = std::thread::spawn(move || { From c4294e0b12d9b716213909ec033ddf8dd0b51b75 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:49:11 -0300 Subject: [PATCH 4/5] feat(app): sign in to Claude with one click "Sign in to Claude" opens Claude Code's own sign-in (`claude auth login`) in a window of its own, launched by the app so it comes to the front, then asks the daemon to check again and watches for the result. It shows on the welcome screen as a "Claude account" check, as a banner when signed out, and on a bot stopped by a sign-in error. The Tauri command only runs an absolute path to an existing claude.exe, and strips the Claude Code session variables an app started from a session would pass on. --- app/src-tauri/src/claude.rs | 67 +++++++++++++++++ app/src-tauri/src/lib.rs | 9 +++ app/src/features/bots/BotStateBadge.tsx | 2 +- app/src/features/bots/BotView.tsx | 2 + app/src/features/onboarding/SignIn.tsx | 82 +++++++++++++++++++++ app/src/features/onboarding/Welcome.tsx | 25 +++++++ app/src/features/onboarding/signIn.test.tsx | 74 +++++++++++++++++++ app/src/lib/fakeHost.ts | 14 ++++ app/src/lib/host.ts | 5 ++ app/src/lib/tauriHost.ts | 1 + app/src/shell/Workspace.tsx | 14 +++- app/src/store/context.tsx | 5 ++ docs/spec.md | 6 +- 13 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 app/src-tauri/src/claude.rs create mode 100644 app/src/features/onboarding/SignIn.tsx create mode 100644 app/src/features/onboarding/signIn.test.tsx diff --git a/app/src-tauri/src/claude.rs b/app/src-tauri/src/claude.rs new file mode 100644 index 00000000..c0931421 --- /dev/null +++ b/app/src-tauri/src/claude.rs @@ -0,0 +1,67 @@ +//! Signing in to Claude Code from the app (spec 15.2): `claude auth login` +//! in a window of its own, where the owner sees what happens and can paste +//! a code if the browser asks for one. The app launches it, not the daemon, +//! because a window started by the foreground app comes to the front. + +use std::path::Path; +use std::process::Command; + +/// Whether `path` is a Claude Code executable the app may run: the daemon +/// found it, and the app still checks it is `claude.exe` and nothing else. +fn is_claude(path: &Path) -> bool { + let expected = if cfg!(windows) { "claude.exe" } else { "claude" }; + path.is_absolute() + && path + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(expected)) + && path.is_file() +} + +/// Runs `claude auth login` in a new console window and waits for it. +/// Returns whether it signed in (exit code 0). +pub fn sign_in(path: &Path) -> Result { + if !is_claude(path) { + return Err(format!("{} is not Claude Code", path.display())); + } + let mut command = Command::new(path); + command.args(["auth", "login"]); + // An app started from inside a Claude Code session would pass its + // session variables on, and the login would think it runs inside it. + for (name, _) in std::env::vars_os() { + let name = name.to_string_lossy(); + if name == "CLAUDECODE" || name.starts_with("CLAUDE_CODE_") { + command.env_remove(name.as_ref()); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; + command.creation_flags(CREATE_NEW_CONSOLE); + } + let status = command + .status() + .map_err(|err| format!("cannot run {}: {err}", path.display()))?; + Ok(status.success()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_claude_code_itself_runs() { + let dir = std::env::temp_dir().join(format!("botloft-claude-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("dir"); + let name = if cfg!(windows) { "claude.exe" } else { "claude" }; + let claude = dir.join(name); + std::fs::write(&claude, b"").expect("write"); + let other = dir.join("calc.exe"); + std::fs::write(&other, b"").expect("write"); + assert!(is_claude(&claude)); + assert!(!is_claude(&other)); + assert!(!is_claude(Path::new(name)), "relative paths are refused"); + assert!(!is_claude(&dir.join("missing").join(name))); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 8068926e..c969bd49 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ //! Tauri shell for the Botloft app. The UI talks to `botloftd` over //! WebSocket; these commands cover what a web page cannot do (spec 15.2). +mod claude; mod daemon; use std::path::Path; @@ -35,6 +36,13 @@ async fn daemon_restart() -> Result { blocking(|| daemon::restart(&daemon::endpoint()?)).await } +/// Opens Claude Code's sign-in in its own window and waits for it to end. +/// Returns whether it signed in. +#[tauri::command] +async fn claude_sign_in(path: String) -> Result { + blocking(move || claude::sign_in(Path::new(&path))).await +} + #[tauri::command] async fn read_owner_token() -> Result { blocking(|| daemon::owner_token(&daemon::endpoint()?)).await @@ -95,6 +103,7 @@ pub fn run() { daemon_status, daemon_install, daemon_restart, + claude_sign_in, read_owner_token, open_path, open_url diff --git a/app/src/features/bots/BotStateBadge.tsx b/app/src/features/bots/BotStateBadge.tsx index 4d4c2711..b574e7b9 100644 --- a/app/src/features/bots/BotStateBadge.tsx +++ b/app/src/features/bots/BotStateBadge.tsx @@ -51,7 +51,7 @@ const views: Record = { label: "Sign-in needed", tone: "danger", icon: KeyRound, - hint: "Claude Code is not signed in. Open Claude Code and sign in, then restart the bot.", + hint: "Claude Code is not signed in, or the account can't be used. Sign in to Claude and the bot starts again by itself. Already signed in? Check your Claude plan, then restart the bot.", }, backoff: { label: "Restarting", diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index dcf0ee3e..6b70f7f0 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -4,6 +4,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { ChatView } from "../chat/ChatView"; +import { SignInButton } from "../onboarding/SignIn"; import { BotHeader } from "./BotHeader"; import { stateView } from "./BotStateBadge"; @@ -51,6 +52,7 @@ function Notices({ notices.push( {view.hint} + {bot.state === "auth_error" && } , ); } diff --git a/app/src/features/onboarding/SignIn.tsx b/app/src/features/onboarding/SignIn.tsx new file mode 100644 index 00000000..f77f85ca --- /dev/null +++ b/app/src/features/onboarding/SignIn.tsx @@ -0,0 +1,82 @@ +// Signing in to Claude from the app (spec 15.2): a window opens with +// Claude Code's own sign-in, the browser does the rest, and the daemon +// starts the bots that were waiting as soon as it sees the sign-in. + +import { LogIn } from "lucide-react"; +import { useState } from "react"; +import { errorText } from "../../lib/api"; +import type { SystemStatus } from "../../lib/protocol.gen"; +import { useApi, useApp, useAppStore, useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Details } from "../../ui/Details"; + +/** How long to wait for the daemon to see a sign-in that just finished. */ +const CONFIRM_TRIES = 20; +const CONFIRM_EVERY_MS = 1000; + +type SignInState = + | { stage: "idle" } + | { stage: "waiting" } + | { stage: "failed"; error: string | null }; + +export function useClaudeSignIn() { + const host = useHost(); + const api = useApi(); + const store = useAppStore(); + const claudePath = useApp((state) => state.system?.claudePath ?? null); + const [state, setState] = useState({ stage: "idle" }); + + const signIn = async () => { + if (!claudePath) { + return; + } + setState({ stage: "waiting" }); + try { + if (!(await host.signInToClaude(claudePath))) { + setState({ stage: "failed", error: null }); + return; + } + // Ask for a check now instead of at the next one, then watch for it. + let system: SystemStatus = await api.call("system.refresh"); + for (let tries = 0; system.claudeSignedIn !== true && tries < CONFIRM_TRIES; tries += 1) { + await new Promise((resolve) => setTimeout(resolve, CONFIRM_EVERY_MS)); + system = await api.call("system.status"); + } + store.setState({ system }); + setState({ stage: "idle" }); + } catch (error) { + setState({ stage: "failed", error: errorText(error) }); + } + }; + + return { signIn, state, available: claudePath !== null }; +} + +/** "Sign in to Claude", with what happens while the window is open. */ +export function SignInButton() { + const { signIn, state, available } = useClaudeSignIn(); + if (!available) { + return null; + } + const waiting = state.stage === "waiting"; + return ( +
+ + {waiting && ( +

+ A window opened with Claude's sign-in. Finish it in your browser; Botloft continues by + itself. +

+ )} + {state.stage === "failed" && ( + + Try again, and finish the sign-in in the browser before closing its window. + {state.error &&
{state.error}
} +
+ )} +
+ ); +} diff --git a/app/src/features/onboarding/Welcome.tsx b/app/src/features/onboarding/Welcome.tsx index 4094cc4a..a00c5eb9 100644 --- a/app/src/features/onboarding/Welcome.tsx +++ b/app/src/features/onboarding/Welcome.tsx @@ -4,6 +4,7 @@ import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { CrewDialog } from "../crews/CrewDialog"; import { ClaudeCodeHelp } from "./ClaudeCodeHelp"; +import { SignInButton } from "./SignIn"; /** First run: what is ready, what is not, and the first crew. */ export function Welcome() { @@ -25,6 +26,29 @@ export function Welcome() { claude = ; } + // Only once Claude Code is there: signing in needs it. + let account: ReactNode = null; + if (system && !system.runtimeError && system.claudeVersion !== null) { + if (system.claudeSignedIn === true) { + account = ; + } else if (system.claudeSignedIn === false) { + account = ( + +

Your bots work with your Claude account. Sign in once and they are ready.

+ + + } + /> + ); + } else { + account = ; + } + } + return (
@@ -40,6 +64,7 @@ export function Welcome() { detail="Running in the background. It starts with Windows, so your bots keep working after you close this window." /> {claude} + {account}

Bots ask before they change things

diff --git a/app/src/features/onboarding/signIn.test.tsx b/app/src/features/onboarding/signIn.test.tsx new file mode 100644 index 00000000..7200746c --- /dev/null +++ b/app/src/features/onboarding/signIn.test.tsx @@ -0,0 +1,74 @@ +import { act, 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 } from "../../lib/fakeHost"; + +afterEach(cleanup); + +function renderApp(fake: FakeBotloft, host = new FakeHost()) { + render( fake as Client} />); + return host; +} + +function signedOut(): FakeBotloft { + const fake = new FakeBotloft(); + fake.system = { ...fake.system, claudeSignedIn: false }; + return fake; +} + +describe("signing in to Claude", () => { + test("the welcome screen says the account is ready", async () => { + renderApp(new FakeBotloft()); + expect(await screen.findByText("Claude account")).toBeDefined(); + expect(screen.getByText("Signed in.")).toBeDefined(); + }); + + test("one click opens Claude's sign-in and the app sees it finish", async () => { + const fake = signedOut(); + const host = new FakeHost(); + host.onSignIn = () => { + fake.system = { ...fake.system, claudeSignedIn: true }; + }; + renderApp(fake, host); + fireEvent.click(await screen.findByRole("button", { name: "Sign in to Claude" })); + expect(await screen.findByText("Signed in.")).toBeDefined(); + expect(host.signIns).toEqual([fake.system.claudePath]); + expect(fake.refreshes).toBe(1); + }); + + test("a sign-in window closed early says so and can be tried again", async () => { + const host = new FakeHost(); + host.signInResult = false; + renderApp(signedOut(), host); + fireEvent.click(await screen.findByRole("button", { name: "Sign in to Claude" })); + expect(await screen.findByText("The sign-in didn't finish")).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Sign in to Claude" })); + expect(host.signIns).toHaveLength(2); + }); + + test("with crews, a banner asks to sign in", async () => { + const fake = signedOut(); + fake.addCrew("Ops"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + const banner = screen.getByText("Sign in to Claude", { selector: "p" }).closest("[role=alert]"); + expect( + within(banner as HTMLElement).getByRole("button", { name: "Sign in to Claude" }), + ).toBeDefined(); + }); + + test("a bot stopped by a sign-in error offers the sign-in", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const bot = fake.addBot(crew.id, "Scout"); + renderApp(fake); + await screen.findByRole("heading", { level: 1, name: "Ops" }); + const sidebar = screen.getByRole("navigation", { name: "Crews" }); + fireEvent.click(within(sidebar).getByRole("button", { name: /Scout/ })); + act(() => fake.setBotState(bot.id, "auth_error")); + const view = screen.getByRole("region", { name: "Scout" }); + expect(within(view).getByRole("button", { name: "Sign in to Claude" })).toBeDefined(); + }); +}); diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index 8d6b727b..f4190df1 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -24,6 +24,11 @@ export class FakeHost implements Host { token: string | Error = "a".repeat(64); readonly opened: string[] = []; readonly installs: ("install" | "restart")[] = []; + /** Paths `signInToClaude` ran, and what it answers. */ + readonly signIns: string[] = []; + signInResult: boolean | Error = true; + /** Runs when a sign-in ends, before it resolves (e.g. to sign the fake daemon in). */ + onSignIn: () => void = () => {}; /** What `checkForUpdate` finds, or its error. */ update: AppUpdate | null | Error = null; updateChecks = 0; @@ -43,6 +48,15 @@ export class FakeHost implements Host { return this.settle("restart"); } + signInToClaude(claudePath: string): Promise { + this.signIns.push(claudePath); + if (this.signInResult instanceof Error) { + return Promise.reject(this.signInResult); + } + this.onSignIn(); + return Promise.resolve(this.signInResult); + } + checkForUpdate(): Promise { this.updateChecks += 1; return this.update instanceof Error diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index dffa91de..2c9b3150 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -52,6 +52,11 @@ export interface Host { installDaemon(): Promise; /** Stops the daemon and starts it again from its scheduled task. */ restartDaemon(): Promise; + /** + * Opens Claude Code's sign-in (`claude auth login`) in its own window and + * waits for it to end. Resolves whether it signed in. + */ + signInToClaude(claudePath: string): Promise; /** A newer version of the app, or null when this one is the latest. */ checkForUpdate(): Promise; readOwnerToken(): Promise; diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index 90194304..b3630472 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -69,6 +69,7 @@ export function tauriHost(): Host { daemonStatus: () => invoke("daemon_status"), installDaemon: () => invoke("daemon_install"), restartDaemon: () => invoke("daemon_restart"), + signInToClaude: (path) => invoke("claude_sign_in", { path }), checkForUpdate, readOwnerToken: () => invoke("read_owner_token"), openPath: (path) => invoke("open_path", { path }), diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 5425ebf2..47f1c397 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -9,6 +9,7 @@ import { CrewView } from "../features/crews/CrewView"; import { Sidebar } from "../features/crews/Sidebar"; import { FailedDeliveries } from "../features/messages/FailedDeliveries"; import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp"; +import { SignInButton } from "../features/onboarding/SignIn"; import { Welcome } from "../features/onboarding/Welcome"; import { UpdateButton } from "../features/updates/UpdateButton"; import { useApp } from "../store/context"; @@ -21,6 +22,7 @@ export function Workspace() { const loadError = useApp((state) => state.loadError); const hasCrews = useApp((state) => Object.keys(state.crews).length > 0); const runtimeError = useApp((state) => state.system?.runtimeError ?? null); + const signedOut = useApp((state) => state.system?.claudeSignedIn === false); useAttentionMark(); let main: ReactNode; @@ -41,12 +43,22 @@ export function Workspace() {
- {runtimeError && ( + {runtimeError ? (
+ ) : ( + signedOut && ( +
+ + Your bots work with your Claude account. Sign in once and they start by + themselves. + + +
+ ) )}
diff --git a/app/src/store/context.tsx b/app/src/store/context.tsx index 7cc2b69a..6a59fac1 100644 --- a/app/src/store/context.tsx +++ b/app/src/store/context.tsx @@ -44,3 +44,8 @@ export function useApi(): BotloftApi { export function useApp(selector: (state: AppState) => T): T { return useStore(required(useContext(StoreContext), "AppStore"), selector); } + +/** The store itself, for actions that set state outside the store's own. */ +export function useAppStore(): AppStore { + return required(useContext(StoreContext), "AppStore"); +} diff --git a/docs/spec.md b/docs/spec.md index 6ba10b80..ba64c340 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -202,7 +202,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **Sessão:** o daemon guarda em `bots.session_id` o id da conversa. O primeiro start usa `--session-id `; os seguintes, `--resume `. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem com um id novo. `bots.restart {fresh: true}` também começa conversa nova. O histórico do chat fica no banco do daemon (seção 8) e não depende do transcript do Claude Code. - Mudanças em nome, papel ou instruções regravam as regras na hora, mas o bot só as lê no próximo start; o daemon não reinicia o bot sozinho. - `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart` ou até o daemon ver o Claude Code conectado de novo. -- **Login do Claude Code:** o daemon roda `claude auth status` (JSON com `loggedIn`; sai com 0 conectado e 1 desconectado, seção 19) no ambiente do usuário, sem janela: quando acha o Claude Code, a cada 30 s enquanto desconectado ou desconhecido, na hora quando um bot cai em `auth_error` e quando o app pede (`system.refresh`). Conectado, não confere de novo sozinho. Quando o resultado passa de desconectado para conectado, os bots em `auth_error` sobem de novo sem o dono pedir. Um erro de conta com o login válido (`billing_error`, `account_on_hold`, organização bloqueada) não muda o resultado e por isso não vira loop. O resultado sai em `system.status.claudeSignedIn`. O login em si é feito fora do bot (`claude auth login` num terminal). +- **Login do Claude Code:** o daemon roda `claude auth status` (JSON com `loggedIn`; sai com 0 conectado e 1 desconectado, seção 19) no ambiente do usuário, sem janela: quando acha o Claude Code, a cada 30 s enquanto desconectado ou desconhecido, na hora quando um bot cai em `auth_error` e quando o app pede (`system.refresh`). Conectado, não confere de novo sozinho. Quando o resultado passa de desconectado para conectado, os bots em `auth_error` sobem de novo sem o dono pedir. Um erro de conta com o login válido (`billing_error`, `account_on_hold`, organização bloqueada) não muda o resultado e por isso não vira loop. O resultado sai em `system.status.claudeSignedIn`. O login em si é feito pelo app (15.2). - Cada processo de bot entra num **Job Object** com `KILL_ON_JOB_CLOSE`. Se o daemon morrer, a árvore de processos dos bots morre junto e não sobra `claude.exe` órfão. ### 7.4 Spawn @@ -562,6 +562,7 @@ Layout, como um app de mensagens: | `daemon_status` | GET `/health` local; diz se o daemon roda, está parado ou se outro programa ocupa a porta. Quando o daemon roda, diz também se ele é `outdated`: versão menor que a do app, que é a do sidecar (um workspace Cargo só). Pré-release é ignorado; versão ilegível nunca é antiga | | `daemon_install` | roda `botloftd --home service install` a partir do sidecar (o `botloftd.exe` ao lado do app), sem janela, e espera: ele se copia para `\bin`, registra e inicia a tarefa e espera o `/health` (seção 14). Um erro volta na mensagem de uma linha que o daemon escreve no stderr. Substitui o `daemon_start` do M4, que iniciava o daemon destacado ao lado do app | | `daemon_restart` | `botloftd --home service restart`, do mesmo jeito | +| `claude_sign_in` | abre `claude auth login` numa janela de console própria (`CREATE_NEW_CONSOLE`) com o `claudePath` do `system.status` e espera; responde se saiu com 0. Só roda um caminho absoluto de arquivo existente chamado `claude.exe`. Tira do ambiente `CLAUDECODE` e `CLAUDE_CODE_*`, que um app aberto de dentro de uma sessão do Claude Code herdaria. Quem abre é o app, e não o daemon, porque a janela aberta pelo app em primeiro plano vem para a frente. Na janela o dono vê o que acontece e pode colar um código se o navegador pedir | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | | `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página | @@ -579,6 +580,8 @@ Onboarding (M5): **configuração sem perguntas e sem jargão.** O dono não pre - Conectando sem sucesso: "Try again" e "Restart Botloft in the background" (`daemon_restart`). - Claude Code ausente ou inutilizável (`runtimeError`), na tela de boas-vindas e no aviso "Bots can't start": explica que os bots rodam no Claude Code, oferece "How to install Claude Code" (abre `https://code.claude.com/docs/en/setup`) e diz que o Botloft percebe sozinho em até 30 s; o erro vai em "Details". +- Login do Claude (`claudeSignedIn`): a tela de boas-vindas mostra "Claude account" (conectado, desconectado com o botão, ou conferindo). Com crews, desconectado vira o aviso "Sign in to Claude" no topo; um bot em `auth_error` também mostra o botão. "Sign in to Claude" chama `claude_sign_in`; se terminou bem, chama `system.refresh` e relê o status a cada 1 s por até 20 s. Os bots parados voltam sozinhos (7.3). Se a janela fechar antes, "The sign-in didn't finish" com o botão de novo. + Em dev, `pnpm tauri dev` usa o `target\debug\botloftd.exe` como sidecar e instala uma tarefa própria da pasta de dev (seção 14). Quem prefere um daemon em primeiro plano roda `cargo run -p botloftd -- serve` antes de abrir o app. ### 15.3 Direção visual @@ -659,6 +662,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | `timeout` por servidor MCP | 10 | Confirmado (`env-vars`): HTTP tem 60 s por request e 5 min sem resposta por padrão; `timeout` >= 1000 no servidor sobe os dois. **Testado com 2.1.284**: a aprovação respondida depois de 95 s chegou ao bot | feito (M4.1) | | `--setting-sources project,local` | 7.4 | Confirmado (`cli-reference`). **Testado com 2.1.284**: sem os hooks, skills e agents do usuário; modo `default`; login da assinatura continua valendo | feito (M4.1) | | `claude auth status` | 7.3 | Confirmado (`cli-reference`): JSON por padrão, sai com 0 conectado e 1 desconectado. **Testado com 2.1.284**: conectado traz `"loggedIn": true`; com `CLAUDE_CONFIG_DIR` vazio, `"loggedIn": false`, `"authMethod": "none"` e saída 1. As credenciais ficam em `%USERPROFILE%\.claude\.credentials.json` (`authentication`) | feito | +| `claude auth login` | 15.2 | Confirmado (`cli-reference`, `authentication`): abre o navegador e volta por um servidor local; se o navegador não alcançar esse servidor, mostra um código para colar no terminal. Se o subcomando sai sozinho depois do login ainda não foi visto: o app só depende do código de saída | manual (PR) | | Regras `allow` do projeto em `-p` sem confiança | 7.4 | Confirmado (`permissions`): não são aplicadas numa pasta nunca confiada; `deny` vale sempre. Por isso `--allowedTools mcp__botloft` | M4.1 | | `--session-id`, `--resume` em `-p` | 7.3 | Confirmado (`cli-reference`, `sessions`): a sessão retoma histórico e modelo; flags como `--mcp-config` têm de ser passadas de novo. **Testado com 2.1.284**: depois de reiniciar o bot e depois de reiniciar o daemon, o bot lembrou arquivos, a imagem e a mensagem de outro bot | feito (M4.1) | | Tools MCP adiadas | 10 | **Visto com 2.1.284**: as tools do `botloft` chegam adiadas; antes da primeira `send_message` o bot chama `ToolSearch` com `select:mcp__botloft__send_message`. O chat mostra isso como "load send_message" | feito (M4.1) | From 801be67ea0f703c5e7ecddfd3d08f87429ff4c79 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:04:00 -0300 Subject: [PATCH 5/5] style(app): format the sign-in command and port tests --- app/src-tauri/src/claude.rs | 12 ++++++++++-- app/src-tauri/src/daemon.rs | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/src-tauri/src/claude.rs b/app/src-tauri/src/claude.rs index c0931421..d9c5668d 100644 --- a/app/src-tauri/src/claude.rs +++ b/app/src-tauri/src/claude.rs @@ -9,7 +9,11 @@ use std::process::Command; /// Whether `path` is a Claude Code executable the app may run: the daemon /// found it, and the app still checks it is `claude.exe` and nothing else. fn is_claude(path: &Path) -> bool { - let expected = if cfg!(windows) { "claude.exe" } else { "claude" }; + let expected = if cfg!(windows) { + "claude.exe" + } else { + "claude" + }; path.is_absolute() && path .file_name() @@ -53,7 +57,11 @@ mod tests { fn only_claude_code_itself_runs() { let dir = std::env::temp_dir().join(format!("botloft-claude-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("dir"); - let name = if cfg!(windows) { "claude.exe" } else { "claude" }; + let name = if cfg!(windows) { + "claude.exe" + } else { + "claude" + }; let claude = dir.join(name); std::fs::write(&claude, b"").expect("write"); let other = dir.join("calc.exe"); diff --git a/app/src-tauri/src/daemon.rs b/app/src-tauri/src/daemon.rs index 45c00080..16dab5e0 100644 --- a/app/src-tauri/src/daemon.rs +++ b/app/src-tauri/src/daemon.rs @@ -266,7 +266,9 @@ mod tests { #[test] fn a_closed_port_reads_as_stopped() { - let _ports = PORTS.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ports = PORTS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); // Bind and drop to find a port nothing listens on. let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind"); let port = listener.local_addr().expect("addr").port(); @@ -276,7 +278,9 @@ mod tests { #[test] fn a_port_that_is_not_botloft_reads_as_foreign() { - let _ports = PORTS.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ports = PORTS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind"); let port = listener.local_addr().expect("addr").port(); let server = std::thread::spawn(move || {