Skip to content

Commit 5f17583

Browse files
authored
feat: list notifications and act on one through Notification Center's custom actions (#74)
1 parent acae444 commit 5f17583

18 files changed

Lines changed: 1480 additions & 6 deletions

‎dist/build.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const patches = [
5656
"peekaboo-dialog-path.patch",
5757
"peekaboo-drag-path.patch",
5858
"peekaboo-minimized-close-test.patch",
59+
"peekaboo-notification-actions.patch",
5960
].map((name) => join(native, "patches", name));
6061
function spawn(command, env) {
6162
return spawnSync(command[0], command.slice(1), { encoding: "utf8", env: env ?? process.env });

‎dist/client.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { join } from "node:path";
66
import { promisify } from "node:util";
77
import { failureAvailability, windowAvailability } from "./availability.js";
88
import { DESKTOP_BUTTONS, DESKTOP_CLICKS, DESKTOP_DIRECTIONS, DESKTOP_ELEMENT_ACTIONS, DESKTOP_MODIFIERS, DESKTOP_SELECTIONS, isDesktopAction, isDesktopKey, isDesktopManagement, } from "./protocol.js";
9+
import { NOTIFICATIONS_MS, notificationAction, readNotifications, validateNotificationAction, } from "./notifications.js";
910
import { waitFor } from "./wait.js";
1011
const exec = promisify(execFile);
1112
const MAX_TEXT = 32_000;
@@ -575,6 +576,10 @@ function validateAction(request) {
575576
}
576577
return validateKey(request);
577578
}
579+
if (request.op === "notification-action") {
580+
validateNotificationAction(request);
581+
return;
582+
}
578583
if (typeof request.snapshot !== "string" || !request.snapshot || request.snapshot.length > 256) {
579584
throw new Error("Use the snapshot_id from a fresh desktop_inspect result.");
580585
}
@@ -1191,6 +1196,8 @@ async function execute(native, request, abort) {
11911196
"inspect",
11921197
"read",
11931198
"wait",
1199+
"notifications",
1200+
"notification-action",
11941201
"screen",
11951202
"click",
11961203
"type",
@@ -1238,6 +1245,30 @@ async function execute(native, request, abort) {
12381245
}),
12391246
}, request, abort);
12401247
}
1248+
// Notifications compose several reads, each with its own 30-second limit, under one overall limit.
1249+
if (request.op === "notifications" || request.op === "notification-action") {
1250+
const signal = AbortSignal.any([...(abort ? [abort] : []), AbortSignal.timeout(NOTIFICATIONS_MS)]);
1251+
const io = {
1252+
observe: (read, signal) => execute(native, read, signal),
1253+
perform: (input, signal, onDispatch) => native(["action"], signal, { input: JSON.stringify(input), onDispatch }),
1254+
};
1255+
if (request.op === "notifications") {
1256+
if (Object.keys(request).some((key) => key !== "op"))
1257+
throw new Error("Reading notifications takes no arguments.");
1258+
return { text: bounded(await readNotifications(io, signal), "notifications") };
1259+
}
1260+
try {
1261+
const { data, outcome } = await notificationAction(io, request, signal);
1262+
return actionResult(data, outcome);
1263+
}
1264+
catch (error) {
1265+
return actionResult({
1266+
outcome: "refused",
1267+
reason: (error instanceof Error ? error.message : String(error)).slice(0, 2000),
1268+
message: "No input was sent.",
1269+
}, "refused");
1270+
}
1271+
}
12411272
const deadline = new AbortController();
12421273
const timer = setTimeout(() => deadline.abort(new Error("Native desktop operation timed out after 30 seconds.")), 30_000);
12431274
const signal = abort ? AbortSignal.any([abort, deadline.signal]) : deadline.signal;

‎dist/notifications.d.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { DesktopRequest, DesktopResult } from "./protocol.js";
2+
/** Notification Center's process; its banners, alerts and open panel are windows of it. */
3+
export declare const NOTIFICATION_CENTER = "com.apple.notificationcenterui";
4+
/** Under the 60-second request timeout common MCP clients use. */
5+
export declare const NOTIFICATIONS_MS = 50000;
6+
type Json = Record<string, unknown>;
7+
export type NotificationIO = {
8+
/** One ordinary read: apps, windows or inspect. */
9+
observe(request: DesktopRequest, signal: AbortSignal): Promise<DesktopResult>;
10+
/** One native action request; `onDispatch` runs once input may have been sent. */
11+
perform(input: Json, signal: AbortSignal, onDispatch: () => void): Promise<Json>;
12+
};
13+
export type NotificationEntry = {
14+
id: string;
15+
pid: number;
16+
window_id: number;
17+
kind: string;
18+
app: string | null;
19+
title: string | null;
20+
subtitle: string | null;
21+
body: string | null;
22+
time: string | null;
23+
description: string | null;
24+
texts: string[];
25+
stack: boolean;
26+
actions: string[];
27+
};
28+
/** Current banners and alerts, and the entries of an open Notification Center, each with an action ID. */
29+
export declare function readNotifications(io: NotificationIO, signal: AbortSignal): Promise<Json>;
30+
export declare function validateNotificationAction(request: {
31+
id?: unknown;
32+
action?: unknown;
33+
}): {
34+
snapshot: string;
35+
element: string;
36+
action: string;
37+
};
38+
/**
39+
* Opens, closes or runs one listed action of one notification through Accessibility, then reads again: the
40+
* result is completed only when the notification is gone, otherwise unknown.
41+
*/
42+
export declare function notificationAction(io: NotificationIO, request: {
43+
id: string;
44+
action: string;
45+
}, signal: AbortSignal): Promise<{
46+
data: Json;
47+
outcome: "completed" | "refused" | "unknown";
48+
}>;
49+
export {};

0 commit comments

Comments
 (0)