From b1252b09f9f2efb5f58fe4d406115a61349b3dcb Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:56:47 -0300 Subject: [PATCH 1/3] feat(app): open folders, files and links, open at sign-in and sign in to Claude Code on Linux and macOS - Folders, files, links and "show in folder" go through open (macOS) or xdg-open (Linux) instead of explorer.exe; reveal_file no longer needs a Windows-only API to build. - Opening the app at sign-in is an autostart entry on Linux and a launch agent on macOS. - Signing in to Claude Code opens a terminal window off Windows; the command writes its exit code to a file the app waits for. - CI checks and tests the app crate on Ubuntu and macOS. --- .github/workflows/ci.yml | 17 +++- app/src-tauri/src/claude.rs | 37 ++++---- app/src-tauri/src/lib.rs | 41 +++------ app/src-tauri/src/open.rs | 68 +++++++++++++++ app/src-tauri/src/sign_in.rs | 131 +++++++++++++++++++++++++--- app/src-tauri/src/terminal.rs | 158 ++++++++++++++++++++++++++++++++++ docs/spec.md | 10 +-- 7 files changed, 397 insertions(+), 65 deletions(-) create mode 100644 app/src-tauri/src/open.rs create mode 100644 app/src-tauri/src/terminal.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5810aea2..096f4d53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,10 @@ jobs: - name: Generated TypeScript bindings are up to date run: git diff --exit-code -- app/src/lib/protocol.gen.ts - # The daemon and its crates off Windows (spec 18, item 8). Plain `cargo` - # builds the default members only: the app crates need the desktop - # libraries of each system and join later. + # The daemon, its crates and the app's Rust side off Windows (spec 18, + # item 8). Plain `cargo` builds the default members; the app crate comes + # after, with the desktop libraries Tauri needs on Linux. The setup + # window is Windows only. rust-unix: name: Rust (${{ matrix.name }}) strategy: @@ -52,6 +53,16 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-targets -- -D warnings + - name: Desktop libraries for the app (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev librsvg2-dev libxdo-dev + - name: The app's Rust side + run: | + cargo clippy -p botloft-app --all-targets -- -D warnings + cargo test -p botloft-app # The runner image ships Edge and Chrome with sandbox helpers that are # not setuid root, as their packages would leave them, so they refuse # to start. The browser tests need a working sandbox. diff --git a/app/src-tauri/src/claude.rs b/app/src-tauri/src/claude.rs index d9c5668d..ed7fc761 100644 --- a/app/src-tauri/src/claude.rs +++ b/app/src-tauri/src/claude.rs @@ -4,6 +4,7 @@ //! because a window started by the foreground app comes to the front. use std::path::Path; +#[cfg(windows)] use std::process::Command; /// Whether `path` is a Claude Code executable the app may run: the daemon @@ -21,32 +22,38 @@ fn is_claude(path: &Path) -> bool { && path.is_file() } -/// Runs `claude auth login` in a new console window and waits for it. -/// Returns whether it signed in (exit code 0). +/// Runs `claude auth login` in a new console window (a terminal window off +/// Windows) and waits for it. Returns whether it signed in (exit code 0). pub fn sign_in(path: &Path) -> Result { if !is_claude(path) { return Err(format!("{} is not Claude Code", path.display())); } - let mut command = Command::new(path); - command.args(["auth", "login"]); // An app started from inside a Claude Code session would pass its // session variables on, and the login would think it runs inside it. - for (name, _) in std::env::vars_os() { - let name = name.to_string_lossy(); - if name == "CLAUDECODE" || name.starts_with("CLAUDE_CODE_") { - command.env_remove(name.as_ref()); - } - } + let session: Vec = std::env::vars_os() + .map(|(name, _)| name.to_string_lossy().into_owned()) + .filter(|name| name == "CLAUDECODE" || name.starts_with("CLAUDE_CODE_")) + .collect(); #[cfg(windows)] { use std::os::windows::process::CommandExt as _; const CREATE_NEW_CONSOLE: u32 = 0x0000_0010; - command.creation_flags(CREATE_NEW_CONSOLE); + let mut command = Command::new(path); + command + .args(["auth", "login"]) + .creation_flags(CREATE_NEW_CONSOLE); + for name in &session { + command.env_remove(name); + } + let status = command + .status() + .map_err(|err| format!("cannot run {}: {err}", path.display()))?; + Ok(status.success()) + } + #[cfg(unix)] + { + crate::terminal::run(path, &["auth", "login"], &session) } - let status = command - .status() - .map_err(|err| format!("cannot run {}: {err}", path.display()))?; - Ok(status.success()) } #[cfg(test)] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 4624e7bc..57e82be4 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,7 +3,10 @@ mod claude; mod daemon; +mod open; mod sign_in; +#[cfg(unix)] +mod terminal; mod window; use std::path::Path; @@ -58,7 +61,8 @@ async fn read_owner_token() -> Result { blocking(|| daemon::owner_token(&daemon::endpoint()?)).await } -/// Opens a folder in Explorer. Files are refused: Explorer would run them. +/// Opens a folder in the file manager. Files are refused: opening one +/// could run it. #[tauri::command] async fn open_path(path: String) -> Result<(), String> { blocking(move || { @@ -66,16 +70,12 @@ async fn open_path(path: String) -> Result<(), String> { if !folder.is_absolute() || !folder.is_dir() { return Err(format!("{path} is not a folder")); } - std::process::Command::new("explorer.exe") - .arg(folder) - .spawn() - .map(drop) - .map_err(|err| format!("cannot open Explorer: {err}")) + open::folder(folder) }) .await } -/// Extensions `open_file` will open with the program Windows picks. Anything +/// Extensions `open_file` will open with the program the system picks. Anything /// else could be a program or a script that runs when opened. const OPENABLE: [&str; 24] = [ "pdf", "png", "jpg", "jpeg", "gif", "webp", "svg", "txt", "md", "csv", "json", "log", "html", @@ -88,7 +88,7 @@ fn is_openable(path: &Path) -> bool { .is_some_and(|extension| OPENABLE.contains(&extension.to_ascii_lowercase().as_str())) } -/// Opens a file a bot made with the program Windows uses for it. Only +/// Opens a file a bot made with the program the system uses for it. Only /// documents, images, sound and video: never something that runs. #[tauri::command] async fn open_file(path: String) -> Result<(), String> { @@ -100,11 +100,7 @@ async fn open_file(path: String) -> Result<(), String> { if !is_openable(file) { return Err("this kind of file is not opened from Botloft".into()); } - std::process::Command::new("explorer.exe") - .arg(file) - .spawn() - .map(drop) - .map_err(|err| format!("cannot open the file: {err}")) + open::file(file) }) .await } @@ -112,18 +108,12 @@ async fn open_file(path: String) -> Result<(), String> { /// Shows a file in its folder, selected. Nothing is run. #[tauri::command] async fn reveal_file(path: String) -> Result<(), String> { - use std::os::windows::process::CommandExt as _; blocking(move || { let file = Path::new(&path); - if !file.is_absolute() || !file.exists() || path.contains('"') { + if !file.is_absolute() || !file.exists() { return Err(format!("{path} is not a file")); } - // Explorer reads `/select,` and the path as one argument. - std::process::Command::new("explorer.exe") - .raw_arg(format!("/select,\"{path}\"")) - .spawn() - .map(drop) - .map_err(|err| format!("cannot open Explorer: {err}")) + open::reveal(file) }) .await } @@ -159,14 +149,7 @@ async fn open_url(url: String) -> Result<(), String> { if !is_web_link(&url) { return Err("only web links can be opened".into()); } - blocking(move || { - std::process::Command::new("explorer.exe") - .arg(&url) - .spawn() - .map(drop) - .map_err(|err| format!("cannot open the browser: {err}")) - }) - .await + blocking(move || open::url(&url)).await } #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/app/src-tauri/src/open.rs b/app/src-tauri/src/open.rs new file mode 100644 index 00000000..b24ea8e1 --- /dev/null +++ b/app/src-tauri/src/open.rs @@ -0,0 +1,68 @@ +//! Handing a folder, a file or a web link to the system (spec 15.2): +//! Explorer on Windows, `open` on macOS, `xdg-open` on Linux. The callers +//! check what may be opened; nothing here runs a program the owner did not +//! pick. + +use std::path::Path; +use std::process::Command; + +#[cfg(windows)] +const LAUNCHER: &str = "explorer.exe"; +#[cfg(target_os = "macos")] +const LAUNCHER: &str = "open"; +#[cfg(all(unix, not(target_os = "macos")))] +const LAUNCHER: &str = "xdg-open"; + +fn spawn(command: &mut Command) -> Result<(), String> { + command + .spawn() + .map(drop) + .map_err(|err| format!("cannot run {LAUNCHER}: {err}")) +} + +/// A folder in the file manager. On macOS an app bundle is a folder too, +/// and `open` would start it. +pub fn folder(path: &Path) -> Result<(), String> { + if cfg!(target_os = "macos") + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) + { + return Err(format!("{} is an app, not a folder", path.display())); + } + spawn(Command::new(LAUNCHER).arg(path)) +} + +/// A document with the program the system uses for it. +pub fn file(path: &Path) -> Result<(), String> { + spawn(Command::new(LAUNCHER).arg(path)) +} + +/// A web link in the default browser. +pub fn url(url: &str) -> Result<(), String> { + spawn(Command::new(LAUNCHER).arg(url)) +} + +/// A file shown selected in its folder. Linux has no common way to select +/// it, so its folder opens. +pub fn reveal(path: &Path) -> Result<(), String> { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + let text = path.to_string_lossy(); + if text.contains('"') { + return Err(format!("{text} is not a file")); + } + // Explorer reads `/select,` and the path as one argument. + spawn(Command::new(LAUNCHER).raw_arg(format!("/select,\"{text}\""))) + } + #[cfg(target_os = "macos")] + { + spawn(Command::new(LAUNCHER).arg("-R").arg(path)) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let folder = path.parent().unwrap_or(path); + spawn(Command::new(LAUNCHER).arg(folder)) + } +} diff --git a/app/src-tauri/src/sign_in.rs b/app/src-tauri/src/sign_in.rs index 7be718d6..3edc7b6c 100644 --- a/app/src-tauri/src/sign_in.rs +++ b/app/src-tauri/src/sign_in.rs @@ -1,24 +1,84 @@ -//! Opening Botloft when the owner signs in to Windows (spec 15.2): a value -//! in the current user's Run key, `"" --autostart`. The daemon starts -//! from its own scheduled task (spec 14); this is only the app, near the -//! clock or with its window. +//! Opening Botloft when the owner signs in (spec 15.2), as +//! `"" --autostart`: a value in the current user's Run key on Windows, +//! an autostart entry on Linux (`~/.config/autostart`), a launch agent on +//! macOS. The daemon starts on its own (spec 14, 14.1); this is only the +//! app, near the clock or with its window. use std::path::Path; -/// Tells the app that Windows opened it at sign-in. +/// Tells the app that the system opened it at sign-in. pub const AUTOSTART_ARG: &str = "--autostart"; -/// Whether Windows opened this app at sign-in. +/// Whether the system opened this app at sign-in. pub fn launched_at_sign_in() -> bool { std::env::args().any(|arg| arg == AUTOSTART_ARG) } /// The Run key's command line, quoted: a path with a space, like /// `C:\Users\Ana Lima\...`, would otherwise be split. +#[cfg_attr(not(windows), allow(dead_code))] fn command_line(exe: &Path) -> String { format!("\"{}\" {AUTOSTART_ARG}", exe.display()) } +/// The Linux autostart entry. +#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] +fn desktop_entry(exe: &Path) -> String { + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Botloft\n\ + Comment=Opens Botloft when you sign in\n\ + Exec={} {AUTOSTART_ARG}\n\ + Terminal=false\n\ + X-GNOME-Autostart-enabled=true\n", + exec_word(&exe.to_string_lossy()) + ) +} + +/// One word of a desktop entry's `Exec`: quoted, with `"`, `` ` ``, `$` and +/// `\` escaped, then every `\` doubled as a string value wants, and `%` +/// doubled so it is not a field code. +#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] +fn exec_word(word: &str) -> String { + let mut quoted = String::new(); + for c in word.chars() { + if matches!(c, '"' | '`' | '$' | '\\') { + quoted.push('\\'); + } + quoted.push(c); + } + format!("\"{}\"", quoted.replace('\\', "\\\\").replace('%', "%%")) +} + +/// The macOS launch agent that opens the app in the owner's session. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn agent_plist(exe: &Path) -> String { + let exe = exe + .to_string_lossy() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + format!( + r#" + + + + Labelio.github.httpsphl.botloft.app + ProgramArguments + + {exe} + {AUTOSTART_ARG} + + RunAtLoad + LimitLoadToSessionTypeAqua + ProcessTypeInteractive + + +"# + ) +} + #[cfg(windows)] mod registry { use std::io; @@ -61,23 +121,50 @@ mod registry { } } +/// Writes the file that opens the app at sign-in, or deletes it. +#[cfg(unix)] +fn write_or_remove(path: &Path, contents: Option) -> std::io::Result<()> { + match contents { + Some(contents) => { + if let Some(folder) = path.parent() { + std::fs::create_dir_all(folder)?; + } + std::fs::write(path, contents) + } + None => match std::fs::remove_file(path) { + Err(err) if err.kind() != std::io::ErrorKind::NotFound => Err(err), + _ => Ok(()), + }, + } +} + /// Opens the app at sign-in, or stops doing so. A dev build never /// registers itself: it needs the dev server. pub fn set(on: bool) -> Result<(), String> { if cfg!(debug_assertions) { return Ok(()); } + let exe = std::env::current_exe().map_err(|err| err.to_string())?; + let failed = |err: std::io::Error| format!("cannot change what opens at sign-in: {err}"); #[cfg(windows)] { - let exe = std::env::current_exe().map_err(|err| err.to_string())?; let command = on.then(|| command_line(&exe)); - registry::set(command.as_deref()) - .map_err(|err| format!("cannot change what opens at sign-in: {err}")) + registry::set(command.as_deref()).map_err(failed) + } + #[cfg(target_os = "macos")] + { + let home = dirs::home_dir().ok_or("cannot find the home folder")?; + let path = home + .join("Library") + .join("LaunchAgents") + .join("io.github.httpsphl.botloft.app.plist"); + write_or_remove(&path, on.then(|| agent_plist(&exe))).map_err(failed) } - #[cfg(not(windows))] + #[cfg(all(unix, not(target_os = "macos")))] { - let _ = on; - Err("opening at sign-in is only supported on Windows".into()) + let config = dirs::config_dir().ok_or("cannot find the configuration folder")?; + let path = config.join("autostart").join("botloft.desktop"); + write_or_remove(&path, on.then(|| desktop_entry(&exe))).map_err(failed) } } @@ -85,7 +172,7 @@ pub fn set(on: bool) -> Result<(), String> { mod tests { use std::path::Path; - use super::command_line; + use super::*; #[test] fn the_command_line_is_quoted() { @@ -96,4 +183,22 @@ mod tests { r#""C:\Users\Ana Lima\AppData\Local\Botloft\Botloft.exe" --autostart"# ); } + + #[test] + fn the_linux_entry_quotes_the_app() { + let entry = desktop_entry(Path::new("/opt/Botloft 100%/botloft")); + assert!(entry.contains("Exec=\"/opt/Botloft 100%%/botloft\" --autostart\n")); + assert_eq!(exec_word("a\"b"), r#""a\\"b""#); + assert_eq!(exec_word(r"a\b"), r#""a\\\\b""#); + } + + #[test] + fn the_macos_agent_opens_the_app_in_the_session() { + let plist = agent_plist(Path::new("/Applications/A & B.app/Contents/MacOS/botloft")); + assert!( + plist.contains("/Applications/A & B.app/Contents/MacOS/botloft") + ); + assert!(plist.contains("--autostart")); + assert!(plist.contains("RunAtLoad")); + } } diff --git a/app/src-tauri/src/terminal.rs b/app/src-tauri/src/terminal.rs new file mode 100644 index 00000000..5572d0f4 --- /dev/null +++ b/app/src-tauri/src/terminal.rs @@ -0,0 +1,158 @@ +//! A terminal window for a command on Linux and macOS (spec 15.2), as the +//! console window on Windows: the owner sees what happens and can type or +//! paste into it. The command writes its exit code to a file that the app +//! waits for, because a terminal may hand the command to a process it +//! already runs and return at once. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// How long the owner has to finish in the terminal. +const WAIT: Duration = Duration::from_secs(15 * 60); + +/// Runs `program` with `args` in a terminal window and waits for it. +/// Returns whether it exited with 0. `clean` names variables the command +/// must not see. +pub fn run(program: &Path, args: &[&str], clean: &[String]) -> Result { + let dir = private_dir()?; + let status = dir.join("status"); + let words: Vec = std::iter::once(program.to_string_lossy().into_owned()) + .chain(args.iter().map(|arg| (*arg).to_owned())) + .map(|word| sh_quote(&word)) + .collect(); + let script = format!( + "{}; echo $? > {}", + words.join(" "), + sh_quote(&status.to_string_lossy()) + ); + let opened = open(&script, clean); + let result = opened.and_then(|()| wait_for(&status)); + let _ = std::fs::remove_dir_all(&dir); + result +} + +/// A folder only the owner can read, for the status file. +fn private_dir() -> Result { + use std::os::unix::fs::DirBuilderExt as _; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |time| time.as_nanos()); + let dir = std::env::temp_dir().join(format!("botloft-{}-{nanos}", std::process::id())); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&dir) + .map_err(|err| format!("cannot create {}: {err}", dir.display()))?; + Ok(dir) +} + +fn wait_for(status: &Path) -> Result { + let deadline = Instant::now() + WAIT; + while Instant::now() < deadline { + if let Ok(code) = std::fs::read_to_string(status) + && !code.trim().is_empty() + { + return Ok(code.trim() == "0"); + } + std::thread::sleep(Duration::from_millis(500)); + } + Ok(false) +} + +/// One word for `sh`, in single quotes. +fn sh_quote(word: &str) -> String { + format!("'{}'", word.replace('\'', r"'\''")) +} + +/// Terminal.app runs the script in a new window and comes to the front. +#[cfg(target_os = "macos")] +fn open(script: &str, clean: &[String]) -> Result<(), String> { + let mut command = Command::new("osascript"); + command.args([ + "-e", + "tell application \"Terminal\" to activate", + "-e", + &format!( + "tell application \"Terminal\" to do script {}", + apple_string(script) + ), + ]); + for name in clean { + command.env_remove(name); + } + let status = command + .status() + .map_err(|err| format!("cannot open Terminal: {err}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("cannot open Terminal ({status})")) + } +} + +/// An AppleScript string literal. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn apple_string(text: &str) -> String { + format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// The first terminal found on `PATH`, with how it takes a command. +#[cfg(not(target_os = "macos"))] +const TERMINALS: &[(&str, &[&str])] = &[ + ("x-terminal-emulator", &["-e"]), + ("gnome-terminal", &["--"]), + ("konsole", &["-e"]), + ("xfce4-terminal", &["-x"]), + ("kitty", &[]), + ("alacritty", &["-e"]), + ("xterm", &["-e"]), +]; + +#[cfg(not(target_os = "macos"))] +fn open(script: &str, clean: &[String]) -> Result<(), String> { + let path = std::env::var_os("PATH").unwrap_or_default(); + let found = TERMINALS.iter().find_map(|(name, flags)| { + std::env::split_paths(&path) + .map(|dir| dir.join(name)) + .find(|candidate| candidate.is_file()) + .map(|program| (program, *flags)) + }); + let Some((program, flags)) = found else { + return Err("no terminal was found to sign in with".into()); + }; + let mut command = Command::new(&program); + command.args(flags).args(["sh", "-c", script]); + for name in clean { + command.env_remove(name); + } + command + .spawn() + .map(drop) + .map_err(|err| format!("cannot open {}: {err}", program.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn words_keep_spaces_and_quotes() { + assert_eq!( + sh_quote("/Users/Ana Lima/claude"), + "'/Users/Ana Lima/claude'" + ); + assert_eq!(sh_quote("it's"), r"'it'\''s'"); + assert_eq!(apple_string(r#"say "hi" \ bye"#), r#""say \"hi\" \\ bye""#); + } + + #[test] + fn the_exit_code_comes_from_the_status_file() { + let dir = private_dir().expect("dir"); + let status = dir.join("status"); + std::fs::write(&status, "0\n").expect("write"); + assert_eq!(wait_for(&status), Ok(true)); + std::fs::write(&status, "1\n").expect("write"); + assert_eq!(wait_for(&status), Ok(false)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/docs/spec.md b/docs/spec.md index c6dc8bed..4eddbb57 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -788,17 +788,17 @@ Layout, como um app de mensagens: | `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`). Se o dono escolheu parar os bots ao fechar, esconde a janela, chama `daemon_stop` e deixa fechar, mesmo se ele falhar; com o ícone perto do relógio, só esconde a janela; senão, fecha | | janela escondida ao abrir | a janela nasce com `visible: false` e o `setup` a mostra, a menos que o app tenha sido aberto com `--autostart` (ao entrar no Windows): aí ela fica escondida se o Botloft espera perto do relógio, e o app a mostra se o dono quer a janela ou se algo deu errado antes de conectar. Mostrar é `show`, `unminimize` e `setFocus` (permissões de cada um) | -| `open_at_sign_in` | grava ou apaga o valor `Botloft` na chave Run do usuário (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`) com `"" --autostart`, entre aspas por causa de caminhos com espaço (o `tauri-plugin-autostart` grava sem aspas). Ligando, também libera o app se ele estava desligado nos apps de inicialização do Gerenciador de Tarefas (`StartupApproved\Run`). Build de dev nunca se registra. O app liga quando os bots iniciam com o Windows e o dono quer o Botloft perto do relógio ou a janela aberta, e desliga senão, a cada mudança | +| `open_at_sign_in` | grava ou apaga o valor `Botloft` na chave Run do usuário (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`) com `"" --autostart`, entre aspas por causa de caminhos com espaço (o `tauri-plugin-autostart` grava sem aspas). Ligando, também libera o app se ele estava desligado nos apps de inicialização do Gerenciador de Tarefas (`StartupApproved\Run`). Build de dev nunca se registra. O app liga quando os bots iniciam com o Windows e o dono quer o Botloft perto do relógio ou a janela aberta, e desliga senão, a cada mudança. No Linux é a entrada `~/.config/autostart/botloft.desktop` (`Exec` com o app entre aspas, `"`, `` ` ``, `$` e `\` escapados e `%` dobrado); no macOS, o launch agent `~/Library/LaunchAgents/io.github.httpsphl.botloft.app.plist` (`RunAtLoad`, `LimitLoadToSessionType = Aqua`). Desligar apaga o arquivo | | `launched_at_sign_in` | se o app foi aberto com `--autostart` | | uma cópia só | `tauri-plugin-single-instance`, só em build de release (a de dev roda ao lado da instalada): abrir o Botloft de novo, pelo menu Iniciar ou por um aviso, traz a janela da cópia que roda e manda `botloft://reopened` ao app | | ícone perto do relógio | não é comando próprio: `TrayIcon` e `Menu` do Tauri (feature `tray-icon`, `core:tray` e `core:menu` do `core:default`, e `core:app:allow-default-window-icon`), com as mudanças em fila para nunca criar dois ícones | | avisos do Windows | `tauri-plugin-notification` (`notification:default`). Sem `sound`, o toast é mudo; com "Tocar um som", `sound: "Default"`. O app instalado usa o próprio AppUserModelID; em dev, o do PowerShell | -| `claude_sign_in` | abre `claude auth login` numa janela de console própria (`CREATE_NEW_CONSOLE`) com o `claudePath` do `system.status` e espera; responde se saiu com 0. Só roda um caminho absoluto de arquivo existente chamado `claude.exe`. Tira do ambiente `CLAUDECODE` e `CLAUDE_CODE_*`, que um app aberto de dentro de uma sessão do Claude Code herdaria. Quem abre é o app, e não o daemon, porque a janela aberta pelo app em primeiro plano vem para a frente. Na janela o dono vê o que acontece e pode colar um código se o navegador pedir | +| `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. No Linux e no macOS (`claude`, sem `.exe`) é uma janela de terminal: o Terminal pelo `osascript` no macOS; no Linux o primeiro de `x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `kitty`, `alacritty` e `xterm` no `PATH`. O terminal roda `sh -c` com o comando, que grava o código de saída num arquivo numa pasta temporária `0700`; o app espera esse arquivo por até 15 min, porque um terminal pode passar o comando a um processo que já roda e voltar na hora. Fechar o terminal sem entrar deixa o app esperando até esse limite | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | -| `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | +| `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria. Fora do Windows, `open` no macOS (que recusa um `.app`, uma pasta que ele iniciaria) e `xdg-open` no Linux, como em `open_file`, `reveal_file` e `open_url` | | seletor de pastas | não é comando próprio: o app usa o `tauri-plugin-dialog` (`open({directory: true})`, permissão `dialog:allow-open`) para a pasta de trabalho da crew (5) | | `open_file` | abre um arquivo do bot com o programa que o Windows usa para ele (`explorer.exe `). Só extensões de documento, imagem, som e vídeo (`pdf`, `png`, `md`, `docx`, `xlsx`, `mp4`...); qualquer outra, como `.exe`, `.bat`, `.ps1` ou `.lnk`, é recusada, porque o Explorer a executaria | -| `reveal_file` | abre o Explorer na pasta do arquivo, com ele marcado (`/select,`). Nada é executado | +| `reveal_file` | abre o Explorer na pasta do arquivo, com ele marcado (`/select,`). Nada é executado. No macOS, `open -R`; no Linux, que não tem um jeito comum de marcar o arquivo, abre a pasta dele | | `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página | | overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: aprovação pendente, bot em `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito | @@ -941,7 +941,7 @@ Esta seção entra no PR do I1, junto com o código. - TS: `tsc --noEmit`, `biome check`, `vitest run`. - Limite **flexível de 300 linhas** por arquivo; passou disso, dividir por responsabilidade. - Toda lógica de supervisor, courier e chat testada com `FakeRuntime` (sem Claude real). O `FakeRuntime` fala `stream-json` de verdade: recebe as linhas do stdin e o teste escreve os eventos do stdout. -- CI: GitHub Actions em `windows-latest` (principal, workspace inteiro e app) e em `ubuntu-latest` e `macos-latest` para os membros padrão (`botloft-core`, `botloft-store`, `botloftd`), com clippy e testes. Os testes do navegador rodam com o Edge ou o Chrome da imagem. No Ubuntu, a imagem traz os auxiliares de sandbox deles sem setuid, e eles se recusam a abrir; o CI os deixa como os pacotes deixariam (`root`, `4755`). Os crates do app entram nesses runners quando o app rodar neles (18, item 8). +- CI: GitHub Actions em `windows-latest` (principal, workspace inteiro e app) e em `ubuntu-latest` e `macos-latest` para os membros padrão (`botloft-core`, `botloft-store`, `botloftd`), com clippy e testes. Os testes do navegador rodam com o Edge ou o Chrome da imagem. No Ubuntu, a imagem traz os auxiliares de sandbox deles sem setuid, e eles se recusam a abrir; o CI os deixa como os pacotes deixariam (`root`, `4755`). O crate do app (`botloft-app`) também passa por clippy e testes nesses runners; no Ubuntu, com as bibliotecas de desktop do Tauri (`libwebkit2gtk-4.1-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`). A janela de instalação (15.7) é só do Windows (18, item 8). ## 17. Marcos do MVP From ac7f46931ecc58ad64cc1887fff348bb73df2ee0 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:01:58 -0300 Subject: [PATCH 2/3] feat(app): texts name the owner's system instead of always Windows "Start with Windows", "when you sign in to Windows", the system's notifications and animations now read macOS or Linux there, from the app window's user agent (lib/system.ts). The browser that did not open names Chrome, Chromium or Edge off Windows. All three languages. --- app/src/features/account/AlertsSettings.tsx | 3 ++- .../features/account/AppearanceSettings.tsx | 3 ++- app/src/features/account/GeneralSettings.tsx | 9 ++++--- app/src/features/account/settings.test.tsx | 9 ++++--- app/src/features/browser/BrowserPanel.tsx | 3 ++- app/src/features/onboarding/Onboarding.tsx | 3 ++- app/src/features/onboarding/Welcome.tsx | 3 ++- app/src/i18n/en/account.ts | 19 ++++++------- app/src/i18n/en/browser.ts | 6 +++-- app/src/i18n/en/onboarding.ts | 8 +++--- app/src/i18n/es/account.ts | 21 ++++++++------- app/src/i18n/es/browser.ts | 6 +++-- app/src/i18n/es/onboarding.ts | 8 +++--- app/src/i18n/pt-BR/account.ts | 20 +++++++------- app/src/i18n/pt-BR/browser.ts | 6 +++-- app/src/i18n/pt-BR/onboarding.ts | 8 +++--- app/src/lib/system.test.ts | 27 +++++++++++++++++++ app/src/lib/system.ts | 21 +++++++++++++++ docs/spec.md | 1 + 19 files changed, 124 insertions(+), 60 deletions(-) create mode 100644 app/src/lib/system.test.ts create mode 100644 app/src/lib/system.ts diff --git a/app/src/features/account/AlertsSettings.tsx b/app/src/features/account/AlertsSettings.tsx index cca79504..6f734e1a 100644 --- a/app/src/features/account/AlertsSettings.tsx +++ b/app/src/features/account/AlertsSettings.tsx @@ -3,6 +3,7 @@ // goes with it. import { useT } from "../../i18n"; +import { SYSTEM } from "../../lib/system"; import { prefs, usePref } from "../../shell/prefs"; import { Section, Toggle } from "./settingsParts"; @@ -18,7 +19,7 @@ export function AlertsSettings() {
diff --git a/app/src/features/account/AppearanceSettings.tsx b/app/src/features/account/AppearanceSettings.tsx index 6c68bccf..9adf596a 100644 --- a/app/src/features/account/AppearanceSettings.tsx +++ b/app/src/features/account/AppearanceSettings.tsx @@ -1,6 +1,7 @@ // Settings, "Appearance": theme, size and motion (spec 15.3). import { useT } from "../../i18n"; +import { SYSTEM } from "../../lib/system"; import { prefs, usePref } from "../../shell/prefs"; import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme"; import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom"; @@ -33,7 +34,7 @@ export function AppearanceSettings() { diff --git a/app/src/features/account/GeneralSettings.tsx b/app/src/features/account/GeneralSettings.tsx index 3c1bc349..874f52ee 100644 --- a/app/src/features/account/GeneralSettings.tsx +++ b/app/src/features/account/GeneralSettings.tsx @@ -9,6 +9,7 @@ import { useLocale, useT, } from "../../i18n"; +import { SYSTEM } from "../../lib/system"; import { prefs, usePref } from "../../shell/prefs"; import { Select } from "../../ui/Select"; import { Section, Toggle } from "./settingsParts"; @@ -51,18 +52,18 @@ export function GeneralSettings() { /> )} change({ startWithWindows: on })} /> {startWithWindows && settings && ( { @@ -30,7 +31,7 @@ describe("settings", () => { const keep = within(dialog).getByRole("switch", { name: "Keep working after you close Botloft", }); - const start = within(dialog).getByRole("switch", { name: "Start with Windows" }); + const start = within(dialog).getByRole("switch", { name: `Start with ${SYSTEM}` }); const awake = within(dialog).getByRole("switch", { name: "Keep the computer awake while bots work", }); @@ -70,7 +71,7 @@ describe("settings", () => { within(dialog).queryByRole("switch", { name: "Show Botloft near the clock" }); const atSignIn = () => within(dialog).queryByRole("switch", { - name: "Open the window when you sign in to Windows", + name: `Open the window when you sign in to ${SYSTEM}`, }); await waitFor(() => expect(atSignIn()).not.toBeNull()); expect(dialog.textContent).toContain( @@ -83,7 +84,7 @@ describe("settings", () => { within(dialog).getByRole("switch", { name: "Keep working after you close Botloft" }), ); expect(icon()).toBeNull(); - fireEvent.click(within(dialog).getByRole("switch", { name: "Start with Windows" })); + fireEvent.click(within(dialog).getByRole("switch", { name: `Start with ${SYSTEM}` })); await waitFor(() => expect(atSignIn()).toBeNull()); }); @@ -110,7 +111,7 @@ describe("settings", () => { 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" }); + const start = within(dialog).getByRole("switch", { name: `Start with ${SYSTEM}` }); await waitFor(() => expect(start.hasAttribute("disabled")).toBe(false)); fake.failNext("settings.update", new RpcError(-32603, "could not save the settings")); fireEvent.click(start); diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index 05f432da..0a05dfd7 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -7,6 +7,7 @@ import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import type { Bot } from "../../lib/protocol.gen"; +import { SYSTEM } from "../../lib/system"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; @@ -121,7 +122,7 @@ export function BrowserPanel({ > {status === "failed" ? ( - {t.failedBody} + {t.failedBody(SYSTEM)} {state?.error && (
{t.details} diff --git a/app/src/features/onboarding/Onboarding.tsx b/app/src/features/onboarding/Onboarding.tsx index d4d50c5c..eb332a99 100644 --- a/app/src/features/onboarding/Onboarding.tsx +++ b/app/src/features/onboarding/Onboarding.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { type StoreApi, useStore } from "zustand"; import { useT } from "../../i18n"; import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; +import { SYSTEM } from "../../lib/system"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { Details } from "../../ui/Details"; @@ -33,7 +34,7 @@ export function Onboarding({ link }: { link: StoreApi }) {
{current.action === "install" && ( -

{o.installNote}

+

{o.installNote(SYSTEM)}

)}
); diff --git a/app/src/features/onboarding/Welcome.tsx b/app/src/features/onboarding/Welcome.tsx index fe5311f1..b1de91f3 100644 --- a/app/src/features/onboarding/Welcome.tsx +++ b/app/src/features/onboarding/Welcome.tsx @@ -1,6 +1,7 @@ import { CircleCheck, CircleX, LoaderCircle, Plus } from "lucide-react"; import { type ReactNode, useState } from "react"; import { useT } from "../../i18n"; +import { SYSTEM } from "../../lib/system"; import { prefs, usePref } from "../../shell/prefs"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; @@ -22,7 +23,7 @@ export function Welcome() { ? w.runningWhileOpen : settings?.startWithWindows === false ? w.runningNotAtStart - : w.running; + : w.running(SYSTEM); let claude: ReactNode; if (!system || (system.claudeVersion === null && system.runtimeError === null)) { diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index c9f48c36..9dd8da0a 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -54,18 +54,18 @@ export const account = { "With the window closed, its icon stays near the clock: a click opens Botloft, and it tells you when a bot needs you.", trayOff: "Closing the window closes Botloft. The bots keep working, with no icon or notifications.", - startWithWindows: "Start with Windows", - startWithWindowsOn: - "When you sign in to Windows, your bots get back to work on their own, without opening this window.", - startWithWindowsOff: "After you restart the computer, the bots wait until you open Botloft.", - openAtSignIn: "Open the window when you sign in to Windows", - openAtSignInOn: "Botloft's window opens when you sign in to Windows.", + startWithSystem: (system: string) => `Start with ${system}`, + startWithSystemOn: (system: string) => + `When you sign in to ${system}, your bots get back to work on their own, without opening this window.`, + startWithSystemOff: "After you restart the computer, the bots wait until you open Botloft.", + openAtSignIn: (system: string) => `Open the window when you sign in to ${system}`, + openAtSignInOn: (system: string) => `Botloft's window opens when you sign in to ${system}.`, openAtSignInNearClock: "Botloft starts near the clock, without opening the window.", openAtSignInOff: "The window opens only when you open Botloft.", alerts: "Notifications", notifyNeeds: "When a bot needs you", - notifyNeedsHint: - "A Windows notification when a bot asks for permission or needs you to sign in, if Botloft is not in front.", + notifyNeedsHint: (system: string) => + `A ${system} notification when a bot asks for permission or needs you to sign in, if Botloft is not in front.`, notifyDone: "When a bot finishes", notifyDoneHint: "A notification when a bot finishes what it was doing.", sound: "Play a sound", @@ -94,7 +94,8 @@ export const account = { ? "1 hour" : `${minutes / 60} hours`, lessMotion: "Less motion", - lessMotionHint: "A still window, as when the Windows animation effects are off.", + lessMotionHint: (system: string) => + `A still window, as when the ${system} animation effects are off.`, appearance: "Appearance", theme: "Theme", themes: { system: "System", light: "Light", dark: "Dark" }, diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts index 2f7685c9..d28e93f0 100644 --- a/app/src/i18n/en/browser.ts +++ b/app/src/i18n/en/browser.ts @@ -33,8 +33,10 @@ export const browser = { closed: "Browser closed", closedBody: "It opens again when the bot needs it. Logins stay.", failedTitle: "The browser couldn't open", - failedBody: - "Botloft uses Microsoft Edge, which comes with Windows. Check that it's installed, then ask the bot to try again.", + failedBody: (system: string): string => + system === "Windows" + ? "Botloft uses Microsoft Edge, which comes with Windows. Check that it's installed, then ask the bot to try again." + : "Botloft uses Google Chrome, Chromium or Microsoft Edge. Check that one of them is installed, then ask the bot to try again.", details: "Details", tabs: { label: "Tabs", diff --git a/app/src/i18n/en/onboarding.ts b/app/src/i18n/en/onboarding.ts index 1fdbc67d..5118eb70 100644 --- a/app/src/i18n/en/onboarding.ts +++ b/app/src/i18n/en/onboarding.ts @@ -12,8 +12,8 @@ export const onboarding = { restart: "Restarting Botloft…", start: "Starting your bots…", }, - installNote: - "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.", + installNote: (system: string) => + `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 ${system}.`, stopped: { title: "Botloft couldn't start", body: "Botloft runs your bots in the background, and that part didn't start.", @@ -48,8 +48,8 @@ export const onboarding = { intro: "A crew is a group of Claude Code bots that keep running, message each other and share a folder. Each crew starts with a chief: tell it what the crew is for, and it plans the work and suggests the bots it needs.", botloft: "Botloft", - running: - "Running in the background. It starts with Windows, so your bots keep working after you close this window.", + running: (system: string) => + `Running in the background. It starts with ${system}, 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.", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 8d29564f..71d42121 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -53,18 +53,19 @@ export const account: Messages["account"] = { trayOn: "Con la ventana cerrada, su icono queda junto al reloj: un clic abre Botloft, y te avisa cuando un bot te necesita.", trayOff: "Cerrar la ventana cierra Botloft. Los bots siguen trabajando, sin icono ni avisos.", - startWithWindows: "Iniciar con Windows", - startWithWindowsOn: - "Cuando inicias sesión en Windows, tus bots vuelven a trabajar solos, sin abrir esta ventana.", - startWithWindowsOff: "Después de reiniciar el equipo, los bots esperan a que abras Botloft.", - openAtSignIn: "Abrir la ventana al iniciar sesión en Windows", - openAtSignInOn: "La ventana de Botloft se abre cuando inicias sesión en Windows.", + startWithSystem: (system: string) => `Iniciar con ${system}`, + startWithSystemOn: (system: string) => + `Cuando inicias sesión en ${system}, tus bots vuelven a trabajar solos, sin abrir esta ventana.`, + startWithSystemOff: "Después de reiniciar el equipo, los bots esperan a que abras Botloft.", + openAtSignIn: (system: string) => `Abrir la ventana al iniciar sesión en ${system}`, + openAtSignInOn: (system: string) => + `La ventana de Botloft se abre cuando inicias sesión en ${system}.`, openAtSignInNearClock: "Botloft empieza junto al reloj, sin abrir la ventana.", openAtSignInOff: "La ventana solo se abre cuando abres Botloft.", alerts: "Avisos", notifyNeeds: "Cuando un bot te necesite", - notifyNeedsHint: - "Un aviso de Windows cuando un bot pide permiso o necesita que inicies sesión, si Botloft no está al frente.", + notifyNeedsHint: (system: string) => + `Un aviso de ${system} cuando un bot pide permiso o necesita que inicies sesión, si Botloft no está al frente.`, notifyDone: "Cuando un bot termine", notifyDoneHint: "Un aviso cuando un bot termina lo que estaba haciendo.", sound: "Reproducir un sonido", @@ -92,8 +93,8 @@ export const account: Messages["account"] = { ? "1 hora" : `${minutes / 60} horas`, lessMotion: "Menos animaciones", - lessMotionHint: - "La ventana queda quieta, como cuando los efectos de animación de Windows están desactivados.", + lessMotionHint: (system: string) => + `La ventana queda quieta, como cuando los efectos de animación de ${system} están desactivados.`, appearance: "Apariencia", theme: "Tema", themes: { system: "Sistema", light: "Claro", dark: "Oscuro" }, diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts index 7e835875..9467b04b 100644 --- a/app/src/i18n/es/browser.ts +++ b/app/src/i18n/es/browser.ts @@ -34,8 +34,10 @@ export const browser: Messages["browser"] = { closed: "Navegador cerrado", closedBody: "Se abre de nuevo cuando el bot lo necesite. Las sesiones iniciadas siguen.", failedTitle: "El navegador no se abrió", - failedBody: - "Botloft usa Microsoft Edge, que viene con Windows. Comprueba que esté instalado y pide al bot que lo intente de nuevo.", + failedBody: (system: string) => + system === "Windows" + ? "Botloft usa Microsoft Edge, que viene con Windows. Comprueba que esté instalado y pide al bot que lo intente de nuevo." + : "Botloft usa Google Chrome, Chromium o Microsoft Edge. Comprueba que uno de ellos esté instalado y pide al bot que lo intente de nuevo.", details: "Detalles", tabs: { label: "Pestañas", diff --git a/app/src/i18n/es/onboarding.ts b/app/src/i18n/es/onboarding.ts index 9db876f2..7f31eae8 100644 --- a/app/src/i18n/es/onboarding.ts +++ b/app/src/i18n/es/onboarding.ts @@ -11,8 +11,8 @@ export const onboarding: Messages["onboarding"] = { restart: "Reiniciando Botloft…", start: "Iniciando tus bots…", }, - installNote: - "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.", + installNote: (system: string) => + `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 ${system}.`, stopped: { title: "Botloft no pudo iniciar", body: "Botloft ejecuta tus bots en segundo plano, y esa parte no se inició.", @@ -47,8 +47,8 @@ export const onboarding: Messages["onboarding"] = { intro: "Un equipo es un grupo de bots de Claude Code que siguen funcionando, se envían mensajes y comparten una carpeta. Cada equipo empieza con un jefe: dile para qué es el equipo, y planea el trabajo y sugiere los bots que necesita.", botloft: "Botloft", - running: - "Funcionando en segundo plano. Se inicia con Windows, así que tus bots siguen trabajando después de cerrar esta ventana.", + running: (system: string) => + `Funcionando en segundo plano. Se inicia con ${system}, 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: diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 8779cfe6..cc7341cb 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -53,19 +53,19 @@ export const account: Messages["account"] = { trayOn: "Com a janela fechada, o ícone fica perto do relógio: um clique abre o Botloft, e ele avisa quando um bot precisa de você.", trayOff: "Fechar a janela fecha o Botloft. Os bots seguem trabalhando, sem ícone nem avisos.", - startWithWindows: "Iniciar com o Windows", - startWithWindowsOn: - "Quando você entra no Windows, seus bots voltam a trabalhar sozinhos, sem abrir esta janela.", - startWithWindowsOff: + startWithSystem: (system: string) => `Iniciar com o ${system}`, + startWithSystemOn: (system: string) => + `Quando você entra no ${system}, seus bots voltam a trabalhar sozinhos, sem abrir esta janela.`, + startWithSystemOff: "Depois que você reinicia o computador, os bots esperam você abrir o Botloft.", - openAtSignIn: "Abrir a janela ao entrar no Windows", - openAtSignInOn: "A janela do Botloft abre quando você entra no Windows.", + openAtSignIn: (system: string) => `Abrir a janela ao entrar no ${system}`, + openAtSignInOn: (system: string) => `A janela do Botloft abre quando você entra no ${system}.`, openAtSignInNearClock: "O Botloft começa perto do relógio, sem abrir a janela.", openAtSignInOff: "A janela só abre quando você abrir o Botloft.", alerts: "Avisos", notifyNeeds: "Quando um bot precisar de você", - notifyNeedsHint: - "Um aviso do Windows quando um bot pede permissão ou precisa que você entre na conta, se o Botloft não estiver na frente.", + notifyNeedsHint: (system: string) => + `Um aviso do ${system} quando um bot pede permissão ou precisa que você entre na conta, se o Botloft não estiver na frente.`, notifyDone: "Quando um bot terminar", notifyDoneHint: "Um aviso quando um bot termina o que estava fazendo.", sound: "Tocar um som", @@ -93,8 +93,8 @@ export const account: Messages["account"] = { ? "1 hora" : `${minutes / 60} horas`, lessMotion: "Menos animações", - lessMotionHint: - "A janela fica parada, como quando os efeitos de animação do Windows estão desligados.", + lessMotionHint: (system: string) => + `A janela fica parada, como quando os efeitos de animação do ${system} estão desligados.`, appearance: "Aparência", theme: "Tema", themes: { system: "Sistema", light: "Claro", dark: "Escuro" }, diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts index 95c6372e..1c8a23cd 100644 --- a/app/src/i18n/pt-BR/browser.ts +++ b/app/src/i18n/pt-BR/browser.ts @@ -35,8 +35,10 @@ export const browser: Messages["browser"] = { closed: "Navegador fechado", closedBody: "Ele abre de novo quando o bot precisar. Os logins continuam.", failedTitle: "O navegador não abriu", - failedBody: - "O Botloft usa o Microsoft Edge, que vem com o Windows. Confira se ele está instalado e peça ao bot para tentar de novo.", + failedBody: (system: string) => + system === "Windows" + ? "O Botloft usa o Microsoft Edge, que vem com o Windows. Confira se ele está instalado e peça ao bot para tentar de novo." + : "O Botloft usa o Google Chrome, o Chromium ou o Microsoft Edge. Confira se um deles está instalado e peça ao bot para tentar de novo.", details: "Detalhes", tabs: { label: "Abas", diff --git a/app/src/i18n/pt-BR/onboarding.ts b/app/src/i18n/pt-BR/onboarding.ts index aac28c04..52957ef9 100644 --- a/app/src/i18n/pt-BR/onboarding.ts +++ b/app/src/i18n/pt-BR/onboarding.ts @@ -11,8 +11,8 @@ export const onboarding: Messages["onboarding"] = { restart: "Reiniciando o Botloft…", start: "Iniciando seus bots…", }, - installNote: - "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.", + installNote: (system: string) => + `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 ${system}.`, stopped: { title: "O Botloft não conseguiu iniciar", body: "O Botloft roda seus bots em segundo plano, e essa parte não iniciou.", @@ -47,8 +47,8 @@ export const onboarding: Messages["onboarding"] = { intro: "Uma equipe é um grupo de bots do Claude Code que ficam rodando, trocam mensagens entre si e compartilham uma pasta. Cada equipe começa com um Chefe: diga a ele para que é a equipe, e ele planeja o trabalho e sugere os bots de que precisa.", botloft: "Botloft", - running: - "Rodando em segundo plano. Inicia com o Windows, então seus bots continuam trabalhando depois que você fecha esta janela.", + running: (system: string) => + `Rodando em segundo plano. Inicia com o ${system}, 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.", diff --git a/app/src/lib/system.test.ts b/app/src/lib/system.test.ts new file mode 100644 index 00000000..d092170a --- /dev/null +++ b/app/src/lib/system.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { systemName } from "./system"; + +describe("systemName", () => { + test("reads the system from the app window's user agent", () => { + expect( + systemName( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36 Edg/140.0", + ), + ).toBe("Windows"); + expect( + systemName( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)", + ), + ).toBe("macOS"); + expect( + systemName( + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)", + ), + ).toBe("Linux"); + }); + + test("anything unknown is Windows", () => { + expect(systemName("")).toBe("Windows"); + expect(systemName("Mozilla/5.0 (Linux; Android 14)")).toBe("Windows"); + }); +}); diff --git a/app/src/lib/system.ts b/app/src/lib/system.ts new file mode 100644 index 00000000..83201634 --- /dev/null +++ b/app/src/lib/system.ts @@ -0,0 +1,21 @@ +// The owner's operating system, by the name texts give it (spec 15.6): the +// settings say "Start with Windows" on Windows and "Start with macOS" on a +// Mac. Read from the app window's user agent; anything unknown is Windows, +// where Botloft started. + +export type SystemName = "Windows" | "macOS" | "Linux"; + +export function systemName( + agent: string = typeof navigator === "undefined" ? "" : navigator.userAgent, +): SystemName { + if (/Macintosh|Mac OS X|darwin/i.test(agent)) { + return "macOS"; + } + if (/Linux|X11/i.test(agent) && !/Android/i.test(agent)) { + return "Linux"; + } + return "Windows"; +} + +/** This computer's system, read once. */ +export const SYSTEM: SystemName = systemName(); diff --git a/docs/spec.md b/docs/spec.md index 4eddbb57..b5b850f1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -874,6 +874,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - Componentes leem com `useT()`; código fora do React (toasts, formatação, erros da conexão) com `t()` na hora do uso. - Escolha na área da conta (Idioma, ou Configurações), e no botão de idioma da barra de título só nas telas de preparo: "Idioma do sistema" segue o Windows (o primeiro idioma suportado entre os preferidos; `pt-PT` vira `pt-BR`; nenhum, inglês) ou um idioma fixo. A escolha fica no `localStorage` do app (`botloft.locale`) e marca ``. - Datas e horas (`lib/format.ts`) usam o idioma escolhido. +- Textos que nomeiam o sistema ("Iniciar com o Windows", "quando você entra no Windows", os avisos e as animações do sistema) recebem o nome dele: `Windows`, `macOS` ou `Linux`, lido uma vez do user agent da janela do app (`lib/system.ts`; um desconhecido conta como Windows). O aviso de navegador que não abriu fala do Edge no Windows e de Chrome, Chromium ou Edge nos outros (21.2). - O daemon não escreve texto para o dono: avisos vêm com `code` e a linha da conversa com `kind` (8.2, 11.2), e o app escreve. Continuam como vêm: nomes, mensagens, respostas e saídas de ferramenta; o resumo de ferramenta que o daemon tira da entrada (o comando, o arquivo), sem o nome da ferramenta, que o app escreve; e as mensagens de erro do daemon (validação, falhas), mostradas como texto técnico. - O instalador NSIS e a tela de instalação (15.7) também trazem os três idiomas e escolhem pelo idioma do Windows. - Tom: palavras simples, sem jargão; "você" em português, "tú" em espanhol. Glossário: crew = equipe / equipo; task = tarefa / tarea; Allow / Deny = Permitir / Negar / Denegar; role = função / rol. From f7f7e3dfd291fd31e5f8bff5ea6bab5f38243d85 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:25:10 -0300 Subject: [PATCH 3/3] docs(spec): slice 4 of Linux/macOS support is done --- docs/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec.md b/docs/spec.md index 4d8e45c8..c75fc359 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -967,7 +967,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale). 7. Sinais entre bots disparando rotinas. -8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1, 2 e 3 (14.1). +8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1 a 4 (14.1, 15.2, 15.6). 9. Navegador dos bots, com o dono assistindo ao vivo. Desenho na seção 21. ## 19. Pontos a verificar na versão alvo do Claude Code