AnyBot/src/macapps.rs

418 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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") {
let _ = crate::computer::spawn_reaped(std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"));
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<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>());
}
}
}