safari-pilot 0.1.0

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.
Files changed (143) hide show
  1. package/.claude-plugin/plugin.json +35 -0
  2. package/.mcp.json +11 -0
  3. package/LICENSE +21 -0
  4. package/README.md +324 -0
  5. package/bin/.gitkeep +0 -0
  6. package/bin/Safari Pilot.app/Contents/CodeResources +0 -0
  7. package/bin/Safari Pilot.app/Contents/Info.plist +58 -0
  8. package/bin/Safari Pilot.app/Contents/MacOS/Safari Pilot +0 -0
  9. package/bin/Safari Pilot.app/Contents/PkgInfo +1 -0
  10. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Info.plist +55 -0
  11. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/MacOS/Safari Pilot Extension +0 -0
  12. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/background.js +294 -0
  13. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-isolated.js +80 -0
  14. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-main.js +310 -0
  15. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/icons/icon-128.png +0 -0
  16. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/icons/icon-48.png +0 -0
  17. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/icons/icon-96.png +0 -0
  18. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/manifest.json +39 -0
  19. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/_CodeSignature/CodeResources +194 -0
  20. package/bin/Safari Pilot.app/Contents/Resources/AppIcon.icns +0 -0
  21. package/bin/Safari Pilot.app/Contents/Resources/Assets.car +0 -0
  22. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.html +19 -0
  23. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/Info.plist +0 -0
  24. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/MainMenu.nib +0 -0
  25. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/NSWindowController-B8D-0N-5wS.nib +0 -0
  26. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/XfG-lQ-9wD-view-m2S-Jp-Qdl.nib +0 -0
  27. package/bin/Safari Pilot.app/Contents/Resources/Icon.png +0 -0
  28. package/bin/Safari Pilot.app/Contents/Resources/Script.js +22 -0
  29. package/bin/Safari Pilot.app/Contents/Resources/Style.css +45 -0
  30. package/bin/Safari Pilot.app/Contents/_CodeSignature/CodeResources +236 -0
  31. package/bin/Safari Pilot.zip +0 -0
  32. package/bin/SafariPilotd +0 -0
  33. package/dist/engine-selector.d.ts +10 -0
  34. package/dist/engine-selector.js +55 -0
  35. package/dist/engine-selector.js.map +1 -0
  36. package/dist/engines/applescript.d.ts +53 -0
  37. package/dist/engines/applescript.js +290 -0
  38. package/dist/engines/applescript.js.map +1 -0
  39. package/dist/engines/daemon.d.ts +19 -0
  40. package/dist/engines/daemon.js +187 -0
  41. package/dist/engines/daemon.js.map +1 -0
  42. package/dist/engines/engine.d.ts +15 -0
  43. package/dist/engines/engine.js +42 -0
  44. package/dist/engines/engine.js.map +1 -0
  45. package/dist/engines/extension.d.ts +34 -0
  46. package/dist/engines/extension.js +66 -0
  47. package/dist/engines/extension.js.map +1 -0
  48. package/dist/errors.d.ts +128 -0
  49. package/dist/errors.js +250 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.js +11 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/security/audit-log.d.ts +23 -0
  55. package/dist/security/audit-log.js +68 -0
  56. package/dist/security/audit-log.js.map +1 -0
  57. package/dist/security/circuit-breaker.d.ts +29 -0
  58. package/dist/security/circuit-breaker.js +114 -0
  59. package/dist/security/circuit-breaker.js.map +1 -0
  60. package/dist/security/domain-policy.d.ts +29 -0
  61. package/dist/security/domain-policy.js +96 -0
  62. package/dist/security/domain-policy.js.map +1 -0
  63. package/dist/security/human-approval.d.ts +20 -0
  64. package/dist/security/human-approval.js +150 -0
  65. package/dist/security/human-approval.js.map +1 -0
  66. package/dist/security/idpi-scanner.d.ts +20 -0
  67. package/dist/security/idpi-scanner.js +102 -0
  68. package/dist/security/idpi-scanner.js.map +1 -0
  69. package/dist/security/kill-switch.d.ts +51 -0
  70. package/dist/security/kill-switch.js +103 -0
  71. package/dist/security/kill-switch.js.map +1 -0
  72. package/dist/security/rate-limiter.d.ts +30 -0
  73. package/dist/security/rate-limiter.js +70 -0
  74. package/dist/security/rate-limiter.js.map +1 -0
  75. package/dist/security/screenshot-redaction.d.ts +42 -0
  76. package/dist/security/screenshot-redaction.js +134 -0
  77. package/dist/security/screenshot-redaction.js.map +1 -0
  78. package/dist/security/tab-ownership.d.ts +46 -0
  79. package/dist/security/tab-ownership.js +85 -0
  80. package/dist/security/tab-ownership.js.map +1 -0
  81. package/dist/server.d.ts +53 -0
  82. package/dist/server.js +347 -0
  83. package/dist/server.js.map +1 -0
  84. package/dist/tools/clipboard.d.ts +15 -0
  85. package/dist/tools/clipboard.js +128 -0
  86. package/dist/tools/clipboard.js.map +1 -0
  87. package/dist/tools/compound.d.ts +68 -0
  88. package/dist/tools/compound.js +491 -0
  89. package/dist/tools/compound.js.map +1 -0
  90. package/dist/tools/extraction.d.ts +26 -0
  91. package/dist/tools/extraction.js +414 -0
  92. package/dist/tools/extraction.js.map +1 -0
  93. package/dist/tools/frames.d.ts +22 -0
  94. package/dist/tools/frames.js +165 -0
  95. package/dist/tools/frames.js.map +1 -0
  96. package/dist/tools/interaction.d.ts +30 -0
  97. package/dist/tools/interaction.js +651 -0
  98. package/dist/tools/interaction.js.map +1 -0
  99. package/dist/tools/navigation.d.ts +41 -0
  100. package/dist/tools/navigation.js +316 -0
  101. package/dist/tools/navigation.js.map +1 -0
  102. package/dist/tools/network.d.ts +27 -0
  103. package/dist/tools/network.js +721 -0
  104. package/dist/tools/network.js.map +1 -0
  105. package/dist/tools/performance.d.ts +16 -0
  106. package/dist/tools/performance.js +240 -0
  107. package/dist/tools/performance.js.map +1 -0
  108. package/dist/tools/permissions.d.ts +25 -0
  109. package/dist/tools/permissions.js +308 -0
  110. package/dist/tools/permissions.js.map +1 -0
  111. package/dist/tools/service-workers.d.ts +15 -0
  112. package/dist/tools/service-workers.js +136 -0
  113. package/dist/tools/service-workers.js.map +1 -0
  114. package/dist/tools/shadow.d.ts +21 -0
  115. package/dist/tools/shadow.js +126 -0
  116. package/dist/tools/shadow.js.map +1 -0
  117. package/dist/tools/storage.d.ts +30 -0
  118. package/dist/tools/storage.js +679 -0
  119. package/dist/tools/storage.js.map +1 -0
  120. package/dist/tools/structured-extraction.d.ts +22 -0
  121. package/dist/tools/structured-extraction.js +433 -0
  122. package/dist/tools/structured-extraction.js.map +1 -0
  123. package/dist/tools/wait.d.ts +18 -0
  124. package/dist/tools/wait.js +182 -0
  125. package/dist/tools/wait.js.map +1 -0
  126. package/dist/types.d.ts +85 -0
  127. package/dist/types.js +2 -0
  128. package/dist/types.js.map +1 -0
  129. package/extension/background.js +294 -0
  130. package/extension/content-isolated.js +80 -0
  131. package/extension/content-main.js +310 -0
  132. package/extension/icons/icon-128.png +0 -0
  133. package/extension/icons/icon-48.png +0 -0
  134. package/extension/icons/icon-96.png +0 -0
  135. package/extension/manifest.json +39 -0
  136. package/hooks/session-end.sh +67 -0
  137. package/hooks/session-start.sh +66 -0
  138. package/package.json +46 -0
  139. package/scripts/build-extension.sh +135 -0
  140. package/scripts/postinstall.sh +91 -0
  141. package/scripts/preuninstall.sh +25 -0
  142. package/scripts/update-daemon.sh +62 -0
  143. package/skills/safari-pilot/SKILL.md +157 -0
