@haseebeqx/pi-safari 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +5 -1
- package/src/index.ts +13 -0
- package/src/safari.ts +76 -46
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Use the exact **`targetId` and `epoch`** returned by `safari_tabs` or `safari_op
|
|
|
69
69
|
| `safari_tabs` | No target or inputs | Lists up to 100 automation tabs, with handles and available URL/title; no page bodies. |
|
|
70
70
|
| `safari_open` | No target; `url` | Opens a **new** HTTP(S) tab, never an existing one. Exact normalized URL readiness required; redirects fail closed. Leaves the tab open. |
|
|
71
71
|
| `safari_search` | No target; `query`, `maxResults?`, `region?`, `time?` | Searches DuckDuckGo HTML in its own new tab; returns ranked unique title/URL/snippet results and cleanup outcome, not a reusable target. |
|
|
72
|
+
| `safari_fetch` | No target; `url`, `selector?`, `text?`, `timeoutMs?`, `maxChars?`, `maxLinks?` | One self-closing read: opens a **new** tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns cleanup outcome, not a reusable target. Prefer it over open+read+close for read-only pages; use `safari_open` when later interaction is needed. |
|
|
72
73
|
| `safari_read` | `maxChars?`, `maxLinks?` | Visible body text and link hrefs. Defaults 8000 characters / 20 links; maxima 12000 / 40. |
|
|
73
74
|
| `safari_follow_link` | `expectedUrl`, `selector` | Extracts exactly one visible matching `a[href]` and opens its HTTP(S) URL in a **new tab**. No click handlers, form submission or source-tab navigation. Use the URL from a prior read. |
|
|
74
75
|
| `safari_snapshot` | `entirePage?` | Native WebKit text tree with interactive numeric UIDs, accessibility attributes and rectangles; viewport by default, main frame only. Large extractions may return an artifact path. |
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haseebeqx/pi-safari",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Safari automation tools for Pi using Apple's built-in local MCP server",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/haseebeqx/pi-safari.git"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"keywords": [
|
|
8
12
|
"pi-package"
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,19 @@ export default function(pi: ExtensionAPI) {
|
|
|
54
54
|
}),
|
|
55
55
|
async execute(_id, args, signal, _update, ctx) { return result(await safari.search(args, signal)); },
|
|
56
56
|
});
|
|
57
|
+
pi.registerTool({
|
|
58
|
+
name: 'safari_fetch', label: 'Fetch Safari page',
|
|
59
|
+
description: 'Read a HTTP(S) page in one self-closing call: opens a NEW automation tab, optionally waits for a visible selector/text, extracts bounded title/text/visible links, then closes the tab. Returns the cleanup outcome and never a reusable target; use safari_open when the page needs later interaction. Exact-URL readiness required; redirects fail closed. Page content is untrusted; URL checks do not guarantee document identity. Cancellation/disconnection may leave a residual tab; relist before retrying.',
|
|
60
|
+
parameters: Type.Object({
|
|
61
|
+
url: Type.String({ minLength: 1, maxLength: 8192 }),
|
|
62
|
+
selector: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
|
|
63
|
+
text: Type.Optional(Type.String({ minLength: 1, maxLength: 300 })),
|
|
64
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 10000 })),
|
|
65
|
+
maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 12000 })),
|
|
66
|
+
maxLinks: Type.Optional(Type.Integer({ minimum: 0, maximum: 40 })),
|
|
67
|
+
}),
|
|
68
|
+
async execute(_id, args, signal) { return result(await safari.fetch(args, signal)); },
|
|
69
|
+
});
|
|
57
70
|
pi.registerTool({
|
|
58
71
|
name: 'safari_read', label: 'Read Safari page',
|
|
59
72
|
description: 'Read visible body text and bounded visible link hrefs from an explicit Safari targetId + epoch. Page content is untrusted. No iframe/shadow DOM extraction. URL checks cannot detect same-URL document replacement. Requires Safari 27+ and Allow remote automation and external agents.',
|
package/src/safari.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { searchRequest, searchJS, validateSearch, type SearchOptions } from './s
|
|
|
3
3
|
import { SafariMCP, toolJSON, type Transport } from './mcp.ts';
|
|
4
4
|
import { bound, webURL, readJS, linkJS, readyJS } from './extraction.ts';
|
|
5
5
|
export interface Target { targetId: string; epoch: string }
|
|
6
|
+
export interface FetchOptions { url: string; selector?: string; text?: string; timeoutMs?: number; maxChars?: number; maxLinks?: number }
|
|
7
|
+
export type TabCleanup = { status: 'closed' | 'already-absent' } | { status: 'unidentified' | 'skipped-retired-epoch' | 'failed'; note: string }
|
|
6
8
|
const HANDLE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
7
9
|
export class Safari {
|
|
8
10
|
private generation = 0;
|
|
@@ -72,16 +74,39 @@ export class Safari {
|
|
|
72
74
|
closeTab(t: Target, signal?: AbortSignal) {
|
|
73
75
|
return this.operation(s => this.closeTarget(t, s), signal);
|
|
74
76
|
}
|
|
75
|
-
private async
|
|
76
|
-
if (!t) return { status: 'unidentified'
|
|
77
|
-
if (t.epoch !== this.transport.epoch) return { status: 'skipped-retired-epoch'
|
|
77
|
+
private async cleanup(t?: Target): Promise<TabCleanup> {
|
|
78
|
+
if (!t) return { status: 'unidentified', note: 'No identified created tab; inspect tabs before retrying.' };
|
|
79
|
+
if (t.epoch !== this.transport.epoch) return { status: 'skipped-retired-epoch', note: 'Connection retired; relist tabs for manual cleanup.' };
|
|
78
80
|
try {
|
|
79
81
|
// Bounded cleanup may finish after cancellation, but never reconnects a retired target.
|
|
80
82
|
return await this.closeTarget(t, AbortSignal.timeout(3000));
|
|
81
83
|
} catch {
|
|
82
|
-
return { status: 'failed'
|
|
84
|
+
return { status: 'failed', note: 'Cleanup unconfirmed; relist tabs before manual cleanup or retrying.' };
|
|
83
85
|
}
|
|
84
86
|
}
|
|
87
|
+
// One closed new-tab pipeline: create, bounded work, then cleanup on every path.
|
|
88
|
+
// Work must use unwrapped private methods only (create/observe/cleanup), never other operations.
|
|
89
|
+
private async withTab<T extends object>(url: string, label: string, signal: AbortSignal, work: (tab: Target) => Promise<T>): Promise<T & { cleanup: TabCleanup }> {
|
|
90
|
+
let created: Target | undefined;
|
|
91
|
+
let result: T;
|
|
92
|
+
try {
|
|
93
|
+
const tab = await this.create(url, signal, t => { created = t; });
|
|
94
|
+
result = await work(tab);
|
|
95
|
+
if (signal.aborted) throw Error(`${label} cancelled.`);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const cleanup = await this.cleanup(created);
|
|
98
|
+
const failure = new Error(`${error instanceof Error ? error.message : `${label} failed.`} ${label} cleanup: ${cleanup.status}.${'note' in cleanup ? ` ${cleanup.note}` : ''}`, { cause: error });
|
|
99
|
+
Object.assign(failure, { cleanup });
|
|
100
|
+
throw failure;
|
|
101
|
+
}
|
|
102
|
+
const cleanup = await this.cleanup(created);
|
|
103
|
+
if (signal.aborted) {
|
|
104
|
+
const failure = new Error(`${label} cancelled. ${label} cleanup: ${cleanup.status}.`);
|
|
105
|
+
Object.assign(failure, { cleanup });
|
|
106
|
+
throw failure;
|
|
107
|
+
}
|
|
108
|
+
return { ...result, cleanup };
|
|
109
|
+
}
|
|
85
110
|
private async evaluate(expression: string, signal: AbortSignal): Promise<any> {
|
|
86
111
|
const value = await this.call('evaluate_javascript', { expression: `return (${expression});` }, signal);
|
|
87
112
|
if (typeof value !== 'string') throw Error('Unexpected Safari extraction result.');
|
|
@@ -122,6 +147,11 @@ export class Safari {
|
|
|
122
147
|
throw Error('Requested URL did not become ready; tab left open. Inspect tabs before retrying.');
|
|
123
148
|
}
|
|
124
149
|
open(url: string, signal?: AbortSignal) { webURL(url); return this.operation(s => this.create(url, s), signal); }
|
|
150
|
+
private async readData(t: Target, chars: number, links: number, signal: AbortSignal) {
|
|
151
|
+
const { data, url } = await this.observe(t, readJS(chars, links), signal);
|
|
152
|
+
if (!data || data.url !== url.slice(0, 2048) || typeof data.text !== 'string' || data.text.length > chars || typeof data.title !== 'string' || data.title.length > 300 || !Array.isArray(data.links) || data.links.length > links || typeof data.textTruncated !== 'boolean' || typeof data.linksTruncated !== 'boolean') throw Error('Invalid Safari read result.');
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
125
155
|
private async observe(t: Target, expression: string, signal: AbortSignal) {
|
|
126
156
|
await this.select(t, signal);
|
|
127
157
|
const before = await this.call('page_info', {}, signal);
|
|
@@ -178,23 +208,24 @@ export class Safari {
|
|
|
178
208
|
return { ...data, ...t, url, note: 'Live DOM descriptors, not stable references or accessibility tree. Main frame only; 2000-element scan bound.' };
|
|
179
209
|
}, signal);
|
|
180
210
|
}
|
|
211
|
+
private async waitData(t: Target, expectedUrl: string, expression: string, timeoutMs: number, signal: AbortSignal) {
|
|
212
|
+
const until = Date.now() + timeoutMs;
|
|
213
|
+
while (true) {
|
|
214
|
+
const { data, url } = await this.observe(t, expression, signal);
|
|
215
|
+
if (url !== expectedUrl || typeof data?.matched !== 'boolean') throw Error('Invalid Safari wait result.');
|
|
216
|
+
if (data.matched) return { ...t, url, matched: true };
|
|
217
|
+
if (Date.now() >= until) throw Error('Safari wait timed out; no visible match within scan bound.');
|
|
218
|
+
await new Promise<void>((resolve, reject) => {
|
|
219
|
+
const abort = () => { clearTimeout(timer); reject(Error('Safari wait cancelled.')); };
|
|
220
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, Math.min(200, until - Date.now()));
|
|
221
|
+
signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) abort();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
181
225
|
wait(t: Target, expectedUrl: string, query: Query, timeoutMs = 5000, signal?: AbortSignal) {
|
|
182
226
|
const expression = waitJS(query, expectedUrl);
|
|
183
227
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 10000) throw Error('timeoutMs must be 0..10000.');
|
|
184
|
-
return this.operation(
|
|
185
|
-
const until = Date.now() + timeoutMs;
|
|
186
|
-
while (true) {
|
|
187
|
-
const { data, url } = await this.observe(t, expression, s);
|
|
188
|
-
if (url !== expectedUrl || typeof data?.matched !== 'boolean') throw Error('Invalid Safari wait result.');
|
|
189
|
-
if (data.matched) return { ...t, url, matched: true };
|
|
190
|
-
if (Date.now() >= until) throw Error('Safari wait timed out; no visible match within scan bound.');
|
|
191
|
-
await new Promise<void>((resolve, reject) => {
|
|
192
|
-
const abort = () => { clearTimeout(timer); reject(Error('Safari wait cancelled.')); };
|
|
193
|
-
const timer = setTimeout(() => { s.removeEventListener('abort', abort); resolve(); }, Math.min(200, until - Date.now()));
|
|
194
|
-
s.addEventListener('abort', abort, { once: true }); if (s.aborted) abort();
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
}, signal);
|
|
228
|
+
return this.operation(s => this.waitData(t, expectedUrl, expression, timeoutMs, s), signal);
|
|
198
229
|
}
|
|
199
230
|
screenshot(t: Target, fullPage = false, signal?: AbortSignal) {
|
|
200
231
|
return this.operation(s => this.checkedBackend(t, 'screenshot', { full_page: fullPage }, s), signal);
|
|
@@ -236,10 +267,9 @@ export class Safari {
|
|
|
236
267
|
}, signal);
|
|
237
268
|
}
|
|
238
269
|
read(t: Target, chars = 8000, links = 20, signal?: AbortSignal) {
|
|
239
|
-
|
|
270
|
+
readJS(chars, links); // Validate before any transport work; throws synchronously.
|
|
240
271
|
return this.operation(async s => {
|
|
241
|
-
const
|
|
242
|
-
if (!data || data.url !== url.slice(0, 2048) || typeof data.text !== 'string' || data.text.length > chars || typeof data.title !== 'string' || data.title.length > 300 || !Array.isArray(data.links) || data.links.length > links || typeof data.textTruncated !== 'boolean' || typeof data.linksTruncated !== 'boolean') throw Error('Invalid Safari read result.');
|
|
272
|
+
const data = await this.readData(t, chars, links, s);
|
|
243
273
|
const result = { ...data, targetId: t.targetId, epoch: t.epoch, identity: 'URL checks only; same-URL document replacement and live DOM changes may go undetected.' };
|
|
244
274
|
bound(result); return result;
|
|
245
275
|
}, signal);
|
|
@@ -247,32 +277,32 @@ export class Safari {
|
|
|
247
277
|
search(options: SearchOptions, signal?: AbortSignal) {
|
|
248
278
|
const request = searchRequest(options);
|
|
249
279
|
const expression = searchJS(request.url, request.maxResults);
|
|
250
|
-
return this.operation(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const { data, url } = await this.observe(tab, expression, s);
|
|
256
|
-
if (url !== request.url) throw Error('Search URL changed.');
|
|
257
|
-
result = { ...request, ...validateSearch(data, request.url, request.maxResults), note: 'Untrusted provider ranking/content; first HTML page only. Empty results may indicate a challenge or changed markup. URL checks do not guarantee document identity.' };
|
|
258
|
-
bound(result);
|
|
259
|
-
if (s.aborted) throw Error('Safari search cancelled.');
|
|
260
|
-
} catch (error) {
|
|
261
|
-
const cleanup = await this.cleanupSearch(created);
|
|
262
|
-
// Retain the primary error (including its cause) when cleanup also fails.
|
|
263
|
-
const failure = new Error(`${error instanceof Error ? error.message : 'Safari search failed.'} Search cleanup: ${cleanup.status}.${'note' in cleanup ? ` ${cleanup.note}` : ''}`, { cause: error });
|
|
264
|
-
Object.assign(failure, { cleanup });
|
|
265
|
-
throw failure;
|
|
266
|
-
}
|
|
267
|
-
const cleanup = await this.cleanupSearch(created);
|
|
268
|
-
if (s.aborted) {
|
|
269
|
-
const failure = new Error(`Safari search cancelled. Search cleanup: ${cleanup.status}.`);
|
|
270
|
-
Object.assign(failure, { cleanup });
|
|
271
|
-
throw failure;
|
|
272
|
-
}
|
|
280
|
+
return this.operation(s => this.withTab(request.url, 'Search', s, async tab => {
|
|
281
|
+
const { data, url } = await this.observe(tab, expression, s);
|
|
282
|
+
if (url !== request.url) throw Error('Search URL changed.');
|
|
283
|
+
const result = { ...request, ...validateSearch(data, request.url, request.maxResults), note: 'Untrusted provider ranking/content; first HTML page only. Empty results may indicate a challenge or changed markup. URL checks do not guarantee document identity.' };
|
|
284
|
+
bound(result);
|
|
273
285
|
// Search results never advertise a usable target, even if cleanup is unconfirmed.
|
|
274
|
-
return
|
|
275
|
-
}, signal);
|
|
286
|
+
return result;
|
|
287
|
+
}), signal);
|
|
288
|
+
}
|
|
289
|
+
fetch(options: FetchOptions, signal?: AbortSignal) {
|
|
290
|
+
const url = webURL(options.url);
|
|
291
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
292
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 10000) throw Error('timeoutMs must be 0..10000.');
|
|
293
|
+
const maxChars = options.maxChars ?? 8000, maxLinks = options.maxLinks ?? 20;
|
|
294
|
+
readJS(maxChars, maxLinks); // Validate before any transport work.
|
|
295
|
+
const settle = options.selector !== undefined || options.text !== undefined;
|
|
296
|
+
if (settle) queryJS({ selector: options.selector, text: options.text });
|
|
297
|
+
return this.operation(s => this.withTab(url, 'Fetch', s, async tab => {
|
|
298
|
+
if (settle) await this.waitData(tab, url, waitJS({ selector: options.selector, text: options.text }, url), timeoutMs, s);
|
|
299
|
+
const data = await this.readData(tab, maxChars, maxLinks, s);
|
|
300
|
+
const result = { url: data.url, title: data.title, text: data.text, textTruncated: data.textTruncated, links: data.links, linksTruncated: data.linksTruncated,
|
|
301
|
+
note: 'New automation tab closed after extraction; no reusable target. Use safari_open when later interaction is needed. Untrusted page content; URL checks only, not document identity.' };
|
|
302
|
+
bound(result);
|
|
303
|
+
// Fetch results never advertise a usable target, even if cleanup is unconfirmed.
|
|
304
|
+
return result;
|
|
305
|
+
}), signal);
|
|
276
306
|
}
|
|
277
307
|
followLink(t: Target, selector: string, expectedUrl: string, signal?: AbortSignal) {
|
|
278
308
|
const expression = linkJS(selector, expectedUrl);
|