From bb7daf55b3496658ab73e97513977d6305888157 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:34:59 -0300 Subject: [PATCH 1/2] feat(daemon): the owner chooses whether Botloft starts with Windows and keeps the computer awake settings.get and settings.update read and change start_with_windows and keep_awake in config.toml, written in place with toml_edit so the owner's own lines and comments stay. keep_awake applies at once. The scheduled task's triggers follow the owner's choices: while running, the minute trigger plus the logon trigger when Botloft starts with Windows; after `service stop` (the app closing with the bots), only the logon trigger when it starts with Windows. The minute trigger also fires after a new sign-in, so a daemon started by the task (`--scheduled`) compares the sign-in with \signin and, when Botloft does not start with Windows, takes the triggers off and exits until the app opens it. Spec 6, 11.2, 14, 15.1 and 15.2 describe it. --- Cargo.lock | 2 + app/src/lib/protocol.gen.ts | 21 +++ crates/botloft-core/src/protocol/export.rs | 8 + crates/botloft-core/src/protocol/mod.rs | 4 + crates/botloft-core/src/protocol/settings.rs | 29 ++++ crates/botloftd/Cargo.toml | 3 +- crates/botloftd/src/autostart/choice.rs | 150 +++++++++++++++++ crates/botloftd/src/autostart/mod.rs | 44 +++-- crates/botloftd/src/config.rs | 4 + crates/botloftd/src/keep_awake.rs | 80 +++++++-- crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/main.rs | 34 ++-- crates/botloftd/src/platform/mod.rs | 17 +- crates/botloftd/src/platform/unix.rs | 5 + crates/botloftd/src/platform/windows.rs | 2 + .../botloftd/src/platform/windows/sign_in.rs | 38 +++++ crates/botloftd/src/platform/windows/task.rs | 8 +- .../botloftd/src/platform/windows/task_xml.rs | 83 +++++++-- crates/botloftd/src/rpc/dispatch.rs | 4 +- crates/botloftd/src/service/mod.rs | 7 +- crates/botloftd/src/service/settings.rs | 36 ++++ crates/botloftd/src/settings.rs | 157 ++++++++++++++++++ crates/botloftd/src/state.rs | 5 + crates/botloftd/tests/common/mod.rs | 3 + crates/botloftd/tests/settings.rs | 49 ++++++ docs/spec.md | 17 +- 26 files changed, 742 insertions(+), 69 deletions(-) create mode 100644 crates/botloft-core/src/protocol/settings.rs create mode 100644 crates/botloftd/src/autostart/choice.rs create mode 100644 crates/botloftd/src/platform/windows/sign_in.rs create mode 100644 crates/botloftd/src/service/settings.rs create mode 100644 crates/botloftd/src/settings.rs create mode 100644 crates/botloftd/tests/settings.rs diff --git a/Cargo.lock b/Cargo.lock index 92df1372..68a297ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.30.0", "toml 1.1.6+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -4575,6 +4576,7 @@ dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 9f64e58d..c6b00730 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -704,6 +704,25 @@ note?: string, */ input?: string, }; +/** + * The daemon's part of the app's Settings. + */ +export type Settings = { +/** + * Botloft starts when the owner signs in to Windows (spec 14). Off, + * the bots wait until the owner opens the app. + */ +startWithWindows: boolean, +/** + * The computer does not sleep while a bot works (spec 14). + */ +keepAwake: boolean, }; + +/** + * The settings to change; the ones left out stay as they are. + */ +export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boolean, }; + /** * When a routine runs (spec 20.2), kept as structure rather than cron text * so the app can show and edit it without jargon. @@ -950,6 +969,8 @@ export interface RpcMethods { "session.hello": { params: HelloParams; result: HelloResult }; "system.status": { params: undefined; result: SystemStatus }; "system.refresh": { params: undefined; result: SystemStatus }; + "settings.get": { params: undefined; result: Settings }; + "settings.update": { params: SettingsUpdateParams; result: Settings }; "crews.list": { params: undefined; result: Array }; "crews.create": { params: CrewsCreateParams; result: Crew }; "crews.rename": { params: CrewsRenameParams; result: Crew }; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index f10569a4..7e5617a0 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -162,6 +162,8 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); routines::decls(&mut out); browser::decls(&mut out); @@ -188,6 +190,12 @@ fn export_bindings() { "undefined", &out.name::(), ); + out.method(method::SETTINGS_GET, "undefined", &out.name::()); + out.method( + method::SETTINGS_UPDATE, + &out.name::(), + &out.name::(), + ); out.method(method::CREWS_LIST, "undefined", &crews); out.method( method::CREWS_CREATE, diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 0fbd4d70..43054c83 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -60,6 +60,7 @@ mod methods; mod model; mod routines; mod screens; +mod settings; pub use browser::*; pub use browser_input::*; @@ -70,6 +71,7 @@ pub use methods::*; pub use model::*; pub use routines::*; pub use screens::*; +pub use settings::*; /// Version negotiated in `session.hello`. 2 replaced the terminal with the /// chat (ADR 0001). @@ -119,6 +121,8 @@ pub mod method { pub const BROWSER_RELEASE: &str = "browser.release"; pub const BROWSER_INPUT: &str = "browser.input"; pub const SCREENS_LIST: &str = "screens.list"; + pub const SETTINGS_GET: &str = "settings.get"; + pub const SETTINGS_UPDATE: &str = "settings.update"; } /// Server notification method names. diff --git a/crates/botloft-core/src/protocol/settings.rs b/crates/botloft-core/src/protocol/settings.rs new file mode 100644 index 00000000..6ac150b5 --- /dev/null +++ b/crates/botloft-core/src/protocol/settings.rs @@ -0,0 +1,29 @@ +//! What the owner changes in the app's Settings and the daemon keeps in +//! `config.toml` (spec 6, 11.2). + +use serde::{Deserialize, Serialize}; + +/// The daemon's part of the app's Settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Settings { + /// Botloft starts when the owner signs in to Windows (spec 14). Off, + /// the bots wait until the owner opens the app. + pub start_with_windows: bool, + /// The computer does not sleep while a bot works (spec 14). + pub keep_awake: bool, +} + +/// The settings to change; the ones left out stay as they are. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct SettingsUpdateParams { + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub start_with_windows: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub keep_awake: Option, +} diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 08ad91bc..ad01857b 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -27,6 +27,7 @@ thiserror = "2.0.21" tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "signal", "time"] } tokio-tungstenite = "0.30.0" toml = "1.1.6" +toml_edit = "0.25.15" tracing = "0.1.44" tracing-appender = "0.2.5" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } @@ -35,7 +36,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } workspace = true [target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_Security_Authentication_Identity"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity"] } [target."cfg(windows)".build-dependencies] embed-resource = "3.0.11" diff --git a/crates/botloftd/src/autostart/choice.rs b/crates/botloftd/src/autostart/choice.rs new file mode 100644 index 00000000..bba95525 --- /dev/null +++ b/crates/botloftd/src/autostart/choice.rs @@ -0,0 +1,150 @@ +//! The owner's choices about when Botloft runs (spec 14): starting with +//! Windows, and stopping it when they close the app. +//! +//! The task's triggers follow them: +//! - running: the minute trigger that brings a stopped daemon back, plus +//! the sign-in trigger when Botloft starts with Windows; +//! - stopped by the owner: only the sign-in trigger, when Botloft starts +//! with Windows, so nothing brings it back before then. +//! +//! The minute trigger also fires after a new sign-in. When Botloft does +//! not start with Windows, the daemon it starts sees that it is a new +//! sign-in (`\signin` names the last one Botloft ran in), takes the +//! triggers off and exits. The app starts it again when the owner opens it. + +use std::path::Path; + +use anyhow::{Context, bail}; +use tracing::{info, warn}; + +use super::{config, register, stop_task, task_name}; +use crate::platform::{self, Triggers}; + +const SIGN_IN_FILE: &str = "signin"; + +/// The triggers while the daemon should run. +pub(super) fn running(start_with_windows: bool) -> Triggers { + Triggers { + logon: start_with_windows, + watchdog: true, + } +} + +/// The triggers after the owner stopped it. +fn stopped(start_with_windows: bool) -> Triggers { + Triggers { + logon: start_with_windows, + watchdog: false, + } +} + +/// Records that Botloft runs in the owner's current sign-in. +pub(super) fn mark_sign_in(home: &Path) { + let Some(id) = platform::sign_in_id() else { + return; + }; + if let Err(err) = std::fs::write(home.join(SIGN_IN_FILE), id) { + warn!("cannot record the sign-in Botloft runs in: {err}"); + } +} + +/// Whether Botloft has not run in this sign-in yet. Unknown sign-ins +/// count as the same one, so the daemon keeps running. +fn is_new_sign_in(home: &Path) -> bool { + let Some(id) = platform::sign_in_id() else { + return false; + }; + std::fs::read_to_string(home.join(SIGN_IN_FILE)).map_or(true, |last| last.trim() != id) +} + +/// What the daemon started by its task does first. Returns whether it +/// should run: not after a new sign-in when Botloft does not start with +/// Windows. +pub fn scheduled_start(home: &Path, start_with_windows: bool) -> bool { + if !start_with_windows && is_new_sign_in(home) { + if let Err(err) = register(home, stopped(false)) { + warn!("cannot take the triggers off the scheduled task: {err:#}"); + } + return false; + } + mark_sign_in(home); + // Started at sign-in after the owner stopped it: the minute trigger + // comes back. + let wanted = running(start_with_windows); + let task = platform::find_task(&task_name(home)); + if task.is_ok_and(|task| task.is_some_and(|task| task.triggers != wanted)) + && let Err(err) = register(home, wanted) + { + warn!("cannot update the scheduled task: {err:#}"); + } + true +} + +/// `service stop`: stops the daemon until the owner opens Botloft again, +/// or until the next sign-in when it starts with Windows. +pub fn stop(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + if platform::find_task(&name)?.is_none() { + bail!("the scheduled task {name} is not installed; run `botloftd service install`"); + } + let config = config(home)?; + register(home, stopped(config.start_with_windows))?; + stop_task(&name, config.port)?; + println!("Stopped the daemon of the scheduled task {name}."); + Ok(()) +} + +/// Follows the owner turning starting with Windows on or off while the +/// daemon runs. A task that is not installed has nothing to follow. +pub fn set_start_with_windows(home: &Path, start: bool) -> anyhow::Result<()> { + let name = task_name(home); + let task = platform::find_task(&name) + .with_context(|| format!("cannot read the scheduled task {name}"))?; + if let Some(task) = task { + register( + home, + Triggers { + logon: start, + watchdog: task.triggers.watchdog, + }, + )?; + } + // The daemon runs in this sign-in, whatever started it. + mark_sign_in(home); + info!(start, "start with Windows"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sign_in_is_new_until_botloft_runs_in_it() { + let home = tempfile::tempdir().expect("home"); + let Some(id) = platform::sign_in_id() else { + // Without a sign-in to name, the daemon always runs. + assert!(!is_new_sign_in(home.path())); + return; + }; + assert!(is_new_sign_in(home.path()), "nothing recorded yet"); + std::fs::write(home.path().join(SIGN_IN_FILE), "1-1").expect("write"); + assert!(is_new_sign_in(home.path()), "another sign-in"); + mark_sign_in(home.path()); + assert_eq!( + std::fs::read_to_string(home.path().join(SIGN_IN_FILE)).expect("read"), + id + ); + assert!(!is_new_sign_in(home.path())); + } + + #[test] + fn running_keeps_the_minute_trigger_and_stopping_takes_it_off() { + for start in [true, false] { + assert_eq!(running(start).logon, start); + assert_eq!(stopped(start).logon, start); + assert!(running(start).watchdog); + assert!(!stopped(start).watchdog); + } + } +} diff --git a/crates/botloftd/src/autostart/mod.rs b/crates/botloftd/src/autostart/mod.rs index 3b92c2ad..21075e4b 100644 --- a/crates/botloftd/src/autostart/mod.rs +++ b/crates/botloftd/src/autostart/mod.rs @@ -4,8 +4,11 @@ //! owner's Claude Code sign-in. pub mod binary; +mod choice; pub mod health; +pub use choice::{scheduled_start, set_start_with_windows, stop}; + use std::path::{Path, PathBuf}; use std::time::Duration; @@ -14,7 +17,7 @@ use sha2::{Digest, Sha256}; use crate::config::Config; use crate::paths; -use crate::platform::{self, TaskDefinition, TaskState}; +use crate::platform::{self, TaskDefinition, TaskState, Triggers}; /// How long a daemon may take to stop or to answer after starting. const STOP_TIMEOUT: Duration = Duration::from_secs(10); @@ -42,20 +45,34 @@ fn quote(arg: &str) -> String { format!("\"{arg}{}\"", "\\".repeat(trailing)) } -fn definition(home: &Path, program: &Path) -> TaskDefinition { +/// `--scheduled` tells the daemon the task started it (`choice`). +fn definition(home: &Path, program: &Path, triggers: Triggers) -> TaskDefinition { let home_text = home.to_string_lossy(); TaskDefinition { description: format!( "Starts the Botloft daemon at logon and keeps it running. Data folder: {home_text}" ), program: program.to_owned(), - arguments: format!("serve --home {}", quote(&home_text)), + arguments: format!("serve --home {} --scheduled", quote(&home_text)), working_dir: home.to_owned(), + triggers, } } +/// Registers the task with `triggers`, running the installed daemon. +fn register(home: &Path, triggers: Triggers) -> anyhow::Result<()> { + let name = task_name(home); + let program = bin_dir(home).join(binary::EXE_NAME); + platform::register_task(&name, &definition(home, &program, triggers)) + .with_context(|| format!("cannot register the scheduled task {name}")) +} + +fn config(home: &Path) -> anyhow::Result { + Ok(Config::load(&home.join("config.toml"), false)?) +} + fn port(home: &Path) -> anyhow::Result { - Ok(Config::load(&home.join("config.toml"), false)?.port) + Ok(config(home)?.port) } fn bin_dir(home: &Path) -> PathBuf { @@ -67,13 +84,15 @@ fn bin_dir(home: &Path) -> PathBuf { /// binary is left alone, so its bots are not interrupted. pub fn install(home: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(home).with_context(|| format!("cannot create {}", home.display()))?; - let port = port(home)?; + let config = config(home)?; + let port = config.port; let exe = std::env::current_exe().context("cannot find this program")?; let installed = binary::install(&exe, &bin_dir(home)) .with_context(|| format!("cannot copy the daemon to {}", bin_dir(home).display()))?; let name = task_name(home); - platform::register_task(&name, &definition(home, &installed.path)) - .with_context(|| format!("cannot register the scheduled task {name}"))?; + register(home, choice::running(config.start_with_windows))?; + // The owner opened Botloft: it runs in this sign-in. + choice::mark_sign_in(home); println!( "Scheduled task {name} starts {} at logon.", installed.path.display() @@ -85,7 +104,7 @@ pub fn install(home: &Path) -> anyhow::Result<()> { println!("The daemon {VERSION} is already running on 127.0.0.1:{port}."); return Ok(()); } - restart_task(&name, port) + restart_task(home, port) } /// Stops the daemon and starts it again from the task. @@ -94,11 +113,13 @@ pub fn restart(home: &Path) -> anyhow::Result<()> { if platform::find_task(&name)?.is_none() { bail!("the scheduled task {name} is not installed; run `botloftd service install`"); } - restart_task(&name, port(home)?) + restart_task(home, port(home)?) } -fn restart_task(name: &str, port: u16) -> anyhow::Result<()> { +fn restart_task(home: &Path, port: u16) -> anyhow::Result<()> { + let name = &task_name(home); stop_task(name, port)?; + choice::mark_sign_in(home); platform::run_task(name).with_context(|| format!("cannot start the scheduled task {name}"))?; let up = health::wait(port, START_TIMEOUT, |health| { health.is_some_and(|health| health.version == VERSION) @@ -206,8 +227,9 @@ mod tests { let task = definition( Path::new(r"C:\data"), Path::new(r"C:\data\bin\botloftd.exe"), + choice::running(true), ); - assert_eq!(task.arguments, r#"serve --home "C:\data""#); + assert_eq!(task.arguments, r#"serve --home "C:\data" --scheduled"#); assert!(task.description.ends_with(r"Data folder: C:\data")); } diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs index b6729008..b8085cd1 100644 --- a/crates/botloftd/src/config.rs +++ b/crates/botloftd/src/config.rs @@ -14,6 +14,9 @@ pub struct Config { pub workspaces_root: String, /// Empty means resolve `claude` from PATH. pub claude_path: String, + /// Start when the owner signs in to Windows (spec 14). + pub start_with_windows: bool, + /// Keep the computer awake while a bot works (spec 14). pub keep_awake: bool, pub log_level: String, pub supervisor: SupervisorConfig, @@ -29,6 +32,7 @@ impl Default for Config { port: 45710, workspaces_root: String::new(), claude_path: String::new(), + start_with_windows: true, keep_awake: true, log_level: "info".to_owned(), supervisor: SupervisorConfig::default(), diff --git a/crates/botloftd/src/keep_awake.rs b/crates/botloftd/src/keep_awake.rs index 3084a468..c0ac3879 100644 --- a/crates/botloftd/src/keep_awake.rs +++ b/crates/botloftd/src/keep_awake.rs @@ -1,13 +1,15 @@ //! Keeps the computer awake while any bot is `busy`, when `keep_awake` is -//! on (spec 14). A bot waiting for the owner or idle lets it sleep. +//! on (spec 14). A bot waiting for the owner or idle lets it sleep, and so +//! does the owner turning it off in Settings. use tokio::sync::watch; use tracing::{debug, warn}; use crate::platform::KeepAwake; -/// Follows the supervisor's count of busy bots until the daemon stops. -pub async fn run(busy: watch::Receiver) { +/// Follows the supervisor's count of busy bots and the owner's setting +/// until the daemon stops. +pub async fn run(busy: watch::Receiver, enabled: watch::Receiver) { let mut request = match KeepAwake::new() { Ok(request) => request, Err(err) => { @@ -15,7 +17,7 @@ pub async fn run(busy: watch::Receiver) { return; } }; - follow(busy, |on| { + follow(busy, enabled, |on| { debug!(on, "keep awake"); if let Err(err) = request.set(on) { warn!("cannot change the keep-awake request: {err}"); @@ -25,16 +27,24 @@ pub async fn run(busy: watch::Receiver) { } /// Calls `hold` with `true` when the first bot starts working and with -/// `false` when the last one stops. -async fn follow(mut busy: watch::Receiver, mut hold: impl FnMut(bool)) { +/// `false` when the last one stops, while `enabled`. +async fn follow( + mut busy: watch::Receiver, + mut enabled: watch::Receiver, + mut hold: impl FnMut(bool), +) { let mut held = false; loop { - let working = *busy.borrow_and_update() > 0; - if working != held { - hold(working); - held = working; + let wanted = *enabled.borrow_and_update() && *busy.borrow_and_update() > 0; + if wanted != held { + hold(wanted); + held = wanted; } - if busy.changed().await.is_err() { + let gone = tokio::select! { + changed = busy.changed() => changed.is_err(), + changed = enabled.changed() => changed.is_err(), + }; + if gone { break; } } @@ -49,24 +59,58 @@ mod tests { use super::*; - #[tokio::test] - async fn holds_only_while_some_bot_is_busy() { - let (count, busy) = watch::channel(0usize); - let calls = Arc::new(Mutex::new(Vec::new())); + type Calls = Arc>>; + + fn start( + busy: watch::Receiver, + enabled: watch::Receiver, + ) -> (tokio::task::JoinHandle<()>, Calls) { + let calls = Calls::default(); let seen = Arc::clone(&calls); - let task = tokio::spawn(follow(busy, move |on| { + let task = tokio::spawn(follow(busy, enabled, move |on| { seen.lock().expect("calls").push(on); })); + (task, calls) + } + + async fn settle() { + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + + #[tokio::test] + async fn holds_only_while_some_bot_is_busy() { + let (count, busy) = watch::channel(0usize); + let (_setting, enabled) = watch::channel(true); + let (task, calls) = start(busy, enabled); for n in [1, 2, 1, 0, 0, 3] { count.send_replace(n); - tokio::task::yield_now().await; - tokio::time::sleep(std::time::Duration::from_millis(5)).await; + settle().await; } drop(count); task.await.expect("follow ends with the daemon"); assert_eq!(*calls.lock().expect("calls"), [true, false, true, false]); } + #[tokio::test] + async fn the_owner_can_turn_it_off_and_on_while_bots_work() { + let (count, busy) = watch::channel(2usize); + let (setting, enabled) = watch::channel(false); + let (task, calls) = start(busy, enabled); + settle().await; + assert!(calls.lock().expect("calls").is_empty(), "off: never held"); + setting.send_replace(true); + settle().await; + setting.send_replace(false); + settle().await; + count.send_replace(0); + setting.send_replace(true); + settle().await; + drop(setting); + task.await.expect("follow ends with the daemon"); + assert_eq!(*calls.lock().expect("calls"), [true, false]); + } + #[test] fn a_keep_awake_request_can_be_set_and_cleared() { let mut request = KeepAwake::new().expect("request"); diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index b4105f28..20e3ac95 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -20,6 +20,7 @@ pub mod screens; pub mod secrets; pub mod server; pub mod service; +pub mod settings; pub mod state; pub mod supervisor; pub mod tools; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index c8a24150..b7c14979 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -15,6 +15,7 @@ use botloftd::paths::{self, Paths}; use botloftd::platform::{self, InstanceLock}; use botloftd::runtime::PipeRuntime; use botloftd::service::tasks::TaskSettings; +use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, SupervisorSettings}; use botloftd::{approvals, autostart, keep_awake, logging, routines, secrets, server}; @@ -39,6 +40,9 @@ enum Command { /// Config file. Defaults to config.toml in the data folder. #[arg(long)] config: Option, + /// Started by the scheduled task (spec 14). + #[arg(long, hide = true)] + scheduled: bool, }, /// Start the daemon at logon, as a scheduled task of the current user. #[command(subcommand)] @@ -53,6 +57,9 @@ enum ServiceCommand { Status, /// Stop the daemon and start it again. Restart, + /// Stop the daemon until Botloft is opened again, or until the next + /// logon if it starts with Windows. + Stop, /// Stop the daemon and remove the task. Bots and data stay. Uninstall, } @@ -73,31 +80,34 @@ fn dispatch(cli: Cli) -> anyhow::Result<()> { let home = paths::resolve_home(cli.home.as_deref()).context("cannot resolve the data folder")?; match cli.command { - Command::Serve { config } => serve(home, config), + Command::Serve { config, scheduled } => serve(home, config, scheduled), Command::Service(ServiceCommand::Install) => autostart::install(&home), Command::Service(ServiceCommand::Status) => autostart::status(&home), Command::Service(ServiceCommand::Restart) => autostart::restart(&home), + Command::Service(ServiceCommand::Stop) => autostart::stop(&home), Command::Service(ServiceCommand::Uninstall) => autostart::uninstall(&home), } } -fn serve(home: PathBuf, config_path: Option) -> anyhow::Result<()> { +fn serve(home: PathBuf, config_path: Option, scheduled: bool) -> anyhow::Result<()> { platform::leave_own_console(); std::fs::create_dir_all(&home).with_context(|| format!("cannot create {}", home.display()))?; - let config = match &config_path { - Some(path) => Config::load(path, true)?, - None => Config::load(&home.join("config.toml"), false)?, - }; + let config_path = config_path.unwrap_or_else(|| home.join("config.toml")); + let config = Config::load(&config_path, config_path != home.join("config.toml"))?; let paths = Paths::new(home, paths::resolve_workspaces_root(&config)?); std::fs::create_dir_all(paths.logs())?; let _log_guard = logging::init(&paths.logs(), &config.log_level)?; // Started by the scheduled task, nobody sees stderr: the log has to // say why the daemon stopped. - run(paths, config).inspect_err(|err| error!("{err:#}")) + run(paths, config, config_path, scheduled).inspect_err(|err| error!("{err:#}")) } -fn run(paths: Paths, config: Config) -> anyhow::Result<()> { +fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> anyhow::Result<()> { let _lock = InstanceLock::acquire(&paths.lock_file())?; + if scheduled && !autostart::scheduled_start(&paths.home, config.start_with_windows) { + info!("a new sign-in, and Botloft does not start with Windows: waiting to be opened"); + return Ok(()); + } let owner_token = secrets::load_or_create_owner_token(&paths.secrets()) .context("cannot prepare the owner token")?; @@ -118,6 +128,7 @@ fn run(paths: Paths, config: Config) -> anyhow::Result<()> { tasks: TaskSettings::from_config(&config), bots: BotSettings::from_config(&config), browser: BrowserSettings::from_config(&config), + settings: LiveSettings::new(Some(config_path), &config), }); // No bot process survived the last run, so nobody waits for these. approvals::expire_all(&daemon); @@ -139,9 +150,10 @@ fn run(paths: Paths, config: Config) -> anyhow::Result<()> { tokio::spawn(courier::run(Arc::clone(&daemon))); tokio::spawn(routines::run(Arc::clone(&daemon))); tokio::spawn(browser::run(Arc::clone(&daemon))); - if config.keep_awake { - tokio::spawn(keep_awake::run(daemon.supervisor.busy_bots())); - } + tokio::spawn(keep_awake::run( + daemon.supervisor.busy_bots(), + daemon.settings.keep_awake(), + )); server::serve(Arc::clone(&daemon), listener, platform::shutdown_signal()).await?; daemon.supervisor.shutdown(); info!("stopped"); diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 5fb51a5e..752556ac 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -13,15 +13,15 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] pub use unix::{ KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, stop_task, user_environment, + restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; #[cfg(windows)] pub use windows::{ KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, stop_task, user_environment, + restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; -/// A program the system starts when the owner logs on (spec 14). +/// A program the system starts for the owner (spec 14). #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskDefinition { pub description: String, @@ -29,6 +29,16 @@ pub struct TaskDefinition { /// Already quoted for the command line. pub arguments: String, pub working_dir: PathBuf, + pub triggers: Triggers, +} + +/// When the system starts the task (spec 14). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Triggers { + /// When the owner signs in to Windows. + pub logon: bool, + /// Every minute, which brings the program back soon after it stops. + pub watchdog: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,6 +54,7 @@ pub enum TaskState { pub struct TaskInfo { pub state: TaskState, pub command: Option, + pub triggers: Triggers, } /// Exclusive lock on `\botloftd.lock`, held for the daemon's lifetime. diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 738a38e0..1b8e49ac 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -102,3 +102,8 @@ pub fn stop_task(_name: &str) -> io::Result<()> { pub fn delete_task(_name: &str) -> io::Result { Err(no_tasks()) } + +/// Unix has no Windows sign-in to tell apart. +pub fn sign_in_id() -> Option { + None +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index 426b9876..e2645193 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -6,6 +6,7 @@ mod env; mod job; mod owner; mod power; +mod sign_in; mod task; mod task_xml; @@ -14,6 +15,7 @@ pub use env::user_environment; pub use job::ProcessJob; pub use owner::owner_name; pub use power::KeepAwake; +pub use sign_in::sign_in_id; pub use task::{delete_task, find_task, register_task, run_task, stop_task}; use std::io; diff --git a/crates/botloftd/src/platform/windows/sign_in.rs b/crates/botloftd/src/platform/windows/sign_in.rs new file mode 100644 index 00000000..c814eb98 --- /dev/null +++ b/crates/botloftd/src/platform/windows/sign_in.rs @@ -0,0 +1,38 @@ +//! Which Windows sign-in this process runs in, to tell a start after a new +//! sign-in from one in the same sign-in (spec 14). + +use windows::Win32::System::RemoteDesktop::{ + WTS_CURRENT_SERVER_HANDLE, WTS_CURRENT_SESSION, WTSFreeMemory, WTSINFOW, + WTSQuerySessionInformationW, WTSSessionInfo, +}; +use windows::core::PWSTR; + +/// The session and the time the owner signed in to it. The app and the +/// daemon started by the scheduled task see the same one; signing out and +/// in again, or shutting down, gives a new one. `None` if Windows does not +/// say. +pub fn sign_in_id() -> Option { + let mut buffer = PWSTR::null(); + let mut bytes = 0u32; + // SAFETY: on success Windows allocates `buffer`, freed below. + unsafe { + WTSQuerySessionInformationW( + Some(WTS_CURRENT_SERVER_HANDLE), + WTS_CURRENT_SESSION, + WTSSessionInfo, + &mut buffer, + &mut bytes, + ) + } + .ok()?; + if buffer.is_null() { + return None; + } + let whole = bytes as usize >= size_of::(); + // SAFETY: a WTSSessionInfo query fills one `WTSINFOW`, checked by size. + let info = whole.then(|| unsafe { buffer.0.cast::().read_unaligned() }); + // SAFETY: `buffer` came from WTSQuerySessionInformationW. + unsafe { WTSFreeMemory(buffer.0.cast()) }; + let info = info?; + (info.LogonTime != 0).then(|| format!("{}-{}", info.SessionId, info.LogonTime)) +} diff --git a/crates/botloftd/src/platform/windows/task.rs b/crates/botloftd/src/platform/windows/task.rs index dd89a366..83b55e0f 100644 --- a/crates/botloftd/src/platform/windows/task.rs +++ b/crates/botloftd/src/platform/windows/task.rs @@ -88,8 +88,8 @@ impl Scheduler { } } -/// Creates the task, or replaces its definition if it exists. It runs as -/// the current user, only while they are logged on. +/// Creates the task, or replaces its definition if it exists (which also +/// enables it). It runs as the current user, only while they are logged on. pub fn register_task(name: &str, task: &TaskDefinition) -> io::Result<()> { let xml = task_xml::render(task, &CurrentUser::query()?.sid_string()?); let scheduler = Scheduler::connect()?; @@ -123,9 +123,11 @@ pub fn find_task(name: &str) -> io::Result> { TASK_STATE_DISABLED => TaskState::Disabled, _ => TaskState::Unknown, }; + let xml = xml.to_string(); Ok(Some(TaskInfo { state, - command: task_xml::command_line(&xml.to_string()), + command: task_xml::command_line(&xml), + triggers: task_xml::triggers(&xml), })) } diff --git a/crates/botloftd/src/platform/windows/task_xml.rs b/crates/botloftd/src/platform/windows/task_xml.rs index 1966eb1b..868acf66 100644 --- a/crates/botloftd/src/platform/windows/task_xml.rs +++ b/crates/botloftd/src/platform/windows/task_xml.rs @@ -1,19 +1,24 @@ //! The definition of the daemon's scheduled task, in Task Scheduler XML. //! -//! - A logon trigger for the current user starts the daemon at logon. +//! - A logon trigger for the current user starts the daemon at logon, +//! when the owner wants Botloft to start with Windows. //! - A time trigger that began in the past and repeats every minute keeps //! it running: with `IgnoreNew`, a repetition does nothing while the //! daemon runs and starts it again within a minute after it dies. The //! logon trigger cannot repeat for this: its repetition only begins at //! the next logon, not when the task is installed. `RestartOnFailure` //! is not used either: it does not fire when the process exits with an -//! error (both tested on Windows 11 25H2, spec 14). +//! error (both tested on Windows 11 25H2, spec 14). It is left out while +//! the owner has stopped Botloft. //! - Interactive token, least privilege: only while the user is logged on, //! in their session, with their Claude Code sign-in. //! - No time limit, runs on battery, normal priority. The default priority //! (7) would pass "below normal" on to every bot. -use crate::platform::TaskDefinition; +use crate::platform::{TaskDefinition, Triggers}; + +const LOGON_TRIGGER: &str = ""; +const WATCHDOG_TRIGGER: &str = ""; pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { let sid = escape(user_sid); @@ -21,6 +26,7 @@ pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { let command = escape(&task.program.to_string_lossy()); let arguments = escape(&task.arguments); let working_dir = escape(&task.working_dir.to_string_lossy()); + let triggers = triggers_xml(task.triggers, &sid); format!( r#" @@ -29,19 +35,7 @@ pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { {description} - - true - {sid} - - - true - 2026-01-01T00:00:00 - - PT1M - false - - - +{triggers} {sid} @@ -80,6 +74,41 @@ pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { ) } +fn triggers_xml(triggers: Triggers, sid: &str) -> String { + let mut xml = String::new(); + if triggers.logon { + xml.push_str(&format!( + r" {LOGON_TRIGGER} + true + {sid} + +" + )); + } + if triggers.watchdog { + xml.push_str(&format!( + r" {WATCHDOG_TRIGGER} + true + 2026-01-01T00:00:00 + + PT1M + false + + +" + )); + } + xml +} + +/// Which triggers a registered task has, from its XML. +pub(super) fn triggers(xml: &str) -> Triggers { + Triggers { + logon: xml.contains(LOGON_TRIGGER), + watchdog: xml.contains(WATCHDOG_TRIGGER), + } +} + /// The command line of the task's first action, as the owner would type it. pub(super) fn command_line(xml: &str) -> Option { let command = unescape(element(xml, "Command")?); @@ -136,6 +165,10 @@ mod tests { program: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft\bin\botloftd.exe"), arguments: r#"serve --home "C:\Users\Ana Lima\AppData\Local\Botloft""#.into(), working_dir: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft"), + triggers: Triggers { + logon: true, + watchdog: true, + }, } } @@ -155,6 +188,24 @@ mod tests { assert!(xml.contains("InteractiveToken")); assert!(xml.contains("IgnoreNew")); assert!(xml.contains("PT0S")); + assert_eq!(triggers(&xml), task().triggers); + } + + #[test] + fn each_trigger_can_be_left_out() { + for (logon, watchdog) in [(true, false), (false, true), (false, false)] { + let mut task = task(); + task.triggers = Triggers { logon, watchdog }; + let xml = render(&task, "S-1-5-21-1-2-3-1001"); + assert_eq!(triggers(&xml), task.triggers); + assert_eq!(xml.contains("PT1M"), watchdog); + let users = xml.matches("S-1-5-21-1-2-3-1001").count(); + assert_eq!( + users, + if logon { 2 } else { 1 }, + "the principal is always there" + ); + } } #[test] diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index bbeba456..4f62a504 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,7 +9,7 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiResult, attachments, bots, chat, crews, deliveries, files, lead, messages, models, - modes, routines, screens, tasks, + modes, routines, screens, settings, tasks, }; use crate::state::Daemon; @@ -57,6 +57,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(tasks::list(daemon, parse(params)?)), method::BROWSER_LIST => reply(Ok(daemon.browsers.list())), method::SCREENS_LIST => reply(screens::list(daemon, parse(params)?)), + method::SETTINGS_GET => reply(settings::get(daemon)), + method::SETTINGS_UPDATE => reply(settings::update(daemon, parse(params)?)), method::SESSION_HELLO => Err(RpcError::new( error_code::CONFLICT, "this connection is already authenticated", diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index b6da8ae7..7aba88e0 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -13,6 +13,7 @@ pub mod models; pub mod modes; pub mod routines; pub mod screens; +pub mod settings; pub mod tasks; use botloft_core::protocol::{OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code}; @@ -39,6 +40,8 @@ pub enum ApiError { }, #[error("could not update the workspace: {0}")] Workspace(#[source] std::io::Error), + #[error("could not save the settings: {0}")] + Settings(String), #[error("internal error")] Internal(#[source] StoreError), } @@ -49,7 +52,9 @@ impl ApiError { Self::NotFound(_) => error_code::NOT_FOUND, Self::Conflict(_) => error_code::CONFLICT, Self::Validation(_) | Self::Rule { .. } => error_code::VALIDATION, - Self::Workspace(_) | Self::Internal(_) => error_code::INTERNAL_ERROR, + Self::Workspace(_) | Self::Settings(_) | Self::Internal(_) => { + error_code::INTERNAL_ERROR + } } } diff --git a/crates/botloftd/src/service/settings.rs b/crates/botloftd/src/service/settings.rs new file mode 100644 index 00000000..9f2b9f04 --- /dev/null +++ b/crates/botloftd/src/service/settings.rs @@ -0,0 +1,36 @@ +//! `settings.get` and `settings.update` (spec 11.2): what the owner +//! changes in the app's Settings. + +use botloft_core::protocol::{Settings, SettingsUpdateParams}; +use tracing::info; + +use super::{ApiError, ApiResult}; +use crate::autostart; +use crate::state::Daemon; + +pub fn get(daemon: &Daemon) -> ApiResult { + Ok(daemon.settings.get()) +} + +/// Saves the change in `config.toml` and applies it: the scheduled task +/// follows starting with Windows (spec 14), and keeping the computer +/// awake turns on or off at once. +pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult { + let before = daemon.settings.get(); + if let Some(start) = params.start_with_windows + && start != before.start_with_windows + { + autostart::set_start_with_windows(&daemon.paths.home, start) + .map_err(|err| ApiError::Settings(format!("{err:#}")))?; + } + let after = daemon + .settings + .save(params) + .map_err(|err| ApiError::Settings(err.to_string()))?; + info!( + start_with_windows = after.start_with_windows, + keep_awake = after.keep_awake, + "settings changed" + ); + Ok(after) +} diff --git a/crates/botloftd/src/settings.rs b/crates/botloftd/src/settings.rs new file mode 100644 index 00000000..e08c1e93 --- /dev/null +++ b/crates/botloftd/src/settings.rs @@ -0,0 +1,157 @@ +//! The settings the owner changes in the app (spec 6, 11.2). They live in +//! `config.toml` and are written back in place, so the owner's own lines +//! and comments stay, and they take effect at once. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +use botloft_core::protocol::{Settings, SettingsUpdateParams}; +use tokio::sync::watch; + +use crate::config::Config; + +pub struct LiveSettings { + /// Where they are saved; `None` keeps them in memory (tests). + file: Option, + current: Mutex, + keep_awake: watch::Sender, +} + +impl LiveSettings { + pub fn new(file: Option, config: &Config) -> Self { + let current = Settings { + start_with_windows: config.start_with_windows, + keep_awake: config.keep_awake, + }; + Self { + file, + current: Mutex::new(current), + keep_awake: watch::Sender::new(current.keep_awake), + } + } + + pub fn get(&self) -> Settings { + *self.lock() + } + + /// Follows `keep_awake` (spec 14). + pub fn keep_awake(&self) -> watch::Receiver { + self.keep_awake.subscribe() + } + + /// Saves the settings in `update` and applies them. + pub fn save(&self, update: SettingsUpdateParams) -> io::Result { + let mut current = self.lock(); + if let Some(file) = &self.file { + write(file, update)?; + } + if let Some(start) = update.start_with_windows { + current.start_with_windows = start; + } + if let Some(keep_awake) = update.keep_awake { + current.keep_awake = keep_awake; + self.keep_awake.send_replace(keep_awake); + } + Ok(*current) + } + + fn lock(&self) -> MutexGuard<'_, Settings> { + self.current + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +/// Sets the changed keys in `config.toml` and leaves the rest as it was. +/// The new file replaces the old one whole, so a crash never leaves half. +fn write(file: &Path, update: SettingsUpdateParams) -> io::Result<()> { + let text = match std::fs::read_to_string(file) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err), + }; + let mut config: toml_edit::DocumentMut = text + .parse() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if let Some(start) = update.start_with_windows { + config["start_with_windows"] = toml_edit::value(start); + } + if let Some(keep_awake) = update.keep_awake { + config["keep_awake"] = toml_edit::value(keep_awake); + } + let staged = file.with_extension("toml.new"); + std::fs::write(&staged, config.to_string())?; + std::fs::rename(&staged, file) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saving_keeps_the_owners_lines_and_comments() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("config.toml"); + let mine = + "# my port\nport = 45999\n\n[bots]\napproval_timeout_minutes = 30 # half an hour\n"; + std::fs::write(&file, mine).expect("write"); + let settings = LiveSettings::new(Some(file.clone()), &Config::parse(mine).expect("parse")); + let mut awake = settings.keep_awake(); + + let saved = settings + .save(SettingsUpdateParams { + keep_awake: Some(false), + ..SettingsUpdateParams::default() + }) + .expect("save"); + assert_eq!( + saved, + Settings { + start_with_windows: true, + keep_awake: false + } + ); + assert!(awake.has_changed().expect("sender") && !*awake.borrow_and_update()); + + let text = std::fs::read_to_string(&file).expect("read"); + assert!(text.starts_with("# my port\nport = 45999\nkeep_awake = false\n")); + assert!(text.contains("approval_timeout_minutes = 30 # half an hour")); + assert!(!text.contains("start_with_windows"), "only what changed"); + let config = Config::parse(&text).expect("still valid"); + assert!(!config.keep_awake && config.start_with_windows && config.port == 45999); + + settings + .save(SettingsUpdateParams { + start_with_windows: Some(false), + keep_awake: Some(true), + }) + .expect("save"); + let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("parse"); + assert!(config.keep_awake && !config.start_with_windows); + assert_eq!( + settings.get(), + Settings { + start_with_windows: false, + keep_awake: true + } + ); + } + + #[test] + fn a_missing_file_is_created_with_just_the_change() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("config.toml"); + let settings = LiveSettings::new(Some(file.clone()), &Config::default()); + settings + .save(SettingsUpdateParams { + start_with_windows: Some(false), + ..SettingsUpdateParams::default() + }) + .expect("save"); + assert_eq!( + std::fs::read_to_string(&file).expect("read"), + "start_with_windows = false\n" + ); + } +} diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 7d75d7a5..3fefc9b0 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -22,6 +22,7 @@ use crate::runtime::Runtime; use crate::screens::Screens; use crate::secrets::TokenHash; use crate::service::tasks::TaskSettings; +use crate::settings::LiveSettings; use crate::supervisor::{Supervisor, SupervisorSettings}; use crate::workspace::WorkspaceEnv; @@ -80,6 +81,8 @@ pub struct DaemonOptions { pub tasks: TaskSettings, pub bots: BotSettings, pub browser: BrowserSettings, + /// What the owner changes in the app's Settings. + pub settings: LiveSettings, } pub struct Daemon { @@ -89,6 +92,7 @@ pub struct Daemon { pub courier: Courier, pub tasks: TaskSettings, pub bots: BotSettings, + pub settings: LiveSettings, pub approvals: Approvals, pub routines: Routines, pub browsers: Browsers, @@ -118,6 +122,7 @@ impl Daemon { courier: Courier::new(options.courier), tasks: options.tasks, bots: options.bots, + settings: options.settings, approvals: Approvals::default(), routines: Routines::default(), browsers: Browsers::new( diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index d55bf75a..c6468bfc 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -20,6 +20,7 @@ use std::time::Duration; use botloft_store::Store; use botloftd::browser::BrowserSettings; use botloftd::clock::ManualClock; +use botloftd::config::Config; use botloftd::courier::{self, CourierSettings}; use botloftd::paths::Paths; use botloftd::runtime::claude::Claude; @@ -27,6 +28,7 @@ use botloftd::runtime::fake::FakeRuntime; use botloftd::secrets::TokenHash; use botloftd::server; use botloftd::service::tasks::TaskSettings; +use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, ClaudeSource, SupervisorSettings}; use futures_util::{SinkExt, StreamExt}; @@ -112,6 +114,7 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { }, bots: bot_settings(), browser: BrowserSettings::default(), + settings: LiveSettings::new(None, &Config::default()), }); Parts { daemon, diff --git a/crates/botloftd/tests/settings.rs b/crates/botloftd/tests/settings.rs new file mode 100644 index 00000000..e1893fef --- /dev/null +++ b/crates/botloftd/tests/settings.rs @@ -0,0 +1,49 @@ +//! The app's Settings (spec 11.2): read and changed over RPC, applied at +//! once. The test daemon has no scheduled task and no `config.toml`, so +//! only the values and the sign-in record change. + +mod common; + +use common::TestDaemon; +use serde_json::json; + +#[tokio::test] +async fn settings_are_read_and_changed_one_at_a_time() { + let t = TestDaemon::start().await; + std::fs::create_dir_all(&t.paths.home).expect("home"); + let mut app = t.session().await; + let settings = app.call("settings.get", json!(null)).await.expect("get"); + assert_eq!( + settings, + json!({ "startWithWindows": true, "keepAwake": true }) + ); + + let changed = app + .call("settings.update", json!({ "keepAwake": false })) + .await + .expect("update"); + assert_eq!( + changed, + json!({ "startWithWindows": true, "keepAwake": false }) + ); + assert!(!*t.daemon.settings.keep_awake().borrow()); + + let changed = app + .call("settings.update", json!({ "startWithWindows": false })) + .await + .expect("update"); + assert_eq!( + changed, + json!({ "startWithWindows": false, "keepAwake": false }) + ); + assert_eq!( + app.call("settings.get", json!(null)).await.expect("get"), + changed + ); + if botloftd::platform::sign_in_id().is_some() { + assert!( + t.paths.home.join("signin").is_file(), + "the daemon runs in this sign-in" + ); + } +} diff --git a/docs/spec.md b/docs/spec.md index 6516f9c4..a15551c8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -141,6 +141,7 @@ Identidade e instruções vão em `.claude/rules/botloft.md` e não na linha de port = 45710 # só 127.0.0.1 no MVP workspaces_root = "" # vazio = %USERPROFILE%\Botloft claude_path = "" # vazio = resolver pelo PATH +start_with_windows = true # o daemon sobe quando o dono entra no Windows (14) keep_awake = true # impede suspensão enquanto houver bot busy log_level = "info" @@ -171,6 +172,8 @@ max_hops = 4 default_deadline_minutes = 120 ``` +`start_with_windows` e `keep_awake` também mudam pelas Configurações do app (`settings.update`, 11.2): o daemon grava no próprio arquivo com `toml_edit`, só a chave que mudou, e as linhas e comentários do dono ficam. O arquivo novo substitui o antigo inteiro (`config.toml.new` renomeado), para uma queda no meio não deixar metade. As duas valem na hora: `keep_awake` liga ou desliga o pedido de energia, e `start_with_windows` muda os gatilhos da tarefa (14). + ## 7. Ciclo de vida do bot ### 7.1 Estados @@ -501,6 +504,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `tasks.list` | `crewId?, status?` | `Task[]` | | `browser.list`, `browser.watch`, `browser.unwatch`, `browser.take`, `browser.release`, `browser.input` | seção 21.7 | o navegador dos bots, a tela ao vivo e o dono no controle | | `screens.list` | seção 22.4 | as telas HTML do bot | +| `settings.get` | | `Settings`: `startWithWindows` e `keepAwake`, como estão no `config.toml` (6) | +| `settings.update` | `startWithWindows?, keepAwake?` | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | `Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model` e `modelInUse` (7.4) e `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. @@ -557,12 +562,13 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Tema | Solução | |---|---| -| Iniciar com o Windows | `botloftd service install` registra uma **Tarefa Agendada por usuário**, pela API COM do Agendador (as mensagens do `schtasks.exe` são traduzidas e não dá para lê-las). Dois gatilhos: "ao fazer logon" do usuário, que sobe o daemon na hora, e um gatilho de horário com início no passado repetido **a cada 1 min**, que o traz de volta se ele morrer: com `MultipleInstancesPolicy = IgnoreNew`, a repetição não faz nada enquanto o daemon roda. Token interativo e privilégio mínimo (só com o usuário logado, na sessão dele), sem limite de execução, roda na bateria, prioridade 5 (a padrão, 7, passaria "abaixo do normal" para todos os bots). A ação é `\bin\botloftd.exe serve --home `. Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service uninstall` (os dados ficam). | +| Iniciar com o Windows | `botloftd service install` registra uma **Tarefa Agendada por usuário**, pela API COM do Agendador (as mensagens do `schtasks.exe` são traduzidas e não dá para lê-las). Dois gatilhos: "ao fazer logon" do usuário, que sobe o daemon na hora, e um gatilho de horário com início no passado repetido **a cada 1 min**, que o traz de volta se ele morrer: com `MultipleInstancesPolicy = IgnoreNew`, a repetição não faz nada enquanto o daemon roda. Token interativo e privilégio mínimo (só com o usuário logado, na sessão dele), sem limite de execução, roda na bateria, prioridade 5 (a padrão, 7, passaria "abaixo do normal" para todos os bots). A ação é `\bin\botloftd.exe serve --home --scheduled`. Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service stop`, `service uninstall` (os dados ficam). | +| Escolhas do dono | As Configurações do app decidem os gatilhos. **Rodando:** o de 1 min, mais o de logon se `start_with_windows` (6). **Parado pelo dono** (`service stop`, que o app chama ao fechar quando o dono escolheu parar os bots, 15.1): só o de logon, se `start_with_windows`, e nada o traz de volta antes disso. `install` e `restart` registram os gatilhos de rodando. O de 1 min também dispara depois de um logon novo; por isso, sem `start_with_windows`, o daemon iniciado pela tarefa (`--scheduled`) confere se é um logon novo: `\signin` guarda o logon em que o Botloft rodou pela última vez (sessão e hora do logon, `WTSQuerySessionInformationW` com `WTSSessionInfo`, iguais para o app e para a tarefa), escrito por `install`, `restart`, pelo daemon ao subir e ao mudar a escolha. Logon novo: ele tira os gatilhos da tarefa e sai, até o app abrir e rodar `install`. Logon igual (o daemon morreu e voltou pelo gatilho de 1 min): roda. Iniciado pelo logon depois de parado pelo dono, o daemon devolve o gatilho de 1 min. Logon desconhecido conta como o mesmo, para o daemon nunca deixar de rodar por não saber | | Reinício da tarefa (testado no Windows 11 25H2) | `RestartOnFailure` **não** reinicia a tarefa quando o processo sai com código de erro, nem numa execução por gatilho de horário nem numa sob demanda. A repetição de um gatilho de logon só começa no próximo logon, não quando a tarefa é registrada. Por isso o gatilho de horário faz o papel de vigia: morto o daemon, ele voltou em 43 s. **Reboot** (0.2.0, 2026-09-29): o daemon subiu 1 s depois do logon, pelo gatilho de logon, com o app fechado, e o bot voltou 13 s depois | | Uma tarefa por pasta de dados | `Botloft` para `%LOCALAPPDATA%\Botloft`; `Botloft-<8 hex do SHA-256 do caminho>` para outra pasta (`--home` ou `BOTLOFT_HOME` de dev), para instalar um daemon de dev sem tocar no real. `--home` vale para todos os subcomandos e vem antes de `BOTLOFT_HOME` | | Binário instalado | `service install` copia o próprio executável para `\bin\botloftd.exe` e a tarefa roda essa cópia, nunca a do app: assim o instalador do app troca os arquivos dele com o daemon rodando. Um exe em uso não pode ser sobrescrito mas pode ser renomeado, então o antigo vai para `botloftd..old` e é apagado numa instalação seguinte. Se o binário não mudou e o daemon dessa versão já roda pela tarefa, `install` não o reinicia; senão para a tarefa, espera o `/health` sumir, inicia de novo e espera o `/health` com a nova versão (20 s) | | Janela de console | O manifesto do daemon pede `consoleAllocationPolicy = detached` (Windows 11 24H2 e depois): iniciado pela tarefa, ele não ganha console nem janela; num terminal, continua usando o console do terminal. Em Windows mais antigo, `serve` larga o console se for o único processo nele (`FreeConsole`). O manifesto entra como recurso (`embed-resource`), porque a ferramenta de manifesto do linker não conhece o elemento e avisa a cada build | -| Não suspender | Um power request (`PowerCreateRequest` + `PowerSetRequest(PowerRequestSystemRequired)`, motivo "Botloft bots are working") enquanto houver bot `busy`, se `keep_awake = true`. Bot esperando aprovação não conta. Diferente de `SetThreadExecutionState`, o pedido é de um handle e não de uma thread, então qualquer worker do Tokio o liga e desliga, e `powercfg /requests` o mostra. Não impede a suspensão pedida pelo usuário (tampa, menu Iniciar) | +| Não suspender | Um power request (`PowerCreateRequest` + `PowerSetRequest(PowerRequestSystemRequired)`, motivo "Botloft bots are working") enquanto houver bot `busy`, se `keep_awake = true` (o dono muda nas Configurações e vale na hora). Bot esperando aprovação não conta. Diferente de `SetThreadExecutionState`, o pedido é de um handle e não de uma thread, então qualquer worker do Tokio o liga e desliga, e `powercfg /requests` o mostra. Não impede a suspensão pedida pelo usuário (tampa, menu Iniciar) | | Processos órfãos | Job Object por bot (seção 7.3) | | Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | @@ -613,7 +619,8 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg 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** (tema, tamanho, idioma e as versões do Botloft e do Claude Code), **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. +- **Á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 quatro partes. **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, no `localStorage`, `botloft.whenClosed`), "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. **Aparência** (tema e tamanho), **Idioma** e **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. - **Á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 (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. @@ -631,6 +638,8 @@ 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 | +| `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 | | `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 | @@ -644,7 +653,7 @@ O app acha o daemon como o daemon acha a si mesmo (seção 5): `BOTLOFT_HOME` ou Onboarding (M5): **configuração sem perguntas e sem jargão.** O dono não precisa saber que existe um daemon, uma porta ou uma tarefa agendada; a interface nunca usa essas palavras. Fala de "Botloft" e de "rodar em segundo plano", e o texto técnico (erro do daemon, caminho, porta) fica dobrado sob "Details". -- Nada rodando: o app chama `daemon_install` sozinho ("Getting Botloft ready…", com uma linha dizendo que o Botloft segue rodando em segundo plano depois que a janela fecha e inicia com o Windows). Se falhar, "Botloft couldn't start" com "Try again". +- Nada rodando: o app chama `daemon_install` sozinho ("Getting Botloft ready…", com uma linha dizendo que o Botloft roda os bots em segundo plano e que nas Configurações o dono escolhe se eles seguem depois que a janela fecha e se o Botloft inicia com o Windows). Se o dono escolheu parar os bots ao fechar, é "Starting your bots…", sem a linha. Se falhar, "Botloft couldn't start" com "Try again". - Daemon `outdated`: o app o atualiza sozinho ("Updating Botloft…"), porque app e daemon são distribuídos juntos e quem atualizou o app espera o daemon novo. Os bots em turno são interrompidos e retomam a sessão (7.3). Se falhar ou a versão não mudar, "Botloft couldn't finish updating" com "Try again". - Cada verificação instala ou atualiza no máximo uma vez: uma falha aparece em vez de virar loop. - Daemon mais novo que o app com outro protocolo: pede para instalar a versão mais recente do Botloft. From 9d8c27b47cb1f3be177a297b08970279e6fcdc03 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:34:59 -0300 Subject: [PATCH 2/2] feat(app): settings for running in the background, starting with Windows and checking for updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gets "In the background" with three switches: keep working after closing Botloft (the app's choice; off, closing hides the window, stops the bots and closes), start with Windows and keep the computer awake (the daemon's). About gets "Check for updates". Reopening after the bots were stopped says "Starting your bots…", and the welcome line follows the choices. The routine switch becomes the shared Switch. --- app/src-tauri/capabilities/default.json | 2 + app/src-tauri/src/daemon.rs | 6 + app/src-tauri/src/lib.rs | 9 ++ app/src/App.tsx | 2 + app/src/features/account/AboutSettings.tsx | 70 +++++++++ .../features/account/BackgroundSettings.tsx | 40 ++++++ app/src/features/account/SettingsDialog.tsx | 39 ++--- app/src/features/account/settings.test.tsx | 133 ++++++++++++++++++ app/src/features/account/settingsParts.tsx | 70 +++++++++ app/src/features/account/useDaemonSettings.ts | 44 ++++++ app/src/features/onboarding/Welcome.tsx | 12 +- app/src/features/onboarding/link.ts | 14 +- app/src/features/routines/RoutineRow.tsx | 23 +-- app/src/features/updates/UpdateButton.tsx | 2 +- app/src/i18n/en/account.ts | 18 +++ app/src/i18n/en/onboarding.ts | 6 +- app/src/i18n/es/account.ts | 18 +++ app/src/i18n/es/onboarding.ts | 7 +- app/src/i18n/pt-BR/account.ts | 19 +++ app/src/i18n/pt-BR/onboarding.ts | 6 +- app/src/lib/fake.ts | 10 ++ app/src/lib/fakeHost.ts | 29 +++- app/src/lib/host.ts | 12 ++ app/src/lib/tauriHost.ts | 3 + app/src/shell/closing.ts | 82 +++++++++++ app/src/ui/Switch.tsx | 46 ++++++ 26 files changed, 664 insertions(+), 58 deletions(-) create mode 100644 app/src/features/account/AboutSettings.tsx create mode 100644 app/src/features/account/BackgroundSettings.tsx create mode 100644 app/src/features/account/settings.test.tsx create mode 100644 app/src/features/account/settingsParts.tsx create mode 100644 app/src/features/account/useDaemonSettings.ts create mode 100644 app/src/shell/closing.ts create mode 100644 app/src/ui/Switch.tsx diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 9eff8b62..c213247d 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -11,6 +11,8 @@ "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-destroy", "core:window:allow-set-overlay-icon", "core:webview:allow-set-webview-zoom", "updater:default", diff --git a/app/src-tauri/src/daemon.rs b/app/src-tauri/src/daemon.rs index 16dab5e0..e3edb1ba 100644 --- a/app/src-tauri/src/daemon.rs +++ b/app/src-tauri/src/daemon.rs @@ -159,6 +159,12 @@ pub fn restart(endpoint: &Endpoint) -> Result { Ok(status(endpoint)) } +/// Stops the daemon and keeps its scheduled task from bringing it back +/// before the app opens again (spec 14). +pub fn stop(endpoint: &Endpoint) -> Result<(), String> { + run_service(endpoint, "stop") +} + /// Runs `botloftd --home service ` without a window and /// returns its error message if it fails. fn run_service(endpoint: &Endpoint, command: &str) -> Result<(), String> { diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 97d5472a..df3f9dce 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -36,6 +36,14 @@ async fn daemon_restart() -> Result { blocking(|| daemon::restart(&daemon::endpoint()?)).await } +/// Stops the daemon until the app opens again, or until the next sign-in +/// when it starts with Windows: for an owner who wants the bots to stop +/// when they close Botloft. +#[tauri::command] +async fn daemon_stop() -> Result<(), String> { + blocking(|| daemon::stop(&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] @@ -158,6 +166,7 @@ pub fn run() { daemon_status, daemon_install, daemon_restart, + daemon_stop, claude_sign_in, read_owner_token, open_path, diff --git a/app/src/App.tsx b/app/src/App.tsx index cf384c2f..dba635fe 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,6 +3,7 @@ import { useStore } from "zustand"; import { type Connect, createLink } from "./features/onboarding/link"; import { Onboarding } from "./features/onboarding/Onboarding"; import type { Host } from "./lib/host"; +import { useCloseBehavior } from "./shell/closing"; import { TitleBar } from "./shell/TitleBar"; import { useThemeRoot } from "./shell/theme"; import { Workspace } from "./shell/Workspace"; @@ -13,6 +14,7 @@ import { Toaster } from "./ui/toast"; export function App({ host, connect }: { host: Host; connect: Connect }) { useThemeRoot(); useZoomRoot(host); + useCloseBehavior(host); const [link] = useState(() => createLink(host, connect)); const current = useStore(link, (state) => state.current); useEffect(() => { diff --git a/app/src/features/account/AboutSettings.tsx b/app/src/features/account/AboutSettings.tsx new file mode 100644 index 00000000..fecccfa1 --- /dev/null +++ b/app/src/features/account/AboutSettings.tsx @@ -0,0 +1,70 @@ +// Settings, "About": the versions, for when something needs reporting, +// and a way to look for a new Botloft now instead of waiting for the +// title bar (spec 15.5). + +import { RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import type { AppUpdate } from "../../lib/host"; +import { useApp, useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { UpdateDialog } from "../updates/UpdateButton"; +import { Section } from "./settingsParts"; + +type Check = + | { stage: "idle" } + | { stage: "checking" } + | { stage: "latest" } + | { stage: "found"; update: AppUpdate } + | { stage: "failed" }; + +export function AboutSettings() { + const s = useT().account.settings; + const host = useHost(); + const system = useApp((state) => state.system); + const [check, setCheck] = useState({ stage: "idle" }); + const [open, setOpen] = useState(false); + + const look = () => { + setCheck({ stage: "checking" }); + host.checkForUpdate().then( + (update) => setCheck(update ? { stage: "found", update } : { stage: "latest" }), + () => setCheck({ stage: "failed" }), + ); + }; + + const said = { + idle: null, + checking: s.checking, + latest: s.upToDate, + found: check.stage === "found" ? s.updateFound(check.update.version) : null, + failed: s.checkFailed, + }[check.stage]; + + return ( +
+
    + {system &&
  • {s.botloft(system.daemonVersion)}
  • } + {system?.claudeVersion &&
  • {s.claudeCode(system.claudeVersion)}
  • } + {system?.account.claude?.email &&
  • {system.account.claude.email}
  • } +
