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}」這個工具")),