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/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 5746f712..c75fc359 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 | @@ -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. @@ -941,7 +942,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 @@ -966,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