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
// 示範教學: record the person doing a task on this Mac so a bot can learn the steps and do it
// again later. The original: 「錄下你執行任務的過程。{Bot} 會學習這些步驟,之後可以自行再次執行。」
// and 「{Bot} 正在觀看並學習」 (it records the screen with ffmpeg plus an input-event log; see
// local-exec-daemon saveRecordingDataPackage {inputEvents, displayWidth, apiWidth…}).
// Here a listen-only event tap collects clicks, keys and scrolls (nothing is blocked or changed),
// and they're written up as numbered steps with screenshot coordinates.
use serde ::{ Deserialize , Serialize } ;
use std ::sync ::Mutex ;
use std ::time ::Instant ;
/// Two clicks closer than this (and near each other) are a double click (estimated).
const DOUBLE_CLICK_MS : u128 = 400 ;
/// Scrolls closer than this are one step (estimated).
const SCROLL_MERGE_MS : u128 = 1500 ;
#[ derive(Clone, Serialize, Deserialize, Debug, PartialEq) ]
pub struct Demo {
pub name : String ,
pub created : String ,
pub steps : Vec < String > ,
/// The screen recording (mp4), like the original's ffmpeg recording; empty when none.
#[ serde(default) ]
pub video : String ,
/// The screen at each step (a JPEG per step; empty when not captured).
#[ serde(default) ]
pub shots : Vec < String > ,
}
#[ derive(Clone, Debug) ]
enum Raw {
Click { x : f64 , y : f64 , right : bool , app : String , at : Instant } ,
Key { text : String , code : i64 , flags : u64 , app : String } ,
Scroll { dy : i64 , app : String , at : Instant } ,
}
static EVENTS : Mutex < Vec < Raw > > = Mutex ::new ( Vec ::new ( ) ) ;
/// The frame number showing on screen when each event happened (same order as EVENTS).
static EVENT_FRAMES : Mutex < Vec < usize > > = Mutex ::new ( Vec ::new ( ) ) ;
static FRAMES : std ::sync ::atomic ::AtomicUsize = std ::sync ::atomic ::AtomicUsize ::new ( 0 ) ;
static FRAME_STOP : std ::sync ::atomic ::AtomicBool = std ::sync ::atomic ::AtomicBool ::new ( false ) ;
2026-11-05 07:48:22 +00:00
static FRAME_HANDLE : Mutex < Option < std ::thread ::JoinHandle < ( ) > > > = Mutex ::new ( None ) ;
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
static REC_DIR : Mutex < Option < std ::path ::PathBuf > > = Mutex ::new ( None ) ;
/// Screen pictures per second while recording (estimated; the original records video).
pub const FRAMES_PER_SEC : u64 = 2 ;
/// Width of the recorded pictures (the original's render proxy is 1080p; smaller here).
pub const FRAME_WIDTH : u32 = 1280 ;
#[ cfg_attr(not(target_os = " macos " ), allow(dead_code)) ]
static RUNLOOP : Mutex < usize > = Mutex ::new ( 0 ) ;
pub static RECORDING : Mutex < Option < ( String , Instant ) > > = Mutex ::new ( None ) ;
pub fn recording ( ) -> Option < ( String , Instant ) > {
RECORDING . lock ( ) . unwrap ( ) . clone ( )
}
/// Start recording for a bot. Err when macOS hasn't allowed listening to input yet.
pub fn start ( bot_name : & str ) -> Result < ( ) , String > {
if RECORDING . lock ( ) . unwrap ( ) . is_some ( ) {
return Err ( " 已經在錄了 " . into ( ) ) ;
}
EVENTS . lock ( ) . unwrap ( ) . clear ( ) ;
EVENT_FRAMES . lock ( ) . unwrap ( ) . clear ( ) ;
2026-11-05 07:48:22 +00:00
// Zero the frame counter before the listener starts: events that arrive in the first
// half second used to carry the *previous* recording's frame numbers (start_frames
// cleared it only after mac::start), mapping steps to pictures from an old demo.
FRAMES . store ( 0 , std ::sync ::atomic ::Ordering ::SeqCst ) ;
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
mac ::start ( ) ? ;
* RECORDING . lock ( ) . unwrap ( ) = Some ( ( bot_name . to_string ( ) , Instant ::now ( ) ) ) ;
start_frames ( ) ;
Ok ( ( ) )
}
fn demos_dir ( ) -> std ::path ::PathBuf {
crate ::model ::State ::path ( ) . parent ( ) . map ( | p | p . to_path_buf ( ) ) . unwrap_or_else ( std ::env ::temp_dir ) . join ( " demos " )
}
/// Pictures of the screen while recording: frame_00000.jpg, frame_00001.jpg…
fn start_frames ( ) {
let dir = demos_dir ( ) . join ( chrono ::Local ::now ( ) . format ( " %Y%m%d-%H%M%S " ) . to_string ( ) ) ;
if std ::fs ::create_dir_all ( & dir ) . is_err ( ) {
return ;
}
* REC_DIR . lock ( ) . unwrap ( ) = Some ( dir . clone ( ) ) ;
FRAMES . store ( 0 , std ::sync ::atomic ::Ordering ::SeqCst ) ;
FRAME_STOP . store ( false , std ::sync ::atomic ::Ordering ::SeqCst ) ;
2026-11-05 07:48:22 +00:00
* FRAME_HANDLE . lock ( ) . unwrap ( ) = Some ( std ::thread ::spawn ( move | | {
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
while ! FRAME_STOP . load ( std ::sync ::atomic ::Ordering ::SeqCst ) {
let t0 = Instant ::now ( ) ;
if crate ::computer ::screen_supported ( ) & & crate ::computer ::screen_capture_ok ( ) {
if let Ok ( img ) = crate ::computer ::capture_screen ( ) {
{
let ( w , h ) = img . dimensions ( ) ;
let small = if w > FRAME_WIDTH { image ::imageops ::resize ( & img , FRAME_WIDTH , ( h as u64 * FRAME_WIDTH as u64 / w . max ( 1 ) as u64 ) as u32 , image ::imageops ::FilterType ::Triangle ) } else { img } ;
let n = FRAMES . load ( std ::sync ::atomic ::Ordering ::SeqCst ) ;
if small . save ( dir . join ( format! ( " frame_ {n:05} .jpg " ) ) ) . is_ok ( ) {
FRAMES . fetch_add ( 1 , std ::sync ::atomic ::Ordering ::SeqCst ) ;
}
}
}
}
let wait = std ::time ::Duration ::from_millis ( 1000 / FRAMES_PER_SEC ) ;
if let Some ( rest ) = wait . checked_sub ( t0 . elapsed ( ) ) {
std ::thread ::sleep ( rest ) ;
}
}
2026-11-05 07:48:22 +00:00
} ) ) ;
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
}
/// Stop the pictures and turn them into a video when ffmpeg is there. Returns (dir, video).
fn stop_frames ( ) -> Option < ( std ::path ::PathBuf , String ) > {
FRAME_STOP . store ( true , std ::sync ::atomic ::Ordering ::SeqCst ) ;
2026-11-05 07:48:22 +00:00
// Let the capturing thread finish its current picture and exit (it ends within a second).
// The old fixed 700 ms sleep raced slow captures (dirty files after the video was made)
// and froze the window for its whole duration.
if let Some ( h ) = FRAME_HANDLE . lock ( ) . unwrap ( ) . take ( ) {
let _ = h . join ( ) ;
}
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 dir = REC_DIR . lock ( ) . unwrap ( ) . take ( ) ? ;
2026-11-05 07:48:22 +00:00
// Encoding runs off the window thread (ffmpeg on a long demo takes seconds; the window
// used to freeze for all of it). The mp4 file appears when it is done.
if dir . join ( " frame_00000.jpg " ) . exists ( ) {
let d = dir . clone ( ) ;
std ::thread ::spawn ( move | | {
if make_video ( & d ) . is_some ( ) {
// The video holds the motion; drop the single pictures it consumed.
if let Ok ( rd ) = std ::fs ::read_dir ( & d ) {
for e in rd . flatten ( ) {
if e . file_name ( ) . to_string_lossy ( ) . starts_with ( " frame_ " ) {
let _ = std ::fs ::remove_file ( e . path ( ) ) ;
}
}
}
}
} ) ;
return Some ( ( dir . clone ( ) , dir . join ( " recording.mp4 " ) . display ( ) . to_string ( ) ) ) ;
}
Some ( ( dir , String ::new ( ) ) )
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
}
/// frame_*.jpg → recording.mp4 (H.264, like the original's render proxy).
pub fn make_video ( dir : & std ::path ::Path ) -> Option < String > {
if ! dir . join ( " frame_00000.jpg " ) . exists ( ) {
return None ;
}
let out = dir . join ( " recording.mp4 " ) ;
let ok = std ::process ::Command ::new ( " ffmpeg " )
. env ( " PATH " , crate ::cloudbox ::tool_path ( ) )
. args ( [ " -hide_banner " , " -loglevel " , " error " , " -y " , " -framerate " , & FRAMES_PER_SEC . to_string ( ) , " -i " ] )
. arg ( dir . join ( " frame_%05d.jpg " ) )
. args ( [ " -vf " , " scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p " , " -c:v " , " libx264 " , " -preset " , " veryfast " , " -movflags " , " +faststart " ] )
. arg ( & out )
. stdin ( std ::process ::Stdio ::null ( ) )
. stdout ( std ::process ::Stdio ::null ( ) )
. stderr ( std ::process ::Stdio ::null ( ) )
. status ( )
. map ( | s | s . success ( ) )
. unwrap_or ( false ) ;
ok . then ( | | out . display ( ) . to_string ( ) )
}
/// Stop and write up the steps.
pub fn finish ( name : & str ) -> Demo {
mac ::stop ( ) ;
2026-11-05 07:48:22 +00:00
// The listener stops asynchronously (the X11 one polls every 150 ms): give the last
// events of the demo time to arrive — they used to be cut off the recording.
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 300 ) ) ;
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
* RECORDING . lock ( ) . unwrap ( ) = None ;
let raw = std ::mem ::take ( & mut * EVENTS . lock ( ) . unwrap ( ) ) ;
let frames_at = std ::mem ::take ( & mut * EVENT_FRAMES . lock ( ) . unwrap ( ) ) ;
let compiled = compile ( & raw , crate ::computer ::points_to_shot ( ) ) ;
let rec = stop_frames ( ) ;
let mut shots = vec! [ ] ;
let mut video = String ::new ( ) ;
if let Some ( ( dir , v ) ) = & rec {
video = v . clone ( ) ;
for ( i , ( _ , first ) ) in compiled . iter ( ) . enumerate ( ) {
// The picture just before the step happened (what the person was looking at).
let f = frames_at . get ( * first ) . copied ( ) . unwrap_or ( 0 ) ;
let src = dir . join ( format! ( " frame_ {f:05} .jpg " ) ) ;
let dst = dir . join ( format! ( " step_ {:02} .jpg " , i + 1 ) ) ;
shots . push ( if std ::fs ::copy ( & src , & dst ) . is_ok ( ) { dst . display ( ) . to_string ( ) } else { String ::new ( ) } ) ;
}
2026-11-05 07:48:22 +00:00
// The frame pictures are removed by the encoder thread once the video is written.
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
}
Demo { name : name . trim ( ) . to_string ( ) , created : chrono ::Local ::now ( ) . format ( " %Y-%m-%d %H:%M " ) . to_string ( ) , steps : compiled . into_iter ( ) . map ( | c | c . 0 ) . collect ( ) , video , shots }
}
pub fn cancel ( ) {
mac ::stop ( ) ;
* RECORDING . lock ( ) . unwrap ( ) = None ;
EVENTS . lock ( ) . unwrap ( ) . clear ( ) ;
EVENT_FRAMES . lock ( ) . unwrap ( ) . clear ( ) ;
if let Some ( ( dir , _ ) ) = stop_frames ( ) {
let _ = std ::fs ::remove_dir_all ( dir ) ;
}
}
/// The view_demo_step tool: the screen at one step of a demonstration, for the bot to look at.
pub fn view_step_spec ( ) -> serde_json ::Value {
serde_json ::json! ( { " name " : " view_demo_step " , " description " : " See what the person's screen looked like at one step of a demonstration they recorded for you (a picture), to find the right buttons when you repeat it. " ,
" parameters " : { " type " : " object " , " properties " : { " demo " : { " type " : " string " , " description " : " The demonstration's name " } , " step " : { " type " : " integer " , " description " : " Step number, from 1 " } } , " required " : [ " demo " , " step " ] } } )
}
/// Returns (text, ok, picture as base64 JPEG).
pub fn view_step ( demos : & [ Demo ] , args : & str ) -> ( String , bool , Option < String > ) {
use base64 ::Engine ;
let v : serde_json ::Value = serde_json ::from_str ( args ) . unwrap_or_default ( ) ;
let name = v [ " demo " ] . as_str ( ) . unwrap_or ( " " ) . trim ( ) ;
let Some ( d ) = demos . iter ( ) . find ( | d | d . name = = name ) . or_else ( | | demos . iter ( ) . find ( | d | d . name . contains ( name ) & & ! name . is_empty ( ) ) ) else {
return ( format! ( " 沒有叫「 {name} 」的示範。有: {} " , demos . iter ( ) . map ( | d | d . name . as_str ( ) ) . collect ::< Vec < _ > > ( ) . join ( " 、 " ) ) , false , None ) ;
} ;
let n = v [ " step " ] . as_u64 ( ) . unwrap_or ( 0 ) as usize ;
if n = = 0 | | n > d . steps . len ( ) {
return ( format! ( " 「 {} 」有 {} 個步驟,請給 1 到 {} 。 " , d . name , d . steps . len ( ) , d . steps . len ( ) ) , false , None ) ;
}
let text = format! ( " 「 {} 」第 {n} 步: {} " , d . name , d . steps [ n - 1 ] ) ;
let Some ( path ) = d . shots . get ( n - 1 ) . filter ( | p | ! p . is_empty ( ) ) else {
return ( format! ( " {text} \n (這一步沒有存到畫面) " ) , true , None ) ;
} ;
match std ::fs ::read ( path ) . ok ( ) . and_then ( | b | image ::load_from_memory ( & b ) . ok ( ) ) {
Some ( img ) = > {
let img = img . to_rgb8 ( ) ;
let ( w , h ) = img . dimensions ( ) ;
let ( fw , fh , resize ) = crate ::computer ::fit ( w , h ) ;
let img = if resize { image ::imageops ::resize ( & img , fw , fh , image ::imageops ::FilterType ::Triangle ) } else { img } ;
match crate ::computer ::encode_jpeg ( & img ) {
Some ( j ) = > ( format! ( " {text} \n 這是使用者做這一步之前的畫面( {fw} × {fh} ,座標和步驟裡的一樣)。 " ) , true , Some ( base64 ::engine ::general_purpose ::STANDARD . encode ( j ) ) ) ,
None = > ( format! ( " {text} \n (畫面太大,沒辦法送出) " ) , true , None ) ,
}
}
None = > ( format! ( " {text} \n (畫面檔案不見了) " ) , true , None ) ,
}
}
fn key_name ( code : i64 ) -> Option < & 'static str > {
Some ( match code {
36 = > " return " ,
48 = > " tab " ,
49 = > " space " ,
51 = > " delete " ,
53 = > " escape " ,
117 = > " forwarddelete " ,
123 = > " left " ,
124 = > " right " ,
125 = > " down " ,
126 = > " up " ,
115 = > " home " ,
119 = > " end " ,
116 = > " pageup " ,
121 = > " pagedown " ,
_ = > return None ,
} )
}
const CMD : u64 = 1 < < 20 ;
const SHIFT : u64 = 1 < < 17 ;
const CTRL : u64 = 1 < < 18 ;
const OPT : u64 = 1 < < 19 ;
/// Raw events → steps a bot can follow.
fn compile ( raw : & [ Raw ] , to_shot : f64 ) -> Vec < ( String , usize ) > {
let mut steps : Vec < ( String , usize ) > = vec! [ ] ;
let mut typed_from = 0 usize ;
let mut typed = String ::new ( ) ;
let mut typed_app = String ::new ( ) ;
let mut last_app = String ::new ( ) ;
let flush = | steps : & mut Vec < ( String , usize ) > , typed : & mut String , app : & str , from : usize | {
if ! typed . is_empty ( ) {
steps . push ( ( format! ( " 在「 {app} 」輸入「 {typed} 」 " ) , from ) ) ;
typed . clear ( ) ;
}
} ;
let mut i = 0 ;
while i < raw . len ( ) {
match & raw [ i ] {
Raw ::Click { x , y , right , app , at } = > {
flush ( & mut steps , & mut typed , & typed_app , typed_from ) ;
let start = i ;
let mut count = 1 ;
while let Some ( Raw ::Click { x : x2 , y : y2 , right : r2 , at : t2 , .. } ) = raw . get ( i + 1 ) {
if r2 = = right & & ( x2 - x ) . abs ( ) < 6.0 & & ( y2 - y ) . abs ( ) < 6.0 & & t2 . duration_since ( * at ) . as_millis ( ) < DOUBLE_CLICK_MS * count as u128 {
count + = 1 ;
i + = 1 ;
} else {
break ;
}
}
let verb = match ( right , count ) {
( true , _ ) = > " 按右鍵 " . to_string ( ) ,
( false , 1 ) = > " 點 " . to_string ( ) ,
( false , n ) = > format! ( " 連點 {n} 下 " ) ,
} ;
let switch = if * app ! = last_app & & ! app . is_empty ( ) { format! ( " 切到「 {app} 」, " ) } else { String ::new ( ) } ;
steps . push ( ( format! ( " {switch} 在「 {app} 」 {verb} ( {:.0} , {:.0} ) " , x * to_shot , y * to_shot ) , start ) ) ;
last_app = app . clone ( ) ;
}
Raw ::Key { text , code , flags , app } = > {
let mods : Vec < & str > = [ ( CMD , " cmd " ) , ( CTRL , " control " ) , ( OPT , " option " ) ] . iter ( ) . filter ( | ( f , _ ) | flags & f ! = 0 ) . map ( | ( _ , n ) | * n ) . collect ( ) ;
if mods . is_empty ( ) & & * code = = 51 & & ! typed . is_empty ( ) {
typed . pop ( ) ;
} else if mods . is_empty ( ) & & key_name ( * code ) . is_none ( ) & & ! text . is_empty ( ) & & ! text . chars ( ) . any ( char ::is_control ) {
if typed . is_empty ( ) {
typed_app = app . clone ( ) ;
typed_from = i ;
}
typed . push_str ( text ) ;
} else {
flush ( & mut steps , & mut typed , & typed_app , typed_from ) ;
let key = key_name ( * code ) . map ( String ::from ) . unwrap_or_else ( | | text . to_lowercase ( ) ) ;
if key . is_empty ( ) {
i + = 1 ;
continue ;
}
let mut combo : Vec < String > = mods . iter ( ) . map ( | m | m . to_string ( ) ) . collect ( ) ;
if flags & SHIFT ! = 0 & & ! mods . is_empty ( ) {
combo . push ( " shift " . into ( ) ) ;
}
combo . push ( key ) ;
steps . push ( ( format! ( " 按 {} " , combo . join ( " + " ) ) , i ) ) ;
}
last_app = app . clone ( ) ;
}
Raw ::Scroll { dy , app , at } = > {
flush ( & mut steps , & mut typed , & typed_app , typed_from ) ;
let start = i ;
let mut total = * dy ;
let mut last = * at ;
while let Some ( Raw ::Scroll { dy : d2 , at : t2 , .. } ) = raw . get ( i + 1 ) {
if t2 . duration_since ( last ) . as_millis ( ) < SCROLL_MERGE_MS & & d2 . signum ( ) = = dy . signum ( ) {
total + = d2 ;
last = * t2 ;
i + = 1 ;
} else {
break ;
}
}
steps . push ( ( format! ( " 在「 {app} 」往 {} 捲動 " , if total < 0 { " 下 " } else { " 上 " } ) , start ) ) ;
}
}
i + = 1 ;
}
flush ( & mut steps , & mut typed , & typed_app , typed_from ) ;
steps
}
/// The steps as told to the bot.
pub fn prompt_section ( demos : & [ Demo ] ) -> String {
if demos . is_empty ( ) {
return String ::new ( ) ;
}
One way to act on a screen: a real click or keystroke, wherever the bot looked
- Acting by an app's number (read_app's e7) is now a real click at that control's spot, the
same path as screenshot coordinates. agent-desktop only reads; its own actions (a second set
with its own key names and failures: no typing in terminals, "/" and "`" failing off the main
thread) are gone, with computer_use_select, background clicks and press_key's app.
- Every action comes back with the screen after it (or, with no picture allowed, the front
app's numbered controls), so the bot no longer spends a step on a screenshot to check.
- Coordinates map without remembering a screenshot: the picture's size depends only on the
screen. The phone's taps go straight to the screen as the person's, in the pixels of the
picture it was sent; they used to borrow the bot's last screenshot scale and landed 1.25x off
on the bots' computer, or failed with "screenshot first".
- Mac typing pastes (⌘V, clipboard put back): with 注音 on, text sent as key events vanished
while return arrived, so commands typed into a terminal ran empty. Bare printing keys
("slash", "r") are typed the same way; keys like "slash" and "grave" have names.
- The bots' Docker computer: one docker exec per step (action, settle, picture), never
`mousemove --sync` (it waits forever when the pointer is already there: a second click on
the same spot hung two minutes), open_app (chromium = the browser browser_* drive, xterm).
Tests: box_screen_is_exact (real Docker: bot and phone coordinates exact, same spot twice in
under 2 s, ASCII + Chinese + named key typed), Calculator by numbers and Terminal typing under
注音 through the signed app, remote_e2e 28/28, Linux build + 151 tests + x11_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:51:19 +00:00
let mut s = String ::from ( " \n \n 你學過的示範(使用者在他的 Mac 上做給你看的步驟)。使用者要你做同樣的事時,照著在「你的電腦」上做:開始先截圖一次,之後每一步做完都會附上新畫面,座標是錄製時截圖上的位置,畫面不同就找對應的按鈕;輸入的內容可以依新的需求調整。想看某一步當時的畫面,用 view_demo_step。 " ) ;
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
for d in demos {
s . push_str ( & format! ( " \n ### {} ( {} ) \n " , d . name , d . created ) ) ;
for ( i , st ) in d . steps . iter ( ) . enumerate ( ) {
s . push_str ( & format! ( " {} . {st} \n " , i + 1 ) ) ;
}
}
s
}
fn push ( e : Raw ) {
// AnyBot's own window (its Stop button, the bar) isn't part of the task.
let own = match & e {
Raw ::Click { app , .. } | Raw ::Key { app , .. } | Raw ::Scroll { app , .. } = > app . to_lowercase ( ) . contains ( " anybot " ) ,
} ;
if ! own {
EVENTS . lock ( ) . unwrap ( ) . push ( e ) ;
// The last picture taken before this moment.
EVENT_FRAMES . lock ( ) . unwrap ( ) . push ( FRAMES . load ( std ::sync ::atomic ::Ordering ::SeqCst ) . saturating_sub ( 1 ) ) ;
}
}
#[ cfg(target_os = " macos " ) ]
mod mac {
use super ::* ;
use std ::ffi ::c_void ;
#[ repr(C) ]
#[ derive(Clone, Copy) ]
struct CGPoint {
x : f64 ,
y : f64 ,
}
type Cb = extern " C " fn ( * mut c_void , u32 , * mut c_void , * mut c_void ) -> * mut c_void ;
#[ link(name = " CoreGraphics " , kind = " framework " ) ]
extern " C " {
fn CGEventTapCreate ( tap : u32 , place : u32 , options : u32 , mask : u64 , cb : Cb , user : * mut c_void ) -> * mut c_void ;
fn CGEventTapEnable ( tap : * mut c_void , enable : bool ) ;
fn CGEventGetLocation ( e : * mut c_void ) -> CGPoint ;
fn CGEventGetFlags ( e : * mut c_void ) -> u64 ;
fn CGEventGetIntegerValueField ( e : * mut c_void , field : u32 ) -> i64 ;
fn CGEventKeyboardGetUnicodeString ( e : * mut c_void , max : usize , actual : * mut usize , buf : * mut u16 ) ;
}
#[ link(name = " CoreFoundation " , kind = " framework " ) ]
extern " C " {
fn CFMachPortCreateRunLoopSource ( alloc : * const c_void , port : * mut c_void , order : isize ) -> * mut c_void ;
fn CFRunLoopGetCurrent ( ) -> * mut c_void ;
fn CFRunLoopAddSource ( rl : * mut c_void , src : * mut c_void , mode : * const c_void ) ;
fn CFRunLoopRun ( ) ;
fn CFRunLoopStop ( rl : * mut c_void ) ;
static kCFRunLoopCommonModes : * const c_void ;
}
static TAP : Mutex < usize > = Mutex ::new ( 0 ) ;
2026-11-05 07:48:22 +00:00
// The foreground app barely moves between keystrokes, and the window-list query is the
// slowest call in the tap callback — slow callbacks make macOS disable the tap (events
// lost until it is re-enabled). Answer keystrokes from a short cache.
static FRONT_CACHE : Mutex < Option < ( Instant , String ) > > = Mutex ::new ( None ) ;
fn front_cached ( ) -> String {
let mut c = FRONT_CACHE . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) ;
if let Some ( ( t , n ) ) = c . as_ref ( ) {
if t . elapsed ( ) < std ::time ::Duration ::from_millis ( 400 ) {
return n . clone ( ) ;
}
}
let n = crate ::computer ::frontmost_app ( ) . unwrap_or_default ( ) ;
* c = Some ( ( Instant ::now ( ) , n . clone ( ) ) ) ;
n
}
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
extern " C " fn callback ( _proxy : * mut c_void , kind : u32 , e : * mut c_void , _user : * mut c_void ) -> * mut c_void {
unsafe {
match kind {
1 | 3 = > {
let p = CGEventGetLocation ( e ) ;
let app = crate ::computer ::app_at_point ( p . x , p . y ) . unwrap_or_default ( ) ;
push ( Raw ::Click { x : p . x , y : p . y , right : kind = = 3 , app , at : Instant ::now ( ) } ) ;
}
10 = > {
let mut buf = [ 0 u16 ; 8 ] ;
let mut n = 0 usize ;
CGEventKeyboardGetUnicodeString ( e , buf . len ( ) , & mut n , buf . as_mut_ptr ( ) ) ;
let text = String ::from_utf16_lossy ( & buf [ .. n . min ( buf . len ( ) ) ] ) ;
let code = CGEventGetIntegerValueField ( e , 9 ) ;
2026-11-05 07:48:22 +00:00
let app = front_cached ( ) ;
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
push ( Raw ::Key { text , code , flags : CGEventGetFlags ( e ) , app } ) ;
}
22 = > {
let dy = CGEventGetIntegerValueField ( e , 11 ) ;
if dy ! = 0 {
let p = CGEventGetLocation ( e ) ;
let app = crate ::computer ::app_at_point ( p . x , p . y ) . unwrap_or_default ( ) ;
push ( Raw ::Scroll { dy , app , at : Instant ::now ( ) } ) ;
}
}
// The system turned the tap off (slow callback): turn it back on.
0xFFFF_FFFE | 0xFFFF_FFFF = > {
let t = * TAP . lock ( ) . unwrap ( ) ;
if t ! = 0 {
CGEventTapEnable ( t as * mut c_void , true ) ;
}
}
_ = > { }
}
}
e
}
pub fn start ( ) -> Result < ( ) , String > {
let ( tx , rx ) = std ::sync ::mpsc ::channel ( ) ;
std ::thread ::spawn ( move | | unsafe {
let mask : u64 = ( 1 < < 1 ) | ( 1 < < 3 ) | ( 1 < < 10 ) | ( 1 < < 22 ) ;
// kCGSessionEventTap, head insert, listen only.
let tap = CGEventTapCreate ( 1 , 0 , 1 , mask , callback , std ::ptr ::null_mut ( ) ) ;
if tap . is_null ( ) {
let _ = tx . send ( false ) ;
return ;
}
* TAP . lock ( ) . unwrap ( ) = tap as usize ;
let src = CFMachPortCreateRunLoopSource ( std ::ptr ::null ( ) , tap , 0 ) ;
let rl = CFRunLoopGetCurrent ( ) ;
CFRunLoopAddSource ( rl , src , kCFRunLoopCommonModes ) ;
CGEventTapEnable ( tap , true ) ;
* RUNLOOP . lock ( ) . unwrap ( ) = rl as usize ;
let _ = tx . send ( true ) ;
CFRunLoopRun ( ) ;
CGEventTapEnable ( tap , false ) ;
* TAP . lock ( ) . unwrap ( ) = 0 ;
} ) ;
match rx . recv_timeout ( std ::time ::Duration ::from_secs ( 3 ) ) {
Ok ( true ) = > Ok ( ( ) ) ,
_ = > {
2026-11-05 07:48:22 +00:00
let _ = crate ::computer ::spawn_reaped ( & mut std ::process ::Command ::new ( " open " ) . arg ( " x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent " ) ) ;
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
Err ( " macOS 還沒讓 AnyBot 觀看你的操作。我已經打開「系統設定 → 隱私權與安全性 → 輸入監控」:把 AnyBot( 或「終端機」) 打開, 再按一次「開始錄製」。 " . into ( ) )
}
}
}
pub fn stop ( ) {
let rl = std ::mem ::take ( & mut * RUNLOOP . lock ( ) . unwrap ( ) ) ;
if rl ! = 0 {
unsafe { CFRunLoopStop ( rl as * mut c_void ) } ;
}
}
}
/// Linux: the X RECORD extension shows every click and key (nothing is blocked or changed).
#[ cfg(target_os = " linux " ) ]
mod mac {
use super ::* ;
use std ::sync ::atomic ::{ AtomicBool , Ordering } ;
static STOP : AtomicBool = AtomicBool ::new ( false ) ;
pub fn start ( ) -> Result < ( ) , String > {
if crate ::desk ::kind ( ) ! = crate ::desk ::Kind ::X11 {
return Err ( if crate ::desk ::wayland ( ) {
" 在 Wayland 上,系統不讓 App 看到你按了哪裡、打了什麼, 所以沒辦法錄製示範( Bot 還是可以操作畫面)。要錄示範,請在登入畫面改選 Xorg 工作階段。 "
} else {
" 錄製示範需要桌面畫面( X11) 。 "
}
. into ( ) ) ;
}
STOP . store ( false , Ordering ::SeqCst ) ;
let ( tx , rx ) = std ::sync ::mpsc ::channel ( ) ;
std ::thread ::spawn ( move | | {
let tx2 = tx . clone ( ) ;
let r = crate ::x11 ::record (
| input | push ( to_raw ( input ) ) ,
move | | {
let _ = tx2 . send ( Ok ( ( ) ) ) ;
! STOP . load ( Ordering ::SeqCst )
} ,
) ;
if let Err ( e ) = r {
let _ = tx . send ( Err ( e ) ) ;
}
} ) ;
2026-11-05 07:48:22 +00:00
// On a timeout the record thread may still be spinning up: close the gate so its
// keep_going() ends it — a thread left running meant a second RECORD context
// (double events) on the next try, plus a leaked X connection.
match rx . recv_timeout ( std ::time ::Duration ::from_secs ( 3 ) ) {
Ok ( Ok ( ( ) ) ) = > Ok ( ( ) ) ,
Ok ( Err ( e ) ) = > Err ( e ) ,
Err ( _ ) = > {
STOP . store ( true , Ordering ::SeqCst ) ;
Err ( " 沒辦法開始錄製( X11 沒有回應) " . into ( ) )
}
}
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
}
/// X input → the same events the macOS tap gives (key codes and flags in macOS terms, so
/// the steps read the same; Linux's Ctrl shows as "control").
pub fn to_raw ( i : crate ::x11 ::Input ) -> Raw {
let app = | x : f64 , y : f64 | crate ::x11 ::window_at ( x , y ) . map ( | a | a . 0 ) . or_else ( | | crate ::x11 ::front_app ( ) . map ( | a | a . 0 ) ) . unwrap_or_default ( ) ;
match i {
crate ::x11 ::Input ::Click { x , y , right } = > Raw ::Click { x , y , right , app : app ( x , y ) , at : Instant ::now ( ) } ,
crate ::x11 ::Input ::Scroll { dy } = > Raw ::Scroll { dy , app : crate ::x11 ::front_app ( ) . map ( | a | a . 0 ) . unwrap_or_default ( ) , at : Instant ::now ( ) } ,
crate ::x11 ::Input ::Key { text , keysym , mods } = > {
let code = crate ::x11 ::keysym_name ( keysym )
. and_then ( crate ::computer ::key_code )
. or_else ( | | crate ::x11 ::char_of_keysym ( keysym ) . and_then ( | c | crate ::computer ::key_code ( & c . to_lowercase ( ) . to_string ( ) ) ) )
. map ( | c | c as i64 )
. unwrap_or ( - 1 ) ;
let mut flags = 0 ;
if mods & 1 ! = 0 {
flags | = SHIFT ;
}
if mods & 4 ! = 0 {
flags | = CTRL ;
}
if mods & 8 ! = 0 {
flags | = OPT ;
}
if mods & 64 ! = 0 {
flags | = CMD ;
}
let text = if keysym = = 0xff0d { " \r " . into ( ) } else { text } ;
Raw ::Key { text , code , flags , app : crate ::x11 ::front_app ( ) . map ( | a | a . 0 ) . unwrap_or_default ( ) }
}
}
}
pub fn stop ( ) {
STOP . store ( true , Ordering ::SeqCst ) ;
}
}
#[ cfg(not(any(target_os = " macos " , target_os = " linux " ))) ]
mod mac {
pub fn start ( ) -> Result < ( ) , String > {
Err ( " 這個系統還不能錄製示範 " . into ( ) )
}
pub fn stop ( ) { }
}
#[ cfg(test) ]
mod tests {
use super ::* ;
#[ test ]
fn frames_become_a_video ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " anybot-demo-test- {} " , std ::process ::id ( ) ) ) ;
std ::fs ::create_dir_all ( & dir ) . unwrap ( ) ;
for i in 0 .. 4 u8 {
image ::RgbImage ::from_pixel ( 64 , 40 , image ::Rgb ( [ i * 60 , 100 , 200 ] ) ) . save ( dir . join ( format! ( " frame_ {i:05} .jpg " ) ) ) . unwrap ( ) ;
}
let v = make_video ( & dir ) ;
if std ::process ::Command ::new ( " ffmpeg " ) . env ( " PATH " , crate ::cloudbox ::tool_path ( ) ) . arg ( " -version " ) . output ( ) . is_ok ( ) {
let v = v . expect ( " video made " ) ;
assert! ( std ::fs ::metadata ( & v ) . unwrap ( ) . len ( ) > 0 ) ;
}
let demo = Demo { name : " 報表 " . into ( ) , created : String ::new ( ) , steps : vec ! [ " 點 (1, 2) " . into ( ) ] , video : String ::new ( ) , shots : vec ! [ dir . join ( " frame_00002.jpg " ) . display ( ) . to_string ( ) ] } ;
let ( t , ok , img ) = view_step ( & [ demo . clone ( ) ] , r # "{"demo":"報表","step":1}"# ) ;
assert! ( ok & & img . is_some ( ) & & t . contains ( " 第 1 步 " ) , " {t} " ) ;
assert! ( ! view_step ( & [ demo ] , r # "{"demo":"報表","step":5}"# ) . 1 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ test ]
fn steps_read_like_instructions ( ) {
let t0 = Instant ::now ( ) ;
let ms = | n : u64 | t0 + std ::time ::Duration ::from_millis ( n ) ;
let raw = vec! [
Raw ::Click { x : 100.0 , y : 50.0 , right : false , app : " Safari " . into ( ) , at : ms ( 0 ) } ,
Raw ::Key { text : " h " . into ( ) , code : 4 , flags : 0 , app : " Safari " . into ( ) } ,
Raw ::Key { text : " i " . into ( ) , code : 34 , flags : 0 , app : " Safari " . into ( ) } ,
Raw ::Key { text : " x " . into ( ) , code : 7 , flags : 0 , app : " Safari " . into ( ) } ,
Raw ::Key { text : " " . into ( ) , code : 51 , flags : 0 , app : " Safari " . into ( ) } ,
Raw ::Key { text : " \r " . into ( ) , code : 36 , flags : 0 , app : " Safari " . into ( ) } ,
Raw ::Key { text : " c " . into ( ) , code : 8 , flags : CMD , app : " Safari " . into ( ) } ,
Raw ::Click { x : 300.0 , y : 200.0 , right : false , app : " 備忘錄 " . into ( ) , at : ms ( 2000 ) } ,
Raw ::Click { x : 301.0 , y : 201.0 , right : false , app : " 備忘錄 " . into ( ) , at : ms ( 2200 ) } ,
Raw ::Scroll { dy : - 3 , app : " 備忘錄 " . into ( ) , at : ms ( 3000 ) } ,
Raw ::Scroll { dy : - 2 , app : " 備忘錄 " . into ( ) , at : ms ( 3300 ) } ,
] ;
let s : Vec < String > = compile ( & raw , 0.5 ) . into_iter ( ) . map ( | c | c . 0 ) . collect ( ) ;
assert_eq! (
s ,
vec! [
" 切到「Safari」, 在「Safari」點 (50, 25) " ,
" 在「Safari」輸入「hi」 " ,
" 按 return " ,
" 按 cmd+c " ,
" 切到「備忘錄」,在「備忘錄」連點 2 下 (150, 100) " ,
" 在「備忘錄」往下捲動 " ,
]
) ;
}
}