AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
// 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 )
}
2026-11-04 09:46:43 +00:00
/// The browser tools, also when the browser is on another computer of the account (裝置連結):
/// then each takes a `computer`, since each computer's AnyBot browser has its own logins.
2026-11-05 06:40:01 +00:00
pub fn specs_for ( here : & str , devices : & [ String ] ) -> Vec < Value > {
2026-11-04 09:46:43 +00:00
let local = crate ::botbrowser ::available ( ) ;
let mut v = specs_inner ( local | | ! devices . is_empty ( ) ) ;
if ! devices . is_empty ( ) {
let mut names : Vec < String > = devices . to_vec ( ) ;
2026-11-05 06:46:42 +00:00
// A brain on a server with the person's computer as its hands: that one's browser first.
let person = devices . iter ( ) . any ( | d | d = = crate ::model ::THIS_COMPUTER_NAME ) ;
2026-11-04 09:46:43 +00:00
if local {
2026-11-05 06:46:42 +00:00
names . insert ( if person { names . len ( ) } else { 0 } , here . to_string ( ) ) ;
2026-11-04 09:46:43 +00:00
}
let desc = format! (
" Which computer's AnyBot browser to use. Each computer's browser has its own logins (a site logged in on one isn't on another). {} " ,
2026-11-05 06:46:42 +00:00
if person {
format! ( " Leave out for {} (the person's own computer, where they can see the window). " , crate ::model ::THIS_COMPUTER_NAME )
} else if local {
format! ( " Leave out for {here} . " )
} else {
" This computer has no browser: always name one. " . to_string ( )
}
2026-11-04 09:46:43 +00:00
) ;
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 " : names , " description " : desc } ) ;
}
}
v
}
fn specs_inner ( browser : bool ) -> Vec < Value > {
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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 " ] } } ) ,
] ;
2026-11-04 09:46:43 +00:00
if browser {
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
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} " ) } ;
if full . contains ( char ::is_whitespace ) | | ! full [ 8 .. ] . contains ( '.' ) & & ! full . contains ( " 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 ( " " , " " ) . replace ( " < " , " < " ) . replace ( " > " , " > " ) . replace ( " " " , " \" " ) . replace ( " ' " , " ' " ) . replace ( " ' " , " ' " ) ;
// Numeric entities like 中
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 ( " & " , " & " )
}
/// 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>第一段 文字 & 更多</p><div>中文</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 " ) ) ;
}
}