+
+ {check.stage === "found" ? ( + + ) : ( + + )} +

+ {said} +

+
+ {open && check.stage === "found" && ( + setOpen(false)} /> + )} +
+ ); +} diff --git a/app/src/features/account/BackgroundSettings.tsx b/app/src/features/account/BackgroundSettings.tsx new file mode 100644 index 00000000..a422e603 --- /dev/null +++ b/app/src/features/account/BackgroundSettings.tsx @@ -0,0 +1,40 @@ +// Settings, "In the background" (spec 15.1): whether the bots keep working +// after the window closes, whether Botloft starts with Windows, and +// whether the computer stays awake while they work. + +import { useT } from "../../i18n"; +import { setWhenClosed, useWhenClosed } from "../../shell/closing"; +import { Section, Toggle } from "./settingsParts"; +import { useDaemonSettings } from "./useDaemonSettings"; + +export function BackgroundSettings() { + const s = useT().account.settings; + const whenClosed = useWhenClosed(); + const { settings, change } = useDaemonSettings(); + const startWithWindows = settings?.startWithWindows ?? true; + + return ( +
+ setWhenClosed(on ? "keep" : "stop")} + /> + change({ startWithWindows: on })} + /> + change({ keepAwake: on })} + /> +
+ ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index 5514d8ac..58e5366b 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -1,7 +1,7 @@ -// Settings (spec 15.1): theme, size and language, which used to sit in the -// title bar, and the versions for when something needs reporting. +// Settings (spec 15.1): whether Botloft works in the background, the +// theme, size and language, which used to sit in the title bar, and the +// versions for when something needs reporting. -import type { ReactNode } from "react"; import { LOCALES, type LocaleChoice, @@ -12,9 +12,11 @@ import { } from "../../i18n"; import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme"; import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom"; -import { useApp } from "../../store/context"; import { Choices } from "../../ui/Choices"; import { Dialog } from "../../ui/Dialog"; +import { AboutSettings } from "./AboutSettings"; +import { BackgroundSettings } from "./BackgroundSettings"; +import { Field, Section } from "./settingsParts"; export function SettingsDialog({ onClose }: { onClose(): void }) { const t = useT(); @@ -22,7 +24,6 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { const { choice: theme } = useTheme(); const zoom = useZoom(); const { choice: locale } = useLocale(); - const system = useApp((state) => state.system); const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English"; const themes: { value: ThemeChoice; label: string }[] = [ @@ -42,6 +43,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { return (
+
@@ -58,33 +60,8 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { onChange={setLocaleChoice} />
-
-
    - {system &&
  • {s.botloft(system.daemonVersion)}
  • } - {system?.claudeVersion &&
  • {s.claudeCode(system.claudeVersion)}
  • } - {system?.account.claude?.email &&
  • {system.account.claude.email}
  • } -
-
+
); } - -function Section({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { - return ( -
-

{label}

- {children} - {hint &&

{hint}

} -
- ); -} diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx new file mode 100644 index 00000000..cf4be40d --- /dev/null +++ b/app/src/features/account/settings.test.tsx @@ -0,0 +1,133 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, 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"; +import { RpcError } from "../../lib/rpc"; +import { setWhenClosed } from "../../shell/closing"; + +afterEach(() => { + cleanup(); + setWhenClosed("keep"); +}); + +function renderApp(fake = new FakeBotloft(), host = new FakeHost()) { + render( fake as Client} />); + return { fake, host }; +} + +async function openSettings() { + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + return screen.getByRole("dialog", { name: "Settings" }); +} + +describe("settings", () => { + test("in the background: each switch says what it does and is saved", async () => { + const { fake } = renderApp(); + const dialog = await openSettings(); + const keep = within(dialog).getByRole("switch", { + name: "Keep working after you close Botloft", + }); + const start = within(dialog).getByRole("switch", { name: "Start with Windows" }); + const awake = within(dialog).getByRole("switch", { + name: "Keep the computer awake while bots work", + }); + await waitFor(() => expect(start.hasAttribute("disabled")).toBe(false)); + expect([keep, start, awake].map((s) => s.getAttribute("aria-checked"))).toEqual([ + "true", + "true", + "true", + ]); + expect(dialog.textContent).toContain("Your bots go on working and answering"); + + fireEvent.click(keep); + expect(keep.getAttribute("aria-checked")).toBe("false"); + expect(dialog.textContent).toContain("Closing Botloft stops every bot."); + expect(localStorage.getItem("botloft.whenClosed")).toBe("stop"); + + fireEvent.click(start); + expect(start.getAttribute("aria-checked")).toBe("false"); + expect(dialog.textContent).toContain("the bots wait until you open Botloft"); + fireEvent.click(awake); + await waitFor(() => + expect(fake.settings).toEqual({ startWithWindows: false, keepAwake: false }), + ); + expect( + fake.calls.filter((call) => call.method === "settings.update").map((c) => c.params), + ).toEqual([{ startWithWindows: false }, { keepAwake: false }]); + }); + + test("a setting that cannot be saved goes back", async () => { + const { fake } = renderApp(); + const dialog = await openSettings(); + const start = within(dialog).getByRole("switch", { name: "Start with Windows" }); + await waitFor(() => expect(start.hasAttribute("disabled")).toBe(false)); + fake.failNext("settings.update", new RpcError(-32603, "could not save the settings")); + fireEvent.click(start); + await screen.findByText(/Could not change the setting/); + expect(start.getAttribute("aria-checked")).toBe("true"); + expect(fake.settings.startWithWindows).toBe(true); + }); + + test("about looks for a new version on request", async () => { + const { host } = renderApp(); + const dialog = await openSettings(); + fireEvent.click(within(dialog).getByRole("button", { name: "Check for updates" })); + expect(await within(dialog).findByText("You have the latest version.")).toBeDefined(); + host.update = new FakeUpdate("9.9.9"); + fireEvent.click(within(dialog).getByRole("button", { name: "Check for updates" })); + expect(await within(dialog).findByText("Botloft 9.9.9 is out.")).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "See the update" })); + expect(screen.getByRole("dialog", { name: "Update Botloft" }).textContent).toContain("9.9.9"); + }); +}); + +describe("closing the window", () => { + test("leaves the bots working unless the owner chose otherwise", async () => { + const { host } = renderApp(); + await screen.findByRole("heading", { name: "Welcome to Botloft" }); + await act(() => host.requestClose()); + expect(host.closed).toBe(true); + expect(host.stops).toBe(0); + }); + + test("stops the bots, with the window out of sight first, when chosen", async () => { + const { host } = renderApp(); + await screen.findByRole("heading", { name: "Welcome to Botloft" }); + setWhenClosed("stop"); + await act(() => host.requestClose()); + expect(host.hidden).toBe(true); + expect(host.stops).toBe(1); + expect(host.closed).toBe(true); + }); + + test("opening Botloft again starts the bots it stopped", async () => { + setWhenClosed("stop"); + const host = new FakeHost(); + host.status = { state: "stopped", port: 45710, home: "C:\\data" }; + let finish: () => void = () => {}; + const installed = host.afterInstall; + host.installDaemon = () => + new Promise((resolve) => { + finish = () => resolve(installed as never); + }); + renderApp(new FakeBotloft(), host); + expect(await screen.findByText("Starting your bots…")).toBeDefined(); + expect(screen.queryByText(/In Settings you choose/)).toBeNull(); + await act(async () => finish()); + expect(await screen.findByText(/Running while this window is open/)).toBeDefined(); + }); + + test("the welcome screen says Botloft waits for the owner after a restart", async () => { + const fake = new FakeBotloft(); + fake.settings = { startWithWindows: false, keepAwake: true }; + renderApp(fake); + expect( + await screen.findByText( + "Running in the background, so your bots keep working after you close this window.", + ), + ).toBeDefined(); + }); +}); diff --git a/app/src/features/account/settingsParts.tsx b/app/src/features/account/settingsParts.tsx new file mode 100644 index 00000000..77beb74f --- /dev/null +++ b/app/src/features/account/settingsParts.tsx @@ -0,0 +1,70 @@ +// The pieces the Settings sections share: a titled section, a labelled +// field and an on/off row with its explanation. + +import { type ReactNode, useId } from "react"; +import { Switch } from "../../ui/Switch"; + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}) { + return ( +
+

{label}

+ {children} + {hint &&

{hint}

} +
+ ); +} + +/** A setting that is on or off; the hint says what the current choice does. */ +export function Toggle({ + label, + hint, + checked, + disabled, + onChange, +}: { + label: string; + hint: string; + checked: boolean; + disabled?: boolean | undefined; + onChange(checked: boolean): void; +}) { + const id = useId(); + return ( +
+
+ +

+ {hint} +

+
+ +
+ ); +} diff --git a/app/src/features/account/useDaemonSettings.ts b/app/src/features/account/useDaemonSettings.ts new file mode 100644 index 00000000..4b10c454 --- /dev/null +++ b/app/src/features/account/useDaemonSettings.ts @@ -0,0 +1,44 @@ +// 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. + +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { Settings, SettingsUpdateParams } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { attempt } from "../../ui/toast"; + +export function useDaemonSettings(): { + settings: Settings | null; + change(update: SettingsUpdateParams): void; +} { + 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 change = (update: SettingsUpdateParams) => { + const before = settings; + if (before) { + setSettings({ + startWithWindows: update.startWithWindows ?? before.startWithWindows, + keepAwake: update.keepAwake ?? before.keepAwake, + }); + } + attempt(failed, async () => setSettings(await api.call("settings.update", update))).then( + (saved) => saved || setSettings(before), + ); + }; + + return { settings, change }; +} diff --git a/app/src/features/onboarding/Welcome.tsx b/app/src/features/onboarding/Welcome.tsx index 5d9e803b..f2680bdc 100644 --- a/app/src/features/onboarding/Welcome.tsx +++ b/app/src/features/onboarding/Welcome.tsx @@ -1,8 +1,10 @@ import { CircleCheck, CircleX, LoaderCircle, Plus } from "lucide-react"; import { type ReactNode, useState } from "react"; import { useT } from "../../i18n"; +import { useWhenClosed } from "../../shell/closing"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; +import { useDaemonSettings } from "../account/useDaemonSettings"; import { BotAvatar } from "../bots/BotAvatar"; import { CrewDialog } from "../crews/CrewDialog"; import { ClaudeCodeHelp } from "./ClaudeCodeHelp"; @@ -13,6 +15,14 @@ export function Welcome() { const w = useT().onboarding.welcome; const system = useApp((state) => state.system); const [creating, setCreating] = useState(false); + const whenClosed = useWhenClosed(); + const { settings } = useDaemonSettings(); + const running = + whenClosed === "stop" + ? w.runningWhileOpen + : settings?.startWithWindows === false + ? w.runningNotAtStart + : w.running; let claude: ReactNode; if (!system || (system.claudeVersion === null && system.runtimeError === null)) { @@ -63,7 +73,7 @@ export function Welcome() {

