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
// The Mac's own apps as tools, like Grok Bot's device tools (local-exec-daemon):
// ios_read_reminders / ios_write_reminders, mobile_read_calendar / mobile_write_calendar,
// mobile_read_contacts, mac_read_notes / mac_write_notes, mac_read_mail / mac_draft_mail /
// mac_send_mail / mac_mail_accounts, and sending an iMessage.
// Rules taken from the original:
// - a note or an email is read up to 20,000 characters (zlt=2e4);
// - contacts are fetched 50 at a time (Dlt=50);
// - device calls time out after 30 s (g4r=3e4);
// - writing (reminders, calendar, notes, mail drafts) needs consent; sending mail is only ever
// approved one message at a time (recordLocalToolApproval refuses a standing grant for it);
// - mac_write_notes appends when given id + currentTitle, else creates (needs a title);
// mac_draft_mail needs to (or replyToId) and a body.
// The original runs them through its signed helper; here they go through osascript (JXA), so
// macOS asks once per app under 「系統設定 → 隱私權與安全性 → 自動化」.
use serde_json ::{ json , Value } ;
use std ::time ::{ Duration , Instant } ;
pub const MAX_CHARS : usize = 20_000 ;
pub const CONTACT_BATCH : usize = 50 ;
pub const CALL_TIMEOUT_MS : u64 = 30_000 ;
/// Messages scanned when searching mail (estimated; Mail scripting is slow on big inboxes).
const MAIL_SCAN : usize = 300 ;
pub const NAMES : [ & str ; 11 ] = [
" ios_read_reminders " ,
" ios_write_reminders " ,
" mobile_read_calendar " ,
" mobile_write_calendar " ,
" mobile_read_contacts " ,
" mac_read_notes " ,
" mac_write_notes " ,
" mac_read_mail " ,
" mac_draft_mail " ,
" mac_send_mail " ,
" mac_mail_accounts " ,
] ;
pub const SEND_MESSAGE : & str = " mac_send_message " ;
pub fn is_mac_tool ( name : & str ) -> bool {
NAMES . contains ( & name ) | | name = = SEND_MESSAGE
}
/// The app a tool uses (for the consent wording and the per-app grant).
pub fn app_of ( name : & str ) -> & 'static str {
match name {
" ios_read_reminders " | " ios_write_reminders " = > " 提醒事項 " ,
" mobile_read_calendar " | " mobile_write_calendar " = > " 行事曆 " ,
" mobile_read_contacts " = > " 聯絡人 " ,
" mac_read_notes " | " mac_write_notes " = > " 備忘錄 " ,
SEND_MESSAGE = > " 訊息 " ,
_ = > " 郵件 " ,
}
}
pub fn is_write ( name : & str ) -> bool {
matches! ( name , " ios_write_reminders " | " mobile_write_calendar " | " mac_write_notes " | " mac_draft_mail " )
}
/// Each one is approved on its own, never "always".
pub fn is_send ( name : & str ) -> bool {
name = = " mac_send_mail " | | name = = SEND_MESSAGE
}
pub fn label ( name : & str ) -> & 'static str {
match name {
" ios_read_reminders " = > " 查看提醒事項 " ,
" ios_write_reminders " = > " 修改提醒事項 " ,
" mobile_read_calendar " = > " 查看行事曆 " ,
" mobile_write_calendar " = > " 修改行事曆 " ,
" mobile_read_contacts " = > " 查聯絡人 " ,
" mac_read_notes " = > " 讀備忘錄 " ,
" mac_write_notes " = > " 寫備忘錄 " ,
" mac_read_mail " = > " 讀郵件 " ,
" mac_draft_mail " = > " 起草郵件 " ,
" mac_send_mail " = > " 寄出郵件 " ,
" mac_mail_accounts " = > " 查看郵件帳號 " ,
SEND_MESSAGE = > " 傳訊息 " ,
_ = > " Mac App " ,
}
}
fn s ( v : & Value , k : & str ) -> String {
v . get ( k ) . and_then ( Value ::as_str ) . unwrap_or ( " " ) . trim ( ) . to_string ( )
}
fn list ( v : & Value , k : & str ) -> Vec < String > {
match v . get ( k ) {
Some ( Value ::Array ( a ) ) = > a . iter ( ) . filter_map ( | x | x . as_str ( ) . map ( | s | s . trim ( ) . to_string ( ) ) ) . filter ( | s | ! s . is_empty ( ) ) . collect ( ) ,
Some ( Value ::String ( x ) ) = > x . split ( [ ',' , ';' ] ) . map ( | s | s . trim ( ) . to_string ( ) ) . filter ( | s | ! s . is_empty ( ) ) . collect ( ) ,
_ = > vec! [ ] ,
}
}
pub fn describe ( name : & str , args : & str ) -> String {
let v : Value = serde_json ::from_str ( args ) . unwrap_or ( Value ::Null ) ;
match name {
" ios_write_reminders " = > format! ( " {} 「 {} 」 " , match s ( & v , " action " ) . as_str ( ) { " complete " = > " 完成 " , " delete " = > " 刪除 " , " update " = > " 修改 " , _ = > " 新增 " } , s ( & v , " title " ) ) ,
" mobile_write_calendar " = > format! ( " {} 「 {} 」 {} " , match s ( & v , " action " ) . as_str ( ) { " delete " = > " 刪除 " , " update " = > " 修改 " , _ = > " 新增 " } , s ( & v , " title " ) , s ( & v , " start " ) ) ,
" mobile_read_calendar " = > format! ( " {} ~ {} " , s ( & v , " from " ) , s ( & v , " to " ) ) ,
" mobile_read_contacts " | " mac_read_notes " | " mac_read_mail " = > s ( & v , " query " ) ,
" mac_write_notes " = > if s ( & v , " id " ) . is_empty ( ) { format! ( " 新備忘錄「 {} 」 " , s ( & v , " title " ) ) } else { format! ( " 加到「 {} 」 " , s ( & v , " currentTitle " ) ) } ,
" mac_draft_mail " | " mac_send_mail " = > format! ( " 給 {} : {} " , list ( & v , " to " ) . join ( " 、 " ) , s ( & v , " subject " ) ) ,
SEND_MESSAGE = > {
let t : String = s ( & v , " text " ) . chars ( ) . take ( 40 ) . collect ( ) ;
format! ( " 給 {} : {t} " , s ( & v , " to " ) )
}
_ = > String ::new ( ) ,
}
}
pub fn specs ( ) -> Vec < Value > {
let strs = json! ( { " type " : " array " , " items " : { " type " : " string " } } ) ;
vec! [
json! ( { " name " : " ios_read_reminders " , " description " : " Read the person's Reminders on this Mac. Optional list name; completed ones only when asked. " ,
" parameters " : { " type " : " object " , " properties " : { " list " : { " type " : " string " } , " includeCompleted " : { " type " : " boolean " } , " query " : { " type " : " string " } , " limit " : { " type " : " integer " } } } } ) ,
json! ( { " name " : " ios_write_reminders " , " description " : " Add, update, complete or delete a reminder in the person's Reminders. due: ISO date-time in the person's time zone. " ,
" parameters " : { " type " : " object " , " properties " : { " action " : { " type " : " string " , " enum " : [ " add " , " update " , " complete " , " delete " ] } , " id " : { " type " : " string " } , " title " : { " type " : " string " } , " notes " : { " type " : " string " } , " due " : { " type " : " string " } , " list " : { " type " : " string " } } , " required " : [ " action " ] } } ) ,
json! ( { " name " : " mobile_read_calendar " , " description " : " Read events from the person's Calendar between from and to (ISO dates). " ,
" parameters " : { " type " : " object " , " properties " : { " from " : { " type " : " string " } , " to " : { " type " : " string " } , " calendar " : { " type " : " string " } } , " required " : [ " from " , " to " ] } } ) ,
json! ( { " name " : " mobile_write_calendar " , " description " : " Add, update or delete an event in the person's Calendar. start/end: ISO date-times. " ,
" parameters " : { " type " : " object " , " properties " : { " action " : { " type " : " string " , " enum " : [ " add " , " update " , " delete " ] } , " id " : { " type " : " string " } , " title " : { " type " : " string " } , " start " : { " type " : " string " } , " end " : { " type " : " string " } , " location " : { " type " : " string " } , " notes " : { " type " : " string " } , " calendar " : { " type " : " string " } , " allDay " : { " type " : " boolean " } } , " required " : [ " action " ] } } ) ,
json! ( { " name " : " mobile_read_contacts " , " description " : " Find people in the person's Contacts by name, email or phone. " ,
" parameters " : { " type " : " object " , " properties " : { " query " : { " type " : " string " } , " limit " : { " type " : " integer " } } , " required " : [ " query " ] } } ) ,
json! ( { " name " : " mac_read_notes " , " description " : " Search the person's Notes (query, folder), or read one note by id (up to 20,000 characters). " ,
" parameters " : { " type " : " object " , " properties " : { " id " : { " type " : " string " } , " query " : { " type " : " string " } , " folder " : { " type " : " string " } , " limit " : { " type " : " integer " } } } } ) ,
json! ( { " name " : " mac_write_notes " , " description " : " Create a note (title + body, optional folder), or append to one (id + its currentTitle + body). " ,
" parameters " : { " type " : " object " , " properties " : { " id " : { " type " : " string " } , " currentTitle " : { " type " : " string " } , " title " : { " type " : " string " } , " body " : { " type " : " string " } , " folder " : { " type " : " string " } } , " required " : [ " body " ] } } ) ,
json! ( { " name " : " mac_read_mail " , " description " : " Search the person's Mail (query, mailbox, unreadOnly, days, limit), or read one message by id (up to 20,000 characters). " ,
" parameters " : { " type " : " object " , " properties " : { " id " : { " type " : " string " } , " query " : { " type " : " string " } , " mailbox " : { " type " : " string " } , " unreadOnly " : { " type " : " boolean " } , " days " : { " type " : " integer " } , " limit " : { " type " : " integer " } } } } ) ,
json! ( { " name " : " mac_draft_mail " , " description " : " Open a draft in Mail for the person to review (to + subject + body, or replyToId + body). It is not sent. " ,
" parameters " : { " type " : " object " , " properties " : { " to " : strs , " cc " : strs , " subject " : { " type " : " string " } , " body " : { " type " : " string " } , " replyToId " : { " type " : " string " } } , " required " : [ " body " ] } } ) ,
json! ( { " name " : " mac_send_mail " , " description " : " Send an email from the person's Mail. The person approves every message. " ,
" parameters " : { " type " : " object " , " properties " : { " to " : strs , " cc " : strs , " subject " : { " type " : " string " } , " body " : { " type " : " string " } , " account " : { " type " : " string " } } , " required " : [ " to " , " subject " , " body " ] } } ) ,
json! ( { " name " : " mac_mail_accounts " , " description " : " List the person's Mail accounts and their addresses. " ,
" parameters " : { " type " : " object " , " properties " : { } } } ) ,
json! ( { " name " : SEND_MESSAGE , " description " : " Send an iMessage/SMS from the person's Messages app to a phone number or email. The person approves every message. " ,
" parameters " : { " type " : " object " , " properties " : { " to " : { " type " : " string " } , " text " : { " type " : " string " } } , " required " : [ " to " , " text " ] } } ) ,
]
}
/// Run a JXA script with the arguments as JSON (argv[0]). Returns its printed result.
fn jxa ( script : & str , args : & Value ) -> Result < String , String > {
use std ::io ::Read ;
let mut child = std ::process ::Command ::new ( " osascript " )
. args ( [ " -l " , " JavaScript " , " -e " , script , " -- " , & args . to_string ( ) ] )
. stdin ( std ::process ::Stdio ::null ( ) )
. stdout ( std ::process ::Stdio ::piped ( ) )
. stderr ( std ::process ::Stdio ::piped ( ) )
. spawn ( )
. map_err ( | e | format! ( " 無法執行 osascript: {e} " ) ) ? ;
let mut so = child . stdout . take ( ) . unwrap ( ) ;
let mut se = child . stderr . take ( ) . unwrap ( ) ;
let ot = std ::thread ::spawn ( move | | {
let mut v = String ::new ( ) ;
let _ = so . read_to_string ( & mut v ) ;
v
} ) ;
let et = std ::thread ::spawn ( move | | {
let mut v = String ::new ( ) ;
let _ = se . read_to_string ( & mut v ) ;
v
} ) ;
let start = Instant ::now ( ) ;
let st = loop {
if let Ok ( Some ( st ) ) = child . try_wait ( ) {
break st ;
}
if start . elapsed ( ) > Duration ::from_millis ( CALL_TIMEOUT_MS ) {
let _ = child . kill ( ) ;
return Err ( " Mac 的 App 沒有在 30 秒內回應。 " . into ( ) ) ;
}
std ::thread ::sleep ( Duration ::from_millis ( 20 ) ) ;
} ;
let out = ot . join ( ) . unwrap_or_default ( ) ;
let err = et . join ( ) . unwrap_or_default ( ) ;
if st . success ( ) {
Ok ( out . trim ( ) . to_string ( ) )
} else {
Err ( err . trim ( ) . to_string ( ) )
}
}
/// The original's wording when macOS says no (a4e), pointed at AnyBot.
fn permission_hint ( app : & str , err : & str ) -> String {
let e = err . to_lowercase ( ) ;
if e . contains ( " -1743 " ) | | e . contains ( " not allowed " ) | | e . contains ( " 不允許 " ) | | e . contains ( " not authorized " ) {
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_Automation " ) ) ;
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
return format! ( " AnyBot 還不能控制這台 Mac 的「 {app} 」。我已經打開「系統設定 → 隱私權與安全性 → 自動化」:在 AnyBot( 或「終端機」) 底下打開「 {app} 」,再叫我試一次。 " ) ;
}
format! ( " 「 {app} 」回報錯誤: {} " , err . lines ( ) . last ( ) . unwrap_or ( err ) )
}
const JS_HELPERS : & str = r #"
function clip ( t , n ) { t = String ( t | | ' ' ) ; return t . length > n ? t . slice ( 0 , n ) + ' \ n … ( 後 面 省 略 ' + ( t . length - n ) + ' 個 字 ) ' : t ; }
function iso ( d ) { try { return d ? d . toISOString ( ) : null ; } catch ( e ) { return null ; } }
function has ( hay , q ) { return ! q | | String ( hay | | ' ' ) . toLowerCase ( ) . indexOf ( q . toLowerCase ( ) ) > = 0 ; }
" #;
fn script ( body : & str ) -> String {
format! ( " {JS_HELPERS} \n function run(argv) {{ const a = JSON.parse(argv[0] || ' {{ }} '); {body} }} " )
}
pub fn run ( name : & str , args : & str ) -> ( String , bool ) {
if ! cfg! ( target_os = " macos " ) {
return ( " 這個工具只支援 macOS " . into ( ) , false ) ;
}
let v : Value = serde_json ::from_str ( if args . trim ( ) . is_empty ( ) { " {} " } else { args } ) . unwrap_or ( json! ( { } ) ) ;
let r = run_inner ( name , & v ) ;
match r {
Ok ( s ) = > ( s , true ) ,
Err ( e ) = > ( e , false ) ,
}
}
fn run_inner ( name : & str , v : & Value ) -> Result < String , String > {
let app = app_of ( name ) ;
let call = | body : & str , a : & Value | jxa ( & script ( body ) , a ) . map_err ( | e | permission_hint ( app , & e ) ) ;
match name {
" ios_read_reminders " = > {
let a = json! ( { " list " : s ( v , " list " ) , " done " : v [ " includeCompleted " ] . as_bool ( ) . unwrap_or ( false ) , " q " : s ( v , " query " ) , " limit " : v [ " limit " ] . as_u64 ( ) . unwrap_or ( 50 ) . min ( 200 ) } ) ;
call ( r #" const R = Application('Reminders'); const out = [];
const lists = a . list ? R . lists . whose ( { name : a . list } ) ( ) : R . lists ( ) ;
for ( const l of lists ) { const rs = a . done ? l . reminders ( ) : l . reminders . whose ( { completed : false } ) ( ) ;
for ( const r of rs ) { if ( out . length > = a . limit ) break ; const n = r . name ( ) ; if ( ! has ( n + ' ' + ( r . body ( ) | | ' ' ) , a . q ) ) continue ;
out . push ( { id : r . id ( ) , title : n , list : l . name ( ) , due : iso ( r . dueDate ( ) ) , completed : r . completed ( ) , notes : clip ( r . body ( ) , 500 ) } ) ; } }
return JSON . stringify ( out ) ; " #, &a)
. map ( | o | format! ( " 提醒事項: {o} " ) )
}
" ios_write_reminders " = > {
let action = s ( v , " action " ) ;
if action . is_empty ( ) {
return Err ( " 請給 action( add、update、complete、delete) " . into ( ) ) ;
}
if action ! = " add " & & s ( v , " id " ) . is_empty ( ) {
return Err ( " 修改、完成或刪除要給 id( 先用 ios_read_reminders 查) " . into ( ) ) ;
}
if action = = " add " & & s ( v , " title " ) . is_empty ( ) {
return Err ( " 新的提醒事項要有 title " . into ( ) ) ;
}
let a = json! ( { " action " : action , " id " : s ( v , " id " ) , " title " : s ( v , " title " ) , " notes " : s ( v , " notes " ) , " due " : s ( v , " due " ) , " list " : s ( v , " list " ) } ) ;
call ( r #" const R = Application('Reminders');
if ( a . action = = = ' add ' ) { const l = a . list ? R . lists . byName ( a . list ) : R . defaultList ( ) ;
const props = { name : a . title } ; if ( a . notes ) props . body = a . notes ; if ( a . due ) props . dueDate = new Date ( a . due ) ;
const r = R . Reminder ( props ) ; l . reminders . push ( r ) ; return JSON . stringify ( { action : ' added ' , id : r . id ( ) , title : a . title } ) ; }
const r = R . reminders . byId ( a . id ) ; const t = r . name ( ) ;
if ( a . action = = = ' complete ' ) { r . completed = true ; return JSON . stringify ( { action : ' completed ' , title : t } ) ; }
if ( a . action = = = ' delete ' ) { R . delete ( r ) ; return JSON . stringify ( { action : ' deleted ' , title : t } ) ; }
if ( a . title ) r . name = a . title ; if ( a . notes ) r . body = a . notes ; if ( a . due ) r . dueDate = new Date ( a . due ) ;
return JSON . stringify ( { action : ' updated ' , title : r . name ( ) } ) ; " #, &a)
}
" mobile_read_calendar " = > {
if s ( v , " from " ) . is_empty ( ) | | s ( v , " to " ) . is_empty ( ) {
return Err ( " 請給 from 和 to( 例如 2026-11-04 和 2026-11-11) " . into ( ) ) ;
}
let a = json! ( { " from " : s ( v , " from " ) , " to " : s ( v , " to " ) , " cal " : s ( v , " calendar " ) } ) ;
call ( r #" const C = Application('Calendar'); const from = new Date(a.from), to = new Date(a.to); const out = [];
const cals = a . cal ? C . calendars . whose ( { name : a . cal } ) ( ) : C . calendars ( ) ;
for ( const c of cals ) { const evs = c . events . whose ( { _and : [ { startDate : { _greaterThan : from } } , { startDate : { _lessThan : to } } ] } ) ( ) ;
for ( const e of evs ) out . push ( { id : e . uid ( ) , calendar : c . name ( ) , title : e . summary ( ) , start : iso ( e . startDate ( ) ) , end : iso ( e . endDate ( ) ) , allDay : e . alldayEvent ( ) , location : e . location ( ) | | ' ' } ) ; }
out . sort ( ( x , y ) = > x . start < y . start ? - 1 : 1 ) ; return JSON . stringify ( out . slice ( 0 , 200 ) ) ; " #, &a)
. map ( | o | format! ( " 行事曆: {o} " ) )
}
" mobile_write_calendar " = > {
let action = s ( v , " action " ) ;
if action ! = " add " & & s ( v , " id " ) . is_empty ( ) {
return Err ( " 修改或刪除要給 id( 先用 mobile_read_calendar 查) " . into ( ) ) ;
}
if action = = " add " & & ( s ( v , " title " ) . is_empty ( ) | | s ( v , " start " ) . is_empty ( ) ) {
return Err ( " 新的行程要有 title 和 start " . into ( ) ) ;
}
let a = json! ( { " action " : action , " id " : s ( v , " id " ) , " title " : s ( v , " title " ) , " start " : s ( v , " start " ) , " end " : s ( v , " end " ) , " loc " : s ( v , " location " ) , " notes " : s ( v , " notes " ) , " cal " : s ( v , " calendar " ) , " allDay " : v [ " allDay " ] . as_bool ( ) . unwrap_or ( false ) } ) ;
call ( r #" const C = Application('Calendar');
function find ( id ) { for ( const c of C . calendars ( ) ) { const m = c . events . whose ( { uid : id } ) ( ) ; if ( m . length ) return m [ 0 ] ; } return null ; }
if ( a . action = = = ' add ' ) { const cal = a . cal ? C . calendars . byName ( a . cal ) : C . calendars . whose ( { writable : true } ) ( ) [ 0 ] ;
const s = new Date ( a . start ) ; const e = a . end ? new Date ( a . end ) : new Date ( s . getTime ( ) + 3600000 ) ;
const props = { summary : a . title , startDate : s , endDate : e , alldayEvent : a . allDay } ; if ( a . loc ) props . location = a . loc ; if ( a . notes ) props . description = a . notes ;
const ev = C . Event ( props ) ; cal . events . push ( ev ) ; return JSON . stringify ( { action : ' added ' , id : ev . uid ( ) , calendar : cal . name ( ) } ) ; }
const ev = find ( a . id ) ; if ( ! ev ) throw new Error ( ' 那 個 行 程 已 經 不 在 這 台 Mac 上 了 ' ) ; const t = ev . summary ( ) ;
if ( a . action = = = ' delete ' ) { C . delete ( ev ) ; return JSON . stringify ( { action : ' deleted ' , title : t } ) ; }
if ( a . title ) ev . summary = a . title ; if ( a . start ) ev . startDate = new Date ( a . start ) ; if ( a . end ) ev . endDate = new Date ( a . end ) ; if ( a . loc ) ev . location = a . loc ; if ( a . notes ) ev . description = a . notes ;
return JSON . stringify ( { action : ' updated ' , title : ev . summary ( ) } ) ; " #, &a)
}
" mobile_read_contacts " = > {
let q = s ( v , " query " ) ;
if q . is_empty ( ) {
return Err ( " 請給要找的名字、電子郵件或電話 " . into ( ) ) ;
}
let a = json! ( { " q " : q , " limit " : v [ " limit " ] . as_u64 ( ) . unwrap_or ( 10 ) . min ( CONTACT_BATCH as u64 ) } ) ;
call ( r #" const P = Application('Contacts'); const out = [];
let ps = P . people . whose ( { name : { _contains : a . q } } ) ( ) ;
if ( ! ps . length ) ps = P . people ( ) . filter ( p = > p . emails ( ) . some ( e = > has ( e . value ( ) , a . q ) ) | | p . phones ( ) . some ( ph = > has ( String ( ph . value ( ) ) . replace ( / \ D / g , ' ' ) , a . q . replace ( / \ D / g , ' ' ) ) & & a . q . replace ( / \ D / g , ' ' ) . length > = 4 ) ) ;
for ( const p of ps . slice ( 0 , a . limit ) ) out . push ( { id : p . id ( ) , name : p . name ( ) , organization : p . organization ( ) | | ' ' , emails : p . emails ( ) . map ( e = > e . value ( ) ) , phones : p . phones ( ) . map ( x = > x . value ( ) ) } ) ;
return JSON . stringify ( out ) ; " #, &a)
. map ( | o | format! ( " 聯絡人: {o} " ) )
}
" mac_read_notes " = > {
let a = json! ( { " id " : s ( v , " id " ) , " q " : s ( v , " query " ) , " folder " : s ( v , " folder " ) , " limit " : v [ " limit " ] . as_u64 ( ) . unwrap_or ( 20 ) . min ( 100 ) , " max " : MAX_CHARS } ) ;
call ( r #" const N = Application('Notes');
if ( a . id ) { const n = N . notes . byId ( a . id ) ; return JSON . stringify ( { id : n . id ( ) , title : n . name ( ) , modified : iso ( n . modificationDate ( ) ) , body : clip ( n . plaintext ( ) , a . max ) } ) ; }
const src = a . folder ? N . folders . byName ( a . folder ) . notes : N . notes ;
const ns = a . q ? src . whose ( { _or : [ { name : { _contains : a . q } } , { plaintext : { _contains : a . q } } ] } ) ( ) : src ( ) ;
return JSON . stringify ( ns . slice ( 0 , a . limit ) . map ( n = > ( { id : n . id ( ) , title : n . name ( ) , modified : iso ( n . modificationDate ( ) ) , preview : clip ( n . plaintext ( ) , 200 ) } ) ) ) ; " #, &a)
}
" mac_write_notes " = > {
let body = s ( v , " body " ) ;
if body . is_empty ( ) {
return Err ( " mac_write_notes: body is required " . into ( ) ) ;
}
let id = s ( v , " id " ) ;
if ! id . is_empty ( ) & & v . get ( " currentTitle " ) . and_then ( Value ::as_str ) . is_none ( ) {
return Err ( " mac_write_notes: currentTitle is required with id " . into ( ) ) ;
}
if id . is_empty ( ) & & s ( v , " title " ) . is_empty ( ) {
return Err ( " mac_write_notes: a new note needs a title " . into ( ) ) ;
}
let a = json! ( { " id " : id , " cur " : s ( v , " currentTitle " ) , " title " : s ( v , " title " ) , " body " : body , " folder " : s ( v , " folder " ) } ) ;
call ( r #" const N = Application('Notes');
function html ( t ) { return t . split ( '\n' ) . map ( l = > ' < div > ' + ( l . replace ( / & / g , ' & amp ; ' ) . replace ( / < / g , ' & lt ; ' ) . replace ( / > / g , ' & gt ; ' ) | | ' < br > ' ) + ' < / div > ' ) . join ( ' ' ) ; }
if ( a . id ) { const n = N . notes . byId ( a . id ) ; if ( n . name ( ) ! = = a . cur ) throw new Error ( ' 那 則 備 忘 錄 的 標 題 已 經 變 了 ( 現 在 是 「 ' + n . name ( ) + ' 」 ) , 請 重 新 讀 一 次 ' ) ;
n . body = n . body ( ) + html ( a . body ) ; return JSON . stringify ( { action : ' appended ' , id : n . id ( ) , title : n . name ( ) } ) ; }
const target = a . folder ? N . folders . byName ( a . folder ) : N . defaultAccount ( ) . defaultFolder ( ) ;
const n = N . Note ( { body : ' < h1 > ' + html ( a . title ) . slice ( 5 , - 6 ) + ' < / h1 > ' + html ( a . body ) } ) ; target . notes . push ( n ) ;
return JSON . stringify ( { action : ' created ' , id : n . id ( ) , title : a . title } ) ; " #, &a)
}
" mac_read_mail " = > {
let a = json! ( { " id " : s ( v , " id " ) , " q " : s ( v , " query " ) , " mailbox " : s ( v , " mailbox " ) , " unread " : v [ " unreadOnly " ] . as_bool ( ) . unwrap_or ( false ) , " days " : v [ " days " ] . as_u64 ( ) . unwrap_or ( 0 ) , " limit " : v [ " limit " ] . as_u64 ( ) . unwrap_or ( 20 ) . min ( 100 ) , " scan " : MAIL_SCAN , " max " : MAX_CHARS } ) ;
call ( r #" const M = Application('Mail');
if ( a . id ) { for ( const acc of M . accounts ( ) ) for ( const mb of acc . mailboxes ( ) ) { const m = mb . messages . whose ( { id : Number ( a . id ) } ) ( ) ; if ( m . length ) { const x = m [ 0 ] ;
return JSON . stringify ( { id : String ( x . id ( ) ) , subject : x . subject ( ) , from : x . sender ( ) , to : x . toRecipients ( ) . map ( r = > r . address ( ) ) , date : iso ( x . dateReceived ( ) ) , body : clip ( x . content ( ) , a . max ) } ) ; } }
const m = M . inbox . messages . whose ( { id : Number ( a . id ) } ) ( ) ; if ( ! m . length ) throw new Error ( ' 那 封 郵 件 已 經 不 在 這 台 Mac 上 了 ' ) ; }
let box = M . inbox ; if ( a . mailbox ) { for ( const acc of M . accounts ( ) ) { const f = acc . mailboxes . whose ( { name : a . mailbox } ) ( ) ; if ( f . length ) { box = f [ 0 ] ; break ; } } }
const since = a . days ? new Date ( Date . now ( ) - a . days * 86400000 ) : null ;
const msgs = box . messages ( ) ; const out = [ ] ;
for ( let i = 0 ; i < Math . min ( msgs . length , a . scan ) & & out . length < a . limit ; i + + ) { const x = msgs [ i ] ;
if ( a . unread & & x . readStatus ( ) ) continue ; const d = x . dateReceived ( ) ; if ( since & & d < since ) continue ;
const subj = x . subject ( ) , from = x . sender ( ) ; if ( a . q & & ! has ( subj + ' ' + from , a . q ) ) continue ;
out . push ( { id : String ( x . id ( ) ) , subject : subj , from : from , date : iso ( d ) , read : x . readStatus ( ) } ) ; }
return JSON . stringify ( out ) ; " #, &a)
. map ( | o | format! ( " 郵件: {o} " ) )
}
" mac_draft_mail " = > {
let body = s ( v , " body " ) ;
if body . is_empty ( ) {
return Err ( " mac_draft_mail: body is required " . into ( ) ) ;
}
let to = list ( v , " to " ) ;
if to . is_empty ( ) & & s ( v , " replyToId " ) . is_empty ( ) {
return Err ( " mac_draft_mail: a new email needs to " . into ( ) ) ;
}
let a = json! ( { " to " : to , " cc " : list ( v , " cc " ) , " subject " : s ( v , " subject " ) , " body " : body , " reply " : s ( v , " replyToId " ) } ) ;
call ( r #" const M = Application('Mail'); M.activate(); let m;
if ( a . reply ) { let orig = null ; for ( const acc of M . accounts ( ) ) { for ( const mb of acc . mailboxes ( ) ) { const f = mb . messages . whose ( { id : Number ( a . reply ) } ) ( ) ; if ( f . length ) { orig = f [ 0 ] ; break ; } } if ( orig ) break ; }
if ( ! orig ) throw new Error ( ' 找 不 到 要 回 覆 的 郵 件 ' ) ; m = M . reply ( orig , { openingWindow : true } ) ; m . content = a . body + ' \ n \ n ' + m . content ( ) ; }
else { m = M . OutgoingMessage ( { subject : a . subject , content : a . body , visible : true } ) ; M . outgoingMessages . push ( m ) ;
for ( const t of a . to ) m . toRecipients . push ( M . Recipient ( { address : t } ) ) ; for ( const c of a . cc ) m . ccRecipients . push ( M . CcRecipient ( { address : c } ) ) ; }
return JSON . stringify ( { action : ' drafted ' , subject : m . subject ( ) , note : ' 草 稿 已 在 「 郵 件 」 打 開 , 使 用 者 檢 查 後 可 以 自 己 寄 出 。 ' } ) ; " #, &a)
}
" mac_send_mail " = > {
let to = list ( v , " to " ) ;
if to . is_empty ( ) | | s ( v , " body " ) . is_empty ( ) {
return Err ( " 寄信要有 to 和 body " . into ( ) ) ;
}
let a = json! ( { " to " : to , " cc " : list ( v , " cc " ) , " subject " : s ( v , " subject " ) , " body " : s ( v , " body " ) , " account " : s ( v , " account " ) } ) ;
call ( r #" const M = Application('Mail');
const m = M . OutgoingMessage ( { subject : a . subject , content : a . body , visible : false } ) ; M . outgoingMessages . push ( m ) ;
if ( a . account ) m . sender = a . account ;
for ( const t of a . to ) m . toRecipients . push ( M . Recipient ( { address : t } ) ) ; for ( const c of a . cc ) m . ccRecipients . push ( M . CcRecipient ( { address : c } ) ) ;
m . send ( ) ; return JSON . stringify ( { action : ' sent ' , to : a . to , subject : a . subject } ) ; " #, &a)
}
" mac_mail_accounts " = > call ( r # "const M = Application('Mail'); return JSON.stringify(M.accounts().map(x => ({name: x.name(), addresses: x.emailAddresses(), enabled: x.enabled()})));"# , & json! ( { } ) ) ,
SEND_MESSAGE = > {
let ( to , text ) = ( s ( v , " to " ) , s ( v , " text " ) ) ;
if to . is_empty ( ) | | text . is_empty ( ) {
return Err ( " 要給 to( 電話或電子郵件) 和 text " . into ( ) ) ;
}
let a = json! ( { " to " : to , " text " : text } ) ;
call ( r #" const Msg = Application('Messages');
let svc = Msg . services . whose ( { serviceType : ' iMessage ' } ) ( ) ; if ( ! svc . length ) svc = Msg . services ( ) ;
const buddy = svc [ 0 ] . buddies . byName ( a . to ) ;
Msg . send ( a . text , { to : buddy } ) ; return JSON . stringify ( { action : ' sent ' , to : a . to } ) ; " #, &a)
}
other = > Err ( format! ( " This Mac has no \" {other} \" tool. " ) ) ,
}
}
#[ cfg(test) ]
mod tests {
use super ::* ;
#[ test ]
fn writes_and_sends_are_told_apart ( ) {
assert! ( is_write ( " mac_write_notes " ) & & is_write ( " mac_draft_mail " ) & & is_write ( " ios_write_reminders " ) ) ;
assert! ( ! is_write ( " mac_read_mail " ) ) ;
assert! ( is_send ( " mac_send_mail " ) & & is_send ( SEND_MESSAGE ) ) ;
assert_eq! ( app_of ( " mobile_read_contacts " ) , " 聯絡人 " ) ;
assert_eq! ( specs ( ) . len ( ) , NAMES . len ( ) + 1 ) ;
}
#[ test ]
fn input_rules_match_the_original ( ) {
assert! ( run_inner ( " mac_write_notes " , & json! ( { " body " : " x " } ) ) . unwrap_err ( ) . contains ( " needs a title " ) ) ;
assert! ( run_inner ( " mac_write_notes " , & json! ( { " id " : " x " , " body " : " x " } ) ) . unwrap_err ( ) . contains ( " currentTitle " ) ) ;
assert! ( run_inner ( " mac_draft_mail " , & json! ( { " body " : " x " } ) ) . unwrap_err ( ) . contains ( " needs to " ) ) ;
assert! ( run_inner ( " mac_draft_mail " , & json! ( { " to " : [ " a@b.c " ] } ) ) . unwrap_err ( ) . contains ( " body is required " ) ) ;
}
/// `cargo test --release -- --ignored mac_apps_read --nocapture` (macOS asks for Automation once).
#[ test ]
#[ ignore ]
fn mac_apps_read ( ) {
for ( n , a ) in [ ( " ios_read_reminders " , " {} " ) , ( " mobile_read_calendar " , r # "{"from":"2026-11-01","to":"2026-11-30"}"# ) , ( " mac_read_notes " , r # "{"limit":3}"# ) , ( " mac_mail_accounts " , " {} " ) ] {
let ( o , ok ) = run ( n , a ) ;
println! ( " {n} : ok= {ok} {} " , o . chars ( ) . take ( 300 ) . collect ::< String > ( ) ) ;
}
}
}