1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
|
// lib/mac.ts — run AppleScript (osascript) for local macOS app integrations
// (Calendar.app, Things3). Server-only. The host process needs Automation
// permission for the target app; macOS prompts once on first use.
import { spawn } from "node:child_process";
// Unit/record separators — safe field/row delimiters that won't appear in user
// text. In AppleScript these are (ASCII character 31) and (ASCII character 30).
export const FS = String.fromCharCode(31);
export const RS = String.fromCharCode(30);
// Run an AppleScript by piping it to osascript's stdin. Passing multi-line
// scripts via a single -e arg is unreliable; stdin always compiles cleanly.
export function runOsa(script: string, timeoutMs = 20_000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn("osascript", [], { stdio: ["pipe", "pipe", "pipe"] });
let out = "";
let errOut = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish(() => reject(new Error("AppleScript timed out")));
}, timeoutMs);
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (errOut += d));
child.on("error", (e) => finish(() => reject(new Error(e.message))));
child.on("close", (code) =>
finish(() => {
if (code === 0) return resolve(out);
const msg = errOut.trim() || `osascript exited ${code}`;
if (/-1743|not authori|not allowed/i.test(msg)) {
reject(new Error("Permission needed — grant Automation access in System Settings."));
} else if (/isn.?t running|can.?t find|-600|-10814|-1728/i.test(msg)) {
reject(new Error("App not running or not installed."));
} else {
reject(new Error(msg));
}
}),
);
child.stdin.write(script);
child.stdin.end();
});
}
// Run an arbitrary local binary, capturing stdout. Used for icalBuddy (reads
// EventKit directly — far faster than Calendar.app AppleScript on big calendars).
// Resolves stdout on exit 0; rejects on non-zero, spawn error, or timeout.
export function runCmd(file: string, args: string[], timeoutMs = 20_000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let errOut = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish(() => reject(new Error(`${file} timed out`)));
}, timeoutMs);
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (errOut += d));
child.on("error", (e) => finish(() => reject(new Error(e.message))));
child.on("close", (code) =>
finish(() => (code === 0 ? resolve(out) : reject(new Error(errOut.trim() || `${file} exited ${code}`)))),
);
});
}
// Parse FS/RS-delimited osascript output into rows of fields.
export function parseRows(out: string): string[][] {
return out
.split(RS)
.map((r) => r.replace(/[\r\n]+$/, ""))
.filter((r) => r.length > 0)
.map((r) => r.split(FS));
}
// Escape a string for safe embedding inside an AppleScript double-quoted literal.
export function osaQuote(s: string): string {
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
|