From 645db96d9ce4d9481a0c16fd8a4287768173e3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sun, 8 Nov 2026 00:55:17 +0800 Subject: [PATCH] AnyBot's browser drives Firefox too, the person's own Firefox profiles included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firefox dropped Chrome's DevTools protocol, so bidi.rs speaks WebDriver BiDi and does each DevTools command the browser tools use the BiDi way (real mouse and keys, typing, screenshots, PDF, dialogs, uploads, downloads, tabs). The 18 tools themselves are unchanged and pass the same dashboard test on Firefox as on Chrome. - A computer with only Firefox runs AnyBot's own profiles in it (under the Snap/Flatpak corner of the home folder when Firefox is packaged that way). - The person's own Firefox profiles are choices too, by the names Firefox shows (profiles.ini and the newer profile switcher's SQLite list): 「阿金(Firefox)」, or just 「阿金」. Firefox can't be taken over once open, so a profile open the everyday way is refused with "close it first"; AnyBot then opens it itself, logins intact. - Settings lists them without a remove button; the messages name Firefox as an option. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4 --- Cargo.lock | 73 ++++++++ Cargo.toml | 3 + src/app.rs | 8 +- src/bidi.rs | 378 +++++++++++++++++++++++++++++++++++++++ src/botbrowser.rs | 444 +++++++++++++++++++++++++++++++++++++++++++--- src/browser.rs | 89 ++++++---- src/i18n_table.rs | 3 +- src/main.rs | 1 + src/web.rs | 6 +- 9 files changed, 940 insertions(+), 65 deletions(-) create mode 100644 src/bidi.rs diff --git a/Cargo.lock b/Cargo.lock index dabb29a..ac2e980 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -361,6 +361,7 @@ dependencies = [ "resvg", "ring", "rodio", + "rusqlite", "rustls", "rustls-native-certs", "serde", @@ -1770,6 +1771,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fast-float2" version = "0.2.4" @@ -2282,6 +2295,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "hayro" version = "0.9.0" @@ -2803,6 +2825,17 @@ dependencies = [ "redox_syscall 0.9.5", ] +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4252,6 +4285,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsqlite-vfs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4723,6 +4780,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +dependencies = [ + "cc", + "rsqlite-vfs", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5535,6 +5602,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vello_common" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index b271612..f21bc36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # works once this computer trusts it, the same as for the browser. webpki-root-certs = "1" rustls-native-certs = "0.8" +# Firefox keeps the names of its newer profiles (「女生」「阿金」…) only in a small SQLite file +# (Profile Groups/*.sqlite); bundled, so no system SQLite is needed. +rusqlite = { version = "0.41", features = ["bundled"] } flate2 = "1" # `anybot setup`: the guided setup in a terminal (servers and Linux computers without a window). crossterm = "0.29" diff --git a/src/app.rs b/src/app.rs index d460768..40734ea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2579,7 +2579,7 @@ impl App { fn browser_profiles_section(&mut self, ui: &mut egui::Ui, p: Palette) { setting_title(ui, "AnyBot 瀏覽器設定檔", "每個設定檔是一個獨立的瀏覽器視窗,有自己的登入(例如工作和私人帳號)。Bot 會用「使用中」的那個,你也可以在聊天裡叫它換。", p); if !crate::botbrowser::available() { - ui.label(RichText::new(t("需要 Chrome、Brave、Edge 或 Chromium 才能用 AnyBot 自己的瀏覽器。")).size(11.5).color(p.text3)); + ui.label(RichText::new(t("需要 Chrome、Brave、Edge、Chromium 或 Firefox 才能用 AnyBot 自己的瀏覽器。")).size(11.5).color(p.text3)); return; } let current = crate::botbrowser::current(); @@ -2601,6 +2601,10 @@ impl App { } return; } + let firefox = name.ends_with(crate::botbrowser::FIREFOX_MARK); + if firefox { + ui.label(RichText::new(t("你自己的 Firefox 設定檔:要讓 Bot 用,請從這裡開啟(先關掉它原本的視窗)")).size(11.5).color(p.text2)); + } if ui.small_button(t("開啟視窗")).on_hover_text(t("打開這個設定檔的瀏覽器視窗,在裡面登入網站")).clicked() { let note = self.browser_note.clone(); let name = name.clone(); @@ -2613,7 +2617,7 @@ impl App { }; }); } - if name != crate::botbrowser::DEFAULT_PROFILE { + if name != crate::botbrowser::DEFAULT_PROFILE && !firefox { if self.confirm_remove_profile.as_deref() == Some(name.as_str()) { if ui.small_button(RichText::new(t("確定移除(登入也會刪掉)")).color(p.danger)).clicked() { *self.browser_note.lock().unwrap() = match crate::botbrowser::remove(&name) { diff --git a/src/bidi.rs b/src/bidi.rs new file mode 100644 index 0000000..ca8477a --- /dev/null +++ b/src/bidi.rs @@ -0,0 +1,378 @@ +// Firefox, driven through WebDriver BiDi: the W3C standard Firefox speaks (it dropped Chrome's +// DevTools protocol). browser.rs keeps speaking DevTools; `cdp` turns each DevTools command it +// uses into the BiDi one, so every browser tool works the same on Firefox: real mouse and keys, +// the numbered elements, screenshots, PDF, page dialogs, uploads and downloads. +// +// Firefox allows one BiDi session per window (profile), so one connection per port is kept and +// shared, one command at a time. + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::ErrorKind; +use std::net::{SocketAddr, TcpStream}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const CALL_TIMEOUT_SECS: u64 = 30; +/// A tab's title for the tab list: a page held up by a dialog mustn't hold the list up (estimated). +const TITLE_TIMEOUT_SECS: u64 = 3; +const SLOW: &str = "瀏覽器太久沒有回應(頁面可能跳出了對話框:可以用 browser_dialog 處理)"; + +struct Conn { + ws: tungstenite::WebSocket, + next: u64, +} + +enum Fail { + /// The connection is gone (Firefox closed): connect again. + Link(String), + /// No answer in time; a late answer is skipped later (by its id). + Slow, + /// Firefox answered with an error. + Said(String), +} + +impl Conn { + fn open(port: u16) -> Result { + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().map_err(|_| "Firefox 的連接埠不對".to_string())?; + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("連不上 Firefox:{e}"))?; + let (ws, _) = tungstenite::client::client(format!("ws://127.0.0.1:{port}/session"), stream).map_err(|e| format!("連不上 Firefox:{e}"))?; + let mut c = Conn { ws, next: 0 }; + // Page dialogs stay open until browser_dialog answers them (as in Chrome). + let caps = json!({"capabilities": {"alwaysMatch": {"unhandledPromptBehavior": {"default": "ignore"}}}}); + if c.call("session.new", caps, CALL_TIMEOUT_SECS).is_err() { + c.call("session.new", json!({"capabilities": {}}), CALL_TIMEOUT_SECS).map_err(|f| match f { + Fail::Said(e) => format!("Firefox 不讓 AnyBot 控制({e})。可能有別的自動化工具正連著它。"), + Fail::Slow => "Firefox 沒有回應".to_string(), + Fail::Link(e) => format!("連不上 Firefox:{e}"), + })?; + } + Ok(c) + } + + fn call(&mut self, method: &str, params: Value, secs: u64) -> Result { + self.next += 1; + let id = self.next; + let _ = self.ws.get_mut().set_read_timeout(Some(Duration::from_secs(secs))); + self.ws.send(tungstenite::Message::Text(json!({"id": id, "method": method, "params": params}).to_string().into())).map_err(|e| Fail::Link(e.to_string()))?; + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_secs(secs) { + return Err(Fail::Slow); + } + match self.ws.read() { + Ok(tungstenite::Message::Text(t)) => { + let v: Value = serde_json::from_str(t.as_str()).unwrap_or(Value::Null); + if v["id"] != id { + continue; // an event, or the late answer to an earlier command + } + if v["type"] == "error" { + return Err(Fail::Said(format!("{}:{}", v["error"].as_str().unwrap_or("error"), v["message"].as_str().unwrap_or("")))); + } + return Ok(v["result"].clone()); + } + Ok(tungstenite::Message::Close(_)) => return Err(Fail::Link("Firefox 關掉了連線".into())), + Ok(_) => {} + Err(tungstenite::Error::Io(e)) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => return Err(Fail::Slow), + Err(e) => return Err(Fail::Link(e.to_string())), + } + } + } +} + +fn conns() -> &'static Mutex>>> { + static C: OnceLock>>>> = OnceLock::new(); + C.get_or_init(Mutex::default) +} + +/// One BiDi command to the Firefox on `port`. +pub fn command(port: u16, method: &str, params: Value) -> Result { + command_within(port, method, params, CALL_TIMEOUT_SECS) +} + +fn command_within(port: u16, method: &str, params: Value, secs: u64) -> Result { + // A connection left from a Firefox that has since closed fails once: connect again and retry. + for attempt in 0..2 { + let conn = { + let mut m = conns().lock().unwrap_or_else(|e| e.into_inner()); + match m.get(&port) { + Some(c) => c.clone(), + None => { + let c = Arc::new(Mutex::new(Conn::open(port)?)); + m.insert(port, c.clone()); + c + } + } + }; + let r = conn.lock().unwrap_or_else(|e| e.into_inner()).call(method, params.clone(), secs); + match r { + Ok(v) => return Ok(v), + Err(Fail::Said(e)) => return Err(e), + Err(Fail::Slow) => return Err(SLOW.into()), + Err(Fail::Link(e)) => { + conns().lock().unwrap_or_else(|e| e.into_inner()).remove(&port); + if attempt == 1 { + return Err(format!("和 Firefox 的連線斷了:{e}")); + } + } + } + } + Err("和 Firefox 的連線斷了".into()) +} + +/// A value Firefox sends back (a BiDi RemoteValue) as plain JSON, like DevTools' returnByValue. +pub fn plain(v: &Value) -> Value { + let list = |v: &Value| Value::Array(v["value"].as_array().map(|a| a.iter().map(plain).collect()).unwrap_or_default()); + match v["type"].as_str().unwrap_or("") { + "string" | "boolean" | "date" => v["value"].clone(), + "number" => match &v["value"] { + Value::String(s) if s == "-0" => json!(0), + Value::String(_) => Value::Null, // NaN, Infinity: what JSON.stringify gives + n => n.clone(), + }, + "bigint" => v["value"].as_str().and_then(|s| s.parse::().ok()).map(Value::from).unwrap_or(Value::Null), + "array" | "set" | "nodelist" | "htmlcollection" => list(v), + "object" | "map" => { + let mut o = serde_json::Map::new(); + for pair in v["value"].as_array().cloned().unwrap_or_default() { + let key = match &pair[0] { + Value::String(s) => s.clone(), + k => plain(k).as_str().map(String::from).unwrap_or_else(|| plain(k).to_string()), + }; + o.insert(key, plain(&pair[1])); + } + Value::Object(o) + } + "regexp" => v["value"]["pattern"].clone(), + "null" | "undefined" => Value::Null, + // A page element, a window…: DevTools gives an empty object for those too. + _ => json!({}), + } +} + +// ---------- tabs ---------- + +/// The tabs AnyBot used last on each Firefox, latest first. +fn recent() -> &'static Mutex>> { + static R: OnceLock>>> = OnceLock::new(); + R.get_or_init(Mutex::default) +} + +/// This tab is the one in use now. +pub fn touch(port: u16, id: &str) { + let mut r = recent().lock().unwrap_or_else(|e| e.into_inner()); + let l = r.entry(port).or_default(); + l.retain(|t| t != id); + l.insert(0, id.to_string()); + l.truncate(50); +} + +/// The open tabs as DevTools lists them ({id, type: "page", url, title}), in the order DevTools +/// gives too: the one in use first (the tab showing in the window, then the ones AnyBot used +/// last, then the newest). +pub fn pages(port: u16) -> Result, String> { + let tree = command(port, "browsingContext.getTree", json!({"maxDepth": 0}))?; + let used = recent().lock().unwrap_or_else(|e| e.into_inner()).get(&port).cloned().unwrap_or_default(); + let mut tabs = vec![]; + for (i, c) in tree["contexts"].as_array().cloned().unwrap_or_default().iter().enumerate() { + let id = c["context"].as_str().unwrap_or("").to_string(); + let info = command_within(port, "script.evaluate", json!({"expression": "JSON.stringify([document.title, document.visibilityState])", "target": {"context": id}, "awaitPromise": false, "resultOwnership": "none"}), TITLE_TIMEOUT_SECS) + .ok() + .and_then(|r| plain(&r["result"]).as_str().and_then(|s| serde_json::from_str::(s).ok())) + .unwrap_or(Value::Null); + let showing = info[1] == "visible"; + let rank = used.iter().position(|u| *u == id).unwrap_or(usize::MAX); + tabs.push(((!showing, rank, std::cmp::Reverse(i)), json!({"id": id, "type": "page", "url": c["url"], "title": info[0].as_str().unwrap_or("")}))); + } + tabs.sort_by_key(|t| t.0); + Ok(tabs.into_iter().map(|(_, t)| t).collect()) +} + +/// Open a new tab at `url` (in front). Returns its id. +pub fn new_tab(port: u16, url: &str) -> Result { + let r = command(port, "browsingContext.create", json!({"type": "tab"}))?; + let id = r["context"].as_str().ok_or("Firefox 沒有開新分頁")?.to_string(); + if url != "about:blank" { + command(port, "browsingContext.navigate", json!({"context": id, "url": url, "wait": "none"}))?; + } + touch(port, &id); + Ok(id) +} + +pub fn activate(port: u16, id: &str) -> Result<(), String> { + command(port, "browsingContext.activate", json!({"context": id}))?; + touch(port, id); + Ok(()) +} + +pub fn close(port: u16, id: &str) -> Result<(), String> { + command(port, "browsingContext.close", json!({"context": id})).map(|_| ()) +} + +// ---------- DevTools commands, done the BiDi way ---------- + +/// WebDriver's code for a key DevTools names (Enter, ArrowDown…); a letter is itself. +fn key_value(key: &str) -> String { + match key { + "Enter" => "\u{E007}", + "Tab" => "\u{E004}", + "Escape" => "\u{E00C}", + "Backspace" => "\u{E003}", + "Delete" => "\u{E017}", + "ArrowDown" => "\u{E015}", + "ArrowUp" => "\u{E013}", + "ArrowLeft" => "\u{E012}", + "ArrowRight" => "\u{E014}", + "PageDown" => "\u{E00F}", + "PageUp" => "\u{E00E}", + "Home" => "\u{E011}", + "End" => "\u{E010}", + other => return other.to_string(), + } + .to_string() +} + +/// DevTools' modifier bits (alt 1, ctrl 2, meta 4, shift 8) as WebDriver keys. +fn modifier_keys(bits: i64) -> Vec<&'static str> { + [(1, "\u{E00A}"), (2, "\u{E009}"), (4, "\u{E03D}"), (8, "\u{E008}")].iter().filter(|(b, _)| bits & b != 0).map(|(_, k)| *k).collect() +} + +fn keys(port: u16, ctx: &str, actions: Vec) -> Result { + command(port, "input.performActions", json!({"context": ctx, "actions": [{"type": "key", "id": "keyboard", "actions": actions}]}))?; + Ok(json!({})) +} + +/// Typing text: each character a real key press (Firefox puts in any character, 中文 too, the way +/// a keyboard would, so editors that only listen to typing get it). A line break is Enter. +fn type_actions(text: &str) -> Vec { + text.chars() + .filter(|c| *c != '\r') + .flat_map(|c| { + let k = if c == '\n' { "\u{E007}".to_string() } else { c.to_string() }; + [json!({"type": "keyDown", "value": k}), json!({"type": "keyUp", "value": k})] + }) + .collect() +} + +/// Run one DevTools command (the ones browser.rs uses) on tab `ctx` of the Firefox on `port`; +/// the answer is shaped like DevTools' own. +pub fn cdp(port: u16, ctx: &str, method: &str, p: &Value) -> Result { + match method { + "Runtime.evaluate" => { + let by_value = p["returnByValue"].as_bool().unwrap_or(false); + let r = command(port, "script.evaluate", json!({ + "expression": p["expression"], "target": {"context": ctx}, + "awaitPromise": p["awaitPromise"].as_bool().unwrap_or(false), + "resultOwnership": if by_value { "none" } else { "root" }, + "userActivation": p["userGesture"].as_bool().unwrap_or(false), + }))?; + if r["type"] == "exception" { + let d = &r["exceptionDetails"]; + let text = d["text"].as_str().unwrap_or("").to_string(); + return Ok(json!({"exceptionDetails": {"text": text, "exception": {"description": text}}})); + } + let v = &r["result"]; + Ok(if by_value { + json!({"result": {"value": plain(v)}}) + } else { + // A page element stays reachable by its shared id (for DOM.setFileInputFiles). + json!({"result": {"objectId": v["sharedId"].as_str().or(v["handle"].as_str())}}) + }) + } + "Input.dispatchMouseEvent" => { + // Firefox refuses a pointer outside the page; DevTools let it pass. + let at = |k: &str| p[k].as_f64().unwrap_or(0.0).max(0.0).round() as i64; + let mv = json!({"type": "pointerMove", "x": at("x"), "y": at("y"), "duration": 0}); + let button = match p["button"].as_str() { + Some("right") => 2, + Some("middle") => 1, + _ => 0, + }; + let actions = match p["type"].as_str() { + Some("mousePressed") => vec![mv, json!({"type": "pointerDown", "button": button})], + Some("mouseReleased") => vec![mv, json!({"type": "pointerUp", "button": button})], + _ => vec![mv], + }; + command(port, "input.performActions", json!({"context": ctx, "actions": [{"type": "pointer", "id": "mouse", "parameters": {"pointerType": "mouse"}, "actions": actions}]}))?; + Ok(json!({})) + } + "Input.dispatchKeyEvent" => { + let key = key_value(p["key"].as_str().unwrap_or("")); + let mods = modifier_keys(p["modifiers"].as_i64().unwrap_or(0)); + let mut actions = vec![]; + if p["type"] == "keyUp" { + actions.push(json!({"type": "keyUp", "value": key})); + actions.extend(mods.iter().rev().map(|m| json!({"type": "keyUp", "value": m}))); + } else { + actions.extend(mods.iter().map(|m| json!({"type": "keyDown", "value": m}))); + actions.push(json!({"type": "keyDown", "value": key})); + } + keys(port, ctx, actions) + } + "Input.insertText" => keys(port, ctx, type_actions(p["text"].as_str().unwrap_or(""))), + "Page.captureScreenshot" => { + let mut q = json!({"context": ctx, "origin": if p["captureBeyondViewport"] == true { "document" } else { "viewport" }}); + if p["format"] == "jpeg" { + q["format"] = json!({"type": "image/jpeg", "quality": p["quality"].as_f64().unwrap_or(75.0) / 100.0}); + } + if let Some(c) = p.get("clip") { + q["clip"] = json!({"type": "box", "x": c["x"], "y": c["y"], "width": c["width"], "height": c["height"]}); + } + let r = command(port, "browsingContext.captureScreenshot", q)?; + Ok(json!({"data": r["data"]})) + } + "Page.getLayoutMetrics" => { + let r = command(port, "script.evaluate", json!({"expression": "JSON.stringify([document.documentElement.scrollWidth, document.documentElement.scrollHeight])", "target": {"context": ctx}, "awaitPromise": false, "resultOwnership": "none"}))?; + let wh: Vec = plain(&r["result"]).as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); + Ok(json!({"cssContentSize": {"width": wh.first().copied().unwrap_or(1280.0), "height": wh.get(1).copied().unwrap_or(2000.0)}})) + } + "Page.reload" => command(port, "browsingContext.reload", json!({"context": ctx, "wait": "none"})).map(|_| json!({})), + "Page.printToPDF" => { + let r = command(port, "browsingContext.print", json!({"context": ctx, "background": p["printBackground"].as_bool().unwrap_or(true)}))?; + Ok(json!({"data": r["data"]})) + } + "Page.handleJavaScriptDialog" => { + let mut q = json!({"context": ctx, "accept": p["accept"].as_bool().unwrap_or(true)}); + if let Some(t) = p["promptText"].as_str() { + q["userText"] = json!(t); + } + command(port, "browsingContext.handleUserPrompt", q).map(|_| json!({})) + } + "DOM.setFileInputFiles" => { + let el = p["objectId"].as_str().ok_or("找不到上傳檔案的欄位")?; + command(port, "input.setFiles", json!({"context": ctx, "element": {"sharedId": el}, "files": p["files"]})).map(|_| json!({})) + } + "Browser.setDownloadBehavior" | "Page.setDownloadBehavior" => { + command(port, "browser.setDownloadBehavior", json!({"downloadBehavior": {"type": "allowed", "destinationFolder": p["downloadPath"]}})).map(|_| json!({})) + } + other => Err(format!("Firefox 不支援 {other}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_come_back_as_plain_json() { + assert_eq!(plain(&json!({"type": "string", "value": "hi"})), json!("hi")); + assert_eq!(plain(&json!({"type": "number", "value": 3})), json!(3)); + assert_eq!(plain(&json!({"type": "number", "value": "NaN"})), Value::Null); + assert_eq!(plain(&json!({"type": "undefined"})), Value::Null); + assert_eq!(plain(&json!({"type": "array", "value": [{"type": "boolean", "value": true}, {"type": "null"}]})), json!([true, null])); + let obj = json!({"type": "object", "value": [["a", {"type": "number", "value": 1}], ["b", {"type": "array", "value": [{"type": "string", "value": "x"}]}]]}); + assert_eq!(plain(&obj), json!({"a": 1, "b": ["x"]})); + assert_eq!(plain(&json!({"type": "node", "sharedId": "abc"})), json!({})); + } + + #[test] + fn keys_and_text_become_webdriver_actions() { + assert_eq!(key_value("Enter"), "\u{E007}"); + assert_eq!(key_value("a"), "a"); + assert_eq!(modifier_keys(2 | 8), vec!["\u{E009}", "\u{E008}"]); + let t = type_actions("中a\n"); + assert_eq!(t.len(), 6); + assert_eq!(t[0], json!({"type": "keyDown", "value": "中"})); + assert_eq!(t[4], json!({"type": "keyDown", "value": "\u{E007}"})); + } +} diff --git a/src/botbrowser.rs b/src/botbrowser.rs index 091046d..db76568 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -7,6 +7,11 @@ // Chrome's data folder, and Chrome refuses remote control of its normal folder), so AnyBot's // browser keeps its own: 「預設」 plus any the person or the bot adds (工作, 私人…). Each is its own // browser folder and window with its own logins; switching picks which one the bot uses. +// +// Firefox works too (through WebDriver BiDi, bidi.rs): it runs AnyBot's own profiles on a +// computer without a Chrome-family browser, and the person's own Firefox profiles are choices +// as well (「阿金(Firefox)」, with their logins). Firefox can't be taken over once it's open, so +// AnyBot opens such a profile itself (the person closes its everyday window first). use serde_json::Value; use std::path::PathBuf; @@ -56,13 +61,211 @@ pub fn engine() -> Option<(&'static str, &'static str)> { CANDIDATES.iter().copied().find(|(_, p)| std::path::Path::new(p).exists()) } +/// Firefox: the usual install places (Snap first: Ubuntu's /usr/bin/firefox only hands over to it). +#[cfg(target_os = "macos")] +const FIREFOX: &[&str] = &["/Applications/Firefox.app/Contents/MacOS/firefox", "/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox", "/Applications/Firefox Nightly.app/Contents/MacOS/firefox"]; +#[cfg(not(target_os = "macos"))] +const FIREFOX: &[&str] = &["/snap/bin/firefox", "/usr/bin/firefox", "/usr/lib/firefox/firefox", "/opt/firefox/firefox", "/var/lib/flatpak/exports/bin/org.mozilla.firefox"]; + +pub fn firefox() -> Option<&'static str> { + FIREFOX.iter().copied().find(|p| std::path::Path::new(p).exists()) +} + +/// Which protocol a profile's browser speaks. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kind { + /// Chrome, Brave, Edge, Chromium (and the box's Chromium): the DevTools protocol. + Chrome, + /// Firefox: WebDriver BiDi. + Firefox, +} + +/// The browser that runs AnyBot's own profiles: a Chrome-family one when there is one, else +/// Firefox. (ANYBOT_BROWSER_ENGINE=firefox picks Firefox anyway, for testing.) +fn own_kind() -> Option { + let want_firefox = std::env::var("ANYBOT_BROWSER_ENGINE").map(|e| e.eq_ignore_ascii_case("firefox")).unwrap_or(false); + match (engine().is_some(), firefox().is_some()) { + (_, true) if want_firefox => Some(Kind::Firefox), + (true, _) => Some(Kind::Chrome), + (false, true) => Some(Kind::Firefox), + (false, false) => None, + } +} + pub fn available() -> bool { if using_box() { return true; } - engine().is_some() + own_kind().is_some() } +/// How the person's own Firefox profiles are listed: 「阿金(Firefox)」. +pub const FIREFOX_MARK: &str = "(Firefox)"; + +/// Where Firefox keeps its profiles on this computer (packaged as Snap or Flatpak too). +fn firefox_roots() -> Vec { + let Some(home) = dirs::home_dir() else { return vec![] }; + if cfg!(target_os = "macos") { + vec![home.join("Library/Application Support/Firefox")] + } else { + vec![home.join(".mozilla/firefox"), home.join(".config/mozilla/firefox"), home.join("snap/firefox/common/.mozilla/firefox"), home.join(".var/app/org.mozilla.firefox/.mozilla/firefox")] + } +} + +/// profiles.ini's profiles: (name, folder). +fn ini_profiles(root: &std::path::Path, ini: &str) -> Vec<(String, PathBuf)> { + let mut out = vec![]; + let (mut name, mut path, mut relative, mut in_profile) = (String::new(), String::new(), true, false); + let mut flush = |name: &mut String, path: &mut String, relative: bool| { + if !name.is_empty() && !path.is_empty() { + out.push((std::mem::take(name), if relative { root.join(&*path) } else { PathBuf::from(&*path) })); + } + name.clear(); + path.clear(); + }; + for line in ini.lines().map(str::trim) { + if line.starts_with('[') { + flush(&mut name, &mut path, relative); + in_profile = line.starts_with("[Profile"); + relative = true; + } else if in_profile { + match line.split_once('=') { + Some(("Name", v)) => name = v.to_string(), + Some(("Path", v)) => path = v.to_string(), + Some(("IsRelative", v)) => relative = v != "0", + _ => {} + } + } + } + flush(&mut name, &mut path, relative); + out +} + +/// The newer profile switcher's profiles (Firefox 138+): their names live only in +/// 「Profile Groups/.sqlite」. Read from a copy (Firefox keeps the file open and busy). +fn group_profiles(root: &std::path::Path) -> Vec<(String, PathBuf)> { + let Ok(files) = std::fs::read_dir(root.join("Profile Groups")) else { return vec![] }; + let mut out = vec![]; + for db in files.flatten().map(|e| e.path()).filter(|p| p.extension().and_then(|e| e.to_str()) == Some("sqlite")) { + let tmp = std::env::temp_dir().join(format!("anybot-ff-{}-{}", std::process::id(), crate::httpd::random_hex(4))); + if std::fs::create_dir_all(&tmp).is_err() { + continue; + } + let copy = tmp.join("groups.sqlite"); + let read = || -> rusqlite::Result> { + let c = rusqlite::Connection::open(©)?; + let mut q = c.prepare("SELECT path, name FROM Profiles")?; + let rows = q.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; + rows.collect() + }; + let copied = std::fs::copy(&db, ©).is_ok(); + // Recent changes may still be in the write-ahead log beside it. + let _ = std::fs::copy(db.with_extension("sqlite-wal"), tmp.join("groups.sqlite-wal")); + for (path, name) in if copied { read().unwrap_or_default() } else { vec![] } { + let p = PathBuf::from(&path); + out.push((name, if p.is_absolute() { p } else { root.join(p) })); + } + let _ = std::fs::remove_dir_all(&tmp); + } + out +} + +/// The person's own Firefox profiles (name, folder), as Firefox lists them. Looked up at most +/// every few seconds (the tool list asks for it at every step). +pub fn firefox_profiles() -> Vec<(String, PathBuf)> { + type Found = Vec<(String, PathBuf)>; + static CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((at, list)) = c.as_ref() { + if at.elapsed() < Duration::from_secs(10) { + return list.clone(); + } + } + let mut list: Vec<(String, PathBuf)> = vec![]; + for root in firefox_roots().iter().filter(|r| r.is_dir()) { + let ini = std::fs::read_to_string(root.join("profiles.ini")).unwrap_or_default(); + // A name from the profile switcher wins over profiles.ini's (often just "default"). + for (name, dir) in group_profiles(root).into_iter().chain(ini_profiles(root, &ini)) { + if !dir.is_dir() || list.iter().any(|(_, d)| *d == dir) { + continue; + } + let name = name.trim().to_string(); + let mut unique = name.clone(); + let mut n = 2; + while list.iter().any(|(m, _)| *m == unique) { + unique = format!("{name} {n}"); + n += 1; + } + list.push((unique, dir)); + } + } + *c = Some((Instant::now(), list.clone())); + list +} + +/// A Snap or Flatpak Firefox only reaches its own corner of the home folder: AnyBot's own +/// Firefox profiles live there. +fn firefox_own_root() -> PathBuf { + let bin = firefox().unwrap_or(""); + let home = dirs::home_dir().unwrap_or_default(); + if bin.starts_with("/snap/") || (bin == "/usr/bin/firefox" && std::path::Path::new("/snap/bin/firefox").exists()) { + home.join("snap/firefox/common/AnyBot") + } else if bin.contains("flatpak") { + home.join(".var/app/org.mozilla.firefox/AnyBot") + } else { + data_dir().join("firefox-profiles") + } +} + +/// Where a profile runs: its browser and its folder. +fn place(name: &str) -> Option<(Kind, PathBuf)> { + if let Some(own) = name.strip_suffix(FIREFOX_MARK) { + return firefox_profiles().into_iter().find(|(n, _)| n == own).map(|(_, d)| (Kind::Firefox, d)); + } + match own_kind()? { + Kind::Firefox => Some((Kind::Firefox, firefox_own_root().join(name))), + Kind::Chrome => Some((Kind::Chrome, dir_for(name))), + } +} + +/// The protocol of a profile's browser (the box's Chromium is Chrome). +pub fn kind_of(profile: Option<&str>) -> Kind { + if using_box() { + return Kind::Chrome; + } + resolve(profile).ok().and_then(|n| place(&n)).map(|(k, _)| k).unwrap_or(Kind::Chrome) +} + +/// Is this Firefox profile open (by anyone)? Firefox holds a lock in it while it runs. +fn firefox_in_use(dir: &std::path::Path) -> bool { + #[cfg(target_os = "linux")] + { + // "lock" → "host:+pid" while it runs (left behind after a crash: then the pid is gone). + std::fs::read_link(dir.join("lock")).ok().and_then(|t| t.to_string_lossy().rsplit('+').next().and_then(|p| p.parse::().ok())).is_some_and(|pid| std::path::Path::new(&format!("/proc/{pid}")).exists()) + } + #[cfg(target_os = "macos")] + { + Command::new("lsof").arg("-t").arg(dir.join(".parentlock")).output().map(|o| !o.stdout.is_empty()).unwrap_or(false) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = dir; + false + } +} + +/// The port AnyBot can drive this Firefox profile on (it was started for AnyBot), if it's open. +fn firefox_port(dir: &std::path::Path) -> Option { + let v: Value = serde_json::from_str(&std::fs::read_to_string(dir.join(FIREFOX_PORT_FILE)).ok()?).ok()?; + let port = u16::try_from(v["ws_port"].as_u64()?).ok().filter(|p| *p != 0)?; + // The file outlives a crash: only a running profile with that port open counts. + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + (firefox_in_use(dir) && std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()).then_some(port) +} + +/// File Firefox writes into its profile with the BiDi port it chose (`--remote-debugging-port 0`). +const FIREFOX_PORT_FILE: &str = "WebDriverBiDiServer.json"; + fn data_dir() -> PathBuf { crate::model::State::path().parent().map(PathBuf::from).unwrap_or_else(std::env::temp_dir) } @@ -95,9 +298,13 @@ fn current_file() -> PathBuf { data_dir().join("browser-profile.txt") } -/// Every choice: AnyBot's own profiles, then the person's own browser. +/// Every choice: AnyBot's own profiles, the person's own Firefox profiles, then the person's +/// own browser. pub fn choices() -> Vec { let mut all = profiles(); + if firefox().is_some() { + all.extend(firefox_profiles().into_iter().map(|(n, _)| format!("{n}{FIREFOX_MARK}"))); + } // Reading the person's own browser goes through macOS accessibility; not on Linux. if cfg!(target_os = "macos") { all.push(PERSON_PROFILE.to_string()); @@ -126,7 +333,15 @@ pub fn match_profile(list: &[String], want: &str) -> Option { if PERSON_ALIASES.contains(&w.as_str()) && list.iter().any(|n| n == PERSON_PROFILE) { return Some(PERSON_PROFILE.to_string()); } - list.iter().find(|n| n.to_lowercase() == w).cloned() + if let Some(n) = list.iter().find(|n| n.to_lowercase() == w) { + return Some(n.clone()); + } + // A Firefox profile by its own name (「阿金」, 「Firefox 阿金」 for 「阿金(Firefox)」), when + // only one has it. + let bare = |s: &str| s.to_lowercase().replace(&FIREFOX_MARK.to_lowercase(), "").replace("(firefox)", "").replace("firefox", "").replace([':', ':'], "").trim().to_string(); + let b = bare(&w); + let hits: Vec<&String> = list.iter().filter(|n| n.ends_with(FIREFOX_MARK) && !b.is_empty() && bare(n) == b).collect(); + (hits.len() == 1).then(|| hits[0].clone()) } /// Which profile a tool call means: the one named, or the current one. @@ -158,6 +373,9 @@ pub fn check_name(name: &str) -> Result { if n.contains('/') || n.contains(':') || n.starts_with('.') || n.chars().any(char::is_control) { return Err("設定檔名稱不能有 / 或 : 也不能用 . 開頭".into()); } + if n.to_lowercase().contains("firefox") { + return Err("名字裡有 Firefox 的是你自己的 Firefox 設定檔,請換一個名字".into()); + } Ok(n.to_string()) } @@ -179,6 +397,9 @@ pub fn remove(name: &str) -> Result<(), String> { if n == DEFAULT_PROFILE || n == PERSON_PROFILE { return Err(format!("「{n}」不能移除")); } + if n.ends_with(FIREFOX_MARK) { + return Err(format!("「{n}」是你自己的 Firefox 設定檔,AnyBot 不會移除它(要刪請在 Firefox 裡刪)")); + } if live_port(&n).is_some() { return Err(format!("「{n}」的視窗還開著,請先關掉它再移除。")); } @@ -218,6 +439,9 @@ pub fn parse_active_port(text: &str) -> Option { /// The port a running window of this profile answers on, if any. fn live_port(name: &str) -> Option { + if let Some((Kind::Firefox, dir)) = place(name) { + return firefox_port(&dir); + } let file = std::fs::read_to_string(dir_for(name).join(ACTIVE_PORT_FILE)).ok().and_then(|t| parse_active_port(&t)); let fixed = (name == DEFAULT_PROFILE).then(default_port); [fixed, file].into_iter().flatten().find(|p| alive(*p)) @@ -330,8 +554,49 @@ pub fn pick_tab(list: &[Value]) -> Option<&Value> { /// The open page tabs' ids (none when the browser isn't open; it isn't started for this). pub fn tab_ids(profile: Option<&str>) -> Vec { let Some(port) = running_port(profile) else { return vec![] }; - let list: Value = local().get(&endpoint(port, "/json/list")).call().ok().and_then(|r| r.into_body().read_json().ok()).unwrap_or_default(); - list.as_array().map(|a| a.iter().filter(|t| t["type"] == "page").filter_map(|t| t["id"].as_str().map(String::from)).collect()).unwrap_or_default() + pages_of(kind_of(profile), port).unwrap_or_default().iter().filter_map(|t| t["id"].as_str().map(String::from)).collect() +} + +// ---------- tabs, whichever the browser ---------- + +/// The open page tabs ({id, url, title}), the one in use first. +pub fn pages_of(kind: Kind, port: u16) -> Result, String> { + match kind { + Kind::Firefox => crate::bidi::pages(port), + Kind::Chrome => { + let v: Value = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string())?.into_body().read_json().map_err(|e| e.to_string())?; + Ok(crate::browser::pages(&v)) + } + } +} + +/// Open a tab at `url`. Returns its id. +pub fn new_tab(kind: Kind, port: u16, url: &str) -> Result { + match kind { + Kind::Firefox => crate::bidi::new_tab(port, url), + Kind::Chrome => { + let r = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(url)))).send_empty().map_err(|e| e.to_string())?; + if r.status().as_u16() >= 400 { + return Err(format!("瀏覽器沒辦法開新分頁({})", r.status())); + } + let made: Value = r.into_body().read_json().unwrap_or_default(); + Ok(made["id"].as_str().unwrap_or("").to_string()) + } + } +} + +pub fn activate_tab(kind: Kind, port: u16, id: &str) -> Result<(), String> { + match kind { + Kind::Firefox => crate::bidi::activate(port, id), + Kind::Chrome => local().get(&endpoint(port, &format!("/json/activate/{id}"))).call().map(|_| ()).map_err(|e| e.to_string()), + } +} + +pub fn close_tab(kind: Kind, port: u16, id: &str) -> Result<(), String> { + match kind { + Kind::Firefox => crate::bidi::close(port, id), + Kind::Chrome => local().get(&endpoint(port, &format!("/json/close/{id}"))).call().map(|_| ()).map_err(|e| e.to_string()), + } } /// Tabs that appeared during this step (a popup a click opened) are its job's. @@ -358,10 +623,13 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u } return Ok(("它桌面上的 Chromium", port, fresh && first_url.is_some())); } - let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) }; if profile == PERSON_PROFILE { return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into()); } + if let Some((Kind::Firefox, dir)) = place(profile) { + return ensure_firefox(profile, &dir, first_url); + } + let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge、Chromium 或 Firefox,沒辦法開 AnyBot 自己的瀏覽器。".into()) }; if let Some(p) = live_port(profile) { return Ok((name, p, false)); } @@ -403,20 +671,58 @@ pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u Err(format!("{name} 沒有及時打開(AnyBot 瀏覽器,設定檔「{profile}」)。請再試一次。")) } +/// Start a Firefox profile so AnyBot can drive it (WebDriver BiDi on a port Firefox picks). +fn ensure_firefox(profile: &str, dir: &std::path::Path, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> { + let bin = firefox().ok_or("這台電腦沒有 Firefox")?; + if let Some(p) = firefox_port(dir) { + return Ok(("Firefox", p, false)); + } + // Open the everyday way: Firefox can't be taken over then, and a second copy of the same + // profile won't start. + if firefox_in_use(dir) { + return Err(format!("「{profile}」的 Firefox 視窗已經開著,但不是 AnyBot 打開的,AnyBot 沒辦法操作它。請使用者先關掉這個設定檔的 Firefox 視窗(登入都會留著),再讓 AnyBot 打開它;之後要用這個設定檔時,從 AnyBot 打開(設定 › 瀏覽器設定檔 › 開啟視窗)。")); + } + let _ = std::fs::create_dir_all(dir); + let _ = std::fs::remove_file(dir.join(FIREFOX_PORT_FILE)); + // Defense in depth, as for Chrome: never a bare "-…" (a Firefox flag) from a tool argument. + let url = match first_url { + Some(u) if u.starts_with("http://") || u.starts_with("https://") => u, + Some(_) => return Err("第一個網址必須是 http(s) 網址".into()), + None => "about:blank", + }; + let mut c = Command::new(bin); + c.arg("--new-instance").arg("--profile").arg(dir).arg("--remote-debugging-port").arg("0"); + if std::env::var_os("ANYBOT_FIREFOX_HEADLESS").is_some() { + c.arg("--headless"); + } + c.arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.process_group(0); + } + c.spawn().map_err(|e| format!("打不開 Firefox:{e}"))?; + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(START_TIMEOUT_SECS) { + if let Some(p) = firefox_port(dir) { + return Ok(("Firefox", p, true)); + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(format!("Firefox 沒有及時打開(設定檔「{profile}」)。請再試一次。")) +} + /// Open a link in a new tab of the bot's browser (in the named profile, which becomes current). pub fn open(url: &str, profile: Option<&str>) -> Result { let prof = resolve(profile)?; let _ = set_current(&prof); let (name, port, fresh) = ensure(&prof, Some(url))?; if !fresh { - let r = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(url)))).send_empty().map_err(|e| e.to_string())?; - if r.status().as_u16() >= 400 { - return Err(format!("{name} 沒辦法開新分頁({})", r.status())); - } + let kind = kind_of(Some(&prof)); + let id = new_tab(kind, port, url).map_err(|e| format!("{name} 沒辦法開新分頁:{e}"))?; // The new tab is this job's (its next steps work there, not on another job's tab). - let made: Value = r.into_body().read_json().unwrap_or_default(); - claim(made["id"].as_str().unwrap_or("")); - close_old_tabs(port); + claim(&id); + close_old_tabs(kind, port); } // Back when the page has loaded (not after a fixed 3 s: a slow page was still blank then), // with what it shows — the same as browser_read, one step fewer for every page. @@ -430,11 +736,11 @@ pub fn open(url: &str, profile: Option<&str>) -> Result { /// Bot 的電腦 once had 19 Threads pages open and its whole 2 GB in use (estimated). const MAX_TABS: usize = 6; -/// Close the tabs past MAX_TABS that were used longest ago (Chrome lists the latest first). -fn close_old_tabs(port: u16) { - let Ok(list) = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string()).and_then(|r| r.into_body().read_json::().map_err(|e| e.to_string())) else { return }; - for id in old_tabs(&list, |id| !others_tab(id)) { - let _ = local().get(&endpoint(port, &format!("/json/close/{id}"))).call(); +/// Close the tabs past MAX_TABS that were used longest ago (the list has the latest first). +fn close_old_tabs(kind: Kind, port: u16) { + let Ok(list) = pages_of(kind, port) else { return }; + for id in old_tabs(&Value::Array(list), |id| !others_tab(id)) { + let _ = close_tab(kind, port, &id); } } @@ -455,11 +761,11 @@ pub fn show(profile: &str) -> Result<(), String> { if fresh { return Ok(()); } - let list: Value = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string())?.into_body().read_json().map_err(|e| e.to_string())?; - let id = list.as_array().and_then(|a| a.iter().find(|t| t["type"] == "page")).and_then(|t| t["id"].as_str()).map(str::to_string); - match id { - Some(id) => local().get(&endpoint(port, &format!("/json/activate/{id}"))).call().map(|_| ()).map_err(|e| e.to_string()), - None => local().put(&endpoint(port, "/json/new?about:blank")).send_empty().map(|_| ()).map_err(|e| e.to_string()), + let kind = kind_of(Some(profile)); + let list = pages_of(kind, port)?; + match list.first().and_then(|t| t["id"].as_str()) { + Some(id) => activate_tab(kind, port, id), + None => new_tab(kind, port, "about:blank").map(|_| ()), } } @@ -470,7 +776,12 @@ pub fn profiles_tool(action: &str, name: &str) -> Result { choices().iter().map(|p| if *p == cur { format!("{p}(使用中)") } else { p.clone() }).collect::>().join("、") }; match action.trim() { - "" | "list" => Ok(format!("AnyBot 瀏覽器的設定檔:{}。每個設定檔有自己的登入。「{PERSON_PROFILE}」是使用者平常的瀏覽器:使用者在那裡切到哪個 Chrome 設定檔,就讀哪個。", list_text())), + "" | "list" => Ok(format!( + "AnyBot 瀏覽器的設定檔:{}。每個設定檔有自己的登入。{}{}", + list_text(), + if choices().iter().any(|c| c.ends_with(FIREFOX_MARK)) { format!("名字後面有「{FIREFOX_MARK}」的是使用者自己的 Firefox 設定檔(用使用者的登入),AnyBot 會用可以操作的方式打開它;如果使用者已經用一般方式開著那個設定檔,要先請使用者關掉那個視窗。") } else { String::new() }, + if choices().iter().any(|c| c == PERSON_PROFILE) { format!("「{PERSON_PROFILE}」是使用者平常的瀏覽器:使用者在那裡切到哪個 Chrome 設定檔,就讀哪個。") } else { String::new() } + )), "add" | "create" => { let n = add(name)?; set_current(&n)?; @@ -614,6 +925,91 @@ mod tests { assert!(check_name(&"字".repeat(PROFILE_NAME_MAX_CHARS)).is_ok()); } + #[test] + fn the_persons_firefox_profiles_are_found_with_their_names() { + let root = std::env::temp_dir().join(format!("anybot-fxtest-{}", crate::httpd::random_hex(4))); + for d in ["lDxV7bhX.設定檔 1", "3F5mWbNN.設定檔 5", "abc.default-release", "/abs-not-here"] { + let _ = std::fs::create_dir_all(root.join(d.trim_start_matches('/'))); + } + let ini = "[General]\nStartWithLastProfile=1\n\n[Profile1]\nName=default-release\nIsRelative=1\nPath=abc.default-release\n\n[Profile0]\nName=default\nIsRelative=1\nPath=3F5mWbNN.設定檔 5\nDefault=1\n\n[Install4F96D1932A9F858E]\nDefault=x\n"; + let got = ini_profiles(&root, ini); + assert_eq!(got, vec![("default-release".to_string(), root.join("abc.default-release")), ("default".to_string(), root.join("3F5mWbNN.設定檔 5"))]); + // The newer profile switcher keeps its names in a SQLite file. + std::fs::create_dir_all(root.join("Profile Groups")).unwrap(); + let c = rusqlite::Connection::open(root.join("Profile Groups/8d582846.sqlite")).unwrap(); + c.execute_batch("CREATE TABLE Profiles (id INTEGER PRIMARY KEY, path TEXT, name TEXT, avatar TEXT); INSERT INTO Profiles (path, name, avatar) VALUES ('lDxV7bhX.設定檔 1', '女生', 'flower'), ('3F5mWbNN.設定檔 5', '阿金', 'hammer');").unwrap(); + drop(c); + assert_eq!(group_profiles(&root), vec![("女生".to_string(), root.join("lDxV7bhX.設定檔 1")), ("阿金".to_string(), root.join("3F5mWbNN.設定檔 5"))]); + let _ = std::fs::remove_dir_all(&root); + } + + /// The person's own Firefox profile, end to end (headless): listed by its name, refused while + /// it's open the everyday way, then opened by AnyBot and driven. HOME is a scratch folder (a + /// Firefox profile list is made there): + /// HOME=$T ANYBOT_DATA_DIR=$T/data ANYBOT_FIREFOX_HEADLESS=1 --ignored a_persons_firefox_profile + #[test] + #[ignore] + fn a_persons_firefox_profile() { + use std::io::{Read, Write}; + let bin = firefox().expect("Firefox is installed"); + let root = firefox_roots()[0].clone(); + assert!(root.starts_with(std::env::var("HOME").unwrap()) && !root.exists(), "run with HOME set to an empty scratch folder"); + let dir = root.join("x1.阿金"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(root.join("profiles.ini"), "[Profile0]\nName=阿金\nIsRelative=1\nPath=x1.阿金\n").unwrap(); + assert!(choices().contains(&format!("阿金{FIREFOX_MARK}")), "{:?}", choices()); + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + for mut c in l.incoming().flatten() { + let mut buf = [0u8; 2048]; + let _ = c.read(&mut buf); + let body = "阿金的頁面"; + let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + } + }); + let wait_for = |want: bool| { + let t = Instant::now(); + while firefox_in_use(&dir) != want && t.elapsed() < Duration::from_secs(15) { + std::thread::sleep(Duration::from_millis(200)); + } + assert_eq!(firefox_in_use(&dir), want); + }; + // Open the everyday way (nobody can drive it): AnyBot asks for it to be closed first. + let mut everyday = Command::new(bin).args(["--headless", "--new-instance", "--profile"]).arg(&dir).arg("about:blank").stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + wait_for(true); + let e = open(&format!("http://127.0.0.1:{port}/"), Some("阿金")).unwrap_err(); + assert!(e.contains("先關掉"), "{e}"); + let _ = everyday.kill(); + let _ = everyday.wait(); + wait_for(false); + // Now AnyBot opens it itself and drives it like any of its own. + let go = || -> Result<(), String> { + let r = open(&format!("http://127.0.0.1:{port}/"), Some("阿金"))?; + assert!(r.contains("Firefox") && r.contains("阿金(Firefox)") && r.contains("按我"), "{r}"); + let (t, _) = crate::browser::run("browser_click", &serde_json::json!({"text": "按我", "profile": "阿金"}))?; + assert!(t.contains("按到了"), "{t}"); + Ok(()) + }; + let r = go(); + let _ = Command::new("pkill").arg("-f").arg(dir.display().to_string()).status(); + r.unwrap(); + } + + #[test] + fn a_firefox_profile_answers_to_its_own_name() { + let list = vec![DEFAULT_PROFILE.to_string(), "工作".to_string(), format!("阿金{FIREFOX_MARK}"), format!("Sunny{FIREFOX_MARK}")]; + for want in ["阿金", "阿金(Firefox)", "Firefox 阿金", "firefox:阿金", "阿金 (firefox)"] { + assert_eq!(match_profile(&list, want).as_deref(), Some("阿金(Firefox)"), "{want}"); + } + assert_eq!(match_profile(&list, "sunny").as_deref(), Some("Sunny(Firefox)")); + // AnyBot's own profile of the same name wins; "Firefox" alone means none of them. + let both = vec!["阿金".to_string(), format!("阿金{FIREFOX_MARK}")]; + assert_eq!(match_profile(&both, "阿金").as_deref(), Some("阿金")); + assert_eq!(match_profile(&list, "firefox"), None); + assert!(check_name("我的 Firefox").is_err()); + } + #[test] fn chosen_port_is_read_back() { assert_eq!(parse_active_port("53211\n/devtools/browser/abc\n"), Some(53211)); diff --git a/src/browser.rs b/src/browser.rs index 0870d95..20ec3c2 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -1,5 +1,6 @@ // AnyBot's browser, the full toolset. botbrowser.rs starts Chrome and keeps profiles; this file -// drives a tab through the Chrome DevTools Protocol like a person would: +// drives a tab through the Chrome DevTools Protocol like a person would (a Firefox tab through +// WebDriver BiDi: bidi.rs does each DevTools command the BiDi way, so the tools are the same): // - real mouse and keyboard (Input.dispatchMouseEvent / insertText / dispatchKeyEvent), so // React date pickers, dropdowns and menus that ignore scripted events still respond; // - a numbered list of everything clickable (links with their addresses, buttons, fields, @@ -14,44 +15,49 @@ use std::io::ErrorKind; use std::net::{SocketAddr, TcpStream}; use std::time::{Duration, Instant}; -use crate::botbrowser; +use crate::botbrowser::{self, Kind}; const CALL_TIMEOUT_SECS: u64 = 30; /// Longest element list in one answer. const MAX_ELEMENTS: usize = 250; -/// One DevTools connection to a tab. +/// One connection to a tab: DevTools (Chrome-family), or a tab of a Firefox driven over BiDi. pub struct Tab { - ws: tungstenite::WebSocket, + link: Link, next: u64, } -fn targets(port: u16) -> Result, String> { - let v: Value = botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/list")).call().map_err(|e| e.to_string())?.into_body().read_json().map_err(|e| e.to_string())?; - Ok(pages(&v)) +enum Link { + Cdp(Box>), + Bidi { port: u16, context: String }, } /// The ordinary page tabs of a /json/list answer, most recently used first. -fn pages(list: &Value) -> Vec { +pub(crate) fn pages(list: &Value) -> Vec { list.as_array().cloned().unwrap_or_default().into_iter().filter(|t| t["type"] == "page" && !t["url"].as_str().unwrap_or("").starts_with("devtools://")).collect() } -/// The browser's port for a profile (starting it if needed). -pub fn port_of(profile: Option<&str>) -> Result<(String, u16), String> { +/// The browser's port for a profile (starting it if needed), and which protocol it speaks. +pub fn port_of(profile: Option<&str>) -> Result<(String, u16, Kind), String> { let prof = botbrowser::resolve(profile)?; let (_, port, _) = botbrowser::ensure(&prof, None)?; - Ok((prof, port)) + Ok((prof.clone(), port, botbrowser::kind_of(Some(&prof)))) } impl Tab { /// The tab this step works on: its job's latest own tab, else the latest one no other job - /// has (Chrome lists the most recently used first). + /// has (the list has the most recently used first). pub fn current(profile: Option<&str>) -> Result { - let (_, port) = port_of(profile)?; - let list = targets(port)?; + let (_, port, kind) = port_of(profile)?; + let list = botbrowser::pages_of(kind, port)?; // This job's own latest tab (another bot's job may have one in front). let t = botbrowser::pick_tab(&list).ok_or("AnyBot 瀏覽器沒有開著的分頁,請先用 browser_open 打開網址。")?; botbrowser::claim(t["id"].as_str().unwrap_or("")); + if kind == Kind::Firefox { + let context = t["id"].as_str().unwrap_or("").to_string(); + crate::bidi::touch(port, &context); + return Ok(Tab { link: Link::Bidi { port, context }, next: 0 }); + } Tab::connect(port, t) } @@ -61,20 +67,29 @@ impl Tab { let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| e.to_string())?; stream.set_read_timeout(Some(Duration::from_secs(CALL_TIMEOUT_SECS))).ok(); let (ws, _) = tungstenite::client::client(ws_url, stream).map_err(|e| format!("連不上 AnyBot 瀏覽器:{e}"))?; - Ok(Tab { ws, next: 0 }) + Ok(Tab { link: Link::Cdp(Box::new(ws)), next: 0 }) } - /// One DevTools command; events that arrive meanwhile are skipped. + pub fn is_firefox(&self) -> bool { + matches!(self.link, Link::Bidi { .. }) + } + + /// One DevTools command; events that arrive meanwhile are skipped. (On Firefox, done the + /// WebDriver BiDi way, with an answer shaped the same.) pub fn call(&mut self, method: &str, params: Value) -> Result { + let ws = match &mut self.link { + Link::Bidi { port, context } => return crate::bidi::cdp(*port, context, method, ¶ms), + Link::Cdp(ws) => ws, + }; self.next += 1; let id = self.next; - self.ws.send(tungstenite::Message::Text(json!({"id": id, "method": method, "params": params}).to_string().into())).map_err(|e| e.to_string())?; + ws.send(tungstenite::Message::Text(json!({"id": id, "method": method, "params": params}).to_string().into())).map_err(|e| e.to_string())?; let start = Instant::now(); loop { if start.elapsed() > Duration::from_secs(CALL_TIMEOUT_SECS) { return Err("瀏覽器太久沒有回應(頁面可能跳出了對話框:可以用 browser_dialog 處理)".into()); } - match self.ws.read() { + match ws.read() { Ok(tungstenite::Message::Text(t)) => { let v: Value = serde_json::from_str(t.as_str()).unwrap_or(Value::Null); if v["id"] == id { @@ -100,8 +115,10 @@ impl Tab { Ok(r["result"]["value"].clone()) } - fn close(mut self) { - let _ = self.ws.close(None); + fn close(self) { + if let Link::Cdp(mut ws) = self.link { + let _ = ws.close(None); + } } } @@ -753,7 +770,13 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> crate::cloudbox::box_mkdir(&dir)?; } let clicked_at = std::time::SystemTime::now() - Duration::from_secs(1); - tab.call("Browser.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir, "eventsEnabled": false})).or_else(|_| tab.call("Page.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir})))?; + let set = tab.call("Browser.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir, "eventsEnabled": false})).or_else(|_| tab.call("Page.setDownloadBehavior", json!({"behavior": "allow", "downloadPath": dir}))); + // A Firefox that can't be told where saves to its own Downloads folder: watch that. + let dir = match set { + Ok(_) => dir, + Err(_) if tab.is_firefox() && !in_box => dirs::download_dir().unwrap_or_else(std::env::temp_dir).display().to_string(), + Err(e) => return Err(e), + }; let (x, y, what) = locate(&mut tab, &rf, &text)?; click_at(&mut tab, x, y, "left", 1)?; let start = Instant::now(); @@ -765,7 +788,7 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> } else { std::fs::read_dir(&dir).map(|r| r.flatten().filter(|e| e.metadata().and_then(|m| m.modified()).map(|t| t >= clicked_at).unwrap_or(false)).map(|e| e.path().display().to_string()).collect()).unwrap_or_default() }; - if let Some(f) = now.iter().find(|p| !(p.ends_with(".crdownload") || p.ends_with(".tmp"))) { + if let Some(f) = now.iter().find(|p| !(p.ends_with(".crdownload") || p.ends_with(".tmp") || p.ends_with(".part"))) { return Ok((format!("已點 {what},下載好了:{f}(可以用 read_file 讀它)"), None)); } } @@ -844,7 +867,8 @@ fn tidy(profile: Option<&str>) -> Result { // (Its tabs are nobody's from here on, whatever happens below.) let mine = if job.is_empty() { vec![] } else { botbrowser::release(&job) }; let Some(port) = botbrowser::running_port(profile) else { return Ok("瀏覽器沒有開著。".into()) }; - let list = targets(port)?; + let kind = botbrowser::kind_of(profile); + let list = botbrowser::pages_of(kind, port)?; let close: Vec<&Value> = if job.is_empty() { // (An older brain that doesn't say which job: the old way, all but the front page.) list.iter().skip(1).collect() @@ -852,7 +876,7 @@ fn tidy(profile: Option<&str>) -> Result { // The job's own tabs but its latest; other jobs' and the person's are left alone. list.iter().filter(|t| mine.iter().any(|m| t["id"].as_str() == Some(m.as_str()))).skip(1).collect() }; - let closed = close.iter().filter(|t| botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/close/{}", t["id"].as_str().unwrap_or(""))).call().is_ok()).count(); + let closed = close.iter().filter(|t| botbrowser::close_tab(kind, port, t["id"].as_str().unwrap_or("")).is_ok()).count(); Ok(format!("關了 {closed} 個用完的分頁。")) } @@ -860,33 +884,28 @@ fn tabs(args: &Value, profile: Option<&str>) -> Result { if s(args, "action") == "tidy" { return tidy(profile); } - let (_, port) = port_of(profile)?; - let http = |path: &str, put: bool| -> Result { - let url = format!("http://127.0.0.1:{port}{path}"); - let r = if put { botbrowser::local().put(&url).send_empty() } else { botbrowser::local().get(&url).call() }; - r.map_err(|e| e.to_string())?.into_body().read_to_string().map_err(|e| e.to_string()) - }; + let (_, port, kind) = port_of(profile)?; // Another job's tabs aren't listed, switched to or closed. - let list: Vec = targets(port)?.into_iter().filter(|t| !botbrowser::others_tab(t["id"].as_str().unwrap_or(""))).collect(); + let list: Vec = botbrowser::pages_of(kind, port)?.into_iter().filter(|t| !botbrowser::others_tab(t["id"].as_str().unwrap_or(""))).collect(); let current = botbrowser::pick_tab(&list).and_then(|t| t["id"].as_str()).unwrap_or("").to_string(); let idx = args["index"].as_u64().map(|i| i as usize); let pick = |i: Option| -> Result<&Value, String> { i.and_then(|i| list.get(i.wrapping_sub(1))).ok_or_else(|| "請給分頁編號(先用 action=list 看)".to_string()) }; match s(args, "action").as_str() { "switch" => { let t = pick(idx)?; - http(&format!("/json/activate/{}", t["id"].as_str().unwrap_or("")), false)?; + botbrowser::activate_tab(kind, port, t["id"].as_str().unwrap_or(""))?; botbrowser::claim(t["id"].as_str().unwrap_or("")); Ok(format!("已切到分頁 {}:{}({})", idx.unwrap_or(0), t["title"].as_str().unwrap_or(""), t["url"].as_str().unwrap_or(""))) } "close" => { let t = pick(idx)?; - http(&format!("/json/close/{}", t["id"].as_str().unwrap_or("")), false)?; + botbrowser::close_tab(kind, port, t["id"].as_str().unwrap_or(""))?; Ok(format!("已關閉分頁:{}", t["title"].as_str().unwrap_or(""))) } "new" => { let url = crate::web::normalize_url(&s(args, "url")).unwrap_or_else(|_| "about:blank".into()); - let made: Value = serde_json::from_str(&http(&format!("/json/new?{}", botbrowser::encode_component(&url)), true)?).unwrap_or_default(); - botbrowser::claim(made["id"].as_str().unwrap_or("")); + let made = botbrowser::new_tab(kind, port, &url)?; + botbrowser::claim(&made); let loaded = wait_loaded(profile, 10_000); Ok(format!("已開新分頁:{url}{}", if loaded { "" } else { "(還在載入,可以用 browser_wait 等它)" })) } diff --git a/src/i18n_table.rs b/src/i18n_table.rs index e98af91..3a988f2 100644 --- a/src/i18n_table.rs +++ b/src/i18n_table.rs @@ -44,7 +44,8 @@ pub const ROWS: &[(&str, &str, &str)] = &[ ("轉成文字…", "Transcribing…", "文字起こし中…"), ("語音輸入(用你的 OpenAI 金鑰轉成文字)", "Voice input (transcribed with your OpenAI key)", "音声入力(あなたの OpenAI キーで文字起こし)"), ("在 Bot 設定裡改成員", "Change members in the bot's settings", "メンバーは Bot の設定で変更"), - ("需要 Chrome、Brave、Edge 或 Chromium 才能用 AnyBot 自己的瀏覽器。", "AnyBot's own browser needs Chrome, Brave, Edge or Chromium.", "AnyBot 専用ブラウザには Chrome、Brave、Edge、Chromium のいずれかが必要です。"), + ("需要 Chrome、Brave、Edge、Chromium 或 Firefox 才能用 AnyBot 自己的瀏覽器。", "AnyBot's own browser needs Chrome, Brave, Edge, Chromium or Firefox.", "AnyBot 専用ブラウザには Chrome、Brave、Edge、Chromium、Firefox のいずれかが必要です。"), + ("你自己的 Firefox 設定檔:要讓 Bot 用,請從這裡開啟(先關掉它原本的視窗)", "Your own Firefox profile: for the bot to use it, open it from here (close its usual window first)", "あなたの Firefox プロファイル:Bot に使わせるには、ここから開いてください(先に元のウィンドウを閉じてください)"), ("使用中", "In use", "使用中"), ("你平常的 Chrome,切到哪個設定檔就讀哪個", "Your usual Chrome; it reads whichever profile you switch to", "いつもの Chrome。切り替えたプロファイルを読み取ります"), ("✓ 輔助使用已打開", "✓ Accessibility is on", "✓ アクセシビリティはオンです"), diff --git a/src/main.rs b/src/main.rs index fbf75b3..a7f8a67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod appctl; mod attach; mod axread; mod avatar; +mod bidi; mod botbrowser; mod browser; mod cloudbox; diff --git a/src/web.rs b/src/web.rs index 01679b7..af208b5 100644 --- a/src/web.rs +++ b/src/web.rs @@ -82,7 +82,7 @@ fn specs_inner(browser: bool) -> Vec { } else { (crate::botbrowser::DEFAULT_PROFILE.to_string(), crate::botbrowser::DEFAULT_PROFILE.to_string()) }; - let profile = json!({"type": "string", "description": format!("Which AnyBot browser profile (設定檔) to use; each has its own logins (e.g. a different account). Profiles now: {names}. \"{}\" is the person's own everyday browser (read through accessibility), in whichever Chrome profile they have open there. Leave out to use the current one ({current}).", crate::botbrowser::PERSON_PROFILE)}); + let profile = json!({"type": "string", "description": format!("Which AnyBot browser profile (設定檔) to use; each has its own logins (e.g. a different account). Profiles now: {names}. Names ending in {} are the person's own Firefox profiles (their logins; AnyBot opens them in a window it can operate — if the person has that profile open the everyday way, ask them to close that window first). \"{}\" is the person's own everyday browser (read through accessibility), in whichever Chrome profile they have open there. Leave out to use the current one ({current}).", crate::botbrowser::FIREFOX_MARK, crate::botbrowser::PERSON_PROFILE)}); let mut v = vec![ json!({"name": "web_fetch", "description": "Download a public web page and return its text (no login). For pages that need the person's login, or that only show text after scripts run, use browser_open.", "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}), @@ -124,7 +124,7 @@ pub fn describe(name: &str, args: &str) -> String { pub fn run_full(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool, Option) { if crate::browser::is_tool(name) { if !crate::botbrowser::available() { - return ("這些瀏覽器工具需要 AnyBot 自己的瀏覽器(Chrome、Brave、Edge 或 Chromium)。".into(), false, None); + return ("這些瀏覽器工具需要 AnyBot 自己的瀏覽器(Chrome、Brave、Edge、Chromium 或 Firefox)。".into(), false, None); } if uses_person_browser(args) { return ("點擊、輸入和其他操作只能在 AnyBot 自己的瀏覽器裡做。請用 browser_open 在 AnyBot 瀏覽器打開網頁(第一次要請使用者在那個視窗登入)。".into(), false, None); @@ -161,7 +161,7 @@ pub fn run(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool) { "browser_open" if crate::botbrowser::available() => normalize_url(&arg(&v, "url")).and_then(|u| crate::botbrowser::open(&u, Some(arg(&v, "profile").as_str()))), "browser_read" if crate::botbrowser::available() => crate::botbrowser::read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), Some(arg(&v, "profile").as_str())), "browser_profiles" if crate::botbrowser::available() => crate::botbrowser::profiles_tool(&arg(&v, "action"), &arg(&v, "name")), - "browser_profiles" => Err("設定檔要 AnyBot 自己的瀏覽器(需要 Chrome、Brave、Edge 或 Chromium)。現在用的是你自己的 Safari。".into()), + "browser_profiles" => Err("設定檔要 AnyBot 自己的瀏覽器(需要 Chrome、Brave、Edge、Chromium 或 Firefox)。現在用的是你自己的 Safari。".into()), "browser_open" => browser_open(&arg(&v, "url"), cancel), "browser_read" => browser_read(v.get("max_chars").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(MAX_PAGE_CHARS).min(MAX_PAGE_CHARS * 2), cancel), other => Err(format!("沒有「{other}」這個工具")),