// 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 { 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 { 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 { 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") { let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation").spawn(); 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}\nfunction 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 { 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 => '
' + (l.replace(/&/g, '&').replace(//g, '>') || '
') + '
').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: '

' + html(a.title).slice(5, -6) + '

' + 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::()); } } }