-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathjxa.ts
More file actions
121 lines (116 loc) · 3.66 KB
/
Copy pathjxa.ts
File metadata and controls
121 lines (116 loc) · 3.66 KB
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
const DEFAULT_TIMEOUT_MS = 60_000;
const LOCK_PATH = "/tmp/nanomail.lock";
const LOCK_STALE_MS = 90_000;
const LOCK_POLL_MS = 2_000;
const LOCK_MAX_WAIT_MS = 60_000;
/** Acquire a lock file so only one osascript process talks to Mail.app at a time. */
async function acquireLock(): Promise<void> {
const deadline = Date.now() + LOCK_MAX_WAIT_MS;
while (Date.now() < deadline) {
try {
// Atomic create — fails if file already exists (O_CREAT|O_EXCL)
const file = await Deno.open(LOCK_PATH, {
write: true,
createNew: true,
});
await file.write(new TextEncoder().encode(String(Deno.pid)));
file.close();
return;
} catch (e) {
if (!(e instanceof Deno.errors.AlreadyExists)) throw e;
}
// Lock exists — check if stale
try {
const stat = await Deno.stat(LOCK_PATH);
if (stat.mtime && Date.now() - stat.mtime.getTime() > LOCK_STALE_MS) {
try {
await Deno.remove(LOCK_PATH);
} catch { /* another process removed it */ }
continue;
}
} catch {
// Lock was just released — retry acquire
continue;
}
await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
}
// Deadline exceeded — proceed anyway rather than hanging forever
await Deno.writeTextFile(LOCK_PATH, String(Deno.pid));
}
/** Release the lock file. */
async function releaseLock(): Promise<void> {
try {
await Deno.remove(LOCK_PATH);
} catch { /* already removed */ }
}
/** Run a JXA script via osascript and return parsed JSON. */
export async function runJxa(
script: string,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<unknown> {
await acquireLock();
try {
const cmd = new Deno.Command("osascript", {
args: ["-l", "JavaScript", "-e", script],
stdout: "piped",
stderr: "piped",
});
const proc = cmd.spawn();
const timer = setTimeout(() => {
try {
proc.kill();
} catch { /* already exited */ }
}, timeoutMs);
const { code, stdout, stderr } = await proc.output();
clearTimeout(timer);
const out = new TextDecoder().decode(stdout).trim();
const err = new TextDecoder().decode(stderr).trim();
if (code !== 0) {
if (code === null || code === 137 || code === 143) {
throw new Error(
`Mail.app timed out after ${timeoutMs / 1000}s. The mailbox may be too large for this query.`,
);
}
throw new Error(err || out || `osascript exited with code ${code}`);
}
if (!out) return null;
try {
return JSON.parse(out);
} catch {
throw new Error(`Failed to parse JXA output: ${out}`);
}
} finally {
await releaseLock();
}
}
/** Escape a value for safe embedding in a JXA script string literal. */
export function jxaStr(s: string): string {
return JSON.stringify(s);
}
/**
* JXA preamble: helper functions that avoid .whose() entirely.
* Uses bulk property access + indexOf for O(1) Apple Events per lookup.
*/
export function jxaPreamble(): string {
return `
var Mail = Application("Mail");
function _findAcct(name) {
var names = Mail.accounts.name();
var idx = names.indexOf(name);
if (idx === -1) throw new Error("Account not found: " + name);
return Mail.accounts[idx];
}
function _findMbox(acct, name) {
var names = acct.mailboxes.name();
var idx = names.indexOf(name);
if (idx === -1) throw new Error("Mailbox not found: " + name);
return acct.mailboxes[idx];
}
function _findMsg(mbox, id) {
var ids = mbox.messages.id();
var idx = ids.indexOf(id);
if (idx === -1) throw new Error("Message not found: " + id);
return mbox.messages[idx];
}
`;
}