@@ -0,0 +1,294 @@
1
+ // background.js — Extension Service Worker
2
+ // Handles native messaging to the Safari app extension handler via
3
+ // browser.runtime.sendNativeMessage, routes commands from content scripts,
4
+ // manages cookie/DNR APIs, and tracks active tabs.
5
+
6
+ (function () {
7
+ 'use strict';
8
+
9
+ // ─── Constants ──────────────────────────────────────────────────────────────
10
+
11
+ // Bundle ID of the containing macOS app — Safari routes
12
+ // sendNativeMessage calls to the app's web extension handler.
13
+ const APP_BUNDLE_ID = 'com.safari-pilot.app';
14
+ const POLL_INTERVAL_MS = 200;
15
+
16
+ // ─── State ────────────────────────────────────────────────────────────────
17
+
18
+ let isConnected = false;
19
+ const activeTabs = new Map(); // tabId → { url, status }
20
+ let pollTimerId = null;
21
+
22
+ // ─── Native Messaging (sendNativeMessage-based) ───────────────────────────
23
+
24
+ /**
25
+ * Send a message to the native extension handler and return the response.
26
+ * Uses browser.runtime.sendNativeMessage (request/response per call).
27
+ */
28
+ function sendNativeRequest(message) {
29
+ return browser.runtime.sendNativeMessage(APP_BUNDLE_ID, message);
30
+ }
31
+
32
+ /**
33
+ * Poll the native handler for pending commands from the daemon.
34
+ * If a command is returned, route it to the content script,
35
+ * collect the result, and send it back via native messaging.
36
+ */
37
+ async function pollForCommands() {
38
+ try {
39
+ const response = await sendNativeRequest({ type: 'poll' });
40
+
41
+ if (response && response.command && response.command !== null) {
42
+ const cmd = response.command;
43
+ await executeAndReturnResult(cmd);
44
+ }
45
+ } catch (e) {
46
+ console.warn('[SafariPilot] Poll error:', e);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Execute a command received from the daemon (via poll) and send the
52
+ * result back through the native handler.
53
+ */
54
+ async function executeAndReturnResult(cmd) {
55
+ const commandId = cmd.id;
56
+
57
+ try {
58
+ let result;
59
+
60
+ if (cmd.script) {
61
+ // Route script execution to the active tab's content script
62
+ const tabs = await browser.tabs.query({
63
+ active: true,
64
+ currentWindow: true,
65
+ });
66
+ const tabId = tabs[0]?.id;
67
+
68
+ if (tabId != null) {
69
+ const responses = await browser.tabs.sendMessage(tabId, {
70
+ type: 'SAFARI_PILOT_COMMAND',
71
+ method: 'execute_script',
72
+ params: { script: cmd.script },
73
+ });
74
+ result = responses;
75
+ } else {
76
+ result = { ok: false, error: { message: 'No active tab' } };
77
+ }
78
+ } else {
79
+ result = { ok: true, value: null };
80
+ }
81
+
82
+ await sendNativeRequest({
83
+ type: 'result',
84
+ id: commandId,
85
+ result: result,
86
+ });
87
+ } catch (err) {
88
+ // Send error result back so the daemon doesn't hang
89
+ try {
90
+ await sendNativeRequest({
91
+ type: 'result',
92
+ id: commandId,
93
+ result: { ok: false, error: { message: err.message } },
94
+ });
95
+ } catch (sendErr) {
96
+ console.error('[SafariPilot] Failed to send error result:', sendErr);
97
+ }
98
+ }
99
+ }
100
+
101
+ // ─── Poll Loop ────────────────────────────────────────────────────────────
102
+
103
+ function startPolling() {
104
+ if (pollTimerId != null) return;
105
+ pollTimerId = setInterval(pollForCommands, POLL_INTERVAL_MS);
106
+ console.log('[SafariPilot] Polling started');
107
+ }
108
+
109
+ function stopPolling() {
110
+ if (pollTimerId != null) {
111
+ clearInterval(pollTimerId);
112
+ pollTimerId = null;
113
+ console.log('[SafariPilot] Polling stopped');
114
+ }
115
+ }
116
+
117
+ // ─── Cookie Operations ────────────────────────────────────────────────────
118
+
119
+ async function handleCookieGet(params) {
120
+ const result = await browser.cookies.get({
121
+ url: params.url,
122
+ name: params.name,
123
+ storeId: params.storeId,
124
+ });
125
+ return { ok: true, value: result };
126
+ }
127
+
128
+ async function handleCookieSet(params) {
129
+ const result = await browser.cookies.set({
130
+ url: params.url,
131
+ name: params.name,
132
+ value: params.value,
133
+ domain: params.domain,
134
+ path: params.path ?? '/',
135
+ secure: params.secure ?? false,
136
+ httpOnly: params.httpOnly ?? false,
137
+ sameSite: params.sameSite,
138
+ expirationDate: params.expirationDate,
139
+ storeId: params.storeId,
140
+ });
141
+ return { ok: true, value: result };
142
+ }
143
+
144
+ async function handleCookieRemove(params) {
145
+ const result = await browser.cookies.remove({
146
+ url: params.url,
147
+ name: params.name,
148
+ storeId: params.storeId,
149
+ });
150
+ return { ok: true, value: result };
151
+ }
152
+
153
+ async function handleCookieGetAll(params) {
154
+ const result = await browser.cookies.getAll({
155
+ url: params.url,
156
+ domain: params.domain,
157
+ name: params.name,
158
+ path: params.path,
159
+ secure: params.secure,
160
+ storeId: params.storeId,
161
+ });
162
+ return { ok: true, value: result };
163
+ }
164
+
165
+ // ─── Declarative Net Request Operations ───────────────────────────────────
166
+
167
+ async function handleDnrAddRule(params) {
168
+ await browser.declarativeNetRequest.updateDynamicRules({
169
+ addRules: [params.rule],
170
+ removeRuleIds: [],
171
+ });
172
+ return { ok: true, value: { added: true, ruleId: params.rule?.id } };
173
+ }
174
+
175
+ async function handleDnrRemoveRule(params) {
176
+ await browser.declarativeNetRequest.updateDynamicRules({
177
+ addRules: [],
178
+ removeRuleIds: [params.ruleId],
179
+ });
180
+ return { ok: true, value: { removed: true, ruleId: params.ruleId } };
181
+ }
182
+
183
+ // ─── execute_in_main (forward to content script) ──────────────────────────
184
+
185
+ async function handleExecuteInMain(message, sender) {
186
+ const tabId = sender?.tab?.id;
187
+ if (tabId == null) {
188
+ return { ok: false, error: { message: 'No tab context available' } };
189
+ }
190
+
191
+ try {
192
+ const [result] = await browser.tabs.sendMessage(tabId, {
193
+ type: 'SAFARI_PILOT_COMMAND',
194
+ method: message.method,
195
+ params: message.params ?? {},
196
+ });
197
+ return result ?? { ok: true, value: null };
198
+ } catch (err) {
199
+ return { ok: false, error: { message: err.message } };
200
+ }
201
+ }
202
+
203
+ // ─── Command Router ────────────────────────────────────────────────────────
204
+
205
+ async function handleCommand(message, sender) {
206
+ const { command, params } = message;
207
+
208
+ try {
209
+ switch (command) {
210
+ case 'execute_in_main':
211
+ return await handleExecuteInMain(message, sender);
212
+
213
+ case 'cookie_get':
214
+ return await handleCookieGet(params ?? {});
215
+
216
+ case 'cookie_set':
217
+ return await handleCookieSet(params ?? {});
218
+
219
+ case 'cookie_remove':
220
+ return await handleCookieRemove(params ?? {});
221
+
222
+ case 'cookie_get_all':
223
+ return await handleCookieGetAll(params ?? {});
224
+
225
+ case 'dnr_add_rule':
226
+ return await handleDnrAddRule(params ?? {});
227
+
228
+ case 'dnr_remove_rule':
229
+ return await handleDnrRemoveRule(params ?? {});
230
+
231
+ default:
232
+ return {
233
+ ok: false,
234
+ error: { message: `Unknown command: ${command}` },
235
+ };
236
+ }
237
+ } catch (err) {
238
+ return {
239
+ ok: false,
240
+ error: { message: err.message, name: err.name },
241
+ };
242
+ }
243
+ }
244
+
245
+ // ─── Runtime Message Listener ─────────────────────────────────────────────
246
+
247
+ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
248
+ // Health check
249
+ if (message && message.type === 'ping') {
250
+ sendResponse({ ok: true, type: 'pong', extensionVersion: '0.1.0' });
251
+ return false;
252
+ }
253
+
254
+ // Command dispatch from content-isolated.js
255
+ if (message && message.type === 'SAFARI_PILOT_COMMAND') {
256
+ handleCommand(message, sender).then(sendResponse);
257
+ return true; // Keep message channel open for async response
258
+ }
259
+
260
+ return false;
261
+ });
262
+
263
+ // ─── Tab Tracking ─────────────────────────────────────────────────────────
264
+
265
+ browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
266
+ if (changeInfo.status) {
267
+ activeTabs.set(tabId, {
268
+ url: tab.url ?? activeTabs.get(tabId)?.url,
269
+ status: changeInfo.status,
270
+ });
271
+ }
272
+ });
273
+
274
+ browser.tabs.onRemoved.addListener((tabId) => {
275
+ activeTabs.delete(tabId);
276
+ });
277
+
278
+ // ─── Initialization ────────────────────────────────────────────────────────
279
+
280
+ // Send status check on startup to register as connected
281
+ sendNativeRequest({ type: 'status' })
282
+ .then((response) => {
283
+ if (response && response.connected) {
284
+ isConnected = true;
285
+ console.log('[SafariPilot] Native handler connected, version:', response.version);
286
+ }
287
+ })
288
+ .catch((err) => {
289
+ console.warn('[SafariPilot] Initial status check failed:', err);
290
+ });
291
+
292
+ // Start polling for daemon commands
293
+ startPolling();
294
+ })();
@@ -0,0 +1,80 @@
1
+ // content-isolated.js — ISOLATED world
2
+ // This script CANNOT be modified by page JS. It serves as a trusted relay.
3
+ //
4
+ // Role: Secure bridge between the background service worker and the MAIN world
5
+ // content script. Page JavaScript operates in a separate context and cannot
6
+ // read or tamper with this script's state or the browser extension API.
7
+ //
8
+ // Message flow:
9
+ // Background (runtime.sendMessage)
10
+ // → ISOLATED world (browser.runtime.onMessage)
11
+ // → MAIN world (window.postMessage with type SAFARI_PILOT_CMD)
12
+ // → ISOLATED world (window.addEventListener 'message' SAFARI_PILOT_RESPONSE)
13
+ // → Background (sendResponse callback)
14
+
15
+ (() => {
16
+ 'use strict';
17
+
18
+ let nextRequestId = 0;
19
+ const pendingRequests = new Map();
20
+
21
+ // ─── MAIN World → ISOLATED World ──────────────────────────────────────────
22
+ // Receive responses from the MAIN world content script.
23
+ // Only process messages from the same window (blocks cross-frame injection).
24
+
25
+ window.addEventListener('message', (event) => {
26
+ if (event.source !== window) return;
27
+ if (event.data?.type !== 'SAFARI_PILOT_RESPONSE') return;
28
+
29
+ const { requestId, ok, value, error } = event.data;
30
+ const pending = pendingRequests.get(requestId);
31
+ if (!pending) return;
32
+
33
+ pendingRequests.delete(requestId);
34
+ if (ok) {
35
+ pending.resolve(value);
36
+ } else {
37
+ pending.reject(error);
38
+ }
39
+ });
40
+
41
+ // ─── Background → ISOLATED World ──────────────────────────────────────────
42
+ // Receive commands from the background service worker via runtime messaging.
43
+ // Returns true to indicate async sendResponse (keeps message channel open).
44
+
45
+ browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
46
+ if (message.type !== 'SAFARI_PILOT_COMMAND') return false;
47
+
48
+ const requestId = `sp_${++nextRequestId}_${Date.now()}`;
49
+
50
+ const promise = new Promise((resolve, reject) => {
51
+ pendingRequests.set(requestId, { resolve, reject });
52
+
53
+ // Forward to MAIN world. Use window.location.origin (never '*') per spec.
54
+ window.postMessage(
55
+ {
56
+ type: 'SAFARI_PILOT_CMD',
57
+ requestId,
58
+ method: message.method,
59
+ params: message.params ?? {},
60
+ },
61
+ window.location.origin
62
+ );
63
+
64
+ // Timeout: MAIN world has 10 s to respond before we fail the request
65
+ setTimeout(() => {
66
+ if (pendingRequests.has(requestId)) {
67
+ pendingRequests.delete(requestId);
68
+ reject({ message: 'MAIN world timeout', code: 'TIMEOUT' });
69
+ }
70
+ }, 10_000);
71
+ });
72
+
73
+ promise.then(
74
+ value => sendResponse({ ok: true, value }),
75
+ error => sendResponse({ ok: false, error })
76
+ );
77
+
78
+ return true; // Keep the message channel open for async sendResponse
79
+ });
80
+ })();
@@ -0,0 +1,310 @@
1
+ // content-main.js — MAIN world
2
+ // WARNING: This code runs in the page's context. It CAN be observed by page JS.
3
+ // Never store secrets. Never read credentials. Minimal footprint.
4
+
5
+ (() => {
6
+ 'use strict';
7
+
8
+ // Namespace to minimize collision risk
9
+ const SP = Object.create(null);
10
+
11
+ // ─── Shadow DOM Traversal ─────────────────────────────────────────────────
12
+ // Traverses open AND closed shadow roots. Closed roots are accessible from
13
+ // MAIN world because we intercept Element.attachShadow at document_idle.
14
+ // Primary reason this extension exists — no other automation layer can do this.
15
+
16
+ SP.queryShadow = (selector, shadowSelector) => {
17
+ // If shadowSelector provided: find hosts matching selector, then query inside their shadows
18
+ // If only selector: query entire document including all shadow subtrees
19
+ const results = [];
20
+
21
+ const walkShadow = (node, targetSelector) => {
22
+ const shadow = node.shadowRoot;
23
+ if (shadow) {
24
+ const found = shadow.querySelectorAll(targetSelector);
25
+ results.push(...found);
26
+ shadow.querySelectorAll('*').forEach(child => walkShadow(child, targetSelector));
27
+ }
28
+ };
29
+
30
+ if (shadowSelector) {
31
+ // Two-phase: find shadow hosts by selector, then query inside their shadows
32
+ const hosts = document.querySelectorAll(selector);
33
+ hosts.forEach(host => {
34
+ if (host.shadowRoot) {
35
+ results.push(...host.shadowRoot.querySelectorAll(shadowSelector));
36
+ host.shadowRoot.querySelectorAll('*').forEach(child => walkShadow(child, shadowSelector));
37
+ }
38
+ });
39
+ } else {
40
+ // Single-phase: query full document + all shadow subtrees
41
+ results.push(...document.querySelectorAll(selector));
42
+ document.querySelectorAll('*').forEach(el => walkShadow(el, selector));
43
+ }
44
+
45
+ return results;
46
+ };
47
+
48
+ SP.queryShadowAll = (selector, root = document) => {
49
+ const results = [];
50
+ const walk = (node) => {
51
+ if (node.shadowRoot) {
52
+ results.push(...node.shadowRoot.querySelectorAll(selector));
53
+ node.shadowRoot.querySelectorAll('*').forEach(walk);
54
+ }
55
+ node.querySelectorAll('*').forEach(child => {
56
+ if (child.shadowRoot) walk(child);
57
+ });
58
+ };
59
+ results.push(...root.querySelectorAll(selector));
60
+ walk(root);
61
+ return results;
62
+ };
63
+
64
+ // ─── Framework-Aware Form Filling ─────────────────────────────────────────
65
+ // React tracks input state via _valueTracker. A plain .value assignment won't
66
+ // trigger React's synthetic event system. We must use the native setter and
67
+ // delete the tracker so React sees the change as "new" input.
68
+
69
+ SP.fillReact = (element, value) => {
70
+ const nativeInputValueSetter =
71
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ||
72
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
73
+
74
+ if (!nativeInputValueSetter) {
75
+ throw new Error('Cannot find native value setter');
76
+ }
77
+
78
+ // Bypass React's _valueTracker so it sees the programmatic change as new
79
+ if (element._valueTracker) {
80
+ element._valueTracker.setValue('');
81
+ }
82
+
83
+ nativeInputValueSetter.call(element, value);
84
+ element.dispatchEvent(new Event('input', { bubbles: true }));
85
+ element.dispatchEvent(new Event('change', { bubbles: true }));
86
+ element.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
87
+ };
88
+
89
+ SP.fillVue = async (element, value) => {
90
+ // Vue 3 uses Proxy-based reactivity — direct assignment triggers v-model
91
+ element.value = value;
92
+ element.dispatchEvent(new Event('input', { bubbles: true }));
93
+ // Wait for Vue's nextTick so watchers run before change event
94
+ await new Promise(resolve => setTimeout(resolve, 0));
95
+ element.dispatchEvent(new Event('change', { bubbles: true }));
96
+ };
97
+
98
+ // ─── Dialog Interception ──────────────────────────────────────────────────
99
+ // Replaces window.alert/confirm/prompt with captured versions.
100
+ // Must run in MAIN world — ISOLATED world cannot override window globals.
101
+
102
+ SP.interceptDialogs = () => {
103
+ const dialogQueue = [];
104
+ const handlers = {};
105
+
106
+ ['alert', 'confirm', 'prompt'].forEach(type => {
107
+ const original = window[type];
108
+ window[type] = function(message, defaultValue) {
109
+ const entry = { type, message, timestamp: Date.now() };
110
+ dialogQueue.push(entry);
111
+
112
+ if (handlers[type]) {
113
+ return handlers[type](message, defaultValue);
114
+ }
115
+ // Sensible defaults: accept alerts, auto-confirm, return empty prompt
116
+ if (type === 'alert') return undefined;
117
+ if (type === 'confirm') return true;
118
+ if (type === 'prompt') return defaultValue || '';
119
+ };
120
+ });
121
+
122
+ return {
123
+ getQueue: () => [...dialogQueue],
124
+ setHandler: (type, fn) => { handlers[type] = fn; },
125
+ clear: () => { dialogQueue.length = 0; },
126
+ };
127
+ };
128
+
129
+ // ─── Network Interception ─────────────────────────────────────────────────
130
+ // Monkey-patches fetch and XMLHttpRequest for capture and mocking.
131
+ // Only JS-initiated requests are interceptable — browser-native resource
132
+ // loading (img src, link href, etc.) bypasses this.
133
+
134
+ SP.interceptNetwork = () => {
135
+ const captured = [];
136
+ const originalFetch = window.fetch;
137
+ const OriginalXHR = window.XMLHttpRequest;
138
+
139
+ window.fetch = async function(...args) {
140
+ const request = new Request(...args);
141
+ const entry = {
142
+ type: 'fetch',
143
+ url: request.url,
144
+ method: request.method,
145
+ timestamp: Date.now(),
146
+ };
147
+ try {
148
+ const response = await originalFetch.apply(this, args);
149
+ entry.status = response.status;
150
+ entry.statusText = response.statusText;
151
+ captured.push(entry);
152
+ return response;
153
+ } catch (error) {
154
+ entry.error = error.message;
155
+ captured.push(entry);
156
+ throw error;
157
+ }
158
+ };
159
+
160
+ // Patch XMLHttpRequest
161
+ window.XMLHttpRequest = function() {
162
+ const xhr = new OriginalXHR();
163
+ const entry = { type: 'xhr', timestamp: Date.now() };
164
+
165
+ const originalOpen = xhr.open.bind(xhr);
166
+ xhr.open = function(method, url, ...rest) {
167
+ entry.method = method;
168
+ entry.url = url;
169
+ return originalOpen(method, url, ...rest);
170
+ };
171
+
172
+ xhr.addEventListener('load', () => {
173
+ entry.status = xhr.status;
174
+ entry.statusText = xhr.statusText;
175
+ captured.push({ ...entry });
176
+ });
177
+
178
+ xhr.addEventListener('error', () => {
179
+ entry.error = 'XHR error';
180
+ captured.push({ ...entry });
181
+ });
182
+
183
+ return xhr;
184
+ };
185
+
186
+ return {
187
+ getCaptured: () => [...captured],
188
+ clear: () => { captured.length = 0; },
189
+ restore: () => {
190
+ window.fetch = originalFetch;
191
+ window.XMLHttpRequest = OriginalXHR;
192
+ },
193
+ };
194
+ };
195
+
196
+ // ─── Framework Detection ──────────────────────────────────────────────────
197
+
198
+ SP.detectFramework = () => {
199
+ const detected = [];
200
+
201
+ if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__ || document.querySelector('[data-reactroot]') || document.querySelector('[data-reactid]')) {
202
+ detected.push('react');
203
+ }
204
+ if (window.__vue_devtools_global_hook__ || document.querySelector('[data-v-app]')) {
205
+ detected.push('vue');
206
+ }
207
+ if (window.ng || window.getAllAngularRootElements?.()?.length > 0) {
208
+ detected.push('angular');
209
+ }
210
+ if (document.querySelector('[data-svelte]') || window.__svelte) {
211
+ detected.push('svelte');
212
+ }
213
+
214
+ return detected;
215
+ };
216
+
217
+ // ─── Expose namespace ──────────────────────────────────────────────────────
218
+ window.__safariPilot = SP;
219
+
220
+ // ─── Message Channel from ISOLATED World ──────────────────────────────────
221
+ // The ISOLATED world relay forwards background script commands here via
222
+ // window.postMessage. We respond with results on the same channel.
223
+ // SECURITY: use window.location.origin (never '*') as postMessage target.
224
+
225
+ window.addEventListener('message', (event) => {
226
+ if (event.source !== window) return;
227
+ if (event.data?.type !== 'SAFARI_PILOT_CMD') return;
228
+
229
+ const { requestId, method, params } = event.data;
230
+
231
+ const respond = (ok, payload) => {
232
+ window.postMessage(
233
+ { type: 'SAFARI_PILOT_RESPONSE', requestId, ok, ...payload },
234
+ window.location.origin
235
+ );
236
+ };
237
+
238
+ (async () => {
239
+ try {
240
+ let result;
241
+ switch (method) {
242
+ case 'queryShadow': {
243
+ const elements = SP.queryShadow(params.selector, params.shadowSelector);
244
+ result = Array.from(elements).map(el => ({
245
+ tagName: el.tagName,
246
+ id: el.id,
247
+ className: el.className,
248
+ textContent: el.textContent?.slice(0, 200),
249
+ }));
250
+ break;
251
+ }
252
+ case 'queryShadowAll': {
253
+ const elements = SP.queryShadowAll(params.selector);
254
+ result = Array.from(elements).map(el => ({
255
+ tagName: el.tagName,
256
+ id: el.id,
257
+ className: el.className,
258
+ textContent: el.textContent?.slice(0, 200),
259
+ }));
260
+ break;
261
+ }
262
+ case 'fillReact': {
263
+ const target = document.querySelector(params.selector);
264
+ if (!target) throw new Error(`Element not found: ${params.selector}`);
265
+ SP.fillReact(target, params.value);
266
+ result = { filled: true };
267
+ break;
268
+ }
269
+ case 'fillVue': {
270
+ const target = document.querySelector(params.selector);
271
+ if (!target) throw new Error(`Element not found: ${params.selector}`);
272
+ await SP.fillVue(target, params.value);
273
+ result = { filled: true };
274
+ break;
275
+ }
276
+ case 'interceptDialogs': {
277
+ const controller = SP.interceptDialogs();
278
+ // Store controller so later commands can query it
279
+ SP._dialogController = controller;
280
+ result = { intercepting: true };
281
+ break;
282
+ }
283
+ case 'getDialogQueue': {
284
+ result = SP._dialogController?.getQueue() ?? [];
285
+ break;
286
+ }
287
+ case 'interceptNetwork': {
288
+ const controller = SP.interceptNetwork();
289
+ SP._networkController = controller;
290
+ result = { intercepting: true };
291
+ break;
292
+ }
293
+ case 'getNetworkCaptures': {
294
+ result = SP._networkController?.getCaptured() ?? [];
295
+ break;
296
+ }
297
+ case 'detectFramework': {
298
+ result = SP.detectFramework();
299
+ break;
300
+ }
301
+ default:
302
+ throw new Error(`Unknown method: ${method}`);
303
+ }
304
+ respond(true, { value: result });
305
+ } catch (error) {
306
+ respond(false, { error: { message: error.message, name: error.name } });
307
+ }
308
+ })();
309
+ });
310
+ })();