{w.title}

{w.intro}

    - + {claude} {account}
diff --git a/app/src/features/onboarding/link.ts b/app/src/features/onboarding/link.ts index c940ef9f..eb4b2092 100644 --- a/app/src/features/onboarding/link.ts +++ b/app/src/features/onboarding/link.ts @@ -10,13 +10,17 @@ import { errorText } from "../../lib/api"; import type { Client } from "../../lib/client"; import type { DaemonStatus, Host } from "../../lib/host"; import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; +import { whenClosed } from "../../shell/closing"; export type LinkStep = | { step: "checking" } /** Not running, and installing it did not help. */ | { step: "stopped"; port: number; home: string; error: string | null } - /** Installing, updating to this app's version, or restarting the daemon. */ - | { step: "installing"; action: "install" | "update" | "restart" } + /** + * Installing, updating to this app's version or restarting the daemon, or + * starting it again after the owner closed Botloft with the bots. + */ + | { step: "installing"; action: "install" | "update" | "restart" | "start" } /** Another program holds the daemon's port. */ | { step: "foreign"; port: number } /** The daemon speaks another protocol version and is not older than the app. */ @@ -95,7 +99,9 @@ export function createLink(host: Host, connect: Connect): StoreApi { switch (status.state) { case "stopped": if (mayChange) { - await change(run, "install", () => host.installDaemon()); + // Closing Botloft stopped the bots: opening it brings them back. + const action = whenClosed() === "stop" ? "start" : "install"; + await change(run, action, () => host.installDaemon()); return; } set({ current: { step: "stopped", port: status.port, home: status.home, error } }); @@ -135,7 +141,7 @@ export function createLink(host: Host, connect: Connect): StoreApi { /** Installs, updates or restarts, then settles on what runs afterwards. */ const change = async ( run: number, - action: "install" | "update" | "restart", + action: "install" | "update" | "restart" | "start", work: () => Promise, ) => { set({ current: { step: "installing", action } }); diff --git a/app/src/features/routines/RoutineRow.tsx b/app/src/features/routines/RoutineRow.tsx index b58b5c9a..c461baf3 100644 --- a/app/src/features/routines/RoutineRow.tsx +++ b/app/src/features/routines/RoutineRow.tsx @@ -18,6 +18,7 @@ import { useApi, useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Confirm } from "../../ui/Confirm"; import { Menu } from "../../ui/Menu"; +import { Switch } from "../../ui/Switch"; import { attempt } from "../../ui/toast"; import { BotAvatar } from "../bots/BotAvatar"; import { describeSchedule, nextText } from "./describe"; @@ -52,22 +53,12 @@ export function RoutineRow({ return (
  • - +
    {showBot && (

    diff --git a/app/src/features/updates/UpdateButton.tsx b/app/src/features/updates/UpdateButton.tsx index cea64fc5..01cb8af6 100644 --- a/app/src/features/updates/UpdateButton.tsx +++ b/app/src/features/updates/UpdateButton.tsx @@ -65,7 +65,7 @@ type Progress = | { stage: "downloading"; fraction: number | null } | { stage: "failed"; error: string }; -function UpdateDialog({ update, onClose }: { update: AppUpdate; onClose(): void }) { +export function UpdateDialog({ update, onClose }: { update: AppUpdate; onClose(): void }) { const t = useT(); const u = t.updates; const [progress, setProgress] = useState({ stage: "idle" }); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index da35e870..f4210bf7 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -30,6 +30,18 @@ export const account = { }, settings: { title: "Settings", + background: "In the background", + keepWorking: "Keep working after you close Botloft", + 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.", + 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.", + 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", appearance: "Appearance", theme: "Theme", themes: { system: "System", light: "Light", dark: "Dark" }, @@ -39,5 +51,11 @@ export const account = { about: "About", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, + checkUpdates: "Check for updates", + checking: "Checking…", + upToDate: "You have the latest version.", + updateFound: (version: string) => `Botloft ${version} is out.`, + seeUpdate: "See the update", + checkFailed: "Could not check for updates. Try again later.", }, }; diff --git a/app/src/i18n/en/onboarding.ts b/app/src/i18n/en/onboarding.ts index 05fe3a18..1fdbc67d 100644 --- a/app/src/i18n/en/onboarding.ts +++ b/app/src/i18n/en/onboarding.ts @@ -10,9 +10,10 @@ export const onboarding = { install: "Getting Botloft ready…", update: "Updating Botloft…", restart: "Restarting Botloft…", + start: "Starting your bots…", }, installNote: - "Botloft keeps your bots running in the background, even after you close this window, and starts with Windows.", + "Botloft runs your bots in the background. In Settings you choose whether they keep working after you close this window and whether Botloft starts with Windows.", stopped: { title: "Botloft couldn't start", body: "Botloft runs your bots in the background, and that part didn't start.", @@ -49,6 +50,9 @@ export const onboarding = { botloft: "Botloft", running: "Running in the background. It starts with Windows, so your bots keep working after you close this window.", + runningNotAtStart: + "Running in the background, so your bots keep working after you close this window.", + runningWhileOpen: "Running while this window is open. Closing it stops your bots.", claudeCode: "Claude Code", checking: "Checking…", version: (version: string) => `Version ${version}`, diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 8a18cf30..61c05369 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -30,6 +30,18 @@ export const account: Messages["account"] = { }, settings: { title: "Configuración", + background: "En segundo plano", + keepWorking: "Seguir trabajando después de cerrar Botloft", + 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.", + 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.", + 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", appearance: "Apariencia", theme: "Tema", themes: { system: "Sistema", light: "Claro", dark: "Oscuro" }, @@ -39,5 +51,11 @@ export const account: Messages["account"] = { about: "Acerca de", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, + checkUpdates: "Buscar actualizaciones", + checking: "Buscando…", + upToDate: "Tienes la versión más reciente.", + updateFound: (version: string) => `Salió Botloft ${version}.`, + seeUpdate: "Ver la actualización", + checkFailed: "No se pudieron buscar actualizaciones. Inténtalo más tarde.", }, }; diff --git a/app/src/i18n/es/onboarding.ts b/app/src/i18n/es/onboarding.ts index f25217f7..9db876f2 100644 --- a/app/src/i18n/es/onboarding.ts +++ b/app/src/i18n/es/onboarding.ts @@ -9,9 +9,10 @@ export const onboarding: Messages["onboarding"] = { install: "Preparando Botloft…", update: "Actualizando Botloft…", restart: "Reiniciando Botloft…", + start: "Iniciando tus bots…", }, installNote: - "Botloft mantiene tus bots funcionando en segundo plano, incluso después de cerrar esta ventana, y se inicia con Windows.", + "Botloft ejecuta tus bots en segundo plano. En Configuración eliges si siguen trabajando después de cerrar esta ventana y si Botloft se inicia con Windows.", stopped: { title: "Botloft no pudo iniciar", body: "Botloft ejecuta tus bots en segundo plano, y esa parte no se inició.", @@ -48,6 +49,10 @@ export const onboarding: Messages["onboarding"] = { botloft: "Botloft", running: "Funcionando en segundo plano. Se inicia con Windows, así que tus bots siguen trabajando después de cerrar esta ventana.", + runningNotAtStart: + "Funcionando en segundo plano, así que tus bots siguen trabajando después de cerrar esta ventana.", + runningWhileOpen: + "Funcionando mientras esta ventana está abierta. Al cerrarla, tus bots se detienen.", claudeCode: "Claude Code", checking: "Comprobando…", version: (version: string) => `Versión ${version}`, diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index aaa52320..b371a93a 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -30,6 +30,19 @@ export const account: Messages["account"] = { }, settings: { title: "Configurações", + background: "Em segundo plano", + keepWorking: "Continuar trabalhando depois de fechar o Botloft", + 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.", + 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.", + 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", appearance: "Aparência", theme: "Tema", themes: { system: "Sistema", light: "Claro", dark: "Escuro" }, @@ -39,5 +52,11 @@ export const account: Messages["account"] = { about: "Sobre", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, + checkUpdates: "Procurar atualizações", + checking: "Procurando…", + upToDate: "Você está na versão mais recente.", + updateFound: (version: string) => `O Botloft ${version} saiu.`, + seeUpdate: "Ver atualização", + checkFailed: "Não foi possível procurar atualizações. Tente mais tarde.", }, }; diff --git a/app/src/i18n/pt-BR/onboarding.ts b/app/src/i18n/pt-BR/onboarding.ts index 1e699416..aac28c04 100644 --- a/app/src/i18n/pt-BR/onboarding.ts +++ b/app/src/i18n/pt-BR/onboarding.ts @@ -9,9 +9,10 @@ export const onboarding: Messages["onboarding"] = { install: "Preparando o Botloft…", update: "Atualizando o Botloft…", restart: "Reiniciando o Botloft…", + start: "Iniciando seus bots…", }, installNote: - "O Botloft mantém seus bots rodando em segundo plano, mesmo depois que você fecha esta janela, e inicia com o Windows.", + "O Botloft roda seus bots em segundo plano. Em Configurações você escolhe se eles continuam trabalhando depois que você fecha esta janela e se o Botloft inicia com o Windows.", stopped: { title: "O Botloft não conseguiu iniciar", body: "O Botloft roda seus bots em segundo plano, e essa parte não iniciou.", @@ -48,6 +49,9 @@ export const onboarding: Messages["onboarding"] = { botloft: "Botloft", running: "Rodando em segundo plano. Inicia com o Windows, então seus bots continuam trabalhando depois que você fecha esta janela.", + runningNotAtStart: + "Rodando em segundo plano, então seus bots continuam trabalhando depois que você fecha esta janela.", + runningWhileOpen: "Rodando enquanto esta janela está aberta. Ao fechá-la, seus bots param.", claudeCode: "Claude Code", checking: "Verificando…", version: (version: string) => `Versão ${version}`, diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 1f4171cc..46360cba 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -19,6 +19,7 @@ import { type Crew, type CrewId, PROTOCOL_VERSION, + type Settings, type SystemStatus, } from "./protocol.gen"; import type { @@ -56,6 +57,7 @@ export class FakeBotloft implements BotloftApi { }; /** How many times the app asked for a new Claude Code check. */ refreshes = 0; + settings: Settings = { startWithWindows: true, keepAwake: true }; /** Every call, in order. */ readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); @@ -210,6 +212,14 @@ export class FakeBotloft implements BotloftApi { this.refreshes += 1; return this.system; }, + "settings.get": () => this.settings, + "settings.update": (change) => { + this.settings = { + startWithWindows: change.startWithWindows ?? this.settings.startWithWindows, + keepAwake: change.keepAwake ?? this.settings.keepAwake, + }; + return this.settings; + }, ...crewHandlers(this), ...botHandlers(this), ...this.chat.handlers(), diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index 0312ea65..6139d399 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -31,6 +31,11 @@ export class FakeHost implements Host { /** Where each folder picker started. */ readonly pickerStarts: (string | null)[] = []; readonly installs: ("install" | "restart")[] = []; + /** 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; + hidden = false; /** Paths `signInToClaude` ran, and what it answers. */ readonly signIns: string[] = []; signInResult: boolean | Error = true; @@ -57,6 +62,17 @@ export class FakeHost implements Host { return this.settle("restart"); } + stopDaemon(): Promise { + this.stops += 1; + return Promise.resolve(); + } + + /** Closes the window the way Windows does: what the app set runs first. */ + async requestClose(): Promise { + await this.beforeClose?.(); + this.closed = true; + } + signInToClaude(claudePath: string): Promise { this.signIns.push(claudePath); if (this.signInResult instanceof Error) { @@ -122,10 +138,19 @@ export class FakeHost implements Host { this.maximized = !this.maximized; return Promise.resolve(); }, - close: () => { - this.closed = true; + close: () => this.requestClose(), + hide: () => { + this.hidden = true; return Promise.resolve(); }, + onCloseRequested: (before: () => Promise) => { + this.beforeClose = before; + return Promise.resolve(() => { + if (this.beforeClose === before) { + this.beforeClose = null; + } + }); + }, isMaximized: () => Promise.resolve(this.maximized), onResized: () => Promise.resolve(() => {}), setAttention: (on: boolean) => { diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index de7da2e5..aa092757 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -35,6 +35,13 @@ export interface AppWindow { minimize(): Promise; toggleMaximize(): Promise; close(): Promise; + hide(): Promise; + /** + * 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. + */ + onCloseRequested(before: () => Promise): Promise<() => void>; isMaximized(): Promise; /** Called when the window is resized; returns the unsubscribe function. */ onResized(listener: () => void): Promise<() => void>; @@ -52,6 +59,11 @@ export interface Host { installDaemon(): Promise; /** Stops the daemon and starts it again from its scheduled task. */ restartDaemon(): Promise; + /** + * Stops the daemon until the app opens again, or until the next sign-in + * when it starts with Windows (spec 14). + */ + stopDaemon(): 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 e563ca3b..2261c403 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -71,6 +71,7 @@ export function tauriHost(): Host { daemonStatus: () => invoke("daemon_status"), installDaemon: () => invoke("daemon_install"), restartDaemon: () => invoke("daemon_restart"), + stopDaemon: () => invoke("daemon_stop"), signInToClaude: (path) => invoke("claude_sign_in", { path }), setZoom: (factor) => getCurrentWebview().setZoom(factor), checkForUpdate, @@ -92,6 +93,8 @@ export function tauriHost(): Host { minimize: () => window.minimize(), toggleMaximize: () => window.toggleMaximize(), close: () => window.close(), + hide: () => window.hide(), + onCloseRequested: (before) => window.onCloseRequested(() => before()), isMaximized: () => window.isMaximized(), onResized: (listener) => window.onResized(() => listener()), setAttention: async (on) => { diff --git a/app/src/shell/closing.ts b/app/src/shell/closing.ts new file mode 100644 index 00000000..82638b77 --- /dev/null +++ b/app/src/shell/closing.ts @@ -0,0 +1,82 @@ +// What closing the window does (spec 15.1): Botloft keeps the bots working +// in the background, or, if the owner chose so in Settings, stops them +// until it opens again. The choice is this app's, kept in localStorage +// like the theme. + +import { useEffect, useSyncExternalStore } from "react"; +import type { Host } from "../lib/host"; + +export type WhenClosed = "keep" | "stop"; + +const KEY = "botloft.whenClosed"; +const listeners = new Set<() => void>(); + +function stored(): WhenClosed { + try { + return localStorage.getItem(KEY) === "stop" ? "stop" : "keep"; + } catch { + return "keep"; + } +} + +let choice: WhenClosed = stored(); + +/** The owner's choice, for code outside React. */ +export function whenClosed(): WhenClosed { + return choice; +} + +export function setWhenClosed(next: WhenClosed): void { + choice = next; + try { + localStorage.setItem(KEY, next); + } catch { + // Private storage off: the choice lasts for this window. + } + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useWhenClosed(): WhenClosed { + return useSyncExternalStore(subscribe, () => choice); +} + +/** + * Stops the bots as the window closes, when the owner chose so. The window + * hides first, since stopping takes a moment; if it fails, it closes all + * the same. + */ +export function useCloseBehavior(host: Host): void { + useEffect(() => { + let off: (() => void) | undefined; + let alive = true; + host.window + .onCloseRequested(async () => { + if (choice !== "stop") { + return; + } + await host.window.hide().catch(() => {}); + await host.stopDaemon().catch(() => {}); + }) + .then( + (unsubscribe) => { + if (alive) { + off = unsubscribe; + } else { + unsubscribe(); + } + }, + () => {}, + ); + return () => { + alive = false; + off?.(); + }; + }, [host]); +} diff --git a/app/src/ui/Switch.tsx b/app/src/ui/Switch.tsx new file mode 100644 index 00000000..830f41fd --- /dev/null +++ b/app/src/ui/Switch.tsx @@ -0,0 +1,46 @@ +/** + * An on/off switch: a button with `role="switch"`, so screen readers say + * whether it is on. Name it with `label` or `labelledBy`. + */ +export function Switch({ + checked, + onChange, + id, + label, + labelledBy, + describedBy, + disabled, + className = "", +}: { + checked: boolean; + onChange(checked: boolean): void; + id?: string; + label?: string; + labelledBy?: string; + describedBy?: string; + disabled?: boolean | undefined; + className?: string; +}) { + return ( + + ); +}