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/claude.rs b/app/src-tauri/src/claude.rs new file mode 100644 index 00000000..d9c5668d --- /dev/null +++ b/app/src-tauri/src/claude.rs @@ -0,0 +1,75 @@ +//! 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/daemon.rs b/app/src-tauri/src/daemon.rs index 8a8de91d..16dab5e0 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,9 @@ 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 +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 listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind"); let port = listener.local_addr().expect("addr").port(); let server = std::thread::spawn(move || { diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 64f0c0cf..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 @@ -89,10 +97,13 @@ 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, daemon_restart, + claude_sign_in, read_owner_token, open_path, open_url 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/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/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/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/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/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/fakeHost.ts b/app/src/lib/fakeHost.ts index e30e068e..f4190df1 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,14 @@ 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; maximized = false; closed = false; attention = false; @@ -40,6 +48,22 @@ 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 + ? 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 +105,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..2c9b3150 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,13 @@ 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; /** Opens a folder in Explorer. */ openPath(path: string): Promise; 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/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index c3c21b67..b3630472 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,8 @@ 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 }), openUrl: (url) => invoke("open_url", { url }), diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index ac2c1668..47f1c397 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -9,7 +9,9 @@ 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"; import { Callout } from "../ui/Callout"; import { useAttentionMark } from "./attention"; @@ -20,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; @@ -40,12 +43,22 @@ export function Workspace() {
- {runtimeError && ( + {runtimeError ? (
+ ) : ( + signedOut && ( +
+ + Your bots work with your Claude account. Sign in once and they start by + themselves. + + +
+ ) )}
@@ -58,6 +71,7 @@ export function Workspace() { + 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/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 26f56774..ba64c340 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 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 @@ -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 @@ -559,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 | @@ -576,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 @@ -599,6 +605,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`. @@ -647,6 +661,8 @@ 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 | +| `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) |