diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index 70617023..57e7b8ef 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -4,8 +4,8 @@ // The owner can take it into their own hands and give it back (spec 21.10), // or open it in a window of its own to sign in (spec 21.11). -import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react"; -import { type CSSProperties, useEffect, useRef, useState } from "react"; +import { Globe, Hand, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react"; +import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import type { Bot } from "../../lib/protocol.gen"; import { SYSTEM } from "../../lib/system"; @@ -24,7 +24,7 @@ import { LessonArea, LessonDialog, useLesson } from "./Lesson"; import { LiveView, useCaption } from "./LiveView"; import { TabStrip } from "./TabStrip"; import { useBrowserView } from "./useBrowserView"; -import { useHands } from "./useHands"; +import { type Hands, useHands } from "./useHands"; import { useFitPage, useRoom } from "./useRoom"; import { WindowBar } from "./WindowBar"; @@ -138,7 +138,12 @@ export function BrowserPanel({ )} ) : status === "closed" && !frame ? ( - !windowed && + !windowed && ( + <> + + + + ) ) : ( <> {live && ask && !hands.held && } @@ -178,12 +183,20 @@ export function BrowserPanel({ {(status !== "open" || !frame) && ( + > + {status === "closed" && !windowed && !hands.opening && ( + + )} + )} @@ -223,7 +236,17 @@ export function BrowserPanel({ ); } -function Overlay({ busy, title, body }: { busy: boolean; title: string; body: string | null }) { +function Overlay({ + busy, + title, + body, + children, +}: { + busy: boolean; + title: string; + body: string | null; + children?: ReactNode; +}) { return (
@@ -232,11 +255,31 @@ function Overlay({ busy, title, body }: { busy: boolean; title: string; body: st {title}

{body &&

{body}

} + {children}
); } +/** The owner opens the closed browser for themselves (spec 21.10). */ +function OpenForMe({ bot, hands }: { bot: Bot; hands: Hands }) { + const t = useT().browser; + return ( +
+ +

{t.openForMeWhy(bot.name)}

+
+ ); +} + function Empty({ bot }: { bot: Bot }) { const t = useT().browser; return ( diff --git a/app/src/features/browser/openForMe.test.tsx b/app/src/features/browser/openForMe.test.tsx new file mode 100644 index 00000000..dbee1f43 --- /dev/null +++ b/app/src/features/browser/openForMe.test.tsx @@ -0,0 +1,42 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(cleanup); + +const panel = () => screen.getByRole("complementary", { name: "Scout's browser" }); + +describe("the owner opens the bot's closed browser", () => { + test("on its last page, in their hands, without telling the bot", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + fake.browser.open(scout.id, "https://example.com/", "Example"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + fireEvent.click(screen.getByRole("button", { name: /Show browser|Hide browser/ })); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + act(() => { + fake.browser.frame(scout.id, "BBBB"); + }); + act(() => { + fake.browser.close(scout.id); + }); + expect(await within(panel()).findByText("Browser closed")).toBeDefined(); + expect(within(panel()).getByText(/Scout is not woken or told/)).toBeDefined(); + + fireEvent.click(within(panel()).getByRole("button", { name: "Open it for me" })); + await waitFor(() => expect(fake.browser.state(scout.id).control).toBe("owner")); + expect(fake.browser.state(scout.id).url).toBe("https://example.com/"); + expect(fake.calls.some((call) => call.method === "messages.send")).toBe(false); + + fireEvent.click( + await within(panel()).findByRole("button", { name: "Done, give it back to Scout" }), + ); + await waitFor(() => expect(fake.browser.state(scout.id).control).toBe("bot")); + }); +}); diff --git a/app/src/features/browser/useHands.ts b/app/src/features/browser/useHands.ts index 295c52ed..cf70c5ae 100644 --- a/app/src/features/browser/useHands.ts +++ b/app/src/features/browser/useHands.ts @@ -4,7 +4,7 @@ // Events go out in order and without waiting for each other: the daemon // queues them for the page. -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useT } from "../../i18n"; import type { Bot, BrowserInput, BrowserState } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; @@ -24,6 +24,10 @@ export interface Hands { switchTab(tabId: string): Promise; /** Takes the active tab to the address the owner typed. */ open(url: string): Promise; + /** Opens the closed browser for the owner, and takes it once open. */ + start(): Promise; + /** Waiting for the browser opened for the owner. */ + opening: boolean; } export function useHands(bot: Pick, state: BrowserState | null): Hands { @@ -79,6 +83,27 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand [api, botId, t.goFailed], ); + // Opened for the owner (spec 21.10), it is theirs as soon as it is up. + const [opening, setOpening] = useState(false); + const start = useCallback(async () => { + const asked = await attempt(t.openForMeFailed, async () => { + putBrowser(await api.call("browser.start", { botId })); + }); + setOpening(asked); + }, [api, botId, putBrowser, t.openForMeFailed]); + const status = state?.status; + useEffect(() => { + if (!opening) { + return; + } + if (status === "open") { + setOpening(false); + void take(); + } else if (status === "failed") { + setOpening(false); + } + }, [opening, status, take]); + return { held: state?.status === "open" && state.control === "owner", busy, @@ -89,5 +114,7 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand newTab, switchTab, open, + start, + opening, }; } diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts index 82c5cfed..3aa5a6f1 100644 --- a/app/src/i18n/en/browser.ts +++ b/app/src/i18n/en/browser.ts @@ -32,6 +32,10 @@ export const browser = { starting: "Opening the browser…", closed: "Browser closed", closedBody: "It opens again when the bot needs it. Logins stay.", + openForMe: "Open it for me", + openForMeWhy: (bot: string) => + `To see what ${bot} left open and use it yourself. ${bot} is not woken or told; give it back when you are done and it rests again.`, + openForMeFailed: "Could not open the browser", failedTitle: "The browser couldn't open", failedBody: (system: string): string => system === "Windows" diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts index 264f29eb..74a70059 100644 --- a/app/src/i18n/es/browser.ts +++ b/app/src/i18n/es/browser.ts @@ -33,6 +33,10 @@ export const browser: Messages["browser"] = { starting: "Abriendo el navegador…", closed: "Navegador cerrado", closedBody: "Se abre de nuevo cuando el bot lo necesite. Las sesiones iniciadas siguen.", + openForMe: "Abrirlo para mí", + openForMeWhy: (bot) => + `Para ver lo que ${bot} dejó abierto y usarlo tú mismo. ${bot} no se despierta ni se entera; devuélvelo cuando termines y el navegador vuelve a descansar.`, + openForMeFailed: "No se pudo abrir el navegador", failedTitle: "El navegador no se abrió", failedBody: (system: string) => system === "Windows" diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts index 42f66965..26925edb 100644 --- a/app/src/i18n/pt-BR/browser.ts +++ b/app/src/i18n/pt-BR/browser.ts @@ -34,6 +34,10 @@ export const browser: Messages["browser"] = { starting: "Abrindo o navegador…", closed: "Navegador fechado", closedBody: "Ele abre de novo quando o bot precisar. Os logins continuam.", + openForMe: "Abrir para mim", + openForMeWhy: (bot) => + `Para ver o que ${bot} deixou aberto e mexer você mesmo. ${bot} não é acordado nem avisado; devolva quando terminar e o navegador volta a descansar.`, + openForMeFailed: "Não foi possível abrir o navegador", failedTitle: "O navegador não abriu", failedBody: (system: string) => system === "Windows" diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts index 85145890..a9309db4 100644 --- a/app/src/lib/fakeBrowser.ts +++ b/app/src/lib/fakeBrowser.ts @@ -37,6 +37,9 @@ export class FakeBrowser { /** Pages that answer the owner's hands, for the preview. */ private readonly pages = new Map void>(); + /** The page each browser showed when it closed, to open it again. */ + private readonly lastUrls = new Map(); + constructor(private readonly fake: FakeBotloft) {} state(botId: BotId): BrowserState { @@ -101,6 +104,7 @@ export class FakeBrowser { close(botId: BotId): BrowserState { this.frames.delete(botId); + this.lastUrls.set(botId, this.state(botId).url ?? "about:blank"); const closed = { status: "closed", loading: false, control: "bot", resting: false } as const; return this.setTabs(botId, [], { ...closed, ask: null }); } @@ -224,6 +228,7 @@ export class FakeBrowser { | "browser.switchTab" | "browser.open" | "browser.window" + | "browser.start" > { return { "browser.list": () => @@ -311,6 +316,15 @@ export class FakeBrowser { this.repaint(botId); return null; }, + // Up a moment later, on its last page, as the daemon does it. + "browser.start": ({ botId }) => { + if (this.watching !== botId) { + throw conflict("watch this browser before opening it"); + } + const url = this.lastUrls.get(botId) ?? "about:blank"; + setTimeout(() => this.open(botId, url, new URL(url).hostname)); + return this.set(botId, { status: "starting" }); + }, "browser.window": ({ botId }) => { if (this.watching !== botId) { throw conflict("watch this browser before opening it in a window"); diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index ad8ccfab..b3779e02 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1696,6 +1696,7 @@ export interface RpcMethods { "browser.take": { params: BrowserControlParams; result: BrowserState }; "browser.release": { params: BrowserControlParams; result: BrowserState }; "browser.window": { params: BrowserControlParams; result: BrowserState }; + "browser.start": { params: BrowserControlParams; result: BrowserState }; "browser.teach": { params: BrowserTeachParams; result: Array }; "browser.input": { params: BrowserInputParams; result: null }; "browser.reload": { params: BrowserControlParams; result: null }; diff --git a/crates/botloft-core/src/protocol/export_browser.rs b/crates/botloft-core/src/protocol/export_browser.rs index e27d9910..40b995f3 100644 --- a/crates/botloft-core/src/protocol/export_browser.rs +++ b/crates/botloft-core/src/protocol/export_browser.rs @@ -54,6 +54,7 @@ pub(super) fn methods(out: &mut Out) { out.method(method::BROWSER_TAKE, &control, &state); out.method(method::BROWSER_RELEASE, &control, &state); out.method(method::BROWSER_WINDOW, &control, &state); + out.method(method::BROWSER_START, &control, &state); out.method( method::BROWSER_TEACH, &out.name::(), diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 24a89126..4e5fd62a 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -161,6 +161,8 @@ pub mod method { pub const BROWSER_OPEN: &str = "browser.open"; pub const BROWSER_WINDOW: &str = "browser.window"; pub const BROWSER_TEACH: &str = "browser.teach"; + /// The owner opens the bot's closed browser for themselves. + pub const BROWSER_START: &str = "browser.start"; pub const SCREENS_LIST: &str = "screens.list"; pub const QUESTIONS_LIST: &str = "questions.list"; pub const QUESTIONS_ANSWER: &str = "questions.answer"; diff --git a/crates/botloftd/src/browser/call.rs b/crates/botloftd/src/browser/call.rs index 96a8b58d..b9bd2b68 100644 --- a/crates/botloftd/src/browser/call.rs +++ b/crates/botloftd/src/browser/call.rs @@ -10,6 +10,7 @@ use botloft_core::protocol::{BrowserState, BrowserStatus}; use tokio::sync::OwnedMutexGuard; use tracing::debug; +use super::owner_open::remember; use super::session::{Hooks, PageInfo, Session}; use super::{BrowserError, Browsers, lock, program, update, watch}; @@ -92,6 +93,7 @@ impl Call<'_> { bot.clone(), ); move |info: PageInfo| { + remember(&slots, &bot, info.url.as_deref()); update(&slots, &events, clock.as_ref(), &bot, |state| { state.url = info.url; state.title = info.title; diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs index c1a68fa0..896e0954 100644 --- a/crates/botloftd/src/browser/mod.rs +++ b/crates/botloftd/src/browser/mod.rs @@ -13,6 +13,7 @@ mod keys; mod launch; mod lesson; mod moves; +mod owner_open; mod page; mod program; mod read; @@ -46,7 +47,7 @@ pub use self::page::{Aim, Done, Scroll}; pub use self::program::find as find_program; pub use self::read::{READ_MAX, Reading}; pub use self::session::Session; -pub use self::sweep::{Want, run}; +pub use self::sweep::{Want, run, want}; pub use self::viewport::Viewport; pub use self::watch::Watching; pub use self::window::WindowError; @@ -119,6 +120,8 @@ struct Slot { asking: Option, /// The owner has the profile open in a window of its own. window: Option, + /// The page it showed last, to open again for the owner. + last_url: Option, } type Slots = Arc>>; @@ -170,6 +173,7 @@ impl Browsers { held: tokio::sync::watch::channel(None).0, asking: None, window: None, + last_url: None, }) } diff --git a/crates/botloftd/src/browser/owner_open.rs b/crates/botloftd/src/browser/owner_open.rs new file mode 100644 index 00000000..db86f22a --- /dev/null +++ b/crates/botloftd/src/browser/owner_open.rs @@ -0,0 +1,42 @@ +//! The owner opens a bot's closed browser for themselves (spec 21.10): on +//! the page it showed last, without the bot, which is neither woken nor +//! told. The app then takes it into the owner's hands. + +use std::path::Path; + +use botloft_core::ids::BotId; +use tracing::debug; + +use super::{BrowserError, Browsers, lock}; + +impl Browsers { + /// Starts the bot's browser if it is closed and opens the last page it + /// showed again; a running one stays as it is. + pub async fn open_for_owner(&self, bot: &BotId, downloads: &Path) -> Result<(), BrowserError> { + let call = self.begin(bot).await; + if call.running().is_some() { + return Ok(()); + } + let last = lock(&self.slots) + .get(bot) + .and_then(|slot| slot.last_url.clone()); + let session = call.session(downloads).await?; + if let Some(url) = last + && let Err(err) = session.open(&url).await + { + debug!(bot = %bot, "browser: the last page did not open again: {err}"); + } + debug!(bot = %bot, "browser: opened for the owner"); + Ok(()) + } +} + +/// Keeps `url` as the last page of the bot's browser, unless it is blank. +pub(super) fn remember(slots: &super::Slots, bot: &BotId, url: Option<&str>) { + let Some(url) = url.filter(|url| !url.is_empty() && *url != "about:blank") else { + return; + }; + if let Some(slot) = lock(slots).get_mut(bot) { + slot.last_url = Some(url.to_owned()); + } +} diff --git a/crates/botloftd/src/browser/sweep.rs b/crates/botloftd/src/browser/sweep.rs index 7b923bab..25f30175 100644 --- a/crates/botloftd/src/browser/sweep.rs +++ b/crates/botloftd/src/browser/sweep.rs @@ -56,7 +56,7 @@ fn working(state: BotState) -> bool { matches!(state, BotState::Busy | BotState::NeedsApproval) } -fn want(daemon: &Daemon, bot: &BotId) -> Want { +pub fn want(daemon: &Daemon, bot: &BotId) -> Want { let store = daemon.store(); let Ok(record) = bots::find(&store, bot) else { return Want::Forget; diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index 0c0531ae..76ef1d88 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -246,6 +246,8 @@ fn route( } // A window of its own outlives the request (spec 21.11). method::BROWSER_WINDOW => watch.window(daemon, request.params), + // The browser starting outlives the request too (spec 21.10). + method::BROWSER_START => watch.start(daemon, request.params), name if ASIDE.contains(&name) => return Routed::Aside(request), _ => return Routed::InOrder(request), }; diff --git a/crates/botloftd/src/rpc/watching.rs b/crates/botloftd/src/rpc/watching.rs index 3472af32..848eee33 100644 --- a/crates/botloftd/src/rpc/watching.rs +++ b/crates/botloftd/src/rpc/watching.rs @@ -14,11 +14,12 @@ use botloft_core::protocol::{ }; use serde::Serialize; use serde_json::Value; +use tracing::debug; use super::dispatch::parse; use super::jsonrpc::{self, RpcError}; use crate::approvals; -use crate::browser::{Hands, InputError, Watching}; +use crate::browser::{Hands, InputError, Want, Watching, want}; use crate::service::{ApiError, bots}; use crate::state::Daemon; @@ -155,6 +156,44 @@ impl Watch { to_value(&state) } + /// Opens the bot's closed browser for the owner, on its last page, + /// without the bot (spec 21.10); answers at once, and the state says + /// when it is open, for the app to take it. + pub(super) fn start( + &mut self, + daemon: &Arc, + params: Option, + ) -> Result { + let params: BrowserControlParams = parse(params)?; + let bot = params.bot_id; + if !self.watches(&bot) { + return Err( + ApiError::Conflict("watch this browser before opening it".to_owned()).into(), + ); + } + if !matches!(want(daemon, &bot), Want::Keep | Want::Rest) { + return Err(ApiError::Conflict( + "the bot or its crew is paused: its browser stays closed".to_owned(), + ) + .into()); + } + let workspace = { + let store = daemon.store(); + let record = bots::find(&store, &bot)?; + let crew = crate::service::crews::find(&store, &record.crew_id)?; + daemon.paths.bot_workspace(&crew.slug, &record.slug) + }; + let state = daemon.browsers.view(&bot).state; + let daemon = Arc::clone(daemon); + tokio::spawn(async move { + let downloads = workspace.join("downloads"); + if let Err(err) = daemon.browsers.open_for_owner(&bot, &downloads).await { + debug!(bot = %bot, "browser: could not open for the owner: {err}"); + } + }); + to_value(&state) + } + /// The owner's hold on the bot's browser, if this connection has it. fn hands(&self, bot: &BotId) -> Result<&Hands, RpcError> { self.hands diff --git a/crates/botloftd/tests/browser_owner_open.rs b/crates/botloftd/tests/browser_owner_open.rs new file mode 100644 index 00000000..576f165c --- /dev/null +++ b/crates/botloftd/tests/browser_owner_open.rs @@ -0,0 +1,82 @@ +//! The owner opens a bot's closed browser for themselves with the real +//! Microsoft Edge (spec 21.10): on the page it showed last, without the +//! bot, then takes it and gives it back. Skipped where Edge is not +//! installed. + +mod common; + +use std::time::Duration; + +use common::Client; +use common::browsing::{answer_site, call, setup}; +use serde_json::{Value, json}; + +async fn browser(app: &mut Client) -> Value { + let list = app.call("browser.list", Value::Null).await.expect("list"); + list[0].clone() +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_owner_opens_the_closed_browser_on_its_last_page_and_takes_it() { + let Some(mut b) = setup().await else { return }; + let url = format!("{}/login", b.site); + let opening = call(&b.mcp, "browser_open", json!({ "url": url })); + answer_site(&mut b.app, true, None).await; + opening.await.expect("task").expect("page"); + b.mcp + .tool_text("browser_close", json!({})) + .await + .expect("closed"); + + // Only the connection watching it opens it. + let id = json!({ "botId": b.bot["id"] }); + assert!(b.app.call("browser.start", id.clone()).await.is_err()); + b.app + .call("browser.watch", id.clone()) + .await + .expect("watch"); + b.app + .call("browser.start", id.clone()) + .await + .expect("start"); + + // It opens on the page it showed last. + let mut state = Value::Null; + for _ in 0..150 { + state = browser(&mut b.app).await; + if state["status"] == "open" + && state["url"] + .as_str() + .is_some_and(|url| url.contains("/login")) + { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!(state["status"], "open", "{state}"); + assert!( + state["url"] + .as_str() + .is_some_and(|url| url.contains("/login")), + "{state}" + ); + + let taken = b.app.call("browser.take", id.clone()).await.expect("take"); + assert_eq!(taken["control"], "owner"); + let given = b + .app + .call("browser.release", id.clone()) + .await + .expect("release"); + assert_eq!(given["control"], "bot"); + + // A paused bot's browser stays closed. + b.app + .call( + "bots.setPaused", + json!({ "botId": b.bot["id"], "paused": true }), + ) + .await + .expect("pause"); + assert!(b.app.call("browser.start", id).await.is_err()); +} diff --git a/docs/spec.md b/docs/spec.md index c79cd1b7..13f8fd2a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1300,6 +1300,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg - **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar ou excluir o bot fecha o navegador e apaga o perfil. - **Descansar:** um navegador aberto que ninguém usa não deve gastar o computador. Assim que o turno do bot termina (o estado dele deixa de ser `busy` ou `needs_approval`, 7.1), o daemon põe o navegador para descansar: tira um quadro da aba ativa, para o painel ter o que mostrar, e minimiza a janela do Edge (`Browser.setWindowBounds` com `windowState: "minimized"`; a janela já não aparecia, mas para o Edge ela existe). As páginas passam a valer como as de uma janela minimizada: `visibilityState` vira `hidden`, os timers caem para um por segundo ou menos, animações e quadros param. Medido com o Edge 154, uma página que gastava 90% de um núcleo fica abaixo de 1% (19). Nada se perde: abas, formulários e sessões continuam como estavam. Não descansa no meio de uma tool nem nas mãos do dono (21.10). - **Acordar:** na hora, com a janela de volta a `normal`, na próxima tool `browser_*` do bot, quando o dono assume o controle e quando ele recarrega a página. Depois de algo que o dono fez com o bot fora de um turno, o navegador volta a descansar sozinho: 20 s sem uso, conferido a cada 30 s. +- **Abrir para o dono:** fechado, o navegador só sobe por uma tool do bot ou quando o dono o abre para ele mesmo (`browser.start`, 21.10). O daemon guarda, na memória, a última página que ele mostrou (o endereço da aba ativa, sem `about:blank`) e a abre de novo; um daemon que reinicia a esquece, e o navegador abre em branco. O bot não é acordado nem avisado. - **Fechar:** depois de `[browser] idle_minutes` sem uso, que é uma tool do bot ou algo que o dono fez com o navegador nas mãos; quando o bot ou a crew pausa; com `browser_close`. Assistir não é uso: o painel abre sozinho (21.8) e seguraria o navegador aberto para sempre. Nas mãos do dono ele não fecha. Se já há `[browser] max_open` navegadores abertos, abrir outro fecha o que está parado há mais tempo, mas nunca um que esteja no meio de uma tool. - Se o processo cai ou o WebSocket fecha, o estado vira `closed` e a próxima tool sobe de novo. @@ -1384,6 +1385,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] | `browser.newTab` | `botId` | `null`: abre uma aba em branco, que vira a ativa. Só da conexão que controla | | `browser.switchTab` | `botId`, `tabId` | `null`: essa aba vira a ativa. Só da conexão que controla; aba que não existe mais é `not_found` | | `browser.open` | `botId`, `url` | `null`: leva a aba ativa a um endereço `http` ou `https`; sem esquema, vale `https://`. Só da conexão que controla; o que não é endereço da web é `validation` | +| `browser.start` | `botId` | `BrowserState`: abre o navegador fechado do bot para o dono, na última página (21.2), e responde na hora; o estado diz quando ele está aberto, e o app o toma (`browser.take`). Aberto, não muda nada. Da conexão que assiste esse bot; com o bot ou a crew pausados é `conflict` | | `browser.window` | `botId` | `BrowserState`: abre o navegador numa janela própria, para o dono entrar numa conta (21.11). Da conexão que assiste esse bot; tira o controle dela, se o tinha. Já aberto numa janela, ou sem navegador no computador, é `conflict` | | `browser.teach` | `botId`, `on` | `LessonStep[]`: começa uma lição (21.13), com o primeiro passo, ou termina e devolve os passos. Só da conexão que controla | @@ -1400,7 +1402,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] - Em cima, as abas: uma por página aberta, na ordem em que abriram, com o título e, embaixo dele, o site (ou o nome do arquivo); a ativa em destaque, "Nova aba" numa aba em branco, e **+** no fim. Não cabendo, a fila rola para o lado. Sem o controle elas só mostram o que o bot abriu, e a dica diz para assumir o controle; no controle, um clique troca de aba e **+** abre uma nova, já com o cursor na barra do endereço (21.10). - Depois, a barra do endereço: **Recarregar** (a seta circular), que vale a qualquer momento (21.10); o endereço, com o cadeado ou a roda de carregando; e "Abrir no meu navegador" (`open_url`, 15.2). O endereço é só leitura e selecionável; no controle vira um campo: Enter leva a aba até o que o dono digitou (`browser.open`), Esc desfaz. Embaixo, a tela ao vivo ocupa o espaço que sobra no painel: o app mede o painel abaixo da barra do endereço, tira a mesa em volta (10 px dos lados e em cima, 26 px embaixo) e o lugar da pílula e da legenda (56 px) e manda o resto ao daemon (`browser.resize`, 300 ms depois da última mudança, e de novo ao voltar a assistir); cada quadro aparece no maior tamanho que cabe, na proporção da página. Arrastar a borda do painel, alargá-lo ou mudar o tamanho da janela muda a página junto. O que entra e sai do painel (o pedido de ajuda e as linhas embaixo da pílula, 21.10) não entra na conta: a página nunca muda de tamanho por causa disso, só a imagem encolhe um pouco para caber. Sobre a tela, o cursor do bot (a seta, na cor dele) indo até cada ponto de `browser.action`, um anel no clique e uma legenda curta do que ele fez ("Clicou em Entrar", "Escreveu em E-mail", "Abriu exemplo.com"). - A tela fica sobre uma mesa na cor do bot, para o painel ler como o computador dele: um degradê da cor misturada com o fundo do painel (suave no tema claro, fundo no escuro), com a página como uma janela com sombra, que ocupa a mesa toda menos uma borda fina (10 px; embaixo, 26 px, onde a pílula do controle se pendura sem nunca cobrir a página). Com o navegador fechado, a mesa perde a cor junto com o último quadro. -- Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado". Em `failed`, o motivo em "Details". +- Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado" e o botão **Abrir para mim** (21.10); sem quadro, o botão fica embaixo do mascote. Em `failed`, o motivo em "Details". - No chat, a linha de uma tool `browser_*` ganha o ícone do globo e o botão "Ver no navegador", que abre o painel. - O pedido de site (21.5) é um cartão próprio: " quer usar o navegador em **wikipedia.org**", o endereço embaixo, Permitir e Negar. Respondido: "Você permitiu wikipedia.org". - O dono no controle e o pedido de ajuda têm a própria parte da tela (21.10). @@ -1423,6 +1425,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, rolar e digitar na tela ao vivo. Serve para o que o bot não deve ou não consegue fazer sozinho: entrar numa conta com a senha do dono, passar de um captcha, confirmar um código que chegou no celular dele. +- **Abrir para mim:** com o navegador fechado, o dono o abre sem passar pelo bot, para ver o que ele deixou aberto ou mexer ele mesmo: `browser.start` e, assim que abre, `browser.take`. A explicação embaixo do botão diz que o bot não é acordado nem avisado e que, devolvido, o navegador volta a descansar (21.2). Devolver não avisa o bot, a menos que ele tenha um pedido de ajuda aberto. - **Assumir:** "Assumir o controle", no painel (21.8), com o navegador aberto. Quem controla é a conexão que assiste o bot (21.7); parar de assistir (fechar o painel, trocar de bot, fechar o app, cair a conexão) devolve o controle na hora, para o bot nunca ficar preso. Cada navegador tem um só controlador: `browser.take` de outra conexão enquanto um controla é `conflict`. - **O bot espera:** enquanto o dono controla, cada tool `browser_*` do bot espera ele devolver, até 10 minutos. Passou disso, a tool volta com erro: o dono está usando o navegador. `browser_ask_owner` não espera, porque é ela que chama o dono. O bot continua `busy` nessa espera, e o painel diz que ele espera. - **Devolver:** "Pronto, devolver para " no painel. Fechar o navegador (bot pausado, `browser_close`, processo que caiu) também devolve.