AnyBot/src/web.rs

500 lines
26 KiB
Rust
Raw Normal View History

// Websites: read a public page, open a link in the person's browser, and read the tab they have
// open there (where they're already logged in: Threads, Instagram, Gmail…). The original reaches
// sites through its own browser; its old "copy logins from Chrome" import is retired (SAND-E0801..
// E0812 all say "removed"), so nothing is copied here either. AnyBot's own browser (botbrowser.rs)
// is used when a Chrome-family browser is installed; AppleScript on the person's browser otherwise.
use serde_json::{json, Value};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
pub const NAMES: [&str; 4] = ["web_fetch", "browser_open", "browser_read", "browser_profiles"];
/// Longest page text handed to the model (estimated).
pub const MAX_PAGE_CHARS: usize = 20_000;
/// Largest download read for web_fetch (estimated).
const MAX_FETCH_BYTES: u64 = 3_000_000;
/// AppleScript can wait on macOS's "allow AnyBot to control Chrome?" prompt; give the person time.
const SCRIPT_TIMEOUT_SECS: u64 = 90;
/// Time for a page to load after opening it (estimated).
const OPEN_SETTLE_SECS: u64 = 4;
/// Browsers in the order they're tried (Chromium-style ones share the same AppleScript).
const BROWSERS: [(&str, bool); 6] = [("Google Chrome", true), ("Arc", true), ("Microsoft Edge", true), ("Brave Browser", true), ("Chromium", true), ("Safari", false)];
pub fn is_web_tool(name: &str) -> bool {
NAMES.contains(&name) || crate::browser::is_tool(name)
}
pub fn label(name: &str) -> &'static str {
match name {
"web_fetch" => "讀取網頁",
"browser_open" => "在瀏覽器打開",
"browser_read" => "讀取瀏覽器分頁",
"browser_profiles" => "瀏覽器設定檔",
n if crate::browser::is_tool(n) => crate::browser::label(n),
_ => "網頁",
}
}
/// Does this call use the person's own browser ("你的瀏覽器", or the fallback when no
/// Chrome-family browser is installed)?
pub fn uses_person_browser(args: &str) -> bool {
if !crate::botbrowser::available() {
return true;
}
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
crate::botbrowser::resolve(Some(arg(&v, "profile").as_str())).map(|p| p == crate::botbrowser::PERSON_PROFILE).unwrap_or(false)
}
/// browser_open / browser_read on the person's own browser run on this Mac.
pub fn uses_this_computer(name: &str, args: &str) -> bool {
// AnyBot's own browser is the bot's, like Grok Bot's; only the person's own browser counts
// as working on their computer. Profiles are the bot's browser's, never the person's.
(name == "browser_open" || name == "browser_read") && uses_person_browser(args)
}
/// Reading a tab asks first only when it's the person's own browser.
pub fn read_needs_permission(args: &str) -> bool {
uses_person_browser(args)
}
/// The browser tools. `browsers`: the computers with an AnyBot browser this bot can use (its own,
/// this one, the account's other computers), by name; each has its own logins.
pub fn specs_for(browsers: &[String], default: &str) -> Vec<Value> {
let mut v = specs_inner(!browsers.is_empty());
if !browsers.is_empty() {
let desc = format!("Which computer's AnyBot browser to use, by the computer's name. Each computer's browser has its own logins (a site logged in on one isn't on another). Default: {default}. Use another only when the person names it.");
for t in v.iter_mut().filter(|t| t["name"].as_str().map(|n| n.starts_with("browser_")).unwrap_or(false)) {
t["parameters"]["properties"]["computer"] = json!({"type": "string", "enum": browsers, "description": desc});
}
}
v
}
fn specs_inner(browser: bool) -> Vec<Value> {
let (names, current) = if crate::botbrowser::available() {
(crate::botbrowser::choices().join(", "), crate::botbrowser::current())
} 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 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, use browser_open then browser_read.",
"parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}),
json!({"name": "browser_open", "description": "Open a link in AnyBot's own browser window (a separate browser profile the person logs in to once, for sites like Threads, Instagram, Facebook, Gmail). Waits a few seconds for it to load; then call browser_read. Never ask the person to change browser settings.",
"parameters": {"type": "object", "properties": {"url": {"type": "string"}, "profile": profile}, "required": ["url"]}}),
json!({"name": "browser_read", "description": "Read the title, address and visible text of the current tab in AnyBot's browser (logged-in pages included), plus a numbered list of its links (with their addresses), buttons, fields and dropdowns. Use those numbers with browser_click / browser_type / browser_select instead of guessing URLs. AnyBot's browser runs on the person's computer, not on 「Bot 的電腦」: screenshots of 「Bot 的電腦」 never show it. If it shows a login page, ask the person to log in once in that window.",
"parameters": {"type": "object", "properties": {"max_chars": {"type": "integer", "description": "Longest text to return (default 20000)."}, "profile": profile}}}),
json!({"name": "browser_profiles", "description": "List, add or switch AnyBot browser profiles (設定檔/profile). Use when the person wants another profile or account: 'list' shows them, 'add' makes a new empty one (they log in once there) and switches to it, 'use' switches. Switching changes which profile browser_open/browser_read use.",
"parameters": {"type": "object", "properties": {"action": {"type": "string", "enum": ["list", "add", "use"]}, "name": {"type": "string", "description": "Profile name for add/use."}}, "required": ["action"]}}),
];
if browser {
v.extend(crate::browser::specs(&profile));
}
v
}
fn arg(v: &Value, k: &str) -> String {
v.get(k).and_then(Value::as_str).unwrap_or("").trim().to_string()
}
pub fn describe(name: &str, args: &str) -> String {
let v: Value = serde_json::from_str(args).unwrap_or(Value::Null);
match name {
"browser_read" => match arg(&v, "profile") {
p if p.is_empty() => "目前的分頁".into(),
p => format!("目前的分頁(設定檔「{p}」)"),
},
"browser_profiles" => match (arg(&v, "action").as_str(), arg(&v, "name")) {
("add" | "create", n) => format!("新增「{n}」"),
("use" | "switch", n) => format!("切換到「{n}」"),
_ => "列出設定檔".into(),
},
n if crate::browser::is_tool(n) => crate::browser::describe(n, args),
_ => arg(&v, "url"),
}
}
/// Run a web tool, with a picture when it took one (browser_screenshot).
pub fn run_full(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool, Option<String>) {
if crate::browser::is_tool(name) {
if !crate::botbrowser::available() {
return ("這些瀏覽器工具需要 AnyBot 自己的瀏覽器(Chrome、Brave、Edge 或 Chromium)。".into(), false, None);
}
if uses_person_browser(args) {
return ("點擊、輸入和其他操作只能在 AnyBot 自己的瀏覽器裡做。請用 browser_open 在 AnyBot 瀏覽器打開網頁(第一次要請使用者在那個視窗登入)。".into(), false, None);
}
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
return match crate::browser::run(name, &v) {
Ok((t, img)) => (t, true, img),
Err(e) => (e, false, None),
};
}
let (t, ok) = run(name, args, cancel);
(t, ok, None)
}
/// Run a web tool. Returns (text, ok).
pub fn run(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool) {
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
let r = match name {
"web_fetch" => fetch(&arg(&v, "url")),
"browser_open" if crate::botbrowser::available() && uses_person_browser(args) => browser_open(&arg(&v, "url"), cancel),
"browser_read" if crate::botbrowser::available() && uses_person_browser(args) => 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),
"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_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}」這個工具")),
};
match r {
Ok(s) => (s, true),
Err(e) => (e, false),
}
}
pub fn normalize_url(u: &str) -> Result<String, String> {
let u = u.trim();
if u.is_empty() {
return Err("請給網址".into());
}
let full = if u.starts_with("http://") || u.starts_with("https://") { u.to_string() } else { format!("https://{u}") };
// Check the host part after the scheme (a fixed [8..] cut one byte short for http://).
let rest = full.split_once("://").map(|(_, r)| r).unwrap_or(&full);
if full.contains(char::is_whitespace) || !rest.contains('.') && !rest.starts_with("localhost") {
return Err(format!("「{u}」看起來不是網址"));
}
Ok(full)
}
// ---------- web_fetch ----------
fn fetch(url: &str) -> Result<String, String> {
let url = normalize_url(url)?;
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(20)))
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15")
.build()
.into();
let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?;
let status = resp.status().as_u16();
let body = resp.into_body().with_config().limit(MAX_FETCH_BYTES).read_to_string().map_err(|e| format!("讀取失敗:{e}"))?;
if status >= 400 {
return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open + browser_read。"));
}
let title = between(&body, "<title", "</title>").map(|t| t.split_once('>').map(|x| x.1).unwrap_or(t).trim().to_string()).unwrap_or_default();
let text = html_to_text(&body);
let thin = text.chars().count() < 200;
let mut out = format!("網址:{url}\n標題:{}\n\n{}", decode_entities(&title), clip(&text, MAX_PAGE_CHARS));
if thin {
out.push_str("\n\n(頁面幾乎沒有文字,可能要登入或靠程式載入。可以改用 browser_open 打開,再用 browser_read 讀。)");
}
Ok(out)
}
fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> {
let lower = s.to_ascii_lowercase();
let i = lower.find(a)?;
let j = lower[i..].find(b)? + i;
s.get(i + a.len()..j)
}
fn clip(s: &str, n: usize) -> String {
let mut t: String = s.chars().take(n).collect();
if s.chars().count() > n {
t.push_str("\n…(後面還有,已截斷)");
}
t
}
pub fn decode_entities(s: &str) -> String {
let mut out = s.replace("&nbsp;", " ").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'").replace("&#x27;", "'");
// Numeric entities like &#20013;
while let Some(i) = out.find("&#") {
let Some(j) = out[i..].find(';').map(|j| j + i) else { break };
let code = &out[i + 2..j];
let ch = if let Some(h) = code.strip_prefix('x').or_else(|| code.strip_prefix('X')) { u32::from_str_radix(h, 16).ok() } else { code.parse().ok() };
match ch.and_then(char::from_u32) {
Some(c) => out.replace_range(i..=j, &c.to_string()),
None => break,
}
}
out.replace("&amp;", "&")
}
/// Visible text of an HTML page: drop scripts, styles and tags, keep line breaks for blocks.
pub fn html_to_text(html: &str) -> String {
let mut s = html.to_string();
for tag in ["script", "style", "noscript", "svg", "head"] {
loop {
let lower = s.to_ascii_lowercase();
let Some(a) = lower.find(&format!("<{tag}")) else { break };
let end_tag = format!("</{tag}>");
match lower[a..].find(&end_tag) {
Some(b) => s.replace_range(a..a + b + end_tag.len(), " "),
None => {
s.truncate(a);
break;
}
}
}
}
let mut out = String::new();
let mut in_tag = false;
let mut tag = String::new();
for c in s.chars() {
match c {
'<' => {
in_tag = true;
tag.clear();
}
'>' if in_tag => {
in_tag = false;
let t = tag.trim_start_matches('/').split(|c: char| c.is_whitespace()).next().unwrap_or("").to_ascii_lowercase();
if ["p", "div", "br", "li", "h1", "h2", "h3", "h4", "tr", "section", "article", "header", "footer"].contains(&t.as_str()) {
out.push('\n');
}
}
_ if in_tag => tag.push(c),
_ => out.push(c),
}
}
let text = decode_entities(&out);
let lines: Vec<String> = text.lines().map(|l| l.split_whitespace().collect::<Vec<_>>().join(" ")).filter(|l| !l.is_empty()).collect();
lines.join("\n")
}
// ---------- the person's browser ----------
fn applescript_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// Run AppleScript and wait (macOS may first ask the person to allow AnyBot to control the browser).
fn osascript(script: &str, cancel: &AtomicBool) -> Result<String, String> {
if !cfg!(target_os = "macos") {
return Err("在 Linux 上讀不到你自己瀏覽器裡的分頁。請改用 AnyBot 的瀏覽器(profile 用「預設」或其他設定檔)打開那個網址再讀;要登入的網站,請先在 AnyBot 的瀏覽器登入一次。".into());
}
let mut child = Command::new("osascript").arg("-e").arg(script).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
let start = Instant::now();
loop {
if let Ok(Some(_)) = child.try_wait() {
let out = child.wait_with_output().map_err(|e| e.to_string())?;
let so = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
let se = String::from_utf8_lossy(&out.stderr).trim().to_string();
return if out.status.success() { Ok(so) } else { Err(se) };
}
if cancel.load(Ordering::Relaxed) {
let _ = child.kill();
return Err("已停止".into());
}
if start.elapsed() > Duration::from_secs(SCRIPT_TIMEOUT_SECS) {
let _ = child.kill();
return Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into());
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn is_running(app: &str, cancel: &AtomicBool) -> bool {
osascript(&format!("application {} is running", applescript_string(app)), cancel).map(|s| s == "true").unwrap_or(false)
}
/// On-screen windows, front to back, the way the original's `listWindows` (sand_window_capture.node
/// 0x52c0) reads them: `CGWindowListCopyWindowInfo(0x11 = on-screen only | exclude desktop, 0)`.
const WINDOW_LIST_JXA: &str = "ObjC.import('CoreGraphics');var a=ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo(0x11,0)))||[];JSON.stringify(a.map(function(w){var b=w.kCGWindowBounds||{};return {owner:w.kCGWindowOwnerName||'',layer:w.kCGWindowLayer||0,alpha:(w.kCGWindowAlpha===undefined?1:w.kCGWindowAlpha),w:b.Width||0,h:b.Height||0};}));";
/// App names of the windows that can be seen, front first. Same filter as `listWindows`: skip
/// fully transparent windows and ones with no width or height; plus only normal app windows
/// (layer 0), so menu-bar items and overlays don't count.
pub fn visible_window_owners(json: &str) -> Vec<String> {
let v: Value = serde_json::from_str(json).unwrap_or(Value::Null);
v.as_array()
.map(|a| {
a.iter()
.filter(|w| w["alpha"].as_f64().unwrap_or(1.0) > 0.0 && w["w"].as_f64().unwrap_or(0.0) > 0.0 && w["h"].as_f64().unwrap_or(0.0) > 0.0 && w["layer"].as_i64().unwrap_or(0) == 0)
.filter_map(|w| w["owner"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn front_window_owners(cancel: &AtomicBool) -> Vec<String> {
if !cfg!(target_os = "macos") {
return vec![];
}
let Ok(mut child) = Command::new("osascript").args(["-l", "JavaScript", "-e", WINDOW_LIST_JXA]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return vec![] };
let start = Instant::now();
while start.elapsed() < Duration::from_secs(10) && !cancel.load(Ordering::Relaxed) {
if let Ok(Some(_)) = child.try_wait() {
return child.wait_with_output().map(|o| visible_window_owners(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default();
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
vec![]
}
/// The browser whose window is closest to the front: the one the person is looking at.
pub fn frontmost_browser(owners: &[String]) -> Option<(&'static str, bool)> {
owners.iter().find_map(|o| BROWSERS.iter().find(|(name, _)| name == o).copied())
}
/// The browser to use: the front-most browser window, else the first one that's open, else
/// Chrome if installed, else Safari.
fn pick_browser(cancel: &AtomicBool) -> (&'static str, bool) {
if let Some(b) = frontmost_browser(&front_window_owners(cancel)) {
return b;
}
for (name, chromium) in BROWSERS {
if is_running(name, cancel) {
return (name, chromium);
}
}
if std::path::Path::new("/Applications/Google Chrome.app").exists() {
return ("Google Chrome", true);
}
("Safari", false)
}
/// macOS refused to let AnyBot control the browser (-1743): open the Automation settings pane
/// right away so the switch is in front of the person, and say what to turn on.
fn explain(err: &str, browser: &str) -> String {
let lower = err.to_lowercase();
if err.contains("-1743") || lower.contains("not authorized") || lower.contains("不允許") {
let _ = Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn();
format!("macOS 擋下了 AnyBot 控制 {browser}。我已經幫你打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot(或「終端機」)底下打開「{browser}」,然後再叫我試一次。")
} else if lower.contains("javascript") {
if browser == "Safari" {
format!("Safari 還沒允許讀取網頁內容:請在 Safari 選單「開發 → 允許來自 Apple 事件的 JavaScript」打開(沒有「開發」選單的話,先到 Safari 設定 → 進階 → 勾選「顯示網頁開發者功能」),然後再叫我試一次。")
} else {
format!("{browser} 還沒允許讀取網頁內容:請在 {browser} 選單「檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript」打開,然後再叫我試一次。")
}
} else {
format!("{browser} 沒有回應:{err}")
}
}
fn browser_open(url: &str, cancel: &AtomicBool) -> Result<String, String> {
let url = normalize_url(url)?;
if !cfg!(target_os = "macos") {
// Linux: the person's default browser.
crate::preview::system_open(&url).status().map_err(|e| format!("打不開瀏覽器:{e}"))?;
return Ok(format!("已在你的預設瀏覽器打開 {url}。"));
}
let (browser, chromium) = pick_browser(cancel);
// A Chromium browser started without a folder hands the link to the person's own running
// window (their normal profile), never to AnyBot's own window, which `open -a` might pick.
let exe = app_exe(browser);
let handed = chromium
&& std::path::Path::new(&exe).exists()
&& {
let mut c = Command::new(&exe);
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().is_ok()
};
if !handed {
let st = Command::new("open").arg("-a").arg(browser).arg(&url).status().map_err(|e| e.to_string())?;
if !st.success() {
Command::new("open").arg(&url).status().map_err(|e| e.to_string())?;
}
}
let until = Instant::now() + Duration::from_secs(OPEN_SETTLE_SECS);
while Instant::now() < until {
if cancel.load(Ordering::Relaxed) {
return Err("已停止".into());
}
std::thread::sleep(Duration::from_millis(100));
}
Ok(format!("已在 {browser} 打開 {url}。接著可以用 browser_read 讀這個分頁。"))
}
/// The main executable of a browser app in /Applications.
pub fn app_exe(browser: &str) -> String {
format!("/Applications/{browser}.app/Contents/MacOS/{browser}")
}
fn browser_read(max_chars: usize, cancel: &AtomicBool) -> Result<String, String> {
let (browser, chromium) = pick_browser(cancel);
// First through accessibility, like Grok Bot's Computer Use helper: no browser setting, and
// it reads whichever profile's window the person has in front.
match crate::axread::read_person_browser(&app_exe(browser), max_chars) {
Ok(page) if !page.text.trim().is_empty() => {
return Ok(format!("瀏覽器:{browser}(你的瀏覽器)\n標題:{}\n網址:{}\n\n{}", page.title, page.url, clip(&page.text, max_chars)));
}
Err(e) if e.contains("輔助使用") => return Err(e),
_ => {}
}
let app = applescript_string(browser);
let (title_s, url_s, text_s) = if chromium {
(
format!("tell application {app} to get title of active tab of front window"),
format!("tell application {app} to get URL of active tab of front window"),
format!("tell application {app} to execute active tab of front window javascript \"document.body ? document.body.innerText : ''\""),
)
} else {
(
format!("tell application {app} to get name of current tab of front window"),
format!("tell application {app} to get URL of current tab of front window"),
format!("tell application {app} to do JavaScript \"document.body ? document.body.innerText : ''\" in current tab of front window"),
)
};
let title = osascript(&title_s, cancel).map_err(|e| explain(&e, browser))?;
let url = osascript(&url_s, cancel).unwrap_or_default();
match osascript(&text_s, cancel) {
Ok(text) => {
let text: String = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect::<Vec<_>>().join("\n");
Ok(format!("瀏覽器:{browser}\n標題:{title}\n網址:{url}\n\n{}", clip(&text, max_chars)))
}
// Title and address still help; say how to allow the page text.
Err(e) => Ok(format!("瀏覽器:{browser}\n標題:{title}\n網址:{url}\n\n(讀不到頁面文字){}", explain(&e, browser))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_becomes_readable_text() {
let html = "<html><head><title>T</title><style>.a{}</style></head><body><script>var x=1;</script><h1>標題</h1><p>第一段&nbsp;文字 &amp; 更多</p><div>中&#25991;</div></body></html>";
assert_eq!(html_to_text(html), "標題\n第一段 文字 & 更多\n中文");
}
#[test]
fn urls_are_checked() {
assert_eq!(normalize_url("threads.net/@me").unwrap(), "https://threads.net/@me");
assert!(normalize_url("not a url").is_err());
assert!(normalize_url("").is_err());
}
#[test]
fn front_browser_follows_window_order() {
let json = r#"[{"owner":"Window Server","layer":24,"alpha":1,"w":1920,"h":30},
{"owner":"Ghost","layer":0,"alpha":0,"w":800,"h":600},
{"owner":"Safari","layer":0,"alpha":1,"w":0,"h":600},
{"owner":"Finder","layer":0,"alpha":1,"w":800,"h":600},
{"owner":"Google Chrome","layer":0,"alpha":1,"w":1200,"h":800},
{"owner":"Safari","layer":0,"alpha":1,"w":1200,"h":800}]"#;
let owners = visible_window_owners(json);
assert_eq!(owners, vec!["Finder", "Google Chrome", "Safari"]);
assert_eq!(frontmost_browser(&owners), Some(("Google Chrome", true)));
assert_eq!(frontmost_browser(&["Finder".to_string()]), None);
}
#[test]
fn automation_refusal_is_explained() {
assert!(explain("execution error: Not authorized to send Apple events to Google Chrome. (-1743)", "Google Chrome").contains("自動化"));
assert!(explain("Executing JavaScript through AppleScript is turned off.", "Google Chrome").contains("允許來自 Apple 事件的 JavaScript"));
}
}