▍ humdrum codex / soft
3.5 KB raw
 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, '\\"')}"`;
}