diff --git a/Cargo.lock b/Cargo.lock index 68a297ed..b95b8b04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,137 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "atk" version = "0.18.2" @@ -271,6 +402,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "botloft-app" version = "0.5.0" @@ -281,8 +425,11 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", + "tauri-plugin-single-instance", "tauri-plugin-updater", "toml 1.1.6+spec-1.1.0", + "winreg", ] [[package]] @@ -598,6 +745,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -1051,6 +1207,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1078,6 +1261,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1222,6 +1425,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1598,6 +1814,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -2229,6 +2451,20 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -2370,6 +2606,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2649,6 +2899,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2688,6 +2948,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2776,6 +3042,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2821,6 +3098,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -4165,6 +4456,41 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9237e5cc1458bf1b31ad3c8c2c8480e86b6ecf133d00648fe6c36f25821f2f79" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.5", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91baf4f5db4b008fc2bcada0f7d3a49b45d911f94575ac236f225ed67554f5c0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.13.0" @@ -4298,6 +4624,17 @@ dependencies = [ "toml 1.1.6+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4810,6 +5147,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "ulid" version = "3.0.0" @@ -5699,6 +6047,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -5802,3 +6220,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/app/package.json b/app/package.json index e17fb4f4..c09a4fcf 100644 --- a/app/package.json +++ b/app/package.json @@ -22,6 +22,7 @@ "@fontsource/ibm-plex-sans": "^5.3.0", "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", + "@tauri-apps/plugin-notification": "^2.5.0", "@tauri-apps/plugin-updater": "^2.13.0", "lucide-react": "^1.48.0", "react": "^19.3.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 7463e88b..bd82fe4a 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.8.0 version: 2.8.0 + '@tauri-apps/plugin-notification': + specifier: ^2.5.0 + version: 2.5.0 '@tauri-apps/plugin-updater': specifier: ^2.13.0 version: 2.13.0 @@ -671,6 +674,9 @@ packages: '@tauri-apps/plugin-dialog@2.8.0': resolution: {integrity: sha512-HSSD5C8UFE9ijD1wue84F7jjaIReFmj8AAPtS+xyjYRltJafIc4BiWHY7SJ6vbbew6u+YljEJHfyNfy5VwywXg==} + '@tauri-apps/plugin-notification@2.5.0': + resolution: {integrity: sha512-S19zXtRLX1YryUh532YcGnjI3fzrBGj4oUcW87ojytmOAOCVMZ2bTTHOhI0enYZPH5uDmdQw+dEGCn5wfCm3zw==} + '@tauri-apps/plugin-updater@2.13.0': resolution: {integrity: sha512-9iwTSOlxMPZGI6M84Nd00rv6g+xERo8g4GDvG9cazOao+HjdIHZYCcvkvSP6JGn7Z50/R8t2jbR+PxQxf2qKMQ==} @@ -1980,6 +1986,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.12.0 + '@tauri-apps/plugin-notification@2.5.0': + dependencies: + '@tauri-apps/api': 2.12.0 + '@tauri-apps/plugin-updater@2.13.0': dependencies: '@tauri-apps/api': 2.12.0 diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 23e08d68..124ea356 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -20,10 +20,15 @@ tauri-build = { version = "2", features = [] } dirs = "7.0.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" +tauri-plugin-notification = "2.5.0" +tauri-plugin-single-instance = "2.5.1" tauri-plugin-updater = "2" toml = "1.1.6" +[target."cfg(windows)".dependencies] +winreg = "0.55.0" + [lints] workspace = true diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index c213247d..b619b92c 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -13,9 +13,14 @@ "core:window:allow-close", "core:window:allow-hide", "core:window:allow-destroy", + "core:window:allow-show", + "core:window:allow-unminimize", + "core:window:allow-set-focus", + "core:app:allow-default-window-icon", "core:window:allow-set-overlay-icon", "core:webview:allow-set-webview-zoom", "updater:default", - "dialog:allow-open" + "dialog:allow-open", + "notification:default" ] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index df3f9dce..4624e7bc 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ mod claude; mod daemon; +mod sign_in; +mod window; use std::path::Path; @@ -126,6 +128,18 @@ async fn reveal_file(path: String) -> Result<(), String> { .await } +/// Opens Botloft when the owner signs in to Windows, or stops doing so. +#[tauri::command] +async fn open_at_sign_in(on: bool) -> Result<(), String> { + blocking(move || sign_in::set(on)).await +} + +/// Whether Windows opened this app at sign-in, with its window hidden. +#[tauri::command] +fn launched_at_sign_in() -> bool { + sign_in::launched_at_sign_in() +} + /// Whether `url` is a plain web link, safe to hand to the default browser. fn is_web_link(url: &str) -> bool { let rest = url @@ -157,11 +171,26 @@ async fn open_url(url: String) -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default(); + // Opening Botloft again brings the running one forward. Not in a dev + // build, which runs beside the installed app. + #[cfg(not(debug_assertions))] + let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + window::reopened(app); + })); + builder // Checks the release feed and installs signed updates (spec 15.5). .plugin(tauri_plugin_updater::Builder::new().build()) // The folder picker for a crew's work folder (spec 5). .plugin(tauri_plugin_dialog::init()) + // Windows notifications when a bot needs the owner (spec 15.1). + .plugin(tauri_plugin_notification::init()) + .setup(|app| { + if !sign_in::launched_at_sign_in() { + window::bring_forward(app.handle()); + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ daemon_status, daemon_install, @@ -172,7 +201,9 @@ pub fn run() { open_path, open_file, reveal_file, - open_url + open_url, + open_at_sign_in, + launched_at_sign_in ]) .run(tauri::generate_context!()) .expect("error while running the Botloft app"); diff --git a/app/src-tauri/src/sign_in.rs b/app/src-tauri/src/sign_in.rs new file mode 100644 index 00000000..7be718d6 --- /dev/null +++ b/app/src-tauri/src/sign_in.rs @@ -0,0 +1,99 @@ +//! Opening Botloft when the owner signs in to Windows (spec 15.2): a value +//! in the current user's Run key, `"" --autostart`. The daemon starts +//! from its own scheduled task (spec 14); this is only the app, near the +//! clock or with its window. + +use std::path::Path; + +/// Tells the app that Windows opened it at sign-in. +pub const AUTOSTART_ARG: &str = "--autostart"; + +/// Whether Windows opened this app at sign-in. +pub fn launched_at_sign_in() -> bool { + std::env::args().any(|arg| arg == AUTOSTART_ARG) +} + +/// The Run key's command line, quoted: a path with a space, like +/// `C:\Users\Ana Lima\...`, would otherwise be split. +fn command_line(exe: &Path) -> String { + format!("\"{}\" {AUTOSTART_ARG}", exe.display()) +} + +#[cfg(windows)] +mod registry { + use std::io; + + use winreg::RegKey; + use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE, REG_BINARY}; + use winreg::types::ToRegValue as _; + + const RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; + /// Where Task Manager keeps its "Startup apps" switch. + const APPROVED_KEY: &str = + r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; + const VALUE: &str = "Botloft"; + /// What Task Manager writes for an app allowed to start. + const APPROVED: [u8; 12] = [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + + pub fn set(command: Option<&str>) -> io::Result<()> { + let user = RegKey::predef(HKEY_CURRENT_USER); + let (run, _) = user.create_subkey(RUN_KEY)?; + let Some(command) = command else { + return match run.delete_value(VALUE) { + Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err), + _ => Ok(()), + }; + }; + run.set_raw_value(VALUE, &command.to_reg_value())?; + // Turned off in Task Manager earlier: turning it on here wins. + if let Ok(approved) = user.open_subkey_with_flags(APPROVED_KEY, KEY_READ | KEY_SET_VALUE) + && approved.get_raw_value(VALUE).is_ok() + { + approved.set_raw_value( + VALUE, + &winreg::RegValue { + bytes: APPROVED.to_vec(), + vtype: REG_BINARY, + }, + )?; + } + Ok(()) + } +} + +/// Opens the app at sign-in, or stops doing so. A dev build never +/// registers itself: it needs the dev server. +pub fn set(on: bool) -> Result<(), String> { + if cfg!(debug_assertions) { + return Ok(()); + } + #[cfg(windows)] + { + let exe = std::env::current_exe().map_err(|err| err.to_string())?; + let command = on.then(|| command_line(&exe)); + registry::set(command.as_deref()) + .map_err(|err| format!("cannot change what opens at sign-in: {err}")) + } + #[cfg(not(windows))] + { + let _ = on; + Err("opening at sign-in is only supported on Windows".into()) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::command_line; + + #[test] + fn the_command_line_is_quoted() { + assert_eq!( + command_line(Path::new( + r"C:\Users\Ana Lima\AppData\Local\Botloft\Botloft.exe" + )), + r#""C:\Users\Ana Lima\AppData\Local\Botloft\Botloft.exe" --autostart"# + ); + } +} diff --git a/app/src-tauri/src/window.rs b/app/src-tauri/src/window.rs new file mode 100644 index 00000000..5ce1a9a6 --- /dev/null +++ b/app/src-tauri/src/window.rs @@ -0,0 +1,27 @@ +//! The main window, which starts hidden (spec 15.2): shown at once when the +//! owner opens Botloft, and left near the clock when Windows opens it at +//! sign-in, unless the owner wants the window then too. + +use tauri::{AppHandle, Manager as _}; + +/// What the app hears when Botloft is opened again while it runs, from the +/// Start menu or a notification: it may show what the notification was about. +#[cfg(not(debug_assertions))] +const REOPENED: &str = "botloft://reopened"; + +pub fn bring_forward(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +/// Botloft was opened again: the running app comes forward instead. Only +/// in a release build, the only one kept to a single instance. +#[cfg(not(debug_assertions))] +pub fn reopened(app: &AppHandle) { + use tauri::Emitter as _; + bring_forward(app); + let _ = app.emit(REOPENED, ()); +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index c36e5e8e..007939e9 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -17,7 +17,8 @@ "height": 800, "minWidth": 900, "minHeight": 560, - "decorations": false + "decorations": false, + "visible": false } ], "security": { diff --git a/app/src-tauri/windows/hooks.nsh b/app/src-tauri/windows/hooks.nsh index 16c1e0d3..af8e7fcc 100644 --- a/app/src-tauri/windows/hooks.nsh +++ b/app/src-tauri/windows/hooks.nsh @@ -9,5 +9,7 @@ ${If} $UpdateMode <> 1 nsExec::Exec '"$INSTDIR\botloftd.exe" service uninstall' Pop $0 + ; Nor does the app open at sign-in any more (spec 15.2). + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "Botloft" ${EndIf} !macroend diff --git a/app/src/App.tsx b/app/src/App.tsx index 7a6a0fd1..ed4d4bfa 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,6 +5,7 @@ import { Onboarding } from "./features/onboarding/Onboarding"; import type { Host } from "./lib/host"; import { useCloseBehavior } from "./shell/closing"; import { useMotionRoot } from "./shell/motionRoot"; +import { useSignInWindow } from "./shell/signIn"; import { TitleBar } from "./shell/TitleBar"; import { useThemeRoot } from "./shell/theme"; import { Workspace } from "./shell/Workspace"; @@ -19,6 +20,7 @@ export function App({ host, connect }: { host: Host; connect: Connect }) { useCloseBehavior(host); const [link] = useState(() => createLink(host, connect)); const current = useStore(link, (state) => state.current); + useSignInWindow(host, current.step); useEffect(() => { link.getState().check(); }, [link]); diff --git a/app/src/features/account/AlertsSettings.tsx b/app/src/features/account/AlertsSettings.tsx new file mode 100644 index 00000000..cca79504 --- /dev/null +++ b/app/src/features/account/AlertsSettings.tsx @@ -0,0 +1,35 @@ +// Settings, "Notifications" (spec 15.1): when Botloft tells the owner, with +// a Windows notification while it is not in front, and whether a sound +// goes with it. + +import { useT } from "../../i18n"; +import { prefs, usePref } from "../../shell/prefs"; +import { Section, Toggle } from "./settingsParts"; + +export function AlertsSettings() { + const s = useT().account.settings; + const needs = usePref(prefs.notifyNeeds); + const done = usePref(prefs.notifyDone); + const sound = usePref(prefs.sound); + const tray = usePref(prefs.tray); + const keep = usePref(prefs.whenClosed) === "keep"; + const nearClock = tray && keep; + return ( +
+ + + + {!nearClock &&

{s.alertsNeedTray}

} +
+ ); +} diff --git a/app/src/features/account/GeneralSettings.tsx b/app/src/features/account/GeneralSettings.tsx index 327afbff..3c1bc349 100644 --- a/app/src/features/account/GeneralSettings.tsx +++ b/app/src/features/account/GeneralSettings.tsx @@ -1,5 +1,5 @@ -// Settings, "General": the language, and what Botloft does in the -// background (spec 15.1). +// Settings, "General": the language, what Botloft does in the background +// and when Windows starts (spec 15.1). import { LOCALES, @@ -18,6 +18,9 @@ export function GeneralSettings() { const t = useT(); const s = t.account.settings; const whenClosed = usePref(prefs.whenClosed); + const tray = usePref(prefs.tray); + const openAtSignIn = usePref(prefs.openAtSignIn); + const nearClock = whenClosed === "keep" && tray; const { settings, change } = useDaemonSettings(); const startWithWindows = settings?.startWithWindows ?? true; const { choice: locale } = useLocale(); @@ -39,6 +42,14 @@ export function GeneralSettings() { checked={whenClosed === "keep"} onChange={(on) => prefs.whenClosed.set(on ? "keep" : "stop")} /> + {whenClosed === "keep" && ( + + )} change({ startWithWindows: on })} /> + {startWithWindows && settings && ( + + )} {page === "general" && } {page === "chat" && } + {page === "alerts" && } {page === "appearance" && } {page === "about" && } diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx index 5c61862c..0f6b932c 100644 --- a/app/src/features/account/settings.test.tsx +++ b/app/src/features/account/settings.test.tsx @@ -63,6 +63,50 @@ describe("settings", () => { ).toEqual([{ startWithWindows: false }, { keepAwake: false }]); }); + test("the icon and the window at sign-in show only where they apply", async () => { + renderApp(); + const dialog = await openSettings(); + const icon = () => + within(dialog).queryByRole("switch", { name: "Show Botloft near the clock" }); + const atSignIn = () => + within(dialog).queryByRole("switch", { + name: "Open the window when you sign in to Windows", + }); + await waitFor(() => expect(atSignIn()).not.toBeNull()); + expect(dialog.textContent).toContain( + "Botloft starts near the clock, without opening the window.", + ); + fireEvent.click(icon() as HTMLElement); + expect(dialog.textContent).toContain("Closing the window closes Botloft."); + expect(dialog.textContent).toContain("The window opens only when you open Botloft."); + fireEvent.click( + within(dialog).getByRole("switch", { name: "Keep working after you close Botloft" }), + ); + expect(icon()).toBeNull(); + fireEvent.click(within(dialog).getByRole("switch", { name: "Start with Windows" })); + await waitFor(() => expect(atSignIn()).toBeNull()); + }); + + test("notifications: when, and with a sound", async () => { + renderApp(); + const dialog = await openSettings(); + fireEvent.click(within(dialog).getByRole("tab", { name: "Notifications" })); + const on = (name: string) => + within(dialog).getByRole("switch", { name }).getAttribute("aria-checked"); + expect([on("When a bot needs you"), on("When a bot finishes"), on("Play a sound")]).toEqual([ + "true", + "false", + "true", + ]); + fireEvent.click(within(dialog).getByRole("switch", { name: "When a bot finishes" })); + expect(prefs.notifyDone.get()).toBe(true); + expect(dialog.textContent).not.toContain("notifications only come"); + act(() => prefs.tray.set(false)); + expect(dialog.textContent).toContain( + "With the window closed, notifications only come with Botloft near the clock", + ); + }); + test("a setting that cannot be saved goes back", async () => { const { fake } = renderApp(); const dialog = await openSettings(); @@ -90,12 +134,15 @@ describe("settings", () => { }); describe("closing the window", () => { - test("leaves the bots working unless the owner chose otherwise", async () => { + test("leaves the bots working: near the clock, or closed without the icon", async () => { const { host } = renderApp(); await screen.findByRole("heading", { name: "Welcome to Botloft" }); + await waitFor(() => expect(host.tray).not.toBeNull()); await act(() => host.requestClose()); - expect(host.closed).toBe(true); - expect(host.stops).toBe(0); + expect([host.hidden, host.closed, host.stops]).toEqual([true, false, 0]); + act(() => prefs.tray.set(false)); + await act(() => host.requestClose()); + expect([host.closed, host.stops]).toEqual([true, 0]); }); test("stops the bots, with the window out of sight first, when chosen", async () => { diff --git a/app/src/features/account/useDaemonSettings.ts b/app/src/features/account/useDaemonSettings.ts index d536c031..abd0c489 100644 --- a/app/src/features/account/useDaemonSettings.ts +++ b/app/src/features/account/useDaemonSettings.ts @@ -1,10 +1,10 @@ -// The daemon's part of Settings (spec 11.2): read when shown and saved on -// each change. A change shows at once and goes back if it cannot be saved. +// The daemon's part of Settings (spec 11.2), kept in the app store: read +// on each connection and saved on each change. A change shows at once and +// goes back if it cannot be saved. -import { useEffect, useState } from "react"; import { useT } from "../../i18n"; import type { Settings, SettingsUpdateParams } from "../../lib/protocol.gen"; -import { useApi } from "../../store/context"; +import { useApi, useApp } from "../../store/context"; import { attempt } from "../../ui/toast"; export function useDaemonSettings(): { @@ -13,31 +13,20 @@ export function useDaemonSettings(): { } { const api = useApi(); const failed = useT().account.settings.saveFailed; - const [settings, setSettings] = useState(null); - - useEffect(() => { - let alive = true; - api.call("settings.get").then( - (found) => alive && setSettings(found), - // Shown without them; the switches wait. - () => {}, - ); - return () => { - alive = false; - }; - }, [api]); + const settings = useApp((state) => state.settings); + const putSettings = useApp((state) => state.putSettings); const change = (update: SettingsUpdateParams) => { const before = settings; if (before) { - setSettings({ + putSettings({ startWithWindows: update.startWithWindows ?? before.startWithWindows, keepAwake: update.keepAwake ?? before.keepAwake, approvalWaitMinutes: update.approvalWaitMinutes ?? before.approvalWaitMinutes, }); } - attempt(failed, async () => setSettings(await api.call("settings.update", update))).then( - (saved) => saved || setSettings(before), + attempt(failed, async () => putSettings(await api.call("settings.update", update))).then( + (saved) => saved || putSettings(before), ); }; diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 102b191e..c4ba71d8 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -35,10 +35,29 @@ export const account = { keepWorkingOn: "Your bots go on working and answering after you close this window.", keepWorkingOff: "Closing Botloft stops every bot. They pick up where they left off when you open it again.", + tray: "Show Botloft near the clock", + trayOn: + "With the window closed, its icon stays near the clock: a click opens Botloft, and it tells you when a bot needs you.", + trayOff: + "Closing the window closes Botloft. The bots keep working, with no icon or notifications.", startWithWindows: "Start with Windows", startWithWindowsOn: "When you sign in to Windows, your bots get back to work on their own, without opening this window.", startWithWindowsOff: "After you restart the computer, the bots wait until you open Botloft.", + openAtSignIn: "Open the window when you sign in to Windows", + openAtSignInOn: "Botloft's window opens when you sign in to Windows.", + openAtSignInNearClock: "Botloft starts near the clock, without opening the window.", + openAtSignInOff: "The window opens only when you open Botloft.", + alerts: "Notifications", + notifyNeeds: "When a bot needs you", + notifyNeedsHint: + "A Windows notification when a bot asks for permission or needs you to sign in, if Botloft is not in front.", + notifyDone: "When a bot finishes", + notifyDoneHint: "A notification when a bot finishes what it was doing.", + sound: "Play a sound", + soundHint: "A short sound with each notification, also with Botloft in front.", + alertsNeedTray: + "With the window closed, notifications only come with Botloft near the clock (in General).", keepAwake: "Keep the computer awake while bots work", keepAwakeHint: "It still sleeps when you close the lid or choose Sleep.", saveFailed: "Could not change the setting", diff --git a/app/src/i18n/en/alerts.ts b/app/src/i18n/en/alerts.ts new file mode 100644 index 00000000..9f4fe7c6 --- /dev/null +++ b/app/src/i18n/en/alerts.ts @@ -0,0 +1,20 @@ +// The icon near the clock and the Windows notifications (spec 15.1). + +export const alerts = { + tray: { + tooltip: (status: string) => `Botloft: ${status}`, + working: (count: number) => (count === 1 ? "1 bot working" : `${count} bots working`), + needsYou: (bot: string) => `${bot} needs you`, + waiting: "Something is waiting for you", + idle: "No bot working", + open: "Open Botloft", + pause: "Pause every crew", + resume: "Resume every crew", + quit: "Quit Botloft", + }, + approval: (bot: string) => `${bot} asks for your permission`, + signIn: (bot: string) => `${bot} needs you to sign in to Claude`, + signInBody: "Open Botloft to sign in.", + done: (bot: string) => `${bot} finished`, + crew: (crew: string) => `Crew ${crew}`, +}; diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index 1ac136c6..704542ae 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -1,6 +1,7 @@ // English, the reference: every other language has exactly this shape. import { account } from "./account"; +import { alerts } from "./alerts"; import { bots } from "./bots"; import { browser } from "./browser"; import { chat } from "./chat"; @@ -21,6 +22,7 @@ export const en = { onboarding, updates, account, + alerts, bots, browser, chat, diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 6452efea..9b79d765 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -35,10 +35,28 @@ export const account: Messages["account"] = { keepWorkingOn: "Tus bots siguen trabajando y respondiendo después de que cierras esta ventana.", keepWorkingOff: "Al cerrar Botloft, todos los bots se detienen. Siguen donde se quedaron cuando lo vuelves a abrir.", + tray: "Mostrar Botloft junto al reloj", + trayOn: + "Con la ventana cerrada, su icono queda junto al reloj: un clic abre Botloft, y te avisa cuando un bot te necesita.", + trayOff: "Cerrar la ventana cierra Botloft. Los bots siguen trabajando, sin icono ni avisos.", startWithWindows: "Iniciar con Windows", startWithWindowsOn: "Cuando inicias sesión en Windows, tus bots vuelven a trabajar solos, sin abrir esta ventana.", startWithWindowsOff: "Después de reiniciar el equipo, los bots esperan a que abras Botloft.", + openAtSignIn: "Abrir la ventana al iniciar sesión en Windows", + openAtSignInOn: "La ventana de Botloft se abre cuando inicias sesión en Windows.", + openAtSignInNearClock: "Botloft empieza junto al reloj, sin abrir la ventana.", + openAtSignInOff: "La ventana solo se abre cuando abres Botloft.", + alerts: "Avisos", + notifyNeeds: "Cuando un bot te necesite", + notifyNeedsHint: + "Un aviso de Windows cuando un bot pide permiso o necesita que inicies sesión, si Botloft no está al frente.", + notifyDone: "Cuando un bot termine", + notifyDoneHint: "Un aviso cuando un bot termina lo que estaba haciendo.", + sound: "Reproducir un sonido", + soundHint: "Un sonido corto con cada aviso, también con Botloft al frente.", + alertsNeedTray: + "Con la ventana cerrada, los avisos solo llegan con Botloft junto al reloj (en General).", keepAwake: "Mantener el equipo despierto mientras los bots trabajan", keepAwakeHint: "Igual se suspende si cierras la tapa o eliges Suspender.", saveFailed: "No se pudo cambiar el ajuste", diff --git a/app/src/i18n/es/alerts.ts b/app/src/i18n/es/alerts.ts new file mode 100644 index 00000000..31117142 --- /dev/null +++ b/app/src/i18n/es/alerts.ts @@ -0,0 +1,22 @@ +// El icono junto al reloj y los avisos de Windows (spec 15.1). + +import type { Messages } from "../en"; + +export const alerts: Messages["alerts"] = { + tray: { + tooltip: (status) => `Botloft: ${status}`, + working: (count) => (count === 1 ? "1 bot trabajando" : `${count} bots trabajando`), + needsYou: (bot) => `${bot} te necesita`, + waiting: "Algo te está esperando", + idle: "Ningún bot trabajando", + open: "Abrir Botloft", + pause: "Pausar todos los equipos", + resume: "Reanudar todos los equipos", + quit: "Salir de Botloft", + }, + approval: (bot) => `${bot} pide tu permiso`, + signIn: (bot) => `${bot} necesita que inicies sesión en Claude`, + signInBody: "Abre Botloft para iniciar sesión.", + done: (bot) => `${bot} terminó`, + crew: (crew) => `Equipo ${crew}`, +}; diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index 4fa952d9..d6183ffa 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -2,6 +2,7 @@ import type { Messages } from "../en"; import { account } from "./account"; +import { alerts } from "./alerts"; import { bots } from "./bots"; import { browser } from "./browser"; import { chat } from "./chat"; @@ -22,6 +23,7 @@ export const es: Messages = { onboarding, updates, account, + alerts, bots, browser, chat, diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index b9bac61d..c798b426 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -35,11 +35,29 @@ export const account: Messages["account"] = { keepWorkingOn: "Seus bots seguem trabalhando e respondendo depois que você fecha esta janela.", keepWorkingOff: "Ao fechar o Botloft, todos os bots param. Eles continuam de onde pararam quando você abrir de novo.", + tray: "Mostrar o Botloft perto do relógio", + trayOn: + "Com a janela fechada, o ícone fica perto do relógio: um clique abre o Botloft, e ele avisa quando um bot precisa de você.", + trayOff: "Fechar a janela fecha o Botloft. Os bots seguem trabalhando, sem ícone nem avisos.", startWithWindows: "Iniciar com o Windows", startWithWindowsOn: "Quando você entra no Windows, seus bots voltam a trabalhar sozinhos, sem abrir esta janela.", startWithWindowsOff: "Depois que você reinicia o computador, os bots esperam você abrir o Botloft.", + openAtSignIn: "Abrir a janela ao entrar no Windows", + openAtSignInOn: "A janela do Botloft abre quando você entra no Windows.", + openAtSignInNearClock: "O Botloft começa perto do relógio, sem abrir a janela.", + openAtSignInOff: "A janela só abre quando você abrir o Botloft.", + alerts: "Avisos", + notifyNeeds: "Quando um bot precisar de você", + notifyNeedsHint: + "Um aviso do Windows quando um bot pede permissão ou precisa que você entre na conta, se o Botloft não estiver na frente.", + notifyDone: "Quando um bot terminar", + notifyDoneHint: "Um aviso quando um bot termina o que estava fazendo.", + sound: "Tocar um som", + soundHint: "Um som curto com cada aviso, também com o Botloft na frente.", + alertsNeedTray: + "Com a janela fechada, os avisos só chegam com o Botloft perto do relógio (em Geral).", keepAwake: "Não deixar o computador dormir enquanto os bots trabalham", keepAwakeHint: "Ele ainda dorme quando você fecha a tampa ou escolhe Suspender.", saveFailed: "Não foi possível mudar a configuração", diff --git a/app/src/i18n/pt-BR/alerts.ts b/app/src/i18n/pt-BR/alerts.ts new file mode 100644 index 00000000..d4e31129 --- /dev/null +++ b/app/src/i18n/pt-BR/alerts.ts @@ -0,0 +1,22 @@ +// O ícone perto do relógio e os avisos do Windows (spec 15.1). + +import type { Messages } from "../en"; + +export const alerts: Messages["alerts"] = { + tray: { + tooltip: (status) => `Botloft: ${status}`, + working: (count) => (count === 1 ? "1 bot trabalhando" : `${count} bots trabalhando`), + needsYou: (bot) => `${bot} precisa de você`, + waiting: "Algo está esperando por você", + idle: "Nenhum bot trabalhando", + open: "Abrir o Botloft", + pause: "Pausar todas as equipes", + resume: "Retomar todas as equipes", + quit: "Sair do Botloft", + }, + approval: (bot) => `${bot} pede sua permissão`, + signIn: (bot) => `${bot} precisa que você entre na conta do Claude`, + signInBody: "Abra o Botloft para entrar.", + done: (bot) => `${bot} terminou`, + crew: (crew) => `Equipe ${crew}`, +}; diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index 3e3a7b69..8362d2f6 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -2,6 +2,7 @@ import type { Messages } from "../en"; import { account } from "./account"; +import { alerts } from "./alerts"; import { bots } from "./bots"; import { browser } from "./browser"; import { chat } from "./chat"; @@ -22,6 +23,7 @@ export const ptBR: Messages = { onboarding, updates, account, + alerts, bots, browser, chat, diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index 6139d399..ee283fb6 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 { AppUpdate, DaemonStatus, Host } from "./host"; +import type { AppUpdate, DaemonStatus, Host, TrayActions, TrayView } from "./host"; import { PROTOCOL_VERSION } from "./protocol.gen"; /** A current daemon on the default port. */ @@ -34,8 +34,17 @@ export class FakeHost implements Host { /** How many times the app stopped the daemon. */ stops = 0; /** What runs before the window closes, as the app set it. */ - beforeClose: (() => Promise) | null = null; + beforeClose: (() => Promise<"close" | "stay">) | null = null; hidden = false; + /** Whether the window is on screen with the focus. */ + front = true; + /** Whether Windows opens the app at sign-in, as the app last set it. */ + openAtSignIn: boolean | null = null; + atSignIn = false; + readonly notices: { title: string; body: string; sound: boolean }[] = []; + /** The icon near the clock, while shown, and what its menu does. */ + tray: { view: TrayView; actions: TrayActions } | null = null; + private readonly reopenListeners = new Set<() => void>(); /** Paths `signInToClaude` ran, and what it answers. */ readonly signIns: string[] = []; signInResult: boolean | Error = true; @@ -69,8 +78,46 @@ export class FakeHost implements Host { /** Closes the window the way Windows does: what the app set runs first. */ async requestClose(): Promise { - await this.beforeClose?.(); - this.closed = true; + const outcome = await this.beforeClose?.(); + if (outcome === "stay") { + this.hidden = true; + this.front = false; + } else { + this.closed = true; + } + } + + /** Botloft opened again from the Start menu or a notification. */ + reopen(): void { + this.hidden = false; + this.front = true; + for (const listener of this.reopenListeners) { + listener(); + } + } + + setOpenAtSignIn(on: boolean): Promise { + this.openAtSignIn = on; + return Promise.resolve(); + } + + launchedAtSignIn(): Promise { + return Promise.resolve(this.atSignIn); + } + + notify(notice: { title: string; body: string; sound: boolean }): Promise { + this.notices.push(notice); + return Promise.resolve(); + } + + showTray(view: TrayView, actions: TrayActions): Promise { + this.tray = { view, actions }; + return Promise.resolve(); + } + + hideTray(): Promise { + this.tray = null; + return Promise.resolve(); } signInToClaude(claudePath: string): Promise { @@ -141,9 +188,26 @@ export class FakeHost implements Host { close: () => this.requestClose(), hide: () => { this.hidden = true; + this.front = false; return Promise.resolve(); }, - onCloseRequested: (before: () => Promise) => { + show: () => { + this.hidden = false; + this.front = true; + return Promise.resolve(); + }, + quit: () => { + this.closed = true; + return Promise.resolve(); + }, + inFront: () => this.front, + onReopened: (listener: () => void) => { + this.reopenListeners.add(listener); + return Promise.resolve(() => { + this.reopenListeners.delete(listener); + }); + }, + onCloseRequested: (before: () => Promise<"close" | "stay">) => { this.beforeClose = before; return Promise.resolve(() => { if (this.beforeClose === before) { diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index aa092757..c4375c0e 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -31,17 +31,47 @@ export interface AppUpdate { install(progress: (fraction: number | null) => void): Promise; } +/** What the icon near the clock shows (spec 15.1). */ +export interface TrayView { + /** The tooltip: Botloft and what the bots are doing. */ + tooltip: string; + /** The first line of the menu, which cannot be clicked. */ + status: string; + /** Something waits for the owner: the icon gets a dot. */ + attention: boolean; + open: string; + pause: string; + quit: string; +} + +export interface TrayActions { + open(): void; + pause(): void; + quit(): void; +} + export interface AppWindow { minimize(): Promise; toggleMaximize(): Promise; close(): Promise; hide(): Promise; + /** Shows the window, restored and in front. */ + show(): Promise; + /** Ends the app, whatever `onCloseRequested` would do. */ + quit(): Promise; + /** Whether the window is on screen and has the focus. */ + inFront(): boolean; /** * Runs `before` when the window is about to close (the close button, - * Alt+F4, the taskbar), and closes it once it settles. Returns the - * unsubscribe function. + * Alt+F4, the taskbar). Unless it answers "stay", the window closes once + * it settles. Returns the unsubscribe function. + */ + onCloseRequested(before: () => Promise<"close" | "stay">): Promise<() => void>; + /** + * Botloft was opened again while it ran (the Start menu, a notification): + * the window already came forward. Returns the unsubscribe function. */ - onCloseRequested(before: () => Promise): Promise<() => void>; + onReopened(listener: () => void): Promise<() => void>; isMaximized(): Promise; /** Called when the window is resized; returns the unsubscribe function. */ onResized(listener: () => void): Promise<() => void>; @@ -64,6 +94,15 @@ export interface Host { * when it starts with Windows (spec 14). */ stopDaemon(): Promise; + /** Opens the app when the owner signs in to Windows, or stops doing so. */ + setOpenAtSignIn(on: boolean): Promise; + /** Whether Windows opened the app at sign-in, with its window hidden. */ + launchedAtSignIn(): Promise; + /** A Windows notification; `sound` plays the Windows notification sound. */ + notify(notice: { title: string; body: string; sound: boolean }): Promise; + /** Shows the icon near the clock, or updates it. */ + showTray(view: TrayView, actions: TrayActions): Promise; + hideTray(): 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. diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index 2261c403..143b0666 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -1,12 +1,19 @@ // `Host` over the Tauri commands in `src-tauri/src/lib.rs`. import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { Image } from "@tauri-apps/api/image"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { open } from "@tauri-apps/plugin-dialog"; +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from "@tauri-apps/plugin-notification"; import { check } from "@tauri-apps/plugin-updater"; import type { AppUpdate, DaemonStatus, Host } from "./host"; +import { hideTray, showTray } from "./tauriTray"; const DOT = 16; @@ -72,6 +79,18 @@ export function tauriHost(): Host { installDaemon: () => invoke("daemon_install"), restartDaemon: () => invoke("daemon_restart"), stopDaemon: () => invoke("daemon_stop"), + // A dev build never registers itself: it needs the dev server. + setOpenAtSignIn: (on) => + import.meta.env.DEV ? Promise.resolve() : invoke("open_at_sign_in", { on }), + launchedAtSignIn: () => invoke("launched_at_sign_in"), + notify: async ({ title, body, sound }) => { + if (!(await isPermissionGranted()) && (await requestPermission()) !== "granted") { + return; + } + sendNotification({ title, body, ...(sound ? { sound: "Default" } : {}) }); + }, + showTray, + hideTray, signInToClaude: (path) => invoke("claude_sign_in", { path }), setZoom: (factor) => getCurrentWebview().setZoom(factor), checkForUpdate, @@ -94,7 +113,20 @@ export function tauriHost(): Host { toggleMaximize: () => window.toggleMaximize(), close: () => window.close(), hide: () => window.hide(), - onCloseRequested: (before) => window.onCloseRequested(() => before()), + show: async () => { + await window.show(); + await window.unminimize(); + await window.setFocus(); + }, + quit: () => window.destroy(), + inFront: () => document.visibilityState === "visible" && document.hasFocus(), + onCloseRequested: (before) => + window.onCloseRequested(async (event) => { + if ((await before()) === "stay") { + event.preventDefault(); + } + }), + onReopened: (listener) => listen("botloft://reopened", () => listener()), isMaximized: () => window.isMaximized(), onResized: (listener) => window.onResized(() => listener()), setAttention: async (on) => { diff --git a/app/src/lib/tauriTray.ts b/app/src/lib/tauriTray.ts new file mode 100644 index 00000000..eabc0575 --- /dev/null +++ b/app/src/lib/tauriTray.ts @@ -0,0 +1,98 @@ +// The icon near the clock (spec 15.1) over Tauri's tray and menu. Updates +// run one after another, so two quick changes never make two icons. + +import { defaultWindowIcon } from "@tauri-apps/api/app"; +import { Image } from "@tauri-apps/api/image"; +import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; +import { TrayIcon } from "@tauri-apps/api/tray"; +import type { TrayActions, TrayView } from "./host"; + +let queue: Promise = Promise.resolve(); +let tray: TrayIcon | null = null; +let menu: Menu | null = null; +let actions: TrayActions | null = null; +const icons = new Map(); + +/** The app's icon, with an accent dot in the corner while something waits. */ +async function icon(attention: boolean): Promise { + const cached = icons.get(attention); + if (cached) { + return cached; + } + const plain = await defaultWindowIcon(); + if (!plain || !attention) { + if (plain) { + icons.set(false, plain); + } + return plain; + } + const { width, height } = await plain.size(); + const rgba = new Uint8Array(await plain.rgba()); + const radius = Math.max(3, Math.round(width * 0.24)); + const cx = width - radius - 0.5; + const cy = height - radius - 0.5; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const distance = Math.hypot(x + 0.5 - cx, y + 0.5 - cy); + if (distance <= radius) { + const ring = distance > radius * 0.72; + rgba.set(ring ? [11, 11, 11, 255] : [255, 122, 89, 255], (y * width + x) * 4); + } + } + } + const marked = await Image.new(rgba, width, height); + icons.set(true, marked); + return marked; +} + +async function apply(view: TrayView): Promise { + const next = await Menu.new({ + items: [ + await MenuItem.new({ text: view.status, enabled: false }), + await PredefinedMenuItem.new({ item: "Separator" }), + await MenuItem.new({ text: view.open, action: () => actions?.open() }), + await MenuItem.new({ text: view.pause, action: () => actions?.pause() }), + await PredefinedMenuItem.new({ item: "Separator" }), + await MenuItem.new({ text: view.quit, action: () => actions?.quit() }), + ], + }); + const image = await icon(view.attention); + if (tray) { + await tray.setMenu(next); + await tray.setTooltip(view.tooltip); + await tray.setIcon(image); + } else { + tray = await TrayIcon.new({ + id: "botloft", + menu: next, + tooltip: view.tooltip, + showMenuOnLeftClick: false, + ...(image ? { icon: image } : {}), + action: (event) => { + if (event.type === "Click" && event.button === "Left" && event.buttonState === "Up") { + actions?.open(); + } + }, + }); + } + await menu?.close(); + menu = next; +} + +export function showTray(view: TrayView, next: TrayActions): Promise { + actions = next; + const run = queue.then(() => apply(view)); + queue = run.catch(() => {}); + return run; +} + +export function hideTray(): Promise { + const run = queue.then(async () => { + await tray?.close(); + await menu?.close(); + tray = null; + menu = null; + }); + queue = run.catch(() => {}); + return run; +} diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index bef1788d..80447e4c 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -15,8 +15,11 @@ import { UpdateButton } from "../features/updates/UpdateButton"; import { useT } from "../i18n"; import { useApp } from "../store/context"; import { Callout } from "../ui/Callout"; +import { useBotAlerts } from "./alerts"; import { useAttentionMark } from "./attention"; +import { useOpenAtSignIn } from "./signIn"; import { TitleBar } from "./TitleBar"; +import { useTray } from "./tray"; export function Workspace() { const t = useT(); @@ -26,6 +29,9 @@ export function Workspace() { const runtimeError = useApp((state) => state.system?.runtimeError ?? null); const signedOut = useApp((state) => state.system?.claudeSignedIn === false); useAttentionMark(); + useTray(); + useBotAlerts(); + useOpenAtSignIn(); let main: ReactNode; if (loadError) { diff --git a/app/src/shell/alerts.test.tsx b/app/src/shell/alerts.test.tsx new file mode 100644 index 00000000..eb7ba40d --- /dev/null +++ b/app/src/shell/alerts.test.tsx @@ -0,0 +1,82 @@ +import { act, cleanup, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../lib/fake"; +import { FakeHost } from "../lib/fakeHost"; +import { crewOpened, renderApp } from "../test/app"; +import { prefs, resetPrefs } from "./prefs"; + +afterEach(() => { + cleanup(); + resetPrefs(); +}); + +async function scoutBehind() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + const writer = fake.addBot(ops.id, "Writer", "Writes"); + fake.setBotState(scout.id, "idle"); + fake.setBotState(writer.id, "idle"); + const host = new FakeHost(); + renderApp(fake, host); + await crewOpened("Ops"); + host.front = false; + return { fake, host, scout, writer }; +} + +describe("notifications", () => { + test("say which bot needs the owner and for what, with the Windows sound", async () => { + const { fake, host, scout } = await scoutBehind(); + act(() => { + fake.chat.ask(scout.id, "Bash", "npm test", '{"command":"npm test"}'); + fake.setBotState(scout.id, "needs_approval"); + }); + expect(host.notices).toEqual([ + { title: "Scout asks for your permission", body: "Run a command", sound: true }, + ]); + act(() => fake.setBotState(scout.id, "auth_error")); + expect(host.notices.at(-1)).toEqual({ + title: "Scout needs you to sign in to Claude", + body: "Open Botloft to sign in.", + sound: true, + }); + }); + + test("tell when a bot finishes only if the owner wants it, and stay quiet without sound", async () => { + const { fake, host, scout } = await scoutBehind(); + act(() => fake.setBotState(scout.id, "busy")); + act(() => fake.setBotState(scout.id, "idle")); + expect(host.notices).toEqual([]); + prefs.notifyDone.set(true); + prefs.sound.set(false); + act(() => fake.setBotState(scout.id, "busy")); + act(() => fake.setBotState(scout.id, "idle")); + expect(host.notices).toEqual([{ title: "Scout finished", body: "Crew Ops", sound: false }]); + }); + + test("do not show while Botloft is in front, nor for what was there on opening", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "needs_approval"); + const host = new FakeHost(); + renderApp(fake, host); + await crewOpened("Ops"); + host.front = false; + act(() => fake.setBotState(scout.id, "needs_approval")); + expect(host.notices).toEqual([]); + host.front = true; + act(() => fake.setBotState(scout.id, "auth_error")); + expect(host.notices).toEqual([]); + }); + + test("opening Botloft from one goes to its bot", async () => { + const { fake, host, writer } = await scoutBehind(); + act(() => fake.setBotState(writer.id, "needs_approval")); + expect(host.notices).toHaveLength(1); + act(() => host.reopen()); + await waitFor(() => + expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(), + ); + }); +}); diff --git a/app/src/shell/alerts.ts b/app/src/shell/alerts.ts new file mode 100644 index 00000000..dd6d5f47 --- /dev/null +++ b/app/src/shell/alerts.ts @@ -0,0 +1,114 @@ +// Telling the owner when a bot needs them or finishes (spec 15.1): a +// Windows notification while Botloft is not in front (with the Windows +// sound, if the owner wants sound), the chime while it is, and that bot +// opened when the owner comes back from the notification. + +import { useEffect } from "react"; +import { toolTitle } from "../features/chat/toolNames"; +import { t } from "../i18n"; +import type { Bot, BotId, BotState } from "../lib/protocol.gen"; +import type { AppState } from "../store/app"; +import { useAppStore, useHost } from "../store/context"; +import { chime } from "./chime"; +import { prefs } from "./prefs"; + +/** How long after a notification opening Botloft goes to its bot. */ +const FOLLOW_MS = 10 * 60 * 1000; + +type Kind = "approval" | "signIn" | "done"; + +function kindOf(before: BotState | undefined, now: BotState): Kind | null { + if (before === undefined || before === now) { + return null; + } + if (now === "needs_approval") { + return "approval"; + } + if (now === "auth_error") { + return "signIn"; + } + return before === "busy" && now === "idle" ? "done" : null; +} + +function notice(state: AppState, bot: Bot, kind: Kind): { title: string; body: string } { + const words = t(); + const crew = state.crews[bot.crewId]?.name ?? ""; + switch (kind) { + case "approval": { + const tool = bot.lastActivity?.kind === "approval" ? bot.lastActivity.tool : null; + return { + title: words.alerts.approval(bot.name), + body: tool ? toolTitle(tool, words.tools) : words.alerts.crew(crew), + }; + } + case "signIn": + return { title: words.alerts.signIn(bot.name), body: words.alerts.signInBody }; + case "done": + return { title: words.alerts.done(bot.name), body: words.alerts.crew(crew) }; + } +} + +export function useBotAlerts(): void { + const host = useHost(); + const store = useAppStore(); + + useEffect(() => { + let seen = new Map(); + let last: { bot: BotId; at: number } | null = null; + + const follow = (state: AppState) => { + const now = new Map(Object.values(state.bots).map((bot) => [bot.id, bot.state])); + // What was there on (re)connecting is not news. + const loading = !state.loaded; + const before = seen; + seen = now; + if (loading || before.size === 0) { + return; + } + for (const bot of Object.values(state.bots)) { + const kind = bot.archivedAt === null ? kindOf(before.get(bot.id), bot.state) : null; + const wanted = kind === "done" ? prefs.notifyDone.get() : prefs.notifyNeeds.get(); + if (!kind || !wanted) { + continue; + } + const sound = prefs.sound.get(); + if (host.window.inFront()) { + if (sound) { + chime(kind === "done" ? "done" : "needs"); + } + continue; + } + last = { bot: bot.id, at: Date.now() }; + host.notify({ ...notice(state, bot, kind), sound }).catch(() => {}); + } + }; + + seen = new Map(Object.values(store.getState().bots).map((bot) => [bot.id, bot.state])); + const unsubscribe = store.subscribe(follow); + let off: (() => void) | undefined; + let alive = true; + host.window + .onReopened(() => { + const bot = last && Date.now() - last.at < FOLLOW_MS ? last.bot : null; + last = null; + if (bot && store.getState().bots[bot]) { + store.getState().selectBot(bot); + } + }) + .then( + (unsubscribeReopen) => { + if (alive) { + off = unsubscribeReopen; + } else { + unsubscribeReopen(); + } + }, + () => {}, + ); + return () => { + alive = false; + unsubscribe(); + off?.(); + }; + }, [host, store]); +} diff --git a/app/src/shell/chime.ts b/app/src/shell/chime.ts new file mode 100644 index 00000000..2658ea57 --- /dev/null +++ b/app/src/shell/chime.ts @@ -0,0 +1,37 @@ +// A short, soft sound for a notification while Botloft is in front (spec +// 15.1): two notes made on the spot, so no sound file ships. + +type AudioWindow = Window & { AudioContext?: typeof AudioContext }; + +let context: AudioContext | null = null; + +/** Two notes, rising when a bot needs the owner and falling when one is done. */ +export function chime(kind: "needs" | "done"): void { + const Audio = (window as AudioWindow).AudioContext; + if (!Audio) { + return; + } + try { + context ??= new Audio(); + const start = context.currentTime + 0.01; + const notes = kind === "needs" ? [660, 880] : [784, 587]; + notes.forEach((frequency, index) => { + const at = start + index * 0.12; + const tone = context?.createOscillator(); + const volume = context?.createGain(); + if (!context || !tone || !volume) { + return; + } + tone.type = "sine"; + tone.frequency.value = frequency; + volume.gain.setValueAtTime(0.0001, at); + volume.gain.exponentialRampToValueAtTime(0.12, at + 0.015); + volume.gain.exponentialRampToValueAtTime(0.0001, at + 0.22); + tone.connect(volume).connect(context.destination); + tone.start(at); + tone.stop(at + 0.25); + }); + } catch { + // No sound device: the notification says it anyway. + } +} diff --git a/app/src/shell/closing.ts b/app/src/shell/closing.ts index 777088b6..b1cbde0c 100644 --- a/app/src/shell/closing.ts +++ b/app/src/shell/closing.ts @@ -1,10 +1,12 @@ -// What closing the window does (spec 15.1): Botloft keeps the bots working -// in the background, or, if the owner chose so in Settings -// (`prefs.whenClosed`), stops them until it opens again. +// What closing the window does (spec 15.1): with the icon near the clock, +// the window only hides; otherwise Botloft closes and the bots keep +// working in the background, or, if the owner chose so in Settings +// (`prefs.whenClosed`), stop until it opens again. import { useEffect } from "react"; import type { Host } from "../lib/host"; import { prefs } from "./prefs"; +import { trayShown } from "./tray"; /** * Stops the bots as the window closes, when the owner chose so. The window @@ -17,11 +19,16 @@ export function useCloseBehavior(host: Host): void { let alive = true; host.window .onCloseRequested(async () => { - if (prefs.whenClosed.get() !== "stop") { - return; + if (prefs.whenClosed.get() === "stop") { + await host.window.hide().catch(() => {}); + await host.stopDaemon().catch(() => {}); + return "close"; } - await host.window.hide().catch(() => {}); - await host.stopDaemon().catch(() => {}); + if (trayShown()) { + await host.window.hide().catch(() => {}); + return "stay"; + } + return "close"; }) .then( (unsubscribe) => { diff --git a/app/src/shell/prefs.ts b/app/src/shell/prefs.ts index 72bdd9db..b4518e93 100644 --- a/app/src/shell/prefs.ts +++ b/app/src/shell/prefs.ts @@ -58,6 +58,16 @@ export const prefs = { followBot: pref("botloft.followBot", true, ON_OFF), /** No motion in the window, whatever Windows says. */ lessMotion: pref("botloft.lessMotion", false, ON_OFF), + /** With the bots working, closing the window leaves Botloft near the clock. */ + tray: pref("botloft.tray", true, ON_OFF), + /** When Windows opens Botloft at sign-in, the window opens too. */ + openAtSignIn: pref("botloft.openAtSignIn", false, ON_OFF), + /** A Windows notification when a bot needs the owner. */ + notifyNeeds: pref("botloft.notifyNeeds", true, ON_OFF), + /** A Windows notification when a bot finishes what it was doing. */ + notifyDone: pref("botloft.notifyDone", false, ON_OFF), + /** A short sound with each of those. */ + sound: pref("botloft.sound", true, ON_OFF), }; export function usePref(choice: Pref): T { diff --git a/app/src/shell/signIn.ts b/app/src/shell/signIn.ts new file mode 100644 index 00000000..17ab4c6e --- /dev/null +++ b/app/src/shell/signIn.ts @@ -0,0 +1,45 @@ +// Opening Botloft when the owner signs in to Windows (spec 15.2): the app +// opens then when the bots start with Windows and the owner wants Botloft +// near the clock or its window open. Opened that way, the window stays +// hidden while Botloft waits near the clock, and shows if anything goes +// wrong before it connects. + +import { useEffect, useState } from "react"; +import type { LinkStep } from "../features/onboarding/link"; +import type { Host } from "../lib/host"; +import { useApp, useHost } from "../store/context"; +import { prefs, usePref } from "./prefs"; + +export function useOpenAtSignIn(): void { + const host = useHost(); + const startWithWindows = useApp((state) => state.settings?.startWithWindows); + const keep = usePref(prefs.whenClosed) === "keep"; + const tray = usePref(prefs.tray); + const openWindow = usePref(prefs.openAtSignIn); + useEffect(() => { + // Not read yet, or an older daemon: leave it as it is. + if (startWithWindows === undefined) { + return; + } + host.setOpenAtSignIn(startWithWindows && ((keep && tray) || openWindow)).catch(() => {}); + }, [host, startWithWindows, keep, tray, openWindow]); +} + +const CALM: LinkStep["step"][] = ["checking", "installing", "connecting", "connected"]; + +export function useSignInWindow(host: Host, step: LinkStep["step"]): void { + const [atSignIn, setAtSignIn] = useState(false); + useEffect(() => { + host.launchedAtSignIn().then(setAtSignIn, () => {}); + }, [host]); + useEffect(() => { + if (!atSignIn) { + return; + } + const nearClock = + prefs.whenClosed.get() === "keep" && prefs.tray.get() && !prefs.openAtSignIn.get(); + if (!nearClock || !CALM.includes(step)) { + host.window.show().catch(() => {}); + } + }, [host, atSignIn, step]); +} diff --git a/app/src/shell/tray.test.tsx b/app/src/shell/tray.test.tsx new file mode 100644 index 00000000..7d145b79 --- /dev/null +++ b/app/src/shell/tray.test.tsx @@ -0,0 +1,129 @@ +import { act, cleanup, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../lib/fake"; +import { FakeHost } from "../lib/fakeHost"; +import { crewOpened, renderApp } from "../test/app"; +import { prefs, resetPrefs } from "./prefs"; + +afterEach(() => { + cleanup(); + resetPrefs(); +}); + +function crews() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const docs = fake.addCrew("Docs"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + return { fake, ops, docs, scout }; +} + +const view = (host: FakeHost) => host.tray?.view; + +describe("the icon near the clock", () => { + test("says what the bots are doing, with a dot when something waits", async () => { + const { fake, scout } = crews(); + const { host } = renderApp(fake); + await crewOpened("Ops"); + await waitFor(() => expect(view(host)?.status).toBe("No bot working")); + expect(view(host)).toMatchObject({ + tooltip: "Botloft: No bot working", + attention: false, + open: "Open Botloft", + pause: "Pause every crew", + quit: "Quit Botloft", + }); + act(() => fake.setBotState(scout.id, "busy")); + expect(view(host)?.status).toBe("1 bot working"); + act(() => fake.setBotState(scout.id, "needs_approval")); + expect(view(host)).toMatchObject({ status: "Scout needs you", attention: true }); + }); + + test("opens the window, pauses and resumes every crew, and quits", async () => { + const { fake, ops, docs } = crews(); + const { host } = renderApp(fake); + await crewOpened("Ops"); + await waitFor(() => expect(host.tray).not.toBeNull()); + + await act(() => host.requestClose()); + expect(host.hidden).toBe(true); + expect(host.closed).toBe(false); + act(() => host.tray?.actions.open()); + expect(host.hidden).toBe(false); + + await act(async () => host.tray?.actions.pause()); + await waitFor(() => expect(view(host)?.pause).toBe("Resume every crew")); + expect([fake.crew(ops.id).paused, fake.crew(docs.id).paused]).toEqual([true, true]); + await act(async () => host.tray?.actions.pause()); + await waitFor(() => expect(view(host)?.pause).toBe("Pause every crew")); + expect([fake.crew(ops.id).paused, fake.crew(docs.id).paused]).toEqual([false, false]); + + act(() => host.tray?.actions.quit()); + expect(host.closed).toBe(true); + expect(host.stops).toBe(0); + }); + + test("is not there when the owner turns it off, and closing then closes Botloft", async () => { + const { fake } = crews(); + const { host } = renderApp(fake); + await crewOpened("Ops"); + await waitFor(() => expect(host.tray).not.toBeNull()); + act(() => prefs.tray.set(false)); + expect(host.tray).toBeNull(); + await act(() => host.requestClose()); + expect(host.closed).toBe(true); + expect(host.stops).toBe(0); + }); +}); + +describe("opening at sign-in", () => { + test("follows starting with Windows, the icon and the window choice", async () => { + const { fake } = crews(); + const { host } = renderApp(fake); + await crewOpened("Ops"); + await waitFor(() => expect(host.openAtSignIn).toBe(true)); + act(() => prefs.tray.set(false)); + expect(host.openAtSignIn).toBe(false); + act(() => prefs.openAtSignIn.set(true)); + expect(host.openAtSignIn).toBe(true); + }); + + test("does not open the app when the bots do not start with Windows", async () => { + const { fake } = crews(); + fake.settings = { ...fake.settings, startWithWindows: false }; + const { host } = renderApp(fake); + await crewOpened("Ops"); + await waitFor(() => expect(host.openAtSignIn).toBe(false)); + }); + + test("leaves the window hidden near the clock, unless the owner wants it", async () => { + const { fake } = crews(); + const host = new FakeHost(); + host.atSignIn = true; + host.hidden = true; + renderApp(fake, host); + await crewOpened("Ops"); + await waitFor(() => expect(host.tray).not.toBeNull()); + expect(host.hidden).toBe(true); + cleanup(); + + prefs.openAtSignIn.set(true); + const again = new FakeHost(); + again.atSignIn = true; + again.hidden = true; + renderApp(crews().fake, again); + await waitFor(() => expect(again.hidden).toBe(false)); + }); + + test("shows the window when Botloft cannot start", async () => { + const host = new FakeHost(); + host.atSignIn = true; + host.hidden = true; + host.status = { state: "stopped", port: 45710, home: "C:\\data" }; + host.afterInstall = new Error("no way"); + renderApp(new FakeBotloft(), host); + await screen.findByRole("alert"); + expect(host.hidden).toBe(false); + }); +}); diff --git a/app/src/shell/tray.ts b/app/src/shell/tray.ts new file mode 100644 index 00000000..3e5100b8 --- /dev/null +++ b/app/src/shell/tray.ts @@ -0,0 +1,108 @@ +// The icon near the clock (spec 15.1): while the bots keep working after +// the window closes and the owner wants it, Botloft stays there with what +// the bots are doing, a dot when something waits, and Open, Pause every +// crew and Quit. + +import { useEffect } from "react"; +import { t, useT } from "../i18n"; +import { type AppState, crewList } from "../store/app"; +import { useApi, useAppStore, useHost } from "../store/context"; +import { attempt } from "../ui/toast"; +import { attentionCount } from "./attention"; +import { prefs, usePref } from "./prefs"; + +let shown = false; + +/** Whether the icon is there: closing the window then only hides it. */ +export function trayShown(): boolean { + return shown; +} + +function activeCrews(state: AppState) { + return crewList(state).filter((crew) => crew.archivedAt === null); +} + +function status(state: AppState): string { + const words = t().alerts.tray; + const bots = Object.values(state.bots).filter((bot) => bot.archivedAt === null); + const needing = bots.find((bot) => bot.state === "needs_approval" || bot.state === "auth_error"); + if (needing) { + return words.needsYou(needing.name); + } + if (attentionCount(state) > 0) { + return words.waiting; + } + const working = bots.filter((bot) => bot.state === "busy").length; + return working > 0 ? words.working(working) : words.idle; +} + +export function useTray(): void { + const host = useHost(); + const api = useApi(); + const store = useAppStore(); + const tray = usePref(prefs.tray); + const keep = usePref(prefs.whenClosed) === "keep"; + const wanted = tray && keep; + // The texts follow the language. + const words = useT().alerts.tray; + + useEffect(() => { + if (!wanted) { + shown = false; + host.hideTray().catch(() => {}); + return; + } + const pauseAll = () => { + const crews = activeCrews(store.getState()); + const paused = !crews.every((crew) => crew.paused); + const failed = t().crews.view.failed; + attempt(paused ? failed.pause : failed.resume, async () => { + for (const crew of crews.filter((each) => each.paused !== paused)) { + store.getState().putCrew(await api.call("crews.setPaused", { crewId: crew.id, paused })); + } + }); + }; + let last = ""; + const draw = (state: AppState) => { + const crews = activeCrews(state); + const allPaused = crews.length > 0 && crews.every((crew) => crew.paused); + const line = status(state); + const view = { + tooltip: words.tooltip(line), + status: line, + attention: attentionCount(state) > 0, + open: words.open, + pause: allPaused ? words.resume : words.pause, + quit: words.quit, + }; + const key = JSON.stringify(view); + if (key === last) { + return; + } + last = key; + host + .showTray(view, { + open: () => { + host.window.show().catch(() => {}); + }, + pause: pauseAll, + quit: () => { + host.window.quit().catch(() => {}); + }, + }) + .then( + () => { + shown = true; + }, + () => {}, + ); + }; + draw(store.getState()); + const unsubscribe = store.subscribe(draw); + return () => { + unsubscribe(); + shown = false; + host.hideTray().catch(() => {}); + }; + }, [host, api, store, wanted, words]); +} diff --git a/app/src/store/app.ts b/app/src/store/app.ts index a3f38eea..eb2af0fe 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -14,6 +14,7 @@ import type { MessageId, Routine, RoutineId, + Settings, SystemStatus, Task, TaskId, @@ -26,6 +27,8 @@ export interface AppState { loaded: boolean; loadError: string | null; system: SystemStatus | null; + /** The daemon's part of Settings (spec 11.2); null until read, or from an older daemon. */ + settings: Settings | null; crews: Record; bots: Record; /** @@ -50,6 +53,7 @@ export interface AppState { putDelivery(delivery: Delivery): void; putRoutine(routine: Routine): void; putBrowser(browser: BrowserState): void; + putSettings(settings: Settings | null): void; } export type AppStore = StoreApi; @@ -86,6 +90,7 @@ export function createAppStore(api: BotloftApi): AppStore { loaded: false, loadError: null, system: null, + settings: null, crews: {}, bots: {}, deliveries: {}, @@ -113,6 +118,7 @@ export function createAppStore(api: BotloftApi): AppStore { putBot: (bot) => set((state) => withBot(state, bot)), putRoutine: (routine) => set((state) => withRoutine(state, routine)), putBrowser: (browser) => set((state) => withBrowser(state, browser)), + putSettings: (settings) => set({ settings }), putDelivery: (delivery) => set((state) => ({ deliveries: { ...state.deliveries, [delivery.messageId]: delivery } })), })); diff --git a/app/src/store/sync.ts b/app/src/store/sync.ts index 21201dcd..3aa82c57 100644 --- a/app/src/store/sync.ts +++ b/app/src/store/sync.ts @@ -25,6 +25,11 @@ export function syncStore(store: AppStore, api: BotloftApi): () => void { store.setState({ loaded: false, loadError: null }); const fail = (error: unknown) => alive && store.setState({ loadError: errorText(error) }); refreshStatus(); + api.call("settings.get").then( + (settings) => alive && store.setState({ settings }), + // An older daemon has no settings: they stay unset. + () => {}, + ); const crews = api.call("crews.list").then((list) => { if (alive) { store.setState({ crews: Object.fromEntries(list.map((crew) => [crew.id, crew])) }); diff --git a/docs/spec.md b/docs/spec.md index d22e81b0..04d77954 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -620,7 +620,9 @@ Layout, como um app de mensagens: - **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. - **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. -- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Aparência** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Iniciar com o Windows" (`startWithWindows`, 14) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. +- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. +- **Perto do relógio:** com os bots continuando depois de fechar e "Mostrar o Botloft perto do relógio" ligada, o app põe um ícone na área de notificação (o ícone do app, com um ponto quando algo espera o dono, como o overlay da taskbar). A dica diz "Botloft: " e o que acontece: um bot que precisa do dono pelo nome, algo esperando, quantos bots trabalham ou nenhum. Um clique abre a janela; o menu tem essa mesma linha, "Abrir o Botloft", "Pausar todas as equipes" (ou "Retomar todas as equipes", com todas pausadas) e "Sair do Botloft", que fecha o app e deixa os bots trabalhando. Com o ícone, fechar a janela só a esconde; sem ele, o app fecha. +- **Avisos:** quando um bot passa a precisar do dono (pedido de permissão, com a ação no idioma do dono, por exemplo "Rodar um comando", sem o comando; ou entrar na conta do Claude) e, se o dono quiser, quando um bot passa de trabalhando a disponível ("Scout terminou", com a equipe). Com o Botloft fora da frente (janela escondida, minimizada ou sem foco), é um aviso do Windows, com o som do Windows se "Tocar um som" estiver ligada; com ele na frente, só um som curto feito na hora (duas notas, sem arquivo). O que já estava assim quando o app conectou não avisa. Abrir o Botloft pelo aviso (o que traz a janela aberta, 15.2) até 10 min depois abre o bot do último aviso. - **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve). - **Modo do bot**, no compositor, ao lado do clipe, como no Claude Code: um botão com o modo atual abre um menu para cima, "Modo", com Automático, Manual, Aceitar edições e Plano, cada um com uma linha que fala do bot pelo nome ("Scout decide o que precisa do seu OK") e a marca no atual. Separado, "Ignorar permissões" com o botão Ativar, que abre uma confirmação dizendo que o bot não fica preso à pasta dele (13). Nesse modo o botão fica vermelho e o cabeçalho mostra "Não pergunta nada" em vermelho. Com o bot ocupado, uma linha acima do compositor avisa que ele muda de modo quando terminar o que está fazendo. - **Modelo do bot**, no compositor, ao lado do botão de enviar: mostra o modelo em uso ("Opus 5.5"; antes do primeiro turno, o nome escolhido ou "Padrão") e abre um menu para cima, "Modelo", com Padrão do plano, Fable, Opus, Sonnet e Haiku. Cada um tem uma linha que fala do bot pelo nome ("Scout fica rápido e capaz, bom para quase tudo"; o padrão diz qual modelo é hoje), e o pé do menu lembra que modelos mais capazes gastam o limite do plano mais rápido. A janela de criar e editar bot tem a mesma escolha. Com o bot ocupado, a mesma linha acima do compositor avisa que ele troca quando terminar. @@ -639,7 +641,13 @@ Layout, como um app de mensagens: | `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 | | `daemon_stop` | `botloftd --home service stop`, do mesmo jeito: para o daemon até o app abrir de novo, ou até o próximo logon se ele inicia com o Windows (14) | -| fechar a janela | não é comando próprio: o app ouve `onCloseRequested` da janela (botão, Alt+F4, barra de tarefas; permissões `core:window:allow-hide` e `core:window:allow-destroy`) e, se o dono escolheu parar os bots ao fechar, esconde a janela, chama `daemon_stop` e deixa fechar, mesmo se ele falhar | +| fechar a janela | não é comando próprio: o app ouve `onCloseRequested` da janela (botão, Alt+F4, barra de tarefas; permissões `core:window:allow-hide` e `core:window:allow-destroy`). Se o dono escolheu parar os bots ao fechar, esconde a janela, chama `daemon_stop` e deixa fechar, mesmo se ele falhar; com o ícone perto do relógio, só esconde a janela; senão, fecha | +| janela escondida ao abrir | a janela nasce com `visible: false` e o `setup` a mostra, a menos que o app tenha sido aberto com `--autostart` (ao entrar no Windows): aí ela fica escondida se o Botloft espera perto do relógio, e o app a mostra se o dono quer a janela ou se algo deu errado antes de conectar. Mostrar é `show`, `unminimize` e `setFocus` (permissões de cada um) | +| `open_at_sign_in` | grava ou apaga o valor `Botloft` na chave Run do usuário (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`) com `"" --autostart`, entre aspas por causa de caminhos com espaço (o `tauri-plugin-autostart` grava sem aspas). Ligando, também libera o app se ele estava desligado nos apps de inicialização do Gerenciador de Tarefas (`StartupApproved\Run`). Build de dev nunca se registra. O app liga quando os bots iniciam com o Windows e o dono quer o Botloft perto do relógio ou a janela aberta, e desliga senão, a cada mudança | +| `launched_at_sign_in` | se o app foi aberto com `--autostart` | +| uma cópia só | `tauri-plugin-single-instance`, só em build de release (a de dev roda ao lado da instalada): abrir o Botloft de novo, pelo menu Iniciar ou por um aviso, traz a janela da cópia que roda e manda `botloft://reopened` ao app | +| ícone perto do relógio | não é comando próprio: `TrayIcon` e `Menu` do Tauri (feature `tray-icon`, `core:tray` e `core:menu` do `core:default`, e `core:app:allow-default-window-icon`), com as mudanças em fila para nunca criar dois ícones | +| avisos do Windows | `tauri-plugin-notification` (`notification:default`). Sem `sound`, o toast é mudo; com "Tocar um som", `sound: "Default"`. O app instalado usa o próprio AppUserModelID; em dev, o do PowerShell | | `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 | @@ -695,7 +703,7 @@ Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em ` - `pnpm bundle` (em `app/`) roda `scripts/sidecar.mjs`, que compila o `botloftd` em release e o copia para `src-tauri/binaries/botloftd-.exe`, e depois `tauri build --config src-tauri/tauri.bundle.conf.json`. Esse arquivo liga o `externalBin` e o NSIS; ele fica fora do `tauri.conf.json` porque o `tauri-build` copia o `externalBin` também em dev e no `cargo clippy`, o que exigiria o sidecar em todo build e sobrescreveria o `target\debug\botloftd.exe` com a cópia de release. - NSIS por usuário (`installMode = currentUser`), sem pedir administrador, em `%LOCALAPPDATA%\Botloft` (seção 5). O instalador não mexe no daemon: ele roda da própria cópia em `\bin`, então o arquivo do sidecar nunca está em uso. -- 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). +- 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, e o valor `Botloft` da chave Run sai (15.2